From 480c50981d29f6306f0dae340070186b61ae4eb6 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Tue, 23 Jun 2026 01:08:23 -0400 Subject: [PATCH] Unify entities navigation, redesign time clock, and add invoice PDF preview. Combine clients and businesses under entities, polish the web time clock, and show live invoice PDF preview with tighter line-item editing. Co-authored-by: Cursor --- src/app/dashboard/businesses/[id]/page.tsx | 2 +- .../_components/businesses-table.tsx | 19 + src/app/dashboard/businesses/page.tsx | 41 +- src/app/dashboard/clients/[id]/page.tsx | 2 +- src/app/dashboard/clients/page.tsx | 30 +- .../entities/_components/entities-view.tsx | 60 +++ src/app/dashboard/entities/page.tsx | 26 + src/app/dashboard/time-clock/page.tsx | 2 +- src/components/forms/business-form.tsx | 6 +- src/components/forms/client-form.tsx | 6 +- src/components/forms/invoice-form.tsx | 84 ++-- src/components/forms/invoice-line-items.tsx | 182 +++---- .../invoice/invoice-pdf-preview-panel.tsx | 105 ++++ src/components/layout/sidebar.tsx | 4 +- src/components/navigation/sidebar-trigger.tsx | 6 +- .../time-clock/time-clock-panel.tsx | 471 +++++++++++++----- src/lib/navigation.ts | 15 +- src/lib/time-clock-prefs.ts | 11 + src/lib/time-clock.ts | 26 + 19 files changed, 734 insertions(+), 364 deletions(-) create mode 100644 src/app/dashboard/businesses/_components/businesses-table.tsx create mode 100644 src/app/dashboard/entities/_components/entities-view.tsx create mode 100644 src/app/dashboard/entities/page.tsx create mode 100644 src/components/forms/invoice/invoice-pdf-preview-panel.tsx create mode 100644 src/lib/time-clock-prefs.ts diff --git a/src/app/dashboard/businesses/[id]/page.tsx b/src/app/dashboard/businesses/[id]/page.tsx index 18c8c8f..7e9cd1a 100644 --- a/src/app/dashboard/businesses/[id]/page.tsx +++ b/src/app/dashboard/businesses/[id]/page.tsx @@ -50,7 +50,7 @@ export default async function BusinessDetailPage({ variant="gradient" > - - - - }> - - - - - ); +export default function BusinessesPage() { + redirect("/dashboard/entities?tab=businesses"); } diff --git a/src/app/dashboard/clients/[id]/page.tsx b/src/app/dashboard/clients/[id]/page.tsx index 961ce12..3353b16 100644 --- a/src/app/dashboard/clients/[id]/page.tsx +++ b/src/app/dashboard/clients/[id]/page.tsx @@ -64,7 +64,7 @@ export default async function ClientDetailPage({ variant="gradient" > - - - - - - - ); +export default function ClientsPage() { + redirect("/dashboard/entities?tab=clients"); } diff --git a/src/app/dashboard/entities/_components/entities-view.tsx b/src/app/dashboard/entities/_components/entities-view.tsx new file mode 100644 index 0000000..04cef7d --- /dev/null +++ b/src/app/dashboard/entities/_components/entities-view.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Plus } from "lucide-react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { PageHeader } from "~/components/layout/page-header"; +import { Button } from "~/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; +import { ClientsTable } from "../../clients/_components/clients-table"; +import { BusinessesTable } from "../../businesses/_components/businesses-table"; + +type EntityTab = "clients" | "businesses"; + +export function EntitiesView({ initialTab }: { initialTab: EntityTab }) { + const router = useRouter(); + const searchParams = useSearchParams(); + const tab: EntityTab = + searchParams.get("tab") === "businesses" ? "businesses" : initialTab; + + function handleTabChange(value: string) { + const next = value === "businesses" ? "businesses" : "clients"; + router.replace(`/dashboard/entities?tab=${next}`, { scroll: false }); + } + + const addHref = + tab === "clients" ? "/dashboard/clients/new" : "/dashboard/businesses/new"; + const addLabel = tab === "clients" ? "Add client" : "Add business"; + + return ( +
+ + + + + + + Clients + Businesses + + + + + + + + + + +
+ ); +} diff --git a/src/app/dashboard/entities/page.tsx b/src/app/dashboard/entities/page.tsx new file mode 100644 index 0000000..abfde3c --- /dev/null +++ b/src/app/dashboard/entities/page.tsx @@ -0,0 +1,26 @@ +import { Suspense } from "react"; +import { DataTableSkeleton } from "~/components/data/data-table"; +import { api, HydrateClient } from "~/trpc/server"; +import { EntitiesView } from "./_components/entities-view"; + +export default async function EntitiesPage({ + searchParams, +}: { + searchParams: Promise<{ tab?: string }>; +}) { + const params = await searchParams; + const initialTab = params.tab === "businesses" ? "businesses" : "clients"; + + void api.clients.getAll.prefetch(); + void api.businesses.getAll.prefetch(); + + return ( +
+ + }> + + + +
+ ); +} diff --git a/src/app/dashboard/time-clock/page.tsx b/src/app/dashboard/time-clock/page.tsx index c5d8203..12f9d3d 100644 --- a/src/app/dashboard/time-clock/page.tsx +++ b/src/app/dashboard/time-clock/page.tsx @@ -17,7 +17,7 @@ export default async function TimeClockPage({ } return ( -
+
{ toast.success("Client created successfully"); - router.push("/dashboard/clients"); + router.push("/dashboard/entities?tab=clients"); }, onError: (error) => { toast.error(error.message || "Failed to create client"); @@ -109,7 +109,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) { const updateClient = api.clients.update.useMutation({ onSuccess: () => { toast.success("Client updated successfully"); - router.push("/dashboard/clients"); + router.push("/dashboard/entities?tab=clients"); }, onError: (error) => { toast.error(error.message || "Failed to update client"); @@ -232,7 +232,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) { ); if (!confirmed) return; } - router.push("/dashboard/clients"); + router.push("/dashboard/entities?tab=clients"); }; if (mode === "edit" && isLoadingClient) { diff --git a/src/components/forms/invoice-form.tsx b/src/components/forms/invoice-form.tsx index 2dc6551..2b2b68b 100644 --- a/src/components/forms/invoice-form.tsx +++ b/src/components/forms/invoice-form.tsx @@ -52,6 +52,7 @@ import { import { STATUS_OPTIONS } from "./invoice/types"; import type { InvoiceFormData, InvoiceItem } from "./invoice/types"; import type { ParsedLineItem } from "~/lib/parse-line-item"; +import { InvoicePdfPreviewPanel } from "./invoice/invoice-pdf-preview-panel"; import { CountUp } from "~/components/ui/count-up"; @@ -135,6 +136,15 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [activeTab, setActiveTab] = useState("details"); const [previewTab, setPreviewTab] = useState("pdf"); + const [previewPinned, setPreviewPinned] = useState(false); + + useEffect(() => { + const media = window.matchMedia("(min-width: 1024px)"); + const update = () => setPreviewPinned(media.matches); + update(); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); // Queries (Same as before) const { data: clients, isLoading: loadingClients } = @@ -254,17 +264,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { [formData], ); - const { data: pdfPreview, isFetching: pdfPreviewLoading } = - api.invoices.previewPdf.useQuery(pdfPreviewInput, { - enabled: - activeTab === "preview" && - previewTab === "pdf" && - Boolean(formData.clientId) && - formData.items.length > 0 && - formData.items.every((item) => item.description.trim() !== ""), - refetchOnWindowFocus: false, - staleTime: 0, - }); const selectedClient = React.useMemo( () => clients?.find((client) => client.id === formData.clientId), [clients, formData.clientId], @@ -480,9 +479,10 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { - +
+ {/* TAB SELECTOR: w-full, p-1, visible background */} - + Preview @@ -863,43 +863,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { - - - - PDF Preview - - - -
- {!formData.clientId ? ( -
- Select a client to generate the PDF preview. -
- ) : formData.items.some( - (item) => item.description.trim() === "", - ) ? ( -
- Add descriptions for all line items to generate the - PDF preview. -
- ) : pdfPreviewLoading && !pdfPreview ? ( -
- Generating server PDF preview... -
- ) : pdfPreview ? ( -