mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-13 09:34:44 -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:
@@ -426,3 +426,9 @@ export const exampleRouter = createTRPCRouter({
|
|||||||
|
|
||||||
## 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.
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">
|
||||||
|
Invoice Details
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
View and manage invoice information
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<InvoiceActionsDropdown invoiceId={invoice.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Left Column */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Invoice Header */}
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</h2>
|
||||||
|
<StatusBadge status={getStatusType()} />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Issued {formatDate(invoice.issueDate)} • Due {formatDate(invoice.dueDate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-muted-foreground">Total Amount</p>
|
||||||
|
<p className="text-3xl font-bold text-primary">
|
||||||
|
{formatCurrency(total)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Overdue Alert */}
|
{/* Overdue Alert */}
|
||||||
{isOverdue && (
|
{isOverdue && (
|
||||||
<Card className="border-red-200 bg-red-50">
|
<Card className="border-destructive/20 bg-destructive/5 shadow-sm">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-2 text-red-700">
|
<div className="flex items-center gap-3 text-destructive">
|
||||||
<AlertTriangle className="h-5 w-5" />
|
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
||||||
<span className="font-medium">
|
<div>
|
||||||
This invoice is{" "}
|
<p className="font-medium">Invoice Overdue</p>
|
||||||
|
<p className="text-sm">
|
||||||
{Math.ceil(
|
{Math.ceil(
|
||||||
(new Date().getTime() - new Date(invoice.dueDate).getTime()) /
|
(new Date().getTime() - new Date(invoice.dueDate).getTime()) /
|
||||||
(1000 * 60 * 60 * 24),
|
(1000 * 60 * 60 * 24),
|
||||||
)}{" "}
|
)}{" "}
|
||||||
days overdue
|
days past due date
|
||||||
</span>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-4 xl:grid-cols-3">
|
{/* Client & Business Info */}
|
||||||
{/* Main Content */}
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-6 lg:col-span-3 xl:col-span-2">
|
|
||||||
{/* Invoice Header */}
|
|
||||||
<Card className="shadow-lg">
|
|
||||||
<CardContent className="p-4 sm:p-6">
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h1 className="text-xl font-bold sm:text-2xl">
|
|
||||||
Invoice #{invoice.invoiceNumber}
|
|
||||||
</h1>
|
|
||||||
<StatusBadge status={getStatusType()} />
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-sm sm:text-base">
|
|
||||||
Issued {formatDate(invoice.issueDate)} • Due{" "}
|
|
||||||
{formatDate(invoice.dueDate)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-left sm:text-right">
|
|
||||||
<p className="text-muted-foreground text-sm sm:text-base">
|
|
||||||
Total Amount
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold text-emerald-600 sm:text-3xl">
|
|
||||||
{formatCurrency(invoice.totalAmount)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Client & Business Information */}
|
|
||||||
<div className="grid gap-4 sm:gap-6 md:grid-cols-2">
|
|
||||||
{/* 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">
|
<span className="font-medium">{formatCurrency(subtotal)}</span>
|
||||||
{formatCurrency(subtotal)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{invoice.taxRate > 0 && (
|
{invoice.taxRate > 0 && (
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">Tax ({invoice.taxRate}%):</span>
|
||||||
Tax ({invoice.taxRate}%):
|
<span className="font-medium">{formatCurrency(taxAmount)}</span>
|
||||||
</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{formatCurrency(taxAmount)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex justify-between text-base font-bold sm:text-lg">
|
<div className="flex justify-between text-lg font-bold">
|
||||||
<span>Total:</span>
|
<span>Total:</span>
|
||||||
<span className="text-emerald-600">
|
<span className="text-primary">{formatCurrency(total)}</span>
|
||||||
{formatCurrency(invoice.totalAmount)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</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" />
|
||||||
|
Actions
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-3">
|
<CardContent className="space-y-3">
|
||||||
<Button
|
<Button asChild variant="outline" className="w-full">
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
className="w-full border-0 shadow-sm"
|
|
||||||
size="default"
|
|
||||||
>
|
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
<span>Edit Invoice</span>
|
Edit Invoice
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<PDFDownloadButton invoiceId={invoice.id} />
|
{invoice.items && invoice.client && (
|
||||||
|
<PDFDownloadButton
|
||||||
|
invoiceId={invoice.id}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<SendInvoiceButton invoiceId={invoice.id} />
|
{invoice.status === "draft" && (
|
||||||
|
<SendInvoiceButton
|
||||||
<Button
|
invoiceId={invoice.id}
|
||||||
variant="outline"
|
className="w-full"
|
||||||
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>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground text-sm">Invoice #</p>
|
|
||||||
<p className="font-medium break-all">
|
|
||||||
{invoice.invoiceNumber}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground text-sm">Status</p>
|
|
||||||
<div className="mt-1">
|
|
||||||
<StatusBadge status={getStatusType()} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground text-sm">Issue Date</p>
|
|
||||||
<p className="font-medium">{formatDate(invoice.issueDate)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground text-sm">Due Date</p>
|
|
||||||
<p className="font-medium">{formatDate(invoice.dueDate)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground text-sm">Tax Rate</p>
|
|
||||||
<p className="font-medium">{invoice.taxRate}%</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground text-sm">Total</p>
|
|
||||||
<p className="font-medium text-emerald-600">
|
|
||||||
{formatCurrency(invoice.totalAmount)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</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 (
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="Invoice Details"
|
|
||||||
description="View and manage invoice information"
|
|
||||||
variant="gradient"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
className="border-0 shadow-sm"
|
|
||||||
size="default"
|
|
||||||
>
|
|
||||||
<Link href="/dashboard/invoices">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Back to Invoices</span>
|
|
||||||
<span className="sm:hidden">Back</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<InvoiceActionsDropdown invoiceId={id} />
|
|
||||||
</div>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<Suspense fallback={<InvoiceDetailsSkeleton />}>
|
<Suspense fallback={<InvoiceDetailsSkeleton />}>
|
||||||
<InvoiceContent invoiceId={id} />
|
<InvoiceContent invoiceId={id} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</HydrateClient>
|
</HydrateClient>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +79,13 @@ const formatCurrency = (amount: number) => {
|
|||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleRowClick = (invoice: Invoice) => {
|
||||||
|
router.push(`/dashboard/invoices/${invoice.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ColumnDef<Invoice>[] = [
|
const columns: ColumnDef<Invoice>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "client.name",
|
accessorKey: "client.name",
|
||||||
@@ -102,10 +110,10 @@ const columns: ColumnDef<Invoice>[] = [
|
|||||||
<DataTableColumnHeader column={column} title="Date" />
|
<DataTableColumnHeader column={column} title="Date" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const date = row.getValue("issueDate") as Date;
|
const date = row.getValue("issueDate");
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm">{formatDate(date)}</p>
|
<p className="truncate text-sm">{formatDate(date as Date)}</p>
|
||||||
<p className="text-muted-foreground truncate text-xs">
|
<p className="text-muted-foreground truncate text-xs">
|
||||||
Due {formatDate(new Date(row.original.dueDate))}
|
Due {formatDate(new Date(row.original.dueDate))}
|
||||||
</p>
|
</p>
|
||||||
@@ -122,7 +130,7 @@ const columns: ColumnDef<Invoice>[] = [
|
|||||||
const invoice = row.original;
|
const invoice = row.original;
|
||||||
return <StatusBadge status={getStatusType(invoice)} />;
|
return <StatusBadge status={getStatusType(invoice)} />;
|
||||||
},
|
},
|
||||||
filterFn: (row, id, value) => {
|
filterFn: (row, id, value: string[]) => {
|
||||||
const invoice = row.original;
|
const invoice = row.original;
|
||||||
const status = getStatusType(invoice);
|
const status = getStatusType(invoice);
|
||||||
return value.includes(status);
|
return value.includes(status);
|
||||||
@@ -138,10 +146,10 @@ const columns: ColumnDef<Invoice>[] = [
|
|||||||
<DataTableColumnHeader column={column} title="Amount" />
|
<DataTableColumnHeader column={column} title="Amount" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const amount = row.getValue("totalAmount") as number;
|
const amount = row.getValue("totalAmount");
|
||||||
return (
|
return (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="font-semibold text-sm">{formatCurrency(amount)}</p>
|
<p className="font-semibold text-sm">{formatCurrency(amount as number)}</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{row.original.items?.length ?? 0} items
|
{row.original.items?.length ?? 0} items
|
||||||
</p>
|
</p>
|
||||||
@@ -160,17 +168,29 @@ const columns: ColumnDef<Invoice>[] = [
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||||
<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"
|
||||||
|
>
|
||||||
<Eye className="h-3.5 w-3.5" />
|
<Eye className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
<Link href={`/dashboard/invoices/${invoice.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"
|
||||||
|
>
|
||||||
<Edit className="h-3.5 w-3.5" />
|
<Edit className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
{invoice.items && invoice.client && (
|
{invoice.items && invoice.client && (
|
||||||
|
<div data-action-button="true">
|
||||||
<PDFDownloadButton invoiceId={invoice.id} variant="icon" />
|
<PDFDownloadButton invoiceId={invoice.id} variant="icon" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -178,7 +198,6 @@ const columns: ColumnDef<Invoice>[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
|
||||||
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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,6 +423,8 @@ export default function NewInvoicePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Issue Date *</Label>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
date={formData.issueDate}
|
date={formData.issueDate}
|
||||||
onDateChange={(date) =>
|
onDateChange={(date) =>
|
||||||
@@ -431,10 +433,11 @@ export default function NewInvoicePage() {
|
|||||||
issueDate: date ?? new Date(),
|
issueDate: date ?? new Date(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
label="Issue Date"
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Due Date *</Label>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
date={formData.dueDate}
|
date={formData.dueDate}
|
||||||
onDateChange={(date) =>
|
onDateChange={(date) =>
|
||||||
@@ -443,10 +446,9 @@ export default function NewInvoicePage() {
|
|||||||
dueDate: date ?? new Date(),
|
dueDate: date ?? new Date(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
label="Due Date"
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -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 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>
|
</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">
|
||||||
|
<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>
|
</TableCell>
|
||||||
))}
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</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 {
|
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,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
{leftContent || (
|
||||||
<p className="text-muted-foreground text-sm">{title}</p>
|
<p className="text-muted-foreground text-sm">{title}</p>
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
)}
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,34 +16,38 @@ 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)}>
|
|
||||||
{label && (
|
|
||||||
<Label htmlFor={id} className="text-sm font-medium">
|
|
||||||
{label}
|
|
||||||
{required && <span className="text-destructive ml-1">*</span>}
|
|
||||||
</Label>
|
|
||||||
)}
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -52,11 +55,13 @@ export function DatePicker({
|
|||||||
id={id}
|
id={id}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 w-full justify-between text-sm font-normal",
|
"w-full justify-between font-normal",
|
||||||
|
sizeClasses[size],
|
||||||
!date && "text-muted-foreground",
|
!date && "text-muted-foreground",
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{date ? format(date, "PPP") : placeholder}
|
{date ? formatDate(date) : placeholder}
|
||||||
<CalendarIcon className="text-muted-foreground h-4 w-4" />
|
<CalendarIcon className="text-muted-foreground h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -72,6 +77,5 @@ export function DatePicker({
|
|||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}
|
|
||||||
role="group"
|
|
||||||
aria-label={
|
|
||||||
ariaLabel || "Number input with increment and decrement buttons"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* 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
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled || !canDecrement}
|
|
||||||
onClick={handleDecrement}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-8 rounded-r-none border-r-0 p-0 transition-all duration-150",
|
"border-input bg-background ring-offset-background flex h-9 items-center justify-center rounded-md border px-2 text-sm",
|
||||||
"hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700",
|
widthClass,
|
||||||
"dark:hover:border-emerald-700 dark:hover:bg-emerald-900/30",
|
disabled && "cursor-not-allowed opacity-50",
|
||||||
"focus:z-10 focus:ring-2 focus:ring-emerald-500/20",
|
className,
|
||||||
!canDecrement && "cursor-not-allowed opacity-40",
|
|
||||||
)}
|
)}
|
||||||
aria-label="Decrease value"
|
|
||||||
tabIndex={disabled ? -1 : 0}
|
|
||||||
>
|
>
|
||||||
<Minus className="h-3 w-3" />
|
<button
|
||||||
</Button>
|
type="button"
|
||||||
|
onClick={handleDecrement}
|
||||||
{/* Input */}
|
disabled={disabled || value <= min}
|
||||||
<Input
|
className="text-muted-foreground hover:text-foreground flex h-6 w-6 items-center justify-center disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
{prefix && (
|
||||||
|
<span className="text-muted-foreground text-xs">{prefix}</span>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
name={name}
|
type="text"
|
||||||
type="number"
|
inputMode="decimal"
|
||||||
value={inputValue}
|
value={displayValue}
|
||||||
onChange={handleInputChange}
|
onChange={handleChange}
|
||||||
onBlur={handleInputBlur}
|
onBlur={handleBlur}
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
step={step}
|
className="w-16 border-0 bg-transparent text-center outline-none focus-visible:ring-0"
|
||||||
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",
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Increment Button */}
|
|
||||||
<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}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Suffix */}
|
|
||||||
{suffix && (
|
{suffix && (
|
||||||
<div className="text-muted-foreground pointer-events-none absolute right-10 z-10 flex items-center text-sm">
|
<span className="text-muted-foreground text-xs">{suffix}</span>
|
||||||
{suffix}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleIncrement}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user