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:
2025-07-15 20:07:00 -04:00
parent ea8531bde6
commit 339684d132
15 changed files with 1655 additions and 1961 deletions

View File

@@ -425,4 +425,10 @@ export const exampleRouter = createTRPCRouter({
- Document emergency procedures - Document emergency procedures
## Remember ## Remember
This is a business application where reliability, security, and professional user experience are critical. Every decision should prioritize these values over development convenience or flashy features. This is a business application where reliability, security, and professional user experience are critical. Every decision should prioritize these values over development convenience or flashy features.
- Don't create demo pages unless absolutely necessary.
- Don't create unnecessary complexity.
- Don't run builds unless absolutely necessary, if you do, kill the dev servers.
- Don't start new dev servers unless asked.
- Don't start drizzle studio- you cannot do anything with it.

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table"; import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
@@ -55,6 +56,7 @@ const formatAddress = (business: Business) => {
export function BusinessesDataTable({ export function BusinessesDataTable({
businesses: initialBusinesses, businesses: initialBusinesses,
}: BusinessesDataTableProps) { }: BusinessesDataTableProps) {
const router = useRouter();
const [businesses, setBusinesses] = useState(initialBusinesses); const [businesses, setBusinesses] = useState(initialBusinesses);
const [businessToDelete, setBusinessToDelete] = useState<Business | null>( const [businessToDelete, setBusinessToDelete] = useState<Business | null>(
null, null,
@@ -79,6 +81,10 @@ export function BusinessesDataTable({
deleteBusinessMutation.mutate({ id: businessToDelete.id }); deleteBusinessMutation.mutate({ id: businessToDelete.id });
}; };
const handleRowClick = (business: Business) => {
router.push(`/dashboard/businesses/${business.id}`);
};
const columns: ColumnDef<Business>[] = [ const columns: ColumnDef<Business>[] = [
{ {
accessorKey: "name", accessorKey: "name",
@@ -153,6 +159,7 @@ export function BusinessesDataTable({
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hidden hover:underline sm:inline" className="text-primary hidden hover:underline sm:inline"
data-action-button="true"
> >
{website} {website}
</a> </a>
@@ -162,6 +169,7 @@ export function BusinessesDataTable({
size="sm" size="sm"
className="h-8 w-8 p-0 sm:hidden" className="h-8 w-8 p-0 sm:hidden"
asChild asChild
data-action-button="true"
> >
<a href={url} target="_blank" rel="noopener noreferrer"> <a href={url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3.5 w-3.5" /> <ExternalLink className="h-3.5 w-3.5" />
@@ -178,7 +186,12 @@ export function BusinessesDataTable({
return ( return (
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<Link href={`/dashboard/businesses/${business.id}/edit`}> <Link href={`/dashboard/businesses/${business.id}/edit`}>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"> <Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
data-action-button="true"
>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
</Link> </Link>
@@ -186,6 +199,7 @@ export function BusinessesDataTable({
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
data-action-button="true"
onClick={() => setBusinessToDelete(business)} onClick={() => setBusinessToDelete(business)}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -203,6 +217,7 @@ export function BusinessesDataTable({
data={businesses} data={businesses}
searchKey="name" searchKey="name"
searchPlaceholder="Search businesses..." searchPlaceholder="Search businesses..."
onRowClick={handleRowClick}
/> />
{/* Delete confirmation dialog */} {/* Delete confirmation dialog */}
@@ -215,8 +230,7 @@ export function BusinessesDataTable({
<DialogTitle>Are you sure?</DialogTitle> <DialogTitle>Are you sure?</DialogTitle>
<DialogDescription> <DialogDescription>
This action cannot be undone. This will permanently delete the This action cannot be undone. This will permanently delete the
business "{businessToDelete?.name}" and remove all associated business "{businessToDelete?.name}" and remove all associated data.
data.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { DataTable, DataTableColumnHeader } from "~/components/data/data-table"; import { DataTable, DataTableColumnHeader } from "~/components/data/data-table";
@@ -52,6 +53,7 @@ const formatAddress = (client: Client) => {
export function ClientsDataTable({ export function ClientsDataTable({
clients: initialClients, clients: initialClients,
}: ClientsDataTableProps) { }: ClientsDataTableProps) {
const router = useRouter();
const [clients, setClients] = useState(initialClients); const [clients, setClients] = useState(initialClients);
const [clientToDelete, setClientToDelete] = useState<Client | null>(null); const [clientToDelete, setClientToDelete] = useState<Client | null>(null);
@@ -74,6 +76,10 @@ export function ClientsDataTable({
deleteClientMutation.mutate({ id: clientToDelete.id }); deleteClientMutation.mutate({ id: clientToDelete.id });
}; };
const handleRowClick = (client: Client) => {
router.push(`/dashboard/clients/${client.id}`);
};
const columns: ColumnDef<Client>[] = [ const columns: ColumnDef<Client>[] = [
{ {
accessorKey: "name", accessorKey: "name",
@@ -123,12 +129,12 @@ export function ClientsDataTable({
<DataTableColumnHeader column={column} title="Created" /> <DataTableColumnHeader column={column} title="Created" />
), ),
cell: ({ row }) => { cell: ({ row }) => {
const date = row.getValue("createdAt") as Date; const date = row.getValue("createdAt");
return new Intl.DateTimeFormat("en-US", { return new Intl.DateTimeFormat("en-US", {
month: "short", month: "short",
day: "2-digit", day: "2-digit",
year: "numeric", year: "numeric",
}).format(new Date(date)); }).format(new Date(date as Date));
}, },
meta: { meta: {
headerClassName: "hidden xl:table-cell", headerClassName: "hidden xl:table-cell",
@@ -142,7 +148,12 @@ export function ClientsDataTable({
return ( return (
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<Link href={`/dashboard/clients/${client.id}/edit`}> <Link href={`/dashboard/clients/${client.id}/edit`}>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"> <Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
data-action-button="true"
>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
</Link> </Link>
@@ -150,6 +161,7 @@ export function ClientsDataTable({
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
data-action-button="true"
onClick={() => setClientToDelete(client)} onClick={() => setClientToDelete(client)}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -167,6 +179,7 @@ export function ClientsDataTable({
data={clients} data={clients}
searchKey="name" searchKey="name"
searchPlaceholder="Search clients..." searchPlaceholder="Search clients..."
onRowClick={handleRowClick}
/> />
{/* Delete confirmation dialog */} {/* Delete confirmation dialog */}

View File

@@ -1,770 +1,11 @@
"use client"; "use client";
import { import { useParams } from "next/navigation";
ArrowLeft, import { InvoiceForm } from "~/components/forms/invoice-form";
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>
);
}
export default function EditInvoicePage() { export default function EditInvoicePage() {
const params = useParams(); 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 <InvoiceForm invoiceId={invoiceId} />;
return <InvoiceEditor invoiceId={invoiceId} />;
} }

View File

@@ -11,7 +11,6 @@ import { PDFDownloadButton } from "./_components/pdf-download-button";
import { SendInvoiceButton } from "./_components/send-invoice-button"; import { SendInvoiceButton } from "./_components/send-invoice-button";
import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton"; import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton";
import { InvoiceActionsDropdown } from "./_components/invoice-actions-dropdown"; import { InvoiceActionsDropdown } from "./_components/invoice-actions-dropdown";
import { InvoiceItemsTable } from "./_components/invoice-items-table";
import { import {
ArrowLeft, ArrowLeft,
Building, Building,
@@ -25,6 +24,12 @@ import {
User, User,
AlertTriangle, AlertTriangle,
Trash2, Trash2,
DollarSign,
Clock,
Eye,
Download,
Send,
Check,
} from "lucide-react"; } from "lucide-react";
interface InvoicePageProps { interface InvoicePageProps {
@@ -55,6 +60,7 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0); const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = (subtotal * invoice.taxRate) / 100; const taxAmount = (subtotal * invoice.taxRate) / 100;
const total = subtotal + taxAmount;
const isOverdue = const isOverdue =
new Date(invoice.dueDate) < new Date() && invoice.status !== "paid"; new Date(invoice.dueDate) < new Date() && invoice.status !== "paid";
@@ -68,103 +74,116 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-6 pb-24">
{/* Overdue Alert */} {/* Header */}
{isOverdue && ( <div className="flex items-center justify-between">
<Card className="border-red-200 bg-red-50"> <div>
<CardContent className="p-4"> <h1 className="text-3xl font-bold text-foreground">
<div className="flex items-center gap-2 text-red-700"> Invoice Details
<AlertTriangle className="h-5 w-5" /> </h1>
<span className="font-medium"> <p className="text-muted-foreground mt-1">
This invoice is{" "} View and manage invoice information
{Math.ceil( </p>
(new Date().getTime() - new Date(invoice.dueDate).getTime()) / </div>
(1000 * 60 * 60 * 24), <div className="flex items-center gap-2">
)}{" "} <InvoiceActionsDropdown invoiceId={invoice.id} />
days overdue </div>
</span> </div>
</div>
</CardContent>
</Card>
)}
<div className="grid gap-6 lg:grid-cols-4 xl:grid-cols-3"> {/* Content */}
{/* Main Content */} <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="space-y-6 lg:col-span-3 xl:col-span-2"> {/* Left Column */}
<div className="lg:col-span-2 space-y-6">
{/* Invoice Header */} {/* Invoice Header */}
<Card className="shadow-lg"> <Card className="shadow-sm">
<CardContent className="p-4 sm:p-6"> <CardContent className="p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div className="space-y-4">
<div className="space-y-3"> <div className="flex items-start justify-between">
<div className="flex items-center gap-3"> <div className="space-y-2">
<h1 className="text-xl font-bold sm:text-2xl"> <div className="flex items-center gap-3">
Invoice #{invoice.invoiceNumber} <h2 className="text-2xl font-bold text-foreground">
</h1> {invoice.invoiceNumber}
<StatusBadge status={getStatusType()} /> </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> </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>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Client & Business Information */} {/* Overdue Alert */}
<div className="grid gap-4 sm:gap-6 md:grid-cols-2"> {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 */} {/* Client Information */}
<Card className="shadow-lg"> <Card className="shadow-sm">
<CardHeader className="pb-2"> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-emerald-600"> <CardTitle className="flex items-center gap-2">
<User className="h-4 w-4 sm:h-5 sm:w-5" /> <User className="h-5 w-5" />
Bill To Bill To
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 sm:space-y-4"> <CardContent className="space-y-4">
<div> <div>
<h3 className="text-foreground text-lg font-semibold sm:text-xl"> <h3 className="text-xl font-semibold text-foreground">
{invoice.client.name} {invoice.client.name}
</h3> </h3>
</div> </div>
<div className="space-y-2 sm:space-y-3"> <div className="space-y-3">
{invoice.client.email && ( {invoice.client.email && (
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-3">
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2"> <div className="rounded-lg bg-primary/10 p-2">
<Mail className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" /> <Mail className="h-4 w-4 text-primary" />
</div> </div>
<span className="text-sm break-all sm:text-base"> <span className="text-sm break-all">{invoice.client.email}</span>
{invoice.client.email}
</span>
</div> </div>
)} )}
{invoice.client.phone && ( {invoice.client.phone && (
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-3">
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2"> <div className="rounded-lg bg-primary/10 p-2">
<Phone className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" /> <Phone className="h-4 w-4 text-primary" />
</div> </div>
<span className="text-sm sm:text-base"> <span className="text-sm">{invoice.client.phone}</span>
{invoice.client.phone}
</span>
</div> </div>
)} )}
{(invoice.client.addressLine1 ?? invoice.client.city) && ( {(invoice.client.addressLine1 ?? invoice.client.city) && (
<div className="flex items-start gap-2 sm:gap-3"> <div className="flex items-start gap-3">
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2"> <div className="rounded-lg bg-primary/10 p-2">
<MapPin className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" /> <MapPin className="h-4 w-4 text-primary" />
</div> </div>
<div className="text-sm sm:text-base"> <div className="text-sm space-y-1">
{invoice.client.addressLine1 && ( {invoice.client.addressLine1 && (
<div>{invoice.client.addressLine1}</div> <div>{invoice.client.addressLine1}</div>
)} )}
@@ -196,40 +215,36 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
{/* Business Information */} {/* Business Information */}
{invoice.business && ( {invoice.business && (
<Card className="shadow-lg"> <Card className="shadow-sm">
<CardHeader className="pb-2"> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-emerald-600"> <CardTitle className="flex items-center gap-2">
<Building className="h-4 w-4 sm:h-5 sm:w-5" /> <Building className="h-5 w-5" />
From From
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 sm:space-y-4"> <CardContent className="space-y-4">
<div> <div>
<h3 className="text-foreground text-lg font-semibold sm:text-xl"> <h3 className="text-xl font-semibold text-foreground">
{invoice.business.name} {invoice.business.name}
</h3> </h3>
</div> </div>
<div className="space-y-2 sm:space-y-3"> <div className="space-y-3">
{invoice.business.email && ( {invoice.business.email && (
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-3">
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2"> <div className="rounded-lg bg-primary/10 p-2">
<Mail className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" /> <Mail className="h-4 w-4 text-primary" />
</div> </div>
<span className="text-sm break-all sm:text-base"> <span className="text-sm break-all">{invoice.business.email}</span>
{invoice.business.email}
</span>
</div> </div>
)} )}
{invoice.business.phone && ( {invoice.business.phone && (
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-3">
<div className="rounded-lg bg-emerald-100 p-1.5 sm:p-2"> <div className="rounded-lg bg-primary/10 p-2">
<Phone className="h-3 w-3 text-emerald-600 sm:h-4 sm:w-4" /> <Phone className="h-4 w-4 text-primary" />
</div> </div>
<span className="text-sm sm:text-base"> <span className="text-sm">{invoice.business.phone}</span>
{invoice.business.phone}
</span>
</div> </div>
)} )}
</div> </div>
@@ -239,43 +254,53 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
</div> </div>
{/* Invoice Items */} {/* Invoice Items */}
<Card className="shadow-lg"> <Card className="shadow-sm">
<CardHeader className="pb-3"> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" /> <FileText className="h-5 w-5" />
Invoice Items Invoice Items
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="space-y-4">
<InvoiceItemsTable items={invoice.items} /> {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 */} {/* Totals */}
<div className="mt-6 border-t pt-4"> <div className="bg-muted/30 rounded-lg p-4">
<div className="flex justify-end"> <div className="space-y-3">
<div className="w-full space-y-2 sm:max-w-64"> <div className="flex justify-between">
<div className="flex justify-between text-sm"> <span className="text-muted-foreground">Subtotal:</span>
<span className="text-muted-foreground">Subtotal:</span> <span className="font-medium">{formatCurrency(subtotal)}</span>
<span className="font-medium"> </div>
{formatCurrency(subtotal)} {invoice.taxRate > 0 && (
</span> <div className="flex justify-between">
</div> <span className="text-muted-foreground">Tax ({invoice.taxRate}%):</span>
{invoice.taxRate > 0 && ( <span className="font-medium">{formatCurrency(taxAmount)}</span>
<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> </div>
)}
<Separator />
<div className="flex justify-between text-lg font-bold">
<span>Total:</span>
<span className="text-primary">{formatCurrency(total)}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -284,103 +309,47 @@ async function InvoiceContent({ invoiceId }: { invoiceId: string }) {
{/* Notes */} {/* Notes */}
{invoice.notes && ( {invoice.notes && (
<Card className="shadow-lg"> <Card className="shadow-sm">
<CardHeader className="pb-3"> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle>Notes</CardTitle>
<FileText className="h-5 w-5" />
Notes
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent>
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap"> <p className="text-foreground whitespace-pre-wrap">{invoice.notes}</p>
{invoice.notes}
</p>
</CardContent> </CardContent>
</Card> </Card>
)} )}
</div> </div>
{/* Sidebar */} {/* Right Column - Actions */}
<div className="space-y-4 sm:space-y-6 lg:col-span-1"> <div className="space-y-6">
{/* Actions */} <Card className="sticky top-6 shadow-sm">
<Card className="shadow-lg"> <CardHeader>
<CardHeader className="pb-3"> <CardTitle className="flex items-center gap-2">
<CardTitle className="text-base sm:text-lg">Actions</CardTitle> <Check className="h-5 w-5" />
</CardHeader> Actions
<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
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-3">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <Button asChild variant="outline" className="w-full">
<div> <Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<p className="text-muted-foreground text-sm">Invoice #</p> <Edit className="mr-2 h-4 w-4" />
<p className="font-medium break-all"> Edit Invoice
{invoice.invoiceNumber} </Link>
</p> </Button>
</div>
<div> {invoice.items && invoice.client && (
<p className="text-muted-foreground text-sm">Status</p> <PDFDownloadButton
<div className="mt-1"> invoiceId={invoice.id}
<StatusBadge status={getStatusType()} /> className="w-full"
</div> />
</div> )}
<div>
<p className="text-muted-foreground text-sm">Issue Date</p> {invoice.status === "draft" && (
<p className="font-medium">{formatDate(invoice.issueDate)}</p> <SendInvoiceButton
</div> invoiceId={invoice.id}
<div> className="w-full"
<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> </CardContent>
</Card> </Card>
</div> </div>
@@ -393,35 +362,10 @@ export default async function InvoicePage({ params }: InvoicePageProps) {
const { id } = await params; const { id } = await params;
return ( return (
<> <HydrateClient>
<PageHeader <Suspense fallback={<InvoiceDetailsSkeleton />}>
title="Invoice Details" <InvoiceContent invoiceId={id} />
description="View and manage invoice information" </Suspense>
variant="gradient" </HydrateClient>
>
<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>
</>
); );
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { StatusBadge, type StatusType } from "~/components/data/status-badge"; import { StatusBadge, type StatusType } from "~/components/data/status-badge";
@@ -78,107 +79,125 @@ const formatCurrency = (amount: number) => {
}).format(amount); }).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) { 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 = [ const filterableColumns = [
{ {
id: "status", id: "status",
@@ -199,6 +218,7 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
searchKey="invoiceNumber" searchKey="invoiceNumber"
searchPlaceholder="Search invoices..." searchPlaceholder="Search invoices..."
filterableColumns={filterableColumns} filterableColumns={filterableColumns}
onRowClick={handleRowClick}
/> />
); );
} }

View File

@@ -423,29 +423,31 @@ export default function NewInvoicePage() {
</div> </div>
</div> </div>
<DatePicker <div className="space-y-2">
date={formData.issueDate} <Label className="text-sm font-medium">Issue Date *</Label>
onDateChange={(date) => <DatePicker
setFormData({ date={formData.issueDate}
...formData, onDateChange={(date) =>
issueDate: date ?? new Date(), setFormData({
}) ...formData,
} issueDate: date ?? new Date(),
label="Issue Date" })
required }
/> />
</div>
<DatePicker <div className="space-y-2">
date={formData.dueDate} <Label className="text-sm font-medium">Due Date *</Label>
onDateChange={(date) => <DatePicker
setFormData({ date={formData.dueDate}
...formData, onDateChange={(date) =>
dueDate: date ?? new Date(), setFormData({
}) ...formData,
} dueDate: date ?? new Date(),
label="Due Date" })
required }
/> />
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -71,6 +71,7 @@ interface DataTableProps<TData, TValue> {
title: string; title: string;
options: { label: string; value: string }[]; options: { label: string; value: string }[];
}[]; }[];
onRowClick?: (row: TData) => void;
} }
export function DataTable<TData, TValue>({ export function DataTable<TData, TValue>({
@@ -87,6 +88,7 @@ export function DataTable<TData, TValue>({
description, description,
actions, actions,
filterableColumns = [], filterableColumns = [],
onRowClick,
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]); const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>( const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
@@ -97,15 +99,28 @@ export function DataTable<TData, TValue>({
const [rowSelection, setRowSelection] = React.useState({}); const [rowSelection, setRowSelection] = React.useState({});
const [globalFilter, setGlobalFilter] = React.useState(""); const [globalFilter, setGlobalFilter] = React.useState("");
// Mobile detection hook
const [isMobile, setIsMobile] = React.useState(false);
React.useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 640); // sm breakpoint
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// Create responsive columns that properly hide on mobile // Create responsive columns that properly hide on mobile
const responsiveColumns = React.useMemo(() => { const responsiveColumns = React.useMemo(() => {
return columns.map((column) => ({ return columns.map((column) => ({
...column, ...column,
// Add a meta property to control responsive visibility // Add a meta property to control responsive visibility
meta: { meta: {
...((column as any).meta || {}), ...((column as ColumnDef<TData, TValue> & { meta?: { headerClassName?: string; cellClassName?: string } }).meta ?? {}),
headerClassName: (column as any).meta?.headerClassName || "", headerClassName: (column as ColumnDef<TData, TValue> & { meta?: { headerClassName?: string; cellClassName?: string } }).meta?.headerClassName ?? "",
cellClassName: (column as any).meta?.cellClassName || "", cellClassName: (column as ColumnDef<TData, TValue> & { meta?: { headerClassName?: string; cellClassName?: string } }).meta?.cellClassName ?? "",
}, },
})); }));
}, [columns]); }, [columns]);
@@ -132,13 +147,34 @@ export function DataTable<TData, TValue>({
}, },
initialState: { initialState: {
pagination: { pagination: {
pageSize: pageSize, pageSize: isMobile ? 5 : pageSize,
}, },
}, },
}); });
// Update page size when mobile state changes
React.useEffect(() => {
table.setPageSize(isMobile ? 5 : pageSize);
}, [isMobile, pageSize, table]);
const pageSizeOptions = [5, 10, 20, 30, 50, 100]; const pageSizeOptions = [5, 10, 20, 30, 50, 100];
// Handle row click
const handleRowClick = (row: TData, event: React.MouseEvent) => {
// Don't trigger row click if clicking on action buttons or their children
const target = event.target as HTMLElement;
const isActionButton = target.closest('[data-action-button="true"]') ??
target.closest('button') ??
target.closest('a') ??
target.closest('[role="button"]');
if (isActionButton) {
return;
}
onRowClick?.(row);
};
return ( return (
<div className={cn("space-y-4", className)}> <div className={cn("space-y-4", className)}>
{/* Header Section */} {/* Header Section */}
@@ -274,7 +310,7 @@ export function DataTable<TData, TValue>({
className="bg-muted/50 hover:bg-muted/50" className="bg-muted/50 hover:bg-muted/50"
> >
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta as any; const meta = header.column.columnDef.meta as { headerClassName?: string; cellClassName?: string } | undefined;
return ( return (
<TableHead <TableHead
key={header.id} key={header.id}
@@ -301,10 +337,14 @@ export function DataTable<TData, TValue>({
<TableRow <TableRow
key={row.id} key={row.id}
data-state={row.getIsSelected() && "selected"} data-state={row.getIsSelected() && "selected"}
className="hover:bg-muted/20 data-[state=selected]:bg-muted/50 border-b transition-colors" className={cn(
"hover:bg-muted/20 data-[state=selected]:bg-muted/50 border-b transition-colors",
onRowClick && "cursor-pointer"
)}
onClick={(event) => onRowClick && handleRowClick(row.original, event)}
> >
{row.getVisibleCells().map((cell) => { {row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta as any; const meta = cell.column.columnDef.meta as { headerClassName?: string; cellClassName?: string } | undefined;
return ( return (
<TableCell <TableCell
key={cell.id} key={cell.id}
@@ -455,7 +495,11 @@ export function DataTableColumnHeader<TData, TValue>({
title, title,
className, className,
}: { }: {
column: any; column: {
getCanSort: () => boolean;
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (isDesc: boolean) => void;
};
title: string; title: string;
className?: string; className?: string;
}) { }) {
@@ -511,27 +555,54 @@ export function DataTableSkeleton({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50"> <TableRow className="bg-muted/50 hover:bg-muted/50">
{Array.from({ length: columns }).map((_, i) => ( {/* Mobile: 3 columns, sm: 5 columns, lg: 6 columns */}
<TableHead <TableHead className="h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
key={i} <div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-24 lg:w-32"></div>
className="h-9 px-3 text-left align-middle sm:h-10 sm:px-4" </TableHead>
> <TableHead className="h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-20"></div> <div className="bg-muted/30 h-4 w-14 animate-pulse rounded sm:w-20 lg:w-24"></div>
</TableHead> </TableHead>
))} <TableHead className="hidden sm:table-cell h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
<div className="bg-muted/30 h-4 w-14 animate-pulse rounded sm:w-20 lg:w-24"></div>
</TableHead>
<TableHead className="hidden sm:table-cell h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-20 lg:w-24"></div>
</TableHead>
<TableHead className="h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
<div className="bg-muted/30 h-4 w-10 animate-pulse rounded sm:w-12 lg:w-16"></div>
</TableHead>
<TableHead className="hidden lg:table-cell h-12 px-3 text-left align-middle sm:h-14 sm:px-4">
<div className="bg-muted/30 h-4 w-20 animate-pulse rounded"></div>
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{Array.from({ length: rows }).map((_, i) => ( {Array.from({ length: rows }).map((_, i) => (
<TableRow key={i} className="border-b"> <TableRow key={i} className="border-b">
{Array.from({ length: columns }).map((_, j) => ( {/* Client */}
<TableCell <TableCell className="px-3 py-3 align-middle sm:px-4 sm:py-4">
key={j} <div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-24 lg:w-32"></div>
className="px-3 py-1.5 align-middle sm:px-4 sm:py-2" </TableCell>
> {/* Date */}
<div className="bg-muted/30 h-4 w-full animate-pulse rounded"></div> <TableCell className="px-3 py-3 align-middle sm:px-4 sm:py-4">
</TableCell> <div className="bg-muted/30 h-4 w-14 animate-pulse rounded sm:w-20 lg:w-24"></div>
))} </TableCell>
{/* Status (sm+) */}
<TableCell className="hidden sm:table-cell px-3 py-3 align-middle sm:px-4 sm:py-4">
<div className="bg-muted/30 h-4 w-14 animate-pulse rounded sm:w-20 lg:w-24"></div>
</TableCell>
{/* Amount (sm+) */}
<TableCell className="hidden sm:table-cell px-3 py-3 align-middle sm:px-4 sm:py-4">
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-20 lg:w-24"></div>
</TableCell>
{/* Actions */}
<TableCell className="px-3 py-3 align-middle sm:px-4 sm:py-4">
<div className="bg-muted/30 h-4 w-10 animate-pulse rounded sm:w-12 lg:w-16"></div>
</TableCell>
{/* Extra (lg+) */}
<TableCell className="hidden lg:table-cell px-3 py-3 align-middle sm:px-4 sm:py-4">
<div className="bg-muted/30 h-4 w-20 animate-pulse rounded"></div>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,440 @@
"use client";
import * as React from "react";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { DatePicker } from "~/components/ui/date-picker";
import { NumberInput } from "~/components/ui/number-input";
import {
Trash2,
Plus,
GripVertical,
ChevronUp,
ChevronDown,
} from "lucide-react";
import { cn } from "~/lib/utils";
interface InvoiceItem {
id: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
}
interface InvoiceLineItemsProps {
items: InvoiceItem[];
onAddItem: () => void;
onRemoveItem: (index: number) => void;
onUpdateItem: (
index: number,
field: string,
value: string | number | Date,
) => void;
onMoveUp: (index: number) => void;
onMoveDown: (index: number) => void;
className?: string;
}
interface LineItemRowProps {
item: InvoiceItem;
index: number;
canRemove: boolean;
onRemove: (index: number) => void;
onUpdate: (
index: number,
field: string,
value: string | number | Date,
) => void;
onMoveUp: (index: number) => void;
onMoveDown: (index: number) => void;
isFirst: boolean;
isLast: boolean;
}
function LineItemRow({
item,
index,
canRemove,
onRemove,
onUpdate,
}: LineItemRowProps) {
return (
<>
{/* Desktop Layout - Table Row */}
<tr className="group hover:bg-muted/20 hidden transition-colors lg:table-row">
{/* Drag Handle */}
<td className="w-6 p-2 text-center align-top">
<GripVertical className="text-muted-foreground mt-1 h-4 w-4 cursor-grab" />
</td>
{/* Main Content */}
<td className="p-2" colSpan={5}>
{/* Description */}
<div className="mb-3">
<Input
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
placeholder="Describe the work performed..."
className="w-full border-0 bg-transparent py-0 pr-0 pl-2 text-sm font-medium focus-visible:ring-0"
/>
</div>
{/* Controls Row */}
<div className="flex items-center gap-3">
{/* Date */}
<DatePicker
date={item.date}
onDateChange={(date) =>
onUpdate(index, "date", date ?? new Date())
}
size="sm"
className="h-9 w-28"
/>
{/* Hours */}
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="auto"
className="h-9 w-28"
/>
{/* Rate */}
<NumberInput
value={item.rate}
onChange={(value) => onUpdate(index, "rate", value)}
min={0}
step={1}
prefix="$"
width="auto"
className="h-9 w-28"
/>
{/* Amount */}
<div className="ml-auto">
<span className="text-primary font-semibold">
${(item.hours * item.rate).toFixed(2)}
</span>
</div>
{/* Actions */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemove(index)}
className={cn(
"text-muted-foreground h-8 w-8 p-0 transition-colors hover:text-red-500",
!canRemove && "cursor-not-allowed opacity-50",
)}
disabled={!canRemove}
aria-label="Remove item"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
{/* Tablet Layout - Condensed Row */}
<tr className="group hover:bg-muted/20 hidden transition-colors md:table-row lg:hidden">
{/* Drag Handle */}
<td className="w-6 p-2 text-center align-top">
<GripVertical className="text-muted-foreground mt-1 h-4 w-4 cursor-grab" />
</td>
{/* Main Content - Description on top, inputs below */}
<td className="p-3" colSpan={6}>
{/* Description */}
<div className="mb-3">
<Input
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
placeholder="Describe the work performed..."
className="w-full pl-3 text-sm font-medium"
/>
</div>
{/* Controls Row - Date/Hours/Rate break to separate rows on smaller screens */}
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<DatePicker
date={item.date}
onDateChange={(date) =>
onUpdate(index, "date", date ?? new Date())
}
size="sm"
className="h-9 w-full sm:w-28"
/>
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="full"
className="h-9 w-1/2 sm:w-28"
/>
<NumberInput
value={item.rate}
onChange={(value) => onUpdate(index, "rate", value)}
min={0}
step={1}
prefix="$"
width="full"
className="h-9 w-1/2 sm:w-28"
/>
{/* Amount and Actions - inline with controls on larger screens */}
<div className="mt-3 flex items-center justify-between sm:mt-0 sm:ml-auto sm:gap-3">
<span className="text-primary font-semibold">
${(item.hours * item.rate).toFixed(2)}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemove(index)}
className={cn(
"text-muted-foreground h-8 w-8 p-0 transition-colors hover:text-red-500",
!canRemove && "cursor-not-allowed opacity-50",
)}
disabled={!canRemove}
aria-label="Remove item"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</td>
</tr>
</>
);
}
function MobileLineItem({
item,
index,
canRemove,
onRemove,
onUpdate,
onMoveUp,
onMoveDown,
isFirst,
isLast,
}: LineItemRowProps) {
return (
<div className="bg-card space-y-3 rounded-lg border p-4 md:hidden">
{/* Description */}
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Description</Label>
<Input
value={item.description}
onChange={(e) => onUpdate(index, "description", e.target.value)}
placeholder="Describe the work performed..."
className="pl-3 text-sm"
/>
</div>
{/* Date */}
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Date</Label>
<DatePicker
date={item.date}
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
size="sm"
/>
</div>
{/* Hours and Rate in a row */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Hours</Label>
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="full"
/>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Rate</Label>
<NumberInput
value={item.rate}
onChange={(value) => onUpdate(index, "rate", value)}
min={0}
step={1}
prefix="$"
width="full"
/>
</div>
</div>
{/* Bottom section with controls, item name, and total */}
<div className="flex items-center justify-between border-t pt-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onMoveUp(index)}
className={cn(
"h-8 w-8 p-0 transition-colors",
isFirst
? "text-muted-foreground/50 cursor-not-allowed"
: "text-muted-foreground hover:text-foreground",
)}
disabled={isFirst}
aria-label="Move up"
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onMoveDown(index)}
className={cn(
"h-8 w-8 p-0 transition-colors",
isLast
? "text-muted-foreground/50 cursor-not-allowed"
: "text-muted-foreground hover:text-foreground",
)}
disabled={isLast}
aria-label="Move down"
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemove(index)}
className={cn(
"text-muted-foreground h-8 w-8 p-0 transition-colors hover:text-red-500",
!canRemove && "cursor-not-allowed opacity-50",
)}
disabled={!canRemove}
aria-label="Remove item"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 px-3 text-center">
<span className="text-muted-foreground block text-sm font-medium">
<span className="hidden sm:inline">Item </span>
<span className="sm:hidden">#</span>
{index + 1}
</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs">Total</span>
<span className="text-primary text-lg font-bold">
${(item.hours * item.rate).toFixed(2)}
</span>
</div>
</div>
</div>
);
}
export function InvoiceLineItems({
items,
onAddItem,
onRemoveItem,
onUpdateItem,
onMoveUp,
onMoveDown,
className,
}: InvoiceLineItemsProps) {
const canRemoveItems = items.length > 1;
return (
<div className={cn("space-y-2", className)}>
{/* Desktop and Tablet Table */}
<div className="hidden md:block">
<div className="overflow-hidden rounded-lg border">
<table className="w-full">
{/* Desktop Header */}
<thead className="bg-muted/30 hidden lg:table-header-group">
<tr>
<th className="w-6 p-2"></th>
<th
className="text-muted-foreground p-2 text-left text-xs font-medium"
colSpan={5}
>
Invoice Items
</th>
</tr>
</thead>
{/* Tablet Header */}
<thead className="bg-muted/30 md:table-header-group lg:hidden">
<tr>
<th className="w-6 p-2"></th>
<th
className="text-muted-foreground p-2 text-left text-xs font-medium"
colSpan={6}
>
Invoice Items
</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item, index) => (
<LineItemRow
key={item.id}
item={item}
index={index}
canRemove={canRemoveItems}
onRemove={onRemoveItem}
onUpdate={onUpdateItem}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
isFirst={index === 0}
isLast={index === items.length - 1}
/>
))}
</tbody>
</table>
</div>
</div>
{/* Mobile Cards */}
<div className="space-y-2 md:hidden">
{items.map((item, index) => (
<MobileLineItem
key={item.id}
item={item}
index={index}
canRemove={canRemoveItems}
onRemove={onRemoveItem}
onUpdate={onUpdateItem}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
isFirst={index === 0}
isLast={index === items.length - 1}
/>
))}
</div>
{/* Add Item Button */}
<div className="px-3 pt-3">
<Button
type="button"
variant="outline"
onClick={onAddItem}
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
Add Another Item
</Button>
</div>
</div>
);
}

View File

@@ -6,8 +6,10 @@ import { cn } from "~/lib/utils";
interface FloatingActionBarProps { interface FloatingActionBarProps {
/** Ref to the element that triggers visibility when scrolled out of view */ /** Ref to the element that triggers visibility when scrolled out of view */
triggerRef: React.RefObject<HTMLElement | null>; triggerRef: React.RefObject<HTMLElement | null>;
/** Title text displayed on the left */ /** Title text displayed on the left (deprecated - use leftContent instead) */
title: string; title?: string;
/** Custom content to display on the left */
leftContent?: React.ReactNode;
/** Action buttons to display on the right */ /** Action buttons to display on the right */
children: React.ReactNode; children: React.ReactNode;
/** Additional className for styling */ /** Additional className for styling */
@@ -21,6 +23,7 @@ interface FloatingActionBarProps {
export function FloatingActionBar({ export function FloatingActionBar({
triggerRef, triggerRef,
title, title,
leftContent,
children, children,
className, className,
show, show,
@@ -28,6 +31,7 @@ export function FloatingActionBar({
}: FloatingActionBarProps) { }: FloatingActionBarProps) {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const floatingRef = useRef<HTMLDivElement>(null); const floatingRef = useRef<HTMLDivElement>(null);
const previousVisibleRef = useRef(false);
useEffect(() => { useEffect(() => {
// If show prop is provided, use it instead of auto-detection // If show prop is provided, use it instead of auto-detection
@@ -46,19 +50,21 @@ export function FloatingActionBar({
// Show floating bar when trigger element is out of view // Show floating bar when trigger element is out of view
const shouldShow = !isInView; const shouldShow = !isInView;
if (shouldShow !== isVisible) { if (shouldShow !== previousVisibleRef.current) {
previousVisibleRef.current = shouldShow;
setIsVisible(shouldShow); setIsVisible(shouldShow);
onVisibilityChange?.(shouldShow); onVisibilityChange?.(shouldShow);
} }
}; };
// Use ResizeObserver and IntersectionObserver for better detection // Use IntersectionObserver for better detection
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
const entry = entries[0]; const entry = entries[0];
if (entry) { if (entry) {
const shouldShow = !entry.isIntersecting; const shouldShow = !entry.isIntersecting;
if (shouldShow !== isVisible) { if (shouldShow !== previousVisibleRef.current) {
previousVisibleRef.current = shouldShow;
setIsVisible(shouldShow); setIsVisible(shouldShow);
onVisibilityChange?.(shouldShow); onVisibilityChange?.(shouldShow);
} }
@@ -86,7 +92,7 @@ export function FloatingActionBar({
observer.disconnect(); observer.disconnect();
window.removeEventListener("scroll", handleScroll); window.removeEventListener("scroll", handleScroll);
}; };
}, [triggerRef, isVisible, show, onVisibilityChange]); }, [triggerRef, show, onVisibilityChange]);
if (!isVisible) return null; if (!isVisible) return null;
@@ -94,14 +100,16 @@ export function FloatingActionBar({
<div <div
ref={floatingRef} ref={floatingRef}
className={cn( className={cn(
"border-border/40 bg-background/60 animate-in slide-in-from-bottom-4 fixed right-3 bottom-3 left-3 z-20 flex items-center justify-between rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150 duration-300 md:right-3 md:left-[279px]", "border-border/40 bg-background/60 animate-in slide-in-from-bottom-4 sticky bottom-4 z-20 flex items-center justify-between rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150 duration-300",
className, className,
)} )}
> >
<p className="text-muted-foreground text-sm">{title}</p> <div className="flex-1">
<div className="flex items-center gap-2 sm:gap-3"> {leftContent || (
{children} <p className="text-muted-foreground text-sm">{title}</p>
)}
</div> </div>
<div className="flex items-center gap-2 sm:gap-3">{children}</div>
</div> </div>
); );
} }

View File

@@ -30,21 +30,6 @@ export function Navbar() {
</Link> </Link>
</div> </div>
<div className="flex items-center gap-2 md:gap-4"> <div className="flex items-center gap-2 md:gap-4">
{/* Quick access to current open invoice */}
{session?.user && currentInvoice && (
<Button
asChild
size="sm"
variant="outline"
className="hidden border-border/40 hover:bg-accent/50 text-xs md:flex md:text-sm"
>
<Link href={`/dashboard/invoices/${currentInvoice.id}/edit`}>
<FileText className="mr-1 h-3 w-3 md:mr-2 md:h-4 md:w-4" />
<span className="hidden lg:inline">Continue Invoice</span>
<span className="lg:hidden">Continue</span>
</Link>
</Button>
)}
{status === "loading" ? ( {status === "loading" ? (
<> <>

View File

@@ -92,10 +92,7 @@ export function DashboardBreadcrumbs() {
if (invoiceLoading) { if (invoiceLoading) {
label = <Skeleton className="inline-block h-5 w-24 align-middle" />; label = <Skeleton className="inline-block h-5 w-24 align-middle" />;
} else if (invoice) { } else if (invoice) {
// You can customize this - show invoice number or date label = format(new Date(invoice.issueDate), "MMM dd, yyyy");
label =
invoice.invoiceNumber ||
format(new Date(invoice.issueDate), "MMM dd, yyyy");
} }
} else if (prevSegment === "businesses") { } else if (prevSegment === "businesses") {
if (businessLoading) { if (businessLoading) {
@@ -148,12 +145,12 @@ export function DashboardBreadcrumbs() {
return ( return (
<Breadcrumb className="mb-4 sm:mb-6"> <Breadcrumb className="mb-4 sm:mb-6">
<BreadcrumbList className="flex-wrap"> <BreadcrumbList className="flex-nowrap overflow-hidden">
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbLink asChild> <BreadcrumbLink asChild>
<Link <Link
href="/dashboard" href="/dashboard"
className="text-sm sm:text-base dark:text-gray-300" className="truncate text-sm sm:text-base dark:text-gray-300"
> >
Dashboard Dashboard
</Link> </Link>
@@ -166,14 +163,14 @@ export function DashboardBreadcrumbs() {
</BreadcrumbSeparator> </BreadcrumbSeparator>
<BreadcrumbItem> <BreadcrumbItem>
{crumb.isLast ? ( {crumb.isLast ? (
<BreadcrumbPage className="text-sm sm:text-base dark:text-white"> <BreadcrumbPage className="truncate text-sm sm:text-base dark:text-white">
{crumb.label} {crumb.label}
</BreadcrumbPage> </BreadcrumbPage>
) : ( ) : (
<BreadcrumbLink asChild> <BreadcrumbLink asChild>
<Link <Link
href={crumb.href} href={crumb.href}
className="text-sm sm:text-base dark:text-gray-300" className="truncate text-sm sm:text-base dark:text-gray-300"
> >
{crumb.label} {crumb.label}
</Link> </Link>

View File

@@ -6,7 +6,6 @@ import * as React from "react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Calendar } from "~/components/ui/calendar"; import { Calendar } from "~/components/ui/calendar";
import { Label } from "~/components/ui/label";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -17,61 +16,66 @@ import { cn } from "~/lib/utils";
interface DatePickerProps { interface DatePickerProps {
date?: Date; date?: Date;
onDateChange: (date: Date | undefined) => void; onDateChange: (date: Date | undefined) => void;
label?: string;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
required?: boolean;
id?: string; id?: string;
size?: "sm" | "md" | "lg";
} }
export function DatePicker({ export function DatePicker({
date, date,
onDateChange, onDateChange,
label,
placeholder = "Select date", placeholder = "Select date",
className, className,
disabled = false, disabled = false,
required = false,
id, id,
size = "md",
}: DatePickerProps) { }: DatePickerProps) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const sizeClasses = {
sm: "h-9 text-xs",
md: "h-9 text-sm",
lg: "h-10 text-sm",
};
const formatDate = (date: Date) => {
if (size === "sm") {
return format(date, "MMM dd");
}
return format(date, "PPP");
};
return ( return (
<div className={cn("flex flex-col gap-2", className)}> <Popover open={open} onOpenChange={setOpen}>
{label && ( <PopoverTrigger asChild>
<Label htmlFor={id} className="text-sm font-medium"> <Button
{label} variant="outline"
{required && <span className="text-destructive ml-1">*</span>} id={id}
</Label> disabled={disabled}
)} className={cn(
<Popover open={open} onOpenChange={setOpen}> "w-full justify-between font-normal",
<PopoverTrigger asChild> sizeClasses[size],
<Button !date && "text-muted-foreground",
variant="outline" className,
id={id} )}
disabled={disabled} >
className={cn( {date ? formatDate(date) : placeholder}
"h-10 w-full justify-between text-sm font-normal", <CalendarIcon className="text-muted-foreground h-4 w-4" />
!date && "text-muted-foreground", </Button>
)} </PopoverTrigger>
> <PopoverContent className="w-auto overflow-hidden p-0" align="start">
{date ? format(date, "PPP") : placeholder} <Calendar
<CalendarIcon className="text-muted-foreground h-4 w-4" /> mode="single"
</Button> selected={date}
</PopoverTrigger> captionLayout="dropdown"
<PopoverContent className="w-auto overflow-hidden p-0" align="start"> onSelect={(selectedDate: Date | undefined) => {
<Calendar onDateChange(selectedDate);
mode="single" setOpen(false);
selected={date} }}
captionLayout="dropdown" />
onSelect={(selectedDate: Date | undefined) => { </PopoverContent>
onDateChange(selectedDate); </Popover>
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
</div>
); );
} }

View File

@@ -1,10 +1,7 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Minus, Plus } from "lucide-react";
interface NumberInputProps { interface NumberInputProps {
value: number; value: number;
@@ -13,13 +10,12 @@ interface NumberInputProps {
max?: number; max?: number;
step?: number; step?: number;
placeholder?: string; placeholder?: string;
disabled?: boolean;
className?: string; className?: string;
disabled?: boolean;
id?: string;
prefix?: string; prefix?: string;
suffix?: string; suffix?: string;
id?: string; width?: "auto" | "full";
name?: string;
"aria-label"?: string;
} }
export function NumberInput({ export function NumberInput({
@@ -29,151 +25,98 @@ export function NumberInput({
max, max,
step = 1, step = 1,
placeholder = "0", placeholder = "0",
disabled = false,
className, className,
disabled = false,
id,
prefix, prefix,
suffix, suffix,
id, width = "auto",
name,
"aria-label": ariaLabel,
}: NumberInputProps) { }: NumberInputProps) {
const [inputValue, setInputValue] = React.useState(value.toString()); const [displayValue, setDisplayValue] = React.useState(
value ? value.toFixed(2) : "0.00",
);
// Update input when external value changes
React.useEffect(() => { React.useEffect(() => {
setInputValue(value.toString()); setDisplayValue(value ? value.toFixed(2) : "0.00");
}, [value]); }, [value]);
const handleIncrement = () => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = Math.min(value + step, max ?? Infinity); const inputValue = e.target.value;
onChange(newValue); setDisplayValue(inputValue);
};
const handleDecrement = () => { if (inputValue === "") {
const newValue = Math.max(value - step, min);
onChange(newValue);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputVal = e.target.value;
setInputValue(inputVal);
// Allow empty input for better UX
if (inputVal === "") {
onChange(0); onChange(0);
return; return;
} }
const newValue = parseFloat(inputValue);
const numValue = parseFloat(inputVal); if (!isNaN(newValue)) {
if (!isNaN(numValue)) { onChange(Math.round(newValue * 100) / 100);
const clampedValue = Math.max(min, Math.min(numValue, max ?? Infinity));
onChange(clampedValue);
} }
}; };
const handleInputBlur = () => { const handleBlur = () => {
// Ensure the input shows the actual value on blur const numValue = parseFloat(displayValue) || 0;
setInputValue(value.toString()); const formattedValue = numValue.toFixed(2);
setDisplayValue(formattedValue);
onChange(numValue);
}; };
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleIncrement = () => {
if (e.key === "ArrowUp" && canIncrement) { if (disabled) return;
e.preventDefault(); onChange((value || 0) + step);
handleIncrement();
} else if (e.key === "ArrowDown" && canDecrement) {
e.preventDefault();
handleDecrement();
}
}; };
const canDecrement = value > min; const handleDecrement = () => {
const canIncrement = !max || value < max; if (disabled) return;
onChange(Math.max(min, (value || 0) - step));
};
const widthClass = width === "full" ? "w-full" : "w-24";
return ( return (
<div <div
className={cn("relative flex items-center", className)} className={cn(
role="group" "border-input bg-background ring-offset-background flex h-9 items-center justify-center rounded-md border px-2 text-sm",
aria-label={ widthClass,
ariaLabel || "Number input with increment and decrement buttons" disabled && "cursor-not-allowed opacity-50",
} className,
)}
> >
{/* Prefix */} <button
{prefix && (
<div className="text-muted-foreground pointer-events-none absolute left-10 z-10 flex items-center text-sm">
{prefix}
</div>
)}
{/* Decrement Button */}
<Button
type="button" type="button"
variant="outline"
size="sm"
disabled={disabled || !canDecrement}
onClick={handleDecrement} onClick={handleDecrement}
className={cn( disabled={disabled || value <= min}
"h-8 w-8 rounded-r-none border-r-0 p-0 transition-all duration-150", className="text-muted-foreground hover:text-foreground flex h-6 w-6 items-center justify-center disabled:cursor-not-allowed disabled:opacity-50"
"hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700",
"dark:hover:border-emerald-700 dark:hover:bg-emerald-900/30",
"focus:z-10 focus:ring-2 focus:ring-emerald-500/20",
!canDecrement && "cursor-not-allowed opacity-40",
)}
aria-label="Decrease value"
tabIndex={disabled ? -1 : 0}
> >
<Minus className="h-3 w-3" />
</Button> </button>
<div className="flex flex-1 items-center justify-center">
{/* Input */} {prefix && (
<Input <span className="text-muted-foreground text-xs">{prefix}</span>
id={id}
name={name}
type="number"
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
step={step}
min={min}
max={max}
aria-label={ariaLabel}
className={cn(
"h-8 rounded-none border-x-0 text-center font-mono focus:z-10",
"focus:border-emerald-300 focus:ring-2 focus:ring-emerald-500/20",
"dark:focus:border-emerald-600",
prefix && "pl-12",
suffix && "pr-12",
)} )}
/> <input
id={id}
{/* Increment Button */} type="text"
<Button inputMode="decimal"
value={displayValue}
onChange={handleChange}
onBlur={handleBlur}
placeholder={placeholder}
disabled={disabled}
className="w-16 border-0 bg-transparent text-center outline-none focus-visible:ring-0"
/>
{suffix && (
<span className="text-muted-foreground text-xs">{suffix}</span>
)}
</div>
<button
type="button" type="button"
variant="outline"
size="sm"
disabled={disabled || !canIncrement}
onClick={handleIncrement} onClick={handleIncrement}
className={cn( disabled={disabled || (max !== undefined && value >= max)}
"h-8 w-8 rounded-l-none border-l-0 p-0 transition-all duration-150", className="text-muted-foreground hover:text-foreground flex h-6 w-6 items-center justify-center disabled:cursor-not-allowed disabled:opacity-50"
"hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700",
"dark:hover:border-emerald-700 dark:hover:bg-emerald-900/30",
"focus:z-10 focus:ring-2 focus:ring-emerald-500/20",
!canIncrement && "cursor-not-allowed opacity-40",
)}
aria-label="Increase value"
tabIndex={disabled ? -1 : 0}
> >
<Plus className="h-3 w-3" /> +
</Button> </button>
{/* Suffix */}
{suffix && (
<div className="text-muted-foreground pointer-events-none absolute right-10 z-10 flex items-center text-sm">
{suffix}
</div>
)}
</div> </div>
); );
} }