feat: improve invoice view responsiveness and settings UX
- Replace custom invoice items table with responsive DataTable component - Fix server/client component error by creating InvoiceItemsTable client component - Merge danger zone with actions sidebar and use destructive button variant - Standardize button text sizing across all action buttons - Remove false claims from homepage (testimonials, ratings, fake user counts) - Focus homepage messaging on freelancers with honest feature descriptions - Fix dark mode support throughout app by replacing hard-coded colors with semantic classes - Remove aggressive red styling from settings, add subtle red accents only - Align import/export buttons and improve delete confirmation UX - Update dark mode background to have subtle green tint instead of pure black - Fix HTML nesting error in AlertDialog by using div instead of nested p tags This update makes the invoice view properly responsive, removes misleading marketing claims, and ensures consistent dark mode support across the entire application.
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { Mail, Phone, MapPin, Edit, Trash2, Eye, Plus, Search } from "lucide-react";
|
||||
|
||||
export function ClientList() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [clientToDelete, setClientToDelete] = useState<string | null>(null);
|
||||
|
||||
const { data: clients, isLoading, refetch } = api.clients.getAll.useQuery();
|
||||
const deleteClient = api.clients.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Client deleted successfully");
|
||||
void refetch();
|
||||
setDeleteDialogOpen(false);
|
||||
setClientToDelete(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to delete client");
|
||||
},
|
||||
});
|
||||
|
||||
const filteredClients = clients?.filter(client =>
|
||||
client.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
client.email?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) ?? [];
|
||||
|
||||
const handleDelete = (clientId: string) => {
|
||||
setClientToDelete(clientId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (clientToDelete) {
|
||||
deleteClient.mutate({ id: clientToDelete });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(3)].map((_, i: number) => (
|
||||
<Card key={i} className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-3 bg-gray-200 rounded w-2/3 animate-pulse" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!clients || clients.length === 0) {
|
||||
return (
|
||||
<Card className="shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl font-bold bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
|
||||
No Clients Yet
|
||||
</CardTitle>
|
||||
<CardDescription className="text-lg">
|
||||
Get started by adding your first client
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Button
|
||||
className="w-full h-12 bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Your First Client
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Label htmlFor="search" className="sr-only">Search clients</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="Search by name or email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 h-12 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Button
|
||||
className="w-full sm:w-auto h-12 bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 text-white font-medium shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Client
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredClients.map((client) => (
|
||||
<Card key={client.id} className="shadow-xl border-0 bg-white/80 backdrop-blur-sm hover:shadow-2xl transition-all duration-300 group">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between text-lg">
|
||||
<span className="font-semibold text-gray-800 group-hover:text-emerald-600 transition-colors">
|
||||
{client.name}
|
||||
</span>
|
||||
<div className="flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Link href={`/clients/${client.id}`}>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-emerald-100">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/clients/${client.id}/edit`}>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-emerald-100">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(client.id)}
|
||||
className="h-8 w-8 p-0 hover:bg-red-100 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{client.email && (
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<div className="p-1.5 bg-emerald-100 rounded mr-3">
|
||||
<Mail className="h-3 w-3 text-emerald-600" />
|
||||
</div>
|
||||
{client.email}
|
||||
</div>
|
||||
)}
|
||||
{client.phone && (
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<div className="p-1.5 bg-blue-100 rounded mr-3">
|
||||
<Phone className="h-3 w-3 text-blue-600" />
|
||||
</div>
|
||||
{client.phone}
|
||||
</div>
|
||||
)}
|
||||
{(client.addressLine1 ?? client.city ?? client.state) && (
|
||||
<div className="flex items-start text-sm text-gray-600">
|
||||
<div className="p-1.5 bg-teal-100 rounded mr-3 mt-0.5 flex-shrink-0">
|
||||
<MapPin className="h-3 w-3 text-teal-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{client.addressLine1 && <div>{client.addressLine1}</div>}
|
||||
{client.addressLine2 && <div>{client.addressLine2}</div>}
|
||||
{(client.city ?? client.state ?? client.postalCode) && (
|
||||
<div>
|
||||
{[client.city, client.state, client.postalCode].filter(Boolean).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{client.country && <div>{client.country}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="bg-white/95 backdrop-blur-sm border-0 shadow-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold text-gray-800">Delete Client</DialogTitle>
|
||||
<DialogDescription className="text-gray-600">
|
||||
Are you sure you want to delete this client? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
className="border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
ChevronDown,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
searchKey?: string;
|
||||
searchPlaceholder?: string;
|
||||
showColumnVisibility?: boolean;
|
||||
showPagination?: boolean;
|
||||
showSearch?: boolean;
|
||||
pageSize?: number;
|
||||
className?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
actions?: React.ReactNode;
|
||||
filterableColumns?: {
|
||||
id: string;
|
||||
title: string;
|
||||
options: { label: string; value: string }[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
searchKey,
|
||||
searchPlaceholder = "Search...",
|
||||
showColumnVisibility = true,
|
||||
showPagination = true,
|
||||
showSearch = true,
|
||||
pageSize = 10,
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
filterableColumns = [],
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[],
|
||||
);
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [globalFilter, setGlobalFilter] = React.useState("");
|
||||
|
||||
// Create responsive columns that properly hide on mobile
|
||||
const responsiveColumns = React.useMemo(() => {
|
||||
return columns.map((column) => ({
|
||||
...column,
|
||||
// Add a meta property to control responsive visibility
|
||||
meta: {
|
||||
...((column as any).meta || {}),
|
||||
headerClassName: (column as any).meta?.headerClassName || "",
|
||||
cellClassName: (column as any).meta?.cellClassName || "",
|
||||
},
|
||||
}));
|
||||
}, [columns]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: responsiveColumns,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
globalFilterFn: "includesString",
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
globalFilter,
|
||||
},
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: pageSize,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const pageSizeOptions = [5, 10, 20, 30, 50, 100];
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* Header Section */}
|
||||
{(title ?? description) && (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
<h3 className="text-foreground text-lg font-semibold">{title}</h3>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Bar Card */}
|
||||
{(showSearch || filterableColumns.length > 0 || showColumnVisibility) && (
|
||||
<Card className="border-0 py-2 shadow-sm">
|
||||
<CardContent className="px-3 py-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{showSearch && (
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={globalFilter ?? ""}
|
||||
onChange={(event) => setGlobalFilter(event.target.value)}
|
||||
className="h-9 w-full pr-3 pl-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{filterableColumns.map((column) => (
|
||||
<Select
|
||||
key={column.id}
|
||||
value={
|
||||
(table.getColumn(column.id)?.getFilterValue() as string) ??
|
||||
"all"
|
||||
}
|
||||
onValueChange={(value) =>
|
||||
table
|
||||
.getColumn(column.id)
|
||||
?.setFilterValue(value === "all" ? "" : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-9 p-0 sm:w-[180px] sm:px-3 [&>svg]:hidden sm:[&>svg]:inline-flex">
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<Filter className="h-4 w-4 sm:hidden" />
|
||||
<span className="hidden sm:inline">
|
||||
<SelectValue placeholder={column.title} />
|
||||
</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All {column.title}</SelectItem>
|
||||
{column.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
))}
|
||||
{filterableColumns.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-9 p-0 sm:w-auto sm:px-4"
|
||||
onClick={() => {
|
||||
table.resetColumnFilters();
|
||||
setGlobalFilter("");
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4 sm:hidden" />
|
||||
<span className="hidden sm:flex sm:items-center">
|
||||
<Filter className="mr-2 h-3.5 w-3.5" />
|
||||
Clear filters
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{showColumnVisibility && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hidden h-9 sm:flex"
|
||||
>
|
||||
Columns <ChevronDown className="ml-2 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[150px]">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Table Content Card */}
|
||||
<Card className="overflow-hidden border-0 p-0 shadow-sm">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
className="bg-muted/50 hover:bg-muted/50"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const meta = header.column.columnDef.meta as any;
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={cn(
|
||||
"text-muted-foreground h-9 px-3 text-left align-middle text-xs font-medium sm:h-10 sm:px-4 sm:text-sm [&:has([role=checkbox])]:pr-3",
|
||||
meta?.headerClassName,
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="hover:bg-muted/20 data-[state=selected]:bg-muted/50 border-b transition-colors"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const meta = cell.column.columnDef.meta as any;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
"px-3 py-1.5 align-middle text-xs sm:px-4 sm:py-2 sm:text-sm [&:has([role=checkbox])]:pr-3",
|
||||
meta?.cellClassName,
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
<p className="text-muted-foreground">No results found</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Pagination Bar Card */}
|
||||
{showPagination && (
|
||||
<Card className="border-0 py-2 shadow-sm">
|
||||
<CardContent className="px-3 py-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-muted-foreground hidden text-xs sm:inline sm:text-sm">
|
||||
{table.getFilteredRowModel().rows.length === 0
|
||||
? "No entries"
|
||||
: `Showing ${
|
||||
table.getState().pagination.pageIndex *
|
||||
table.getState().pagination.pageSize +
|
||||
1
|
||||
} to ${Math.min(
|
||||
(table.getState().pagination.pageIndex + 1) *
|
||||
table.getState().pagination.pageSize,
|
||||
table.getFilteredRowModel().rows.length,
|
||||
)} of ${table.getFilteredRowModel().rows.length} entries`}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs sm:hidden">
|
||||
{table.getFilteredRowModel().rows.length === 0
|
||||
? "0"
|
||||
: `${
|
||||
table.getState().pagination.pageIndex *
|
||||
table.getState().pagination.pageSize +
|
||||
1
|
||||
}-${Math.min(
|
||||
(table.getState().pagination.pageIndex + 1) *
|
||||
table.getState().pagination.pageSize,
|
||||
table.getFilteredRowModel().rows.length,
|
||||
)} of ${table.getFilteredRowModel().rows.length}`}
|
||||
</p>
|
||||
<Select
|
||||
value={table.getState().pagination.pageSize.toString()}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map((size) => (
|
||||
<SelectItem key={size} value={size.toString()}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
<span className="sr-only">First page</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous page</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
<span className="text-muted-foreground text-xs sm:text-sm">
|
||||
Page{" "}
|
||||
<span className="text-foreground font-medium">
|
||||
{table.getState().pagination.pageIndex + 1}
|
||||
</span>{" "}
|
||||
of{" "}
|
||||
<span className="text-foreground font-medium">
|
||||
{table.getPageCount() || 1}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next page</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
<span className="sr-only">Last page</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper component for sortable column headers
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
column: any;
|
||||
title: string;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn("text-xs sm:text-sm", className)}>{title}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"data-[state=open]:bg-accent -ml-2 h-8 px-2 text-xs font-medium hover:bg-transparent sm:text-sm",
|
||||
className,
|
||||
)}
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
<span className="mr-2">{title}</span>
|
||||
{column.getIsSorted() === "desc" ? (
|
||||
<ArrowUpDown className="h-3 w-3 rotate-180 sm:h-3.5 sm:w-3.5" />
|
||||
) : column.getIsSorted() === "asc" ? (
|
||||
<ArrowUpDown className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
) : (
|
||||
<ArrowUpDown className="text-muted-foreground/50 h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Export skeleton component for loading states
|
||||
export function DataTableSkeleton({
|
||||
columns = 5,
|
||||
rows = 5,
|
||||
}: {
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filter bar skeleton */}
|
||||
<Card className="border-0 py-2 shadow-sm">
|
||||
<CardContent className="px-3 py-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted/30 h-9 w-full flex-1 animate-pulse rounded-md sm:max-w-sm"></div>
|
||||
<div className="bg-muted/30 h-9 w-24 animate-pulse rounded-md"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Table skeleton */}
|
||||
<Card className="overflow-hidden border-0 p-0 shadow-sm">
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<TableHead
|
||||
key={i}
|
||||
className="h-9 px-3 text-left align-middle sm:h-10 sm:px-4"
|
||||
>
|
||||
<div className="bg-muted/30 h-4 w-16 animate-pulse rounded sm:w-20"></div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<TableRow key={i} className="border-b">
|
||||
{Array.from({ length: columns }).map((_, j) => (
|
||||
<TableCell
|
||||
key={j}
|
||||
className="px-3 py-1.5 align-middle sm:px-4 sm:py-2"
|
||||
>
|
||||
<div className="bg-muted/30 h-4 w-full animate-pulse rounded"></div>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Pagination skeleton */}
|
||||
<Card className="border-0 py-2 shadow-sm">
|
||||
<CardContent className="px-3 py-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted/30 h-4 w-20 animate-pulse rounded text-xs sm:w-32 sm:text-sm"></div>
|
||||
<div className="bg-muted/30 h-8 w-[70px] animate-pulse rounded"></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-muted/30 h-8 w-8 animate-pulse rounded"
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Trash2, GripVertical, CalendarIcon } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { Calendar } from "~/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface EditableInvoiceItemsProps {
|
||||
items: InvoiceItem[];
|
||||
onItemsChange: (items: InvoiceItem[]) => void;
|
||||
onRemoveItem: (index: number) => void;
|
||||
}
|
||||
|
||||
function SortableItem({
|
||||
item,
|
||||
index,
|
||||
onItemChange,
|
||||
onRemove,
|
||||
}: {
|
||||
item: InvoiceItem;
|
||||
index: number;
|
||||
onItemChange: (
|
||||
index: number,
|
||||
field: string,
|
||||
value: string | number | Date,
|
||||
) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: item.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const handleItemChange = (field: string, value: string | number | Date) => {
|
||||
onItemChange(index, field, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`grid grid-cols-12 items-center gap-2 rounded-lg border border-gray-200 p-4 transition-colors hover:border-emerald-300 dark:border-gray-700 dark:hover:border-emerald-500 ${
|
||||
isDragging ? "opacity-50 shadow-lg" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
<div className="col-span-1 flex h-10 items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab rounded p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 active:cursor-grabbing dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-400"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="col-span-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 w-full justify-between border-gray-200 text-sm font-normal focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
|
||||
>
|
||||
{item.date ? format(item.date, "MMM dd") : "Date"}
|
||||
<CalendarIcon className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={item.date}
|
||||
captionLayout="dropdown"
|
||||
onSelect={(selectedDate: Date | undefined) => {
|
||||
handleItemChange("date", selectedDate ?? new Date());
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="col-span-4">
|
||||
<Input
|
||||
value={item.description}
|
||||
onChange={(e) => handleItemChange("description", e.target.value)}
|
||||
placeholder="Work description"
|
||||
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hours */}
|
||||
<div className="col-span-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.25"
|
||||
min="0"
|
||||
value={item.hours}
|
||||
onChange={(e) => handleItemChange("hours", e.target.value)}
|
||||
placeholder="0"
|
||||
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rate */}
|
||||
<div className="col-span-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={item.rate}
|
||||
onChange={(e) => handleItemChange("rate", e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="h-10 border-gray-200 focus:border-emerald-500 focus:ring-emerald-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="col-span-1">
|
||||
<div className="flex h-10 items-center rounded-md border border-gray-200 bg-gray-50 px-3 font-medium text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
${item.amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<div className="col-span-1">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-10 w-10 border-red-200 p-0 text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditableInvoiceItems({
|
||||
items,
|
||||
onItemsChange,
|
||||
onRemoveItem,
|
||||
}: EditableInvoiceItemsProps) {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (active.id !== over?.id) {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over?.id);
|
||||
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
onItemsChange(newItems);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemChange = (
|
||||
index: number,
|
||||
field: string,
|
||||
value: string | number | Date,
|
||||
) => {
|
||||
const newItems = [...items];
|
||||
if (field === "hours" || field === "rate") {
|
||||
if (newItems[index]) {
|
||||
const numValue =
|
||||
typeof value === "string"
|
||||
? parseFloat(value)
|
||||
: typeof value === "number"
|
||||
? value
|
||||
: 0;
|
||||
newItems[index][field] = numValue || 0;
|
||||
newItems[index].amount = newItems[index].hours * newItems[index].rate;
|
||||
}
|
||||
} else if (field === "date") {
|
||||
if (newItems[index]) {
|
||||
const dateValue =
|
||||
value instanceof Date ? value : new Date(String(value));
|
||||
newItems[index].date = dateValue;
|
||||
}
|
||||
} else {
|
||||
if (newItems[index]) {
|
||||
const stringValue = typeof value === "string" ? value : String(value);
|
||||
newItems[index].description = stringValue;
|
||||
}
|
||||
}
|
||||
onItemsChange(newItems);
|
||||
};
|
||||
|
||||
// Show skeleton loading on server-side
|
||||
if (!isClient) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((item, _index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="grid animate-pulse grid-cols-12 items-center gap-2 rounded-lg border border-gray-200 p-4"
|
||||
>
|
||||
<div className="col-span-1 flex h-10 items-center justify-center">
|
||||
<div className="h-4 w-4 rounded bg-gray-300"></div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="h-10 rounded bg-gray-300"></div>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<div className="h-10 rounded bg-gray-300"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="h-10 rounded bg-gray-300"></div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="h-10 rounded bg-gray-300"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="h-10 rounded bg-gray-300"></div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="h-10 w-10 rounded bg-gray-300"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={items.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<SortableItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
onItemChange={handleItemChange}
|
||||
onRemove={onRemoveItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
FileText,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
Plus,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
export function InvoiceList() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [invoiceToDelete, setInvoiceToDelete] = useState<string | null>(null);
|
||||
|
||||
const { data: invoices, isLoading, refetch } = api.invoices.getAll.useQuery();
|
||||
const deleteInvoice = api.invoices.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Invoice deleted successfully");
|
||||
refetch();
|
||||
setDeleteDialogOpen(false);
|
||||
setInvoiceToDelete(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to delete invoice");
|
||||
},
|
||||
});
|
||||
|
||||
const filteredInvoices =
|
||||
invoices?.filter(
|
||||
(invoice) =>
|
||||
invoice.invoiceNumber
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase()) ||
|
||||
invoice.client.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
) || [];
|
||||
|
||||
const handleDelete = (invoiceId: string) => {
|
||||
setInvoiceToDelete(invoiceId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (invoiceToDelete) {
|
||||
deleteInvoice.mutate({ id: invoiceToDelete });
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString();
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<div className="bg-muted h-4 animate-pulse rounded" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted h-3 animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-2/3 animate-pulse rounded" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!invoices || invoices.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>No Invoices Yet</CardTitle>
|
||||
<CardDescription>
|
||||
Get started by creating your first invoice
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Button className="w-full">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Your First Invoice
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="search">Search invoices</Label>
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="Search by invoice number or client..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Link href="/dashboard/invoices/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Invoice
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredInvoices.map((invoice) => (
|
||||
<Card key={invoice.id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="truncate">{invoice.invoiceNumber}</span>
|
||||
<div className="flex space-x-1">
|
||||
<Link href={`/invoices/${invoice.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/invoices/${invoice.id}/edit`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(invoice.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<StatusBadge status={invoice.status as StatusType} />
|
||||
<span className="text-lg font-bold text-green-600">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
{invoice.client.name}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Due: {formatDate(invoice.dueDate)}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
{invoice.items.length} item
|
||||
{invoice.items.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Invoice</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this invoice? This action cannot
|
||||
be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Calendar,
|
||||
FileText,
|
||||
User,
|
||||
DollarSign,
|
||||
Trash2,
|
||||
Edit,
|
||||
Download,
|
||||
Send,
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
MapPin,
|
||||
Mail,
|
||||
Phone,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { generateInvoicePDF } from "~/lib/pdf-export";
|
||||
import { InvoiceViewSkeleton } from "~/components/ui/skeleton";
|
||||
|
||||
interface InvoiceViewProps {
|
||||
invoiceId: string;
|
||||
}
|
||||
|
||||
const statusIconConfig = {
|
||||
draft: FileText,
|
||||
sent: Send,
|
||||
paid: DollarSign,
|
||||
overdue: AlertCircle,
|
||||
} as const;
|
||||
|
||||
export function InvoiceView({ invoiceId }: InvoiceViewProps) {
|
||||
const router = useRouter();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isExportingPDF, setIsExportingPDF] = useState(false);
|
||||
|
||||
// Fetch invoice data
|
||||
const {
|
||||
data: invoice,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = api.invoices.getById.useQuery({ id: invoiceId });
|
||||
|
||||
// Delete mutation
|
||||
const deleteInvoice = api.invoices.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Invoice deleted successfully");
|
||||
setDeleteDialogOpen(false);
|
||||
router.push("/dashboard/invoices");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message ?? "Failed to delete invoice");
|
||||
},
|
||||
});
|
||||
|
||||
// Update status mutation
|
||||
const updateStatus = api.invoices.updateStatus.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Status updated successfully");
|
||||
void refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message ?? "Failed to update status");
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
deleteInvoice.mutate({ id: invoiceId });
|
||||
};
|
||||
|
||||
const handleStatusUpdate = (
|
||||
newStatus: "draft" | "sent" | "paid" | "overdue",
|
||||
) => {
|
||||
updateStatus.mutate({ id: invoiceId, status: newStatus });
|
||||
};
|
||||
|
||||
const handlePDFExport = async () => {
|
||||
if (!invoice) return;
|
||||
|
||||
setIsExportingPDF(true);
|
||||
try {
|
||||
await generateInvoicePDF(invoice);
|
||||
toast.success("PDF exported successfully");
|
||||
} catch (error) {
|
||||
console.error("PDF export error:", error);
|
||||
toast.error("Failed to export PDF. Please try again.");
|
||||
} finally {
|
||||
setIsExportingPDF(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return format(new Date(date), "MMM dd, yyyy");
|
||||
};
|
||||
|
||||
const isOverdue =
|
||||
invoice &&
|
||||
new Date(invoice.dueDate) < new Date() &&
|
||||
invoice.status !== "paid";
|
||||
|
||||
if (isLoading) {
|
||||
return <InvoiceViewSkeleton />;
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return (
|
||||
<div className="py-12 text-center">
|
||||
<FileText className="mx-auto mb-4 h-12 w-12 text-gray-400" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900 dark:text-white">
|
||||
Invoice not found
|
||||
</h3>
|
||||
<p className="mb-4 text-gray-500 dark:text-gray-400">
|
||||
The invoice you're looking for doesn't exist or has been
|
||||
deleted.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/dashboard/invoices">Back to Invoices</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const StatusIcon =
|
||||
statusIconConfig[invoice.status as keyof typeof statusIconConfig];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status Alert */}
|
||||
{isOverdue && (
|
||||
<Card className="border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span className="font-medium">This invoice is overdue</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Invoice Header Card */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<CardContent>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<FileText className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{invoice.invoiceNumber}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Professional Invoice
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
Issue Date
|
||||
</span>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{formatDate(invoice.issueDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
Due Date
|
||||
</span>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{formatDate(invoice.dueDate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-right">
|
||||
<StatusBadge
|
||||
status={invoice.status as StatusType}
|
||||
className="px-3 py-1 text-sm font-medium"
|
||||
>
|
||||
<StatusIcon className="mr-1 h-3 w-3" />
|
||||
</StatusBadge>
|
||||
<div className="text-3xl font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePDFExport}
|
||||
disabled={isExportingPDF}
|
||||
variant="brand"
|
||||
className="transform-none"
|
||||
>
|
||||
{isExportingPDF ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
Generating PDF...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download PDF
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Client Information */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
|
||||
<User className="h-5 w-5" />
|
||||
Bill To
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{invoice.client?.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
|
||||
{invoice.client?.email && (
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
|
||||
<Mail className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
{invoice.client.email}
|
||||
</div>
|
||||
)}
|
||||
{invoice.client?.phone && (
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
|
||||
<Phone className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
{invoice.client.phone}
|
||||
</div>
|
||||
)}
|
||||
{(invoice.client?.addressLine1 ??
|
||||
invoice.client?.city ??
|
||||
invoice.client?.state) && (
|
||||
<div className="flex items-start gap-2 text-gray-600 md:col-span-2 dark:text-gray-300">
|
||||
<MapPin className="mt-0.5 h-4 w-4 flex-shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
<div>
|
||||
{invoice.client?.addressLine1 && (
|
||||
<div>{invoice.client.addressLine1}</div>
|
||||
)}
|
||||
{invoice.client?.addressLine2 && (
|
||||
<div>{invoice.client.addressLine2}</div>
|
||||
)}
|
||||
{(invoice.client?.city ??
|
||||
invoice.client?.state ??
|
||||
invoice.client?.postalCode) && (
|
||||
<div>
|
||||
{[
|
||||
invoice.client?.city,
|
||||
invoice.client?.state,
|
||||
invoice.client?.postalCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{invoice.client?.country && (
|
||||
<div>{invoice.client.country}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice Items */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
|
||||
<Clock className="h-5 w-5" />
|
||||
Invoice Items
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Description
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Hours
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Rate
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Amount
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoice.items?.map((item, index) => (
|
||||
<tr
|
||||
key={item.id || index}
|
||||
className="border-t border-gray-100 hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
||||
{formatDate(item.date)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
||||
{item.description}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-gray-900 dark:text-gray-300">
|
||||
{item.hours}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm text-gray-900 dark:text-gray-300">
|
||||
{formatCurrency(item.rate)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
{formatCurrency(item.amount)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-emerald-700 dark:text-emerald-400">
|
||||
Notes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">
|
||||
{invoice.notes}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Status Actions */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-emerald-700 dark:text-emerald-400">
|
||||
Status Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{invoice.status === "draft" && (
|
||||
<Button
|
||||
onClick={() => handleStatusUpdate("sent")}
|
||||
disabled={updateStatus.isPending}
|
||||
className="w-full bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Mark as Sent
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{invoice.status === "sent" && (
|
||||
<Button
|
||||
onClick={() => handleStatusUpdate("paid")}
|
||||
disabled={updateStatus.isPending}
|
||||
className="w-full bg-green-600 text-white hover:bg-green-700"
|
||||
>
|
||||
<DollarSign className="mr-2 h-4 w-4" />
|
||||
Mark as Paid
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{invoice.status === "overdue" && (
|
||||
<Button
|
||||
onClick={() => handleStatusUpdate("paid")}
|
||||
disabled={updateStatus.isPending}
|
||||
className="w-full bg-green-600 text-white hover:bg-green-700"
|
||||
>
|
||||
<DollarSign className="mr-2 h-4 w-4" />
|
||||
Mark as Paid
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{invoice.status === "paid" && (
|
||||
<div className="py-4 text-center">
|
||||
<DollarSign className="mx-auto mb-2 h-8 w-8 text-green-600 dark:text-green-400" />
|
||||
<p className="font-medium text-green-600 dark:text-green-400">
|
||||
Invoice Paid
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice Summary */}
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-emerald-700 dark:text-emerald-400">
|
||||
Summary
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
Subtotal
|
||||
</span>
|
||||
<span className="font-medium dark:text-white">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-300">Tax</span>
|
||||
<span className="font-medium dark:text-white">$0.00</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg font-bold">
|
||||
<span className="dark:text-white">Total</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 text-center dark:border-gray-700">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{invoice.items?.length ?? 0} item
|
||||
{invoice.items?.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-0 border-red-200 bg-white/80 shadow-xl backdrop-blur-sm dark:border-red-800 dark:bg-gray-800/80">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-700 dark:text-red-400">
|
||||
Danger Zone
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="outline"
|
||||
className="w-full border-red-200 text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Invoice
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="border-0 bg-white/95 shadow-2xl backdrop-blur-sm dark:bg-gray-800/95">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold text-gray-800 dark:text-white">
|
||||
Delete Invoice
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-600 dark:text-gray-300">
|
||||
Are you sure you want to delete this invoice? This action cannot
|
||||
be undone and will permanently remove the invoice and all its
|
||||
data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
className="border-gray-300 text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDelete}
|
||||
disabled={deleteInvoice.isPending}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import * as React from "react";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
description?: string;
|
||||
icon?: LucideIcon;
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
};
|
||||
variant?: "default" | "success" | "warning" | "error" | "info";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
default: {
|
||||
icon: "text-foreground",
|
||||
background: "bg-muted/50",
|
||||
},
|
||||
success: {
|
||||
icon: "text-status-success",
|
||||
background: "bg-status-success-muted",
|
||||
},
|
||||
warning: {
|
||||
icon: "text-status-warning",
|
||||
background: "bg-status-warning-muted",
|
||||
},
|
||||
error: {
|
||||
icon: "text-status-error",
|
||||
background: "bg-status-error-muted",
|
||||
},
|
||||
info: {
|
||||
icon: "text-status-info",
|
||||
background: "bg-status-info-muted",
|
||||
},
|
||||
};
|
||||
|
||||
export function StatsCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
icon: Icon,
|
||||
trend,
|
||||
variant = "default",
|
||||
className,
|
||||
}: StatsCardProps) {
|
||||
const styles = variantStyles[variant];
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"border-0 shadow-md transition-shadow hover:shadow-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-sm font-medium">{title}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
{trend && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
trend.isPositive
|
||||
? "text-status-success"
|
||||
: "text-status-error",
|
||||
)}
|
||||
>
|
||||
{trend.isPositive ? "+" : ""}
|
||||
{trend.value}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-xs">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{Icon && (
|
||||
<div className={cn("rounded-full p-3", styles.background)}>
|
||||
<Icon className={cn("h-6 w-6", styles.icon)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsCardSkeleton() {
|
||||
return (
|
||||
<Card className="border-0 shadow-md">
|
||||
<CardContent className="p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-muted mb-2 h-4 w-1/2 rounded"></div>
|
||||
<div className="bg-muted mb-2 h-8 w-3/4 rounded"></div>
|
||||
<div className="bg-muted h-3 w-1/3 rounded"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
import { Badge, type badgeVariants } from "~/components/ui/badge";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
|
||||
type StatusType =
|
||||
| "draft"
|
||||
| "sent"
|
||||
| "paid"
|
||||
| "overdue"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "error"
|
||||
| "info";
|
||||
|
||||
interface StatusBadgeProps
|
||||
extends Omit<React.ComponentProps<typeof Badge>, "variant"> {
|
||||
status: StatusType;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const statusVariantMap: Record<
|
||||
StatusType,
|
||||
VariantProps<typeof badgeVariants>["variant"]
|
||||
> = {
|
||||
draft: "secondary",
|
||||
sent: "info",
|
||||
paid: "success",
|
||||
overdue: "error",
|
||||
success: "success",
|
||||
warning: "warning",
|
||||
error: "error",
|
||||
info: "info",
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<StatusType, string> = {
|
||||
draft: "Draft",
|
||||
sent: "Sent",
|
||||
paid: "Paid",
|
||||
overdue: "Overdue",
|
||||
success: "Success",
|
||||
warning: "Warning",
|
||||
error: "Error",
|
||||
info: "Info",
|
||||
};
|
||||
|
||||
export function StatusBadge({ status, children, ...props }: StatusBadgeProps) {
|
||||
const variant = statusVariantMap[status];
|
||||
const label = children || statusLabelMap[status];
|
||||
|
||||
return (
|
||||
<Badge variant={variant} {...props}>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export { type StatusType };
|
||||
Reference in New Issue
Block a user