feat: polish invoice editor and viewer UI with custom NumberInput
component - Create custom NumberInput component with increment/decrement buttons - Add 0.25 step increments for hours and rates in invoice forms - Implement emerald-themed styling with hover states and accessibility - Add keyboard navigation (arrow keys) and proper ARIA support - Condense invoice editor tax/totals section into efficient grid layout - Update client dropdown to single-line format (name + email) - Add fixed footer with floating action bar pattern matching business forms - Redesign invoice viewer with better space utilization and visual hierarchy - Maintain professional appearance and consistent design system - Fix Next.js 15 params Promise handling across all invoice pages - Resolve TypeScript compilation errors and type-only imports
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import { ClientForm } from "~/components/client-form";
|
||||
import { PageHeader } from "~/components/page-header";
|
||||
|
||||
interface EditClientPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -10,14 +12,11 @@ export default async function EditClientPage({ params }: EditClientPageProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Edit Client
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600">
|
||||
Update client information below.
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Edit Client"
|
||||
description="Update client information below."
|
||||
variant="gradient"
|
||||
/>
|
||||
<HydrateClient>
|
||||
<ClientForm mode="edit" clientId={id} />
|
||||
</HydrateClient>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api } from "~/trpc/server";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { PageHeader } from "~/components/page-header";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Edit,
|
||||
@@ -53,32 +54,27 @@ export default async function ClientDetailPage({
|
||||
client.invoices?.filter((invoice) => invoice.status === "sent").length || 0;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:mr-4 md:ml-72 md:p-6">
|
||||
<div>
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
{client.name}
|
||||
</h1>
|
||||
<p className="text-muted-foreground dark:text-gray-300">
|
||||
Client Details
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/clients/${client.id}/edit`}>
|
||||
<Button className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700">
|
||||
<PageHeader
|
||||
title={client.name}
|
||||
description="Client Details"
|
||||
variant="gradient"
|
||||
>
|
||||
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
||||
<Button variant="brand">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Client
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Client Information Card */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="border-0 bg-white/80 shadow-xl backdrop-blur-sm dark:bg-gray-800/80">
|
||||
<Card className="border-0 shadow-xl backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-emerald-700 dark:text-emerald-400">
|
||||
<CardTitle className="flex items-center space-x-2 text-green-600">
|
||||
<Building className="h-5 w-5" />
|
||||
<span>Contact Information</span>
|
||||
</CardTitle>
|
||||
@@ -92,10 +88,10 @@ export default async function ClientDetailPage({
|
||||
<Mail className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Email
|
||||
</p>
|
||||
<p className="text-sm dark:text-gray-300">
|
||||
<p className="text-foreground text-sm">
|
||||
{client.email}
|
||||
</p>
|
||||
</div>
|
||||
@@ -108,10 +104,10 @@ export default async function ClientDetailPage({
|
||||
<Phone className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Phone
|
||||
</p>
|
||||
<p className="text-sm dark:text-gray-300">
|
||||
<p className="text-foreground text-sm">
|
||||
{client.phone}
|
||||
</p>
|
||||
</div>
|
||||
@@ -127,12 +123,12 @@ export default async function ClientDetailPage({
|
||||
<MapPin className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Address
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-11 space-y-1 text-sm dark:text-gray-300">
|
||||
<div className="text-foreground ml-11 space-y-1 text-sm">
|
||||
{client.addressLine1 && <p>{client.addressLine1}</p>}
|
||||
{client.addressLine2 && <p>{client.addressLine2}</p>}
|
||||
{(client.city ?? client.state ?? client.postalCode) && (
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DataTable, DataTableColumnHeader } from "~/components/ui/data-table";
|
||||
import { UserPlus, Pencil, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Type for client data
|
||||
interface Client {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
addressLine1: string | null;
|
||||
addressLine2: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
postalCode: string | null;
|
||||
country: string | null;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
}
|
||||
|
||||
interface ClientsDataTableProps {
|
||||
clients: Client[];
|
||||
}
|
||||
|
||||
const formatAddress = (client: Client) => {
|
||||
const parts = [
|
||||
client.addressLine1,
|
||||
client.addressLine2,
|
||||
client.city,
|
||||
client.state,
|
||||
client.postalCode,
|
||||
].filter(Boolean);
|
||||
return parts.join(", ") || "—";
|
||||
};
|
||||
|
||||
export function ClientsDataTable({
|
||||
clients: initialClients,
|
||||
}: ClientsDataTableProps) {
|
||||
const [clients, setClients] = useState(initialClients);
|
||||
const [clientToDelete, setClientToDelete] = useState<Client | null>(null);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteClientMutation = api.clients.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Client deleted successfully");
|
||||
setClients(clients.filter((c) => c.id !== clientToDelete?.id));
|
||||
setClientToDelete(null);
|
||||
void utils.clients.getAll.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to delete client: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!clientToDelete) return;
|
||||
deleteClientMutation.mutate({ id: clientToDelete.id });
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Client>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Name" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const client = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-status-info-muted hidden rounded-lg p-2 sm:flex">
|
||||
<UserPlus className="text-status-info h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{client.name}</p>
|
||||
<p className="text-muted-foreground truncate text-sm">
|
||||
{client.email || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "phone",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Phone" />
|
||||
),
|
||||
cell: ({ row }) => row.original.phone || "—",
|
||||
meta: {
|
||||
headerClassName: "hidden md:table-cell",
|
||||
cellClassName: "hidden md:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "address",
|
||||
header: "Address",
|
||||
cell: ({ row }) => formatAddress(row.original),
|
||||
meta: {
|
||||
headerClassName: "hidden lg:table-cell",
|
||||
cellClassName: "hidden lg:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("createdAt") as Date;
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(new Date(date));
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden xl:table-cell",
|
||||
cellClassName: "hidden xl:table-cell",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const client = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link href={`/dashboard/clients/${client.id}/edit`}>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setClientToDelete(client)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={clients}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Search clients..."
|
||||
/>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog
|
||||
open={!!clientToDelete}
|
||||
onOpenChange={(open) => !open && setClientToDelete(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
client "{clientToDelete?.name}" and remove all associated data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setClientToDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteClientMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { UniversalTable } from "~/components/ui/universal-table";
|
||||
import { TableSkeleton } from "~/components/ui/skeleton";
|
||||
import { DataTableSkeleton } from "~/components/ui/data-table";
|
||||
import { ClientsDataTable } from "./clients-data-table";
|
||||
|
||||
export function ClientsTable() {
|
||||
const { isLoading } = api.clients.getAll.useQuery();
|
||||
const { data: clients, isLoading } = api.clients.getAll.useQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return <TableSkeleton rows={8} />;
|
||||
return <DataTableSkeleton columns={5} rows={8} />;
|
||||
}
|
||||
|
||||
return <UniversalTable resource="clients" />;
|
||||
}
|
||||
if (!clients) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ClientsDataTable clients={clients} />;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import Link from "next/link";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import { ClientForm } from "~/components/client-form";
|
||||
import { PageHeader } from "~/components/page-header";
|
||||
|
||||
export default async function NewClientPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Add Client
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600">
|
||||
Enter client details below to add a new client.
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Add Client"
|
||||
description="Enter client details below to add a new client."
|
||||
variant="gradient"
|
||||
/>
|
||||
<HydrateClient>
|
||||
<ClientForm mode="create" />
|
||||
</HydrateClient>
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { HydrateClient } from "~/trpc/server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { ClientsTable } from "./_components/clients-table";
|
||||
import { PageHeader } from "~/components/page-header";
|
||||
import { PageContent, PageSection } from "~/components/ui/page-layout";
|
||||
|
||||
export default async function ClientsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
|
||||
Clients
|
||||
</h1>
|
||||
<p className="mt-1 text-lg text-gray-600">
|
||||
Manage your clients and their information.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
|
||||
>
|
||||
<>
|
||||
<PageHeader
|
||||
title="Clients"
|
||||
description="Manage your clients and their information."
|
||||
variant="gradient"
|
||||
>
|
||||
<Button asChild variant="brand">
|
||||
<Link href="/dashboard/clients/new">
|
||||
<Plus className="mr-2 h-5 w-5" /> Add Client
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
<span>Add Client</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<HydrateClient>
|
||||
<ClientsTable />
|
||||
</HydrateClient>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user