mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-02-05 00:06:36 -05: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:
@@ -71,6 +71,7 @@ interface DataTableProps<TData, TValue> {
|
||||
title: string;
|
||||
options: { label: string; value: string }[];
|
||||
}[];
|
||||
onRowClick?: (row: TData) => void;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
@@ -87,6 +88,7 @@ export function DataTable<TData, TValue>({
|
||||
description,
|
||||
actions,
|
||||
filterableColumns = [],
|
||||
onRowClick,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
@@ -97,15 +99,28 @@ export function DataTable<TData, TValue>({
|
||||
const [rowSelection, setRowSelection] = 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
|
||||
const responsiveColumns = React.useMemo(() => {
|
||||
return columns.map((column) => ({
|
||||
...column,
|
||||
// Add a meta property to control responsive visibility
|
||||
meta: {
|
||||
...((column as any).meta || {}),
|
||||
headerClassName: (column as any).meta?.headerClassName || "",
|
||||
cellClassName: (column as any).meta?.cellClassName || "",
|
||||
...((column as ColumnDef<TData, TValue> & { meta?: { headerClassName?: string; cellClassName?: string } }).meta ?? {}),
|
||||
headerClassName: (column as ColumnDef<TData, TValue> & { meta?: { headerClassName?: string; cellClassName?: string } }).meta?.headerClassName ?? "",
|
||||
cellClassName: (column as ColumnDef<TData, TValue> & { meta?: { headerClassName?: string; cellClassName?: string } }).meta?.cellClassName ?? "",
|
||||
},
|
||||
}));
|
||||
}, [columns]);
|
||||
@@ -132,13 +147,34 @@ export function DataTable<TData, TValue>({
|
||||
},
|
||||
initialState: {
|
||||
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];
|
||||
|
||||
// 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 (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* Header Section */}
|
||||
@@ -274,7 +310,7 @@ export function DataTable<TData, TValue>({
|
||||
className="bg-muted/50 hover:bg-muted/50"
|
||||
>
|
||||
{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 (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
@@ -301,10 +337,14 @@ export function DataTable<TData, TValue>({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
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) => {
|
||||
const meta = cell.column.columnDef.meta as any;
|
||||
const meta = cell.column.columnDef.meta as { headerClassName?: string; cellClassName?: string } | undefined;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
@@ -455,7 +495,11 @@ export function DataTableColumnHeader<TData, TValue>({
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
column: any;
|
||||
column: {
|
||||
getCanSort: () => boolean;
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
toggleSorting: (isDesc: boolean) => void;
|
||||
};
|
||||
title: string;
|
||||
className?: string;
|
||||
}) {
|
||||
@@ -511,27 +555,54 @@ export function DataTableSkeleton({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<TableHead
|
||||
key={i}
|
||||
className="h-9 px-3 text-left align-middle sm:h-10 sm:px-4"
|
||||
>
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-20"></div>
|
||||
</TableHead>
|
||||
))}
|
||||
{/* Mobile: 3 columns, sm: 5 columns, lg: 6 columns */}
|
||||
<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-24 lg:w-32"></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-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-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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<TableRow key={i} className="border-b">
|
||||
{Array.from({ length: columns }).map((_, j) => (
|
||||
<TableCell
|
||||
key={j}
|
||||
className="px-3 py-1.5 align-middle sm:px-4 sm:py-2"
|
||||
>
|
||||
<div className="bg-muted/30 h-4 w-full animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
))}
|
||||
{/* Client */}
|
||||
<TableCell className="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-24 lg:w-32"></div>
|
||||
</TableCell>
|
||||
{/* Date */}
|
||||
<TableCell className="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>
|
||||
{/* 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>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
440
src/components/forms/invoice-line-items.tsx
Normal file
440
src/components/forms/invoice-line-items.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import { cn } from "~/lib/utils";
|
||||
interface FloatingActionBarProps {
|
||||
/** Ref to the element that triggers visibility when scrolled out of view */
|
||||
triggerRef: React.RefObject<HTMLElement | null>;
|
||||
/** Title text displayed on the left */
|
||||
title: string;
|
||||
/** Title text displayed on the left (deprecated - use leftContent instead) */
|
||||
title?: string;
|
||||
/** Custom content to display on the left */
|
||||
leftContent?: React.ReactNode;
|
||||
/** Action buttons to display on the right */
|
||||
children: React.ReactNode;
|
||||
/** Additional className for styling */
|
||||
@@ -21,6 +23,7 @@ interface FloatingActionBarProps {
|
||||
export function FloatingActionBar({
|
||||
triggerRef,
|
||||
title,
|
||||
leftContent,
|
||||
children,
|
||||
className,
|
||||
show,
|
||||
@@ -28,6 +31,7 @@ export function FloatingActionBar({
|
||||
}: FloatingActionBarProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const floatingRef = useRef<HTMLDivElement>(null);
|
||||
const previousVisibleRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 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
|
||||
const shouldShow = !isInView;
|
||||
|
||||
if (shouldShow !== isVisible) {
|
||||
if (shouldShow !== previousVisibleRef.current) {
|
||||
previousVisibleRef.current = shouldShow;
|
||||
setIsVisible(shouldShow);
|
||||
onVisibilityChange?.(shouldShow);
|
||||
}
|
||||
};
|
||||
|
||||
// Use ResizeObserver and IntersectionObserver for better detection
|
||||
// Use IntersectionObserver for better detection
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
const shouldShow = !entry.isIntersecting;
|
||||
if (shouldShow !== isVisible) {
|
||||
if (shouldShow !== previousVisibleRef.current) {
|
||||
previousVisibleRef.current = shouldShow;
|
||||
setIsVisible(shouldShow);
|
||||
onVisibilityChange?.(shouldShow);
|
||||
}
|
||||
@@ -86,7 +92,7 @@ export function FloatingActionBar({
|
||||
observer.disconnect();
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [triggerRef, isVisible, show, onVisibilityChange]);
|
||||
}, [triggerRef, show, onVisibilityChange]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
@@ -94,14 +100,16 @@ export function FloatingActionBar({
|
||||
<div
|
||||
ref={floatingRef}
|
||||
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,
|
||||
)}
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">{title}</p>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{children}
|
||||
<div className="flex-1">
|
||||
{leftContent || (
|
||||
<p className="text-muted-foreground text-sm">{title}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:gap-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,21 +30,6 @@ export function Navbar() {
|
||||
</Link>
|
||||
</div>
|
||||
<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" ? (
|
||||
<>
|
||||
|
||||
@@ -92,10 +92,7 @@ export function DashboardBreadcrumbs() {
|
||||
if (invoiceLoading) {
|
||||
label = <Skeleton className="inline-block h-5 w-24 align-middle" />;
|
||||
} else if (invoice) {
|
||||
// You can customize this - show invoice number or date
|
||||
label =
|
||||
invoice.invoiceNumber ||
|
||||
format(new Date(invoice.issueDate), "MMM dd, yyyy");
|
||||
label = format(new Date(invoice.issueDate), "MMM dd, yyyy");
|
||||
}
|
||||
} else if (prevSegment === "businesses") {
|
||||
if (businessLoading) {
|
||||
@@ -148,12 +145,12 @@ export function DashboardBreadcrumbs() {
|
||||
|
||||
return (
|
||||
<Breadcrumb className="mb-4 sm:mb-6">
|
||||
<BreadcrumbList className="flex-wrap">
|
||||
<BreadcrumbList className="flex-nowrap overflow-hidden">
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-sm sm:text-base dark:text-gray-300"
|
||||
className="truncate text-sm sm:text-base dark:text-gray-300"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
@@ -166,14 +163,14 @@ export function DashboardBreadcrumbs() {
|
||||
</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
{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}
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
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}
|
||||
</Link>
|
||||
|
||||
@@ -6,7 +6,6 @@ import * as React from "react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Calendar } from "~/components/ui/calendar";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -17,61 +16,66 @@ import { cn } from "~/lib/utils";
|
||||
interface DatePickerProps {
|
||||
date?: Date;
|
||||
onDateChange: (date: Date | undefined) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
id?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
date,
|
||||
onDateChange,
|
||||
label,
|
||||
placeholder = "Select date",
|
||||
className,
|
||||
disabled = false,
|
||||
required = false,
|
||||
id,
|
||||
size = "md",
|
||||
}: DatePickerProps) {
|
||||
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 (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
{label && (
|
||||
<Label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-10 w-full justify-between text-sm font-normal",
|
||||
!date && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{date ? format(date, "PPP") : placeholder}
|
||||
<CalendarIcon className="text-muted-foreground h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
captionLayout="dropdown"
|
||||
onSelect={(selectedDate: Date | undefined) => {
|
||||
onDateChange(selectedDate);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
sizeClasses[size],
|
||||
!date && "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{date ? formatDate(date) : placeholder}
|
||||
<CalendarIcon className="text-muted-foreground h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
captionLayout="dropdown"
|
||||
onSelect={(selectedDate: Date | undefined) => {
|
||||
onDateChange(selectedDate);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
|
||||
interface NumberInputProps {
|
||||
value: number;
|
||||
@@ -13,13 +10,12 @@ interface NumberInputProps {
|
||||
max?: number;
|
||||
step?: number;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
"aria-label"?: string;
|
||||
width?: "auto" | "full";
|
||||
}
|
||||
|
||||
export function NumberInput({
|
||||
@@ -29,151 +25,98 @@ export function NumberInput({
|
||||
max,
|
||||
step = 1,
|
||||
placeholder = "0",
|
||||
disabled = false,
|
||||
className,
|
||||
disabled = false,
|
||||
id,
|
||||
prefix,
|
||||
suffix,
|
||||
id,
|
||||
name,
|
||||
"aria-label": ariaLabel,
|
||||
width = "auto",
|
||||
}: 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(() => {
|
||||
setInputValue(value.toString());
|
||||
setDisplayValue(value ? value.toFixed(2) : "0.00");
|
||||
}, [value]);
|
||||
|
||||
const handleIncrement = () => {
|
||||
const newValue = Math.min(value + step, max ?? Infinity);
|
||||
onChange(newValue);
|
||||
};
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value;
|
||||
setDisplayValue(inputValue);
|
||||
|
||||
const handleDecrement = () => {
|
||||
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 === "") {
|
||||
if (inputValue === "") {
|
||||
onChange(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const numValue = parseFloat(inputVal);
|
||||
if (!isNaN(numValue)) {
|
||||
const clampedValue = Math.max(min, Math.min(numValue, max ?? Infinity));
|
||||
onChange(clampedValue);
|
||||
const newValue = parseFloat(inputValue);
|
||||
if (!isNaN(newValue)) {
|
||||
onChange(Math.round(newValue * 100) / 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
// Ensure the input shows the actual value on blur
|
||||
setInputValue(value.toString());
|
||||
const handleBlur = () => {
|
||||
const numValue = parseFloat(displayValue) || 0;
|
||||
const formattedValue = numValue.toFixed(2);
|
||||
setDisplayValue(formattedValue);
|
||||
onChange(numValue);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowUp" && canIncrement) {
|
||||
e.preventDefault();
|
||||
handleIncrement();
|
||||
} else if (e.key === "ArrowDown" && canDecrement) {
|
||||
e.preventDefault();
|
||||
handleDecrement();
|
||||
}
|
||||
const handleIncrement = () => {
|
||||
if (disabled) return;
|
||||
onChange((value || 0) + step);
|
||||
};
|
||||
|
||||
const canDecrement = value > min;
|
||||
const canIncrement = !max || value < max;
|
||||
const handleDecrement = () => {
|
||||
if (disabled) return;
|
||||
onChange(Math.max(min, (value || 0) - step));
|
||||
};
|
||||
|
||||
const widthClass = width === "full" ? "w-full" : "w-24";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex items-center", className)}
|
||||
role="group"
|
||||
aria-label={
|
||||
ariaLabel || "Number input with increment and decrement buttons"
|
||||
}
|
||||
className={cn(
|
||||
"border-input bg-background ring-offset-background flex h-9 items-center justify-center rounded-md border px-2 text-sm",
|
||||
widthClass,
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Prefix */}
|
||||
{prefix && (
|
||||
<div className="text-muted-foreground pointer-events-none absolute left-10 z-10 flex items-center text-sm">
|
||||
{prefix}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decrement Button */}
|
||||
<Button
|
||||
<button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled || !canDecrement}
|
||||
onClick={handleDecrement}
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-r-none border-r-0 p-0 transition-all duration-150",
|
||||
"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}
|
||||
disabled={disabled || value <= min}
|
||||
className="text-muted-foreground hover:text-foreground flex h-6 w-6 items-center justify-center disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
{/* Input */}
|
||||
<Input
|
||||
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",
|
||||
−
|
||||
</button>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{prefix && (
|
||||
<span className="text-muted-foreground text-xs">{prefix}</span>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Increment Button */}
|
||||
<Button
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
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"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled || !canIncrement}
|
||||
onClick={handleIncrement}
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-l-none border-l-0 p-0 transition-all duration-150",
|
||||
"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}
|
||||
disabled={disabled || (max !== undefined && value >= max)}
|
||||
className="text-muted-foreground hover:text-foreground flex h-6 w-6 items-center justify-center disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
{/* Suffix */}
|
||||
{suffix && (
|
||||
<div className="text-muted-foreground pointer-events-none absolute right-10 z-10 flex items-center text-sm">
|
||||
{suffix}
|
||||
</div>
|
||||
)}
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user