mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 17:48:55 -04:00
Add clickable rows and standardize action button styles
The changes add row click functionality and consistent action button styling across data tables. Main updates: - Add `onRowClick` handler to make rows clickable and navigate to details pages - Add `data-action-button` attribute to exclude action buttons from row click - Fix TypeScript errors and types
This commit is contained in:
@@ -1,770 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building,
|
||||
DollarSign,
|
||||
Edit3,
|
||||
Eye,
|
||||
FileText,
|
||||
Hash,
|
||||
Loader2,
|
||||
Plus,
|
||||
Save,
|
||||
Send,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
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"
|
||||
size="sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 sm:mr-2" />
|
||||
<span className="hidden sm: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 sm:mr-2" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 sm:mr-2" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Save Draft</span>
|
||||
</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"
|
||||
size="sm"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
|
||||
) : (
|
||||
<Send className="h-4 w-4 sm:mr-2" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Update Invoice</span>
|
||||
</Button>
|
||||
</FloatingActionBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useParams } from "next/navigation";
|
||||
import { InvoiceForm } from "~/components/forms/invoice-form";
|
||||
|
||||
export default function EditInvoicePage() {
|
||||
const params = useParams();
|
||||
const invoiceId = Array.isArray(params?.id) ? params.id[0] : params?.id;
|
||||
const invoiceId = params.id as string;
|
||||
|
||||
if (!invoiceId) return null;
|
||||
|
||||
return <InvoiceEditor invoiceId={invoiceId} />;
|
||||
return <InvoiceForm invoiceId={invoiceId} />;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { PDFDownloadButton } from "./_components/pdf-download-button";
|
||||
import { SendInvoiceButton } from "./_components/send-invoice-button";
|
||||
import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton";
|
||||
import { InvoiceActionsDropdown } from "./_components/invoice-actions-dropdown";
|
||||
import { InvoiceItemsTable } from "./_components/invoice-items-table";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building,
|
||||
@@ -25,6 +24,12 @@ import {
|
||||
User,
|
||||
AlertTriangle,
|
||||
Trash2,
|
||||
DollarSign,
|
||||
Clock,
|
||||
Eye,
|
||||
Download,
|
||||
Send,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
|
||||
interface InvoicePageProps {
|
||||
@@ -55,6 +60,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
|
||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||
const taxAmount = (subtotal * invoice.taxRate) / 100;
|
||||
const total = subtotal + taxAmount;
|
||||
const isOverdue =
|
||||
new Date(invoice.dueDate) < new Date() && invoice.status !== "paid";
|
||||
|
||||
@@ -68,103 +74,116 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Overdue Alert */}
|
||||
{isOverdue && (
|
||||
<Card className="border-red-200 bg-red-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-red-700">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<span className="font-medium">
|
||||
This invoice is{" "}
|
||||
{Math.ceil(
|
||||
(new Date().getTime() - new Date(invoice.dueDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)}{" "}
|
||||
days overdue
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<div className="space-y-6 pb-24">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">
|
||||
Invoice Details
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
View and manage invoice information
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<InvoiceActionsDropdown invoiceId={invoice.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-4 xl:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 lg:col-span-3 xl:col-span-2">
|
||||
{/* Content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Invoice Header */}
|
||||
<Card className="shadow-lg">
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold sm:text-2xl">
|
||||
Invoice #{invoice.invoiceNumber}
|
||||
</h1>
|
||||
<StatusBadge status={getStatusType()} />
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-2xl font-bold text-foreground">
|
||||
{invoice.invoiceNumber}
|
||||
</h2>
|
||||
<StatusBadge status={getStatusType()} />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Issued {formatDate(invoice.issueDate)} • Due {formatDate(invoice.dueDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-muted-foreground">Total Amount</p>
|
||||
<p className="text-3xl font-bold text-primary">
|
||||
{formatCurrency(total)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm sm:text-base">
|
||||
Issued {formatDate(invoice.issueDate)} • Due{" "}
|
||||
{formatDate(invoice.dueDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-left sm:text-right">
|
||||
<p className="text-muted-foreground text-sm sm:text-base">
|
||||
Total Amount
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-emerald-600 sm:text-3xl">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Client & Business Information */}
|
||||
<div className="grid gap-4 sm:gap-6 md:grid-cols-2">
|
||||
{/* Overdue Alert */}
|
||||
{isOverdue && (
|
||||
<Card className="border-destructive/20 bg-destructive/5 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3 text-destructive">
|
||||
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Invoice Overdue</p>
|
||||
<p className="text-sm">
|
||||
{Math.ceil(
|
||||
(new Date().getTime() - new Date(invoice.dueDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)}{" "}
|
||||
days past due date
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Client & Business Info */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Client Information */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-600">
|
||||
<User className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Bill To
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 sm:space-y-4">
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-foreground text-lg font-semibold sm:text-xl">
|
||||
<h3 className="text-xl font-semibold text-foreground">
|
||||
{invoice.client.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
<div className="space-y-3">
|
||||
{invoice.client.email && (
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2">
|
||||
<Mail className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-primary/10 p-2">
|
||||
<Mail className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<span className="text-sm break-all sm:text-base">
|
||||
{invoice.client.email}
|
||||
</span>
|
||||
<span className="text-sm break-all">{invoice.client.email}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoice.client.phone && (
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2">
|
||||
<Phone className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-primary/10 p-2">
|
||||
<Phone className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<span className="text-sm sm:text-base">
|
||||
{invoice.client.phone}
|
||||
</span>
|
||||
<span className="text-sm">{invoice.client.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(invoice.client.addressLine1 ?? invoice.client.city) && (
|
||||
<div className="flex items-start gap-2 sm:gap-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2">
|
||||
<MapPin className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-primary/10 p-2">
|
||||
<MapPin className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="text-sm sm:text-base">
|
||||
<div className="text-sm space-y-1">
|
||||
{invoice.client.addressLine1 && (
|
||||
<div>{invoice.client.addressLine1}</div>
|
||||
)}
|
||||
@@ -196,40 +215,36 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
|
||||
{/* Business Information */}
|
||||
{invoice.business && (
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-600">
|
||||
<Building className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building className="h-5 w-5" />
|
||||
From
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 sm:space-y-4">
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-foreground text-lg font-semibold sm:text-xl">
|
||||
<h3 className="text-xl font-semibold text-foreground">
|
||||
{invoice.business.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
<div className="space-y-3">
|
||||
{invoice.business.email && (
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2">
|
||||
<Mail className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-primary/10 p-2">
|
||||
<Mail className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<span className="text-sm break-all sm:text-base">
|
||||
{invoice.business.email}
|
||||
</span>
|
||||
<span className="text-sm break-all">{invoice.business.email}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoice.business.phone && (
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2">
|
||||
<Phone className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-primary/10 p-2">
|
||||
<Phone className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<span className="text-sm sm:text-base">
|
||||
{invoice.business.phone}
|
||||
</span>
|
||||
<span className="text-sm">{invoice.business.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -239,43 +254,53 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
</div>
|
||||
|
||||
{/* Invoice Items */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-3">
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Invoice Items
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<InvoiceItemsTable items={invoice.items} />
|
||||
<CardContent className="space-y-4">
|
||||
{invoice.items.map((item, index) => (
|
||||
<div key={item.id} className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-foreground text-base font-medium mb-2">{item.description}</p>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{formatDate(item.date)}</span>
|
||||
<span>•</span>
|
||||
<span>{item.hours} hours</span>
|
||||
<span>•</span>
|
||||
<span>@ ${item.rate}/hr</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold text-primary">
|
||||
{formatCurrency(item.amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Totals */}
|
||||
<div className="mt-6 border-t pt-4">
|
||||
<div className="flex justify-end">
|
||||
<div className="w-full space-y-2 sm:max-w-64">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Subtotal:</span>
|
||||
<span className="font-medium">
|
||||
{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-medium">
|
||||
{formatCurrency(taxAmount)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex justify-between text-base font-bold sm:text-lg">
|
||||
<span>Total:</span>
|
||||
<span className="text-emerald-600">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</span>
|
||||
<div className="bg-muted/30 rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Subtotal:</span>
|
||||
<span className="font-medium">{formatCurrency(subtotal)}</span>
|
||||
</div>
|
||||
{invoice.taxRate > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Tax ({invoice.taxRate}%):</span>
|
||||
<span className="font-medium">{formatCurrency(taxAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span>Total:</span>
|
||||
<span className="text-primary">{formatCurrency(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,103 +309,47 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
|
||||
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Notes
|
||||
</CardTitle>
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{invoice.notes}
|
||||
</p>
|
||||
<CardContent>
|
||||
<p className="text-foreground whitespace-pre-wrap">{invoice.notes}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-4 sm:space-y-6 lg:col-span-1">
|
||||
{/* Actions */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base sm:text-lg">Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="w-full border-0 shadow-sm"
|
||||
size="default"
|
||||
>
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<span>Edit Invoice</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<PDFDownloadButton invoiceId={invoice.id} />
|
||||
|
||||
<SendInvoiceButton invoiceId={invoice.id} />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-0 shadow-sm"
|
||||
size="default"
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<span>Duplicate</span>
|
||||
</Button>
|
||||
|
||||
<Button variant="destructive" size="default" className="w-full">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>Delete Invoice</span>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice Details */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<Calendar className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
Details
|
||||
{/* Right Column - Actions */}
|
||||
<div className="space-y-6">
|
||||
<Card className="sticky top-6 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Check className="h-5 w-5" />
|
||||
Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">Invoice #</p>
|
||||
<p className="font-medium break-all">
|
||||
{invoice.invoiceNumber}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">Status</p>
|
||||
<div className="mt-1">
|
||||
<StatusBadge status={getStatusType()} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">Issue Date</p>
|
||||
<p className="font-medium">{formatDate(invoice.issueDate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">Due Date</p>
|
||||
<p className="font-medium">{formatDate(invoice.dueDate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">Tax Rate</p>
|
||||
<p className="font-medium">{invoice.taxRate}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">Total</p>
|
||||
<p className="font-medium text-emerald-600">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="space-y-3">
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Invoice
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{invoice.items && invoice.client && (
|
||||
<PDFDownloadButton
|
||||
invoiceId={invoice.id}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
{invoice.status === "draft" && (
|
||||
<SendInvoiceButton
|
||||
invoiceId={invoice.id}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -393,35 +362,10 @@ export default async function InvoicePage({ params }: InvoicePageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Invoice Details"
|
||||
description="View and manage invoice information"
|
||||
variant="gradient"
|
||||
>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="border-0 shadow-sm"
|
||||
size="default"
|
||||
>
|
||||
<Link href="/dashboard/invoices">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
<span className="hidden sm:inline">Back to Invoices</span>
|
||||
<span className="sm:hidden">Back</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<InvoiceActionsDropdown invoiceId={id} />
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<InvoiceDetailsSkeleton />}>
|
||||
<InvoiceContent invoiceId={id} />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
</>
|
||||
<HydrateClient>
|
||||
<Suspense fallback={<InvoiceDetailsSkeleton />}>
|
||||
<InvoiceContent invoiceId={id} />
|
||||
</Suspense>
|
||||
</HydrateClient>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||
@@ -78,107 +79,125 @@ const formatCurrency = (amount: number) => {
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Invoice>[] = [
|
||||
{
|
||||
accessorKey: "client.name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Client" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const invoice = row.original;
|
||||
return (
|
||||
<div className="min-w-0 max-w-[80px] sm:max-w-[200px] lg:max-w-[300px]">
|
||||
<p className="truncate font-medium">{invoice.client?.name ?? "—"}</p>
|
||||
<p className="text-muted-foreground truncate text-xs sm:text-sm">
|
||||
{invoice.invoiceNumber}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "issueDate",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Date" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("issueDate") as Date;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm">{formatDate(date)}</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
Due {formatDate(new Date(row.original.dueDate))}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "totalAmount",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Amount" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const amount = row.getValue("totalAmount") as number;
|
||||
return (
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-sm">{formatCurrency(amount)}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{row.original.items?.length ?? 0} items
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
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 invoiceId={invoice.id} variant="icon" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleRowClick = (invoice: Invoice) => {
|
||||
router.push(`/dashboard/invoices/${invoice.id}`);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Invoice>[] = [
|
||||
{
|
||||
accessorKey: "client.name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Client" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const invoice = row.original;
|
||||
return (
|
||||
<div className="min-w-0 max-w-[80px] sm:max-w-[200px] lg:max-w-[300px]">
|
||||
<p className="truncate font-medium">{invoice.client?.name ?? "—"}</p>
|
||||
<p className="text-muted-foreground truncate text-xs sm:text-sm">
|
||||
{invoice.invoiceNumber}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "issueDate",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Date" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("issueDate");
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm">{formatDate(date as Date)}</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
Due {formatDate(new Date(row.original.dueDate))}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const invoice = row.original;
|
||||
return <StatusBadge status={getStatusType(invoice)} />;
|
||||
},
|
||||
filterFn: (row, id, value: string[]) => {
|
||||
const invoice = row.original;
|
||||
const status = getStatusType(invoice);
|
||||
return value.includes(status);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "totalAmount",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Amount" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const amount = row.getValue("totalAmount");
|
||||
return (
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-sm">{formatCurrency(amount as number)}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{row.original.items?.length ?? 0} items
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
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"
|
||||
data-action-button="true"
|
||||
>
|
||||
<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"
|
||||
data-action-button="true"
|
||||
>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
{invoice.items && invoice.client && (
|
||||
<div data-action-button="true">
|
||||
<PDFDownloadButton invoiceId={invoice.id} variant="icon" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const filterableColumns = [
|
||||
{
|
||||
id: "status",
|
||||
@@ -199,6 +218,7 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
searchKey="invoiceNumber"
|
||||
searchPlaceholder="Search invoices..."
|
||||
filterableColumns={filterableColumns}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -423,29 +423,31 @@ export default function NewInvoicePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DatePicker
|
||||
date={formData.issueDate}
|
||||
onDateChange={(date) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
issueDate: date ?? new Date(),
|
||||
})
|
||||
}
|
||||
label="Issue Date"
|
||||
required
|
||||
/>
|
||||
<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>
|
||||
|
||||
<DatePicker
|
||||
date={formData.dueDate}
|
||||
onDateChange={(date) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
dueDate: date ?? new Date(),
|
||||
})
|
||||
}
|
||||
label="Due Date"
|
||||
required
|
||||
/>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user