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 <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 01:08:23 -04:00
parent 0b7ffac4e7
commit 480c50981d
19 changed files with 734 additions and 364 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ export default async function BusinessDetailPage({
variant="gradient"
>
<Button asChild variant="outline" className="shadow-sm">
<Link href="/dashboard/businesses">
<Link href="/dashboard/entities?tab=businesses">
<ArrowLeft className="mr-2 h-4 w-4" />
<span>Back to Businesses</span>
</Link>
@@ -0,0 +1,19 @@
"use client";
import { api } from "~/trpc/react";
import { DataTableSkeleton } from "~/components/data/data-table";
import { BusinessesDataTable } from "./businesses-data-table";
export function BusinessesTable() {
const { data: businesses, isLoading } = api.businesses.getAll.useQuery();
if (isLoading) {
return <DataTableSkeleton columns={7} rows={5} />;
}
if (!businesses) {
return null;
}
return <BusinessesDataTable businesses={businesses} />;
}
+3 -38
View File
@@ -1,40 +1,5 @@
import { Plus } from "lucide-react";
import Link from "next/link";
import { Suspense } from "react";
import { DataTableSkeleton } from "~/components/data/data-table";
import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button";
import { api, HydrateClient } from "~/trpc/server";
import { BusinessesDataTable } from "./_components/businesses-data-table";
import { redirect } from "next/navigation";
// Businesses Table Component
async function BusinessesTable() {
const businesses = await api.businesses.getAll();
return <BusinessesDataTable businesses={businesses} />;
}
export default async function BusinessesPage() {
return (
<div className="page-enter space-y-8">
<PageHeader
title="Businesses"
description="Manage your businesses and their information"
variant="gradient"
>
<Button asChild variant="default" className="hover-lift shadow-md">
<Link href="/dashboard/businesses/new">
<Plus className="mr-2 h-5 w-5" />
<span>Add Business</span>
</Link>
</Button>
</PageHeader>
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
<BusinessesTable />
</Suspense>
</HydrateClient>
</div>
);
export default function BusinessesPage() {
redirect("/dashboard/entities?tab=businesses");
}
+1 -1
View File
@@ -64,7 +64,7 @@ export default async function ClientDetailPage({
variant="gradient"
>
<Button asChild variant="outline" className="shadow-sm">
<Link href="/dashboard/clients">
<Link href="/dashboard/entities?tab=clients">
<ArrowLeft className="mr-2 h-4 w-4" />
<span>Back to Clients</span>
</Link>
+3 -27
View File
@@ -1,29 +1,5 @@
import { Plus } from "lucide-react";
import Link from "next/link";
import { PageHeader } from "~/components/layout/page-header";
import { Button } from "~/components/ui/button";
import { HydrateClient } from "~/trpc/server";
import { ClientsTable } from "./_components/clients-table";
import { redirect } from "next/navigation";
export default async function ClientsPage() {
return (
<div className="page-enter space-y-6">
<PageHeader
title="Clients"
description="Manage your clients and their information."
variant="gradient"
>
<Button asChild variant="default" className="hover-lift shadow-md">
<Link href="/dashboard/clients/new">
<Plus className="mr-2 h-5 w-5" />
<span>Add Client</span>
</Link>
</Button>
</PageHeader>
<HydrateClient>
<ClientsTable />
</HydrateClient>
</div>
);
export default function ClientsPage() {
redirect("/dashboard/entities?tab=clients");
}
@@ -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 (
<div className="space-y-6">
<PageHeader
title="Entities"
description="Clients you bill and businesses you send from"
variant="gradient"
>
<Button asChild variant="default" className="hover-lift shadow-md">
<Link href={addHref}>
<Plus className="mr-2 h-5 w-5" />
<span>{addLabel}</span>
</Link>
</Button>
</PageHeader>
<Tabs value={tab} onValueChange={handleTabChange}>
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="clients">Clients</TabsTrigger>
<TabsTrigger value="businesses">Businesses</TabsTrigger>
</TabsList>
<TabsContent value="clients" className="mt-6">
<ClientsTable />
</TabsContent>
<TabsContent value="businesses" className="mt-6">
<BusinessesTable />
</TabsContent>
</Tabs>
</div>
);
}
+26
View File
@@ -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 (
<div className="page-enter space-y-6">
<HydrateClient>
<Suspense fallback={<DataTableSkeleton columns={5} rows={8} />}>
<EntitiesView initialTab={initialTab} />
</Suspense>
</HydrateClient>
</div>
);
}
+1 -1
View File
@@ -17,7 +17,7 @@ export default async function TimeClockPage({
}
return (
<div className="page-enter mx-auto max-w-3xl space-y-6">
<div className="page-enter space-y-6">
<DashboardPageHeader
title="Time clock"
description="Track billable hours and save them directly to an invoice"
+3 -3
View File
@@ -338,7 +338,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
}
toast.success("Business created successfully");
router.push("/dashboard/businesses");
router.push("/dashboard/entities?tab=businesses");
} else {
// Update business data (excluding email config fields)
const businessData = {
@@ -386,7 +386,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
}
toast.success("Business updated successfully");
router.push("/dashboard/businesses");
router.push("/dashboard/entities?tab=businesses");
}
} finally {
setIsSubmitting(false);
@@ -400,7 +400,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
);
if (!confirmed) return;
}
router.push("/dashboard/businesses");
router.push("/dashboard/entities?tab=businesses");
};
if (
+3 -3
View File
@@ -99,7 +99,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
const createClient = api.clients.create.useMutation({
onSuccess: () => {
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) {
+33 -51
View File
@@ -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) {
</Button>
</PageHeader>
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]">
<Tabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
{/* TAB SELECTOR: w-full, p-1, visible background */}
<TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1">
<TabsList className="bg-muted grid h-auto w-full grid-cols-4 rounded-xl p-1 lg:grid-cols-3">
<TabsTrigger
value="details"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
@@ -503,7 +503,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</TabsTrigger>
<TabsTrigger
value="preview"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm"
className="data-[state=active]:bg-background rounded-lg py-2.5 data-[state=active]:shadow-sm lg:hidden"
>
Preview
</TabsTrigger>
@@ -863,43 +863,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</TabsList>
<TabsContent value="pdf" className="mt-6">
<Card>
<CardHeader>
<CardTitle className="flex gap-2">
<FileText className="h-5 w-5" /> PDF Preview
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="bg-muted/20 h-[760px] overflow-hidden border-t">
{!formData.clientId ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Select a client to generate the PDF preview.
</div>
) : formData.items.some(
(item) => item.description.trim() === "",
) ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Add descriptions for all line items to generate the
PDF preview.
</div>
) : pdfPreviewLoading && !pdfPreview ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Generating server PDF preview...
</div>
) : pdfPreview ? (
<iframe
title="Server-generated PDF preview"
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
className="h-full w-full border-0"
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
PDF preview will appear here.
</div>
)}
</div>
</CardContent>
</Card>
<InvoicePdfPreviewPanel input={pdfPreviewInput} />
</TabsContent>
<TabsContent value="email" className="mt-6">
@@ -954,6 +918,24 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</Tabs>
</TabsContent>
</Tabs>
<aside className="hidden lg:block">
<div className="sticky top-4 space-y-4">
<InvoicePdfPreviewPanel
input={pdfPreviewInput}
enabled={previewPinned || activeTab === "preview"}
/>
<Card className="border-primary/20 bg-primary/5">
<CardContent className="flex items-center justify-between p-4">
<span className="text-muted-foreground text-sm">Invoice total</span>
<span className="font-mono text-2xl font-bold">
<CountUp value={totals.total} prefix="$" />
</span>
</CardContent>
</Card>
</div>
</aside>
</div>
</div>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+75 -107
View File
@@ -8,7 +8,6 @@ import Link from "next/link";
import { Button } from "~/components/ui/button";
import { DatePicker } from "~/components/ui/date-picker";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { NumberInput } from "~/components/ui/number-input";
import { cn } from "~/lib/utils";
import { parseLineItem, type ParsedLineItem } from "~/lib/parse-line-item";
@@ -156,7 +155,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
<div
ref={ref}
className={cn(
"group hover:bg-muted/40 hidden min-h-16 grid-cols-[140px_minmax(200px,1fr)_124px_136px_104px_32px] items-center gap-2 border-b px-3 py-2 transition-colors md:grid",
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[108px_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
)}
>
<DatePicker
@@ -164,7 +163,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
size="sm"
className="w-full"
inputClassName="h-9"
inputClassName="h-8 text-xs"
disabled={readOnly}
/>
@@ -173,8 +172,8 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
onChange={(v) => onDescriptionChange(index, v)}
onSelect={(s) => onSelectSuggestion(index, s)}
suggestions={suggestions}
placeholder="Describe the work performed..."
className="h-9 w-full text-sm font-medium"
placeholder="Description"
className="h-8 w-full text-sm"
disabled={readOnly}
/>
@@ -184,7 +183,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
min={0}
step={0.25}
width="full"
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-12"
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
suffix="h"
disabled={readOnly}
/>
@@ -196,11 +195,11 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
step={1}
prefix="$"
width="full"
className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-14"
className="h-8 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-12 [&_input]:text-xs"
disabled={readOnly}
/>
<div className="text-primary text-right font-mono font-semibold">
<div className="text-primary text-right font-mono text-sm font-semibold tabular-nums">
${(item.hours * item.rate).toFixed(2)}
</div>
@@ -210,11 +209,11 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
variant="ghost"
size="sm"
onClick={() => onRemove(index)}
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
className="text-muted-foreground hover:text-destructive h-7 w-7 p-0"
disabled={!canRemove}
aria-label="Remove item"
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : (
<span />
@@ -237,103 +236,72 @@ function MobileLineItem({
readOnly,
}: LineItemRowProps) {
return (
<motion.div
layout
<div
id={`invoice-item-${index}-mobile`}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="border-border bg-card overflow-hidden rounded-lg border md:hidden"
className="border-border space-y-1.5 border-b px-3 py-2 md:hidden"
>
<div className="space-y-3 p-4">
{/* Description */}
<div className="space-y-1">
<Label className="text-muted-foreground text-xs">Description</Label>
<DescriptionAutocomplete
value={item.description}
onChange={(v) => onDescriptionChange(index, v)}
onSelect={(s) => onSelectSuggestion(index, s)}
suggestions={suggestions}
placeholder="Describe the work performed..."
className="pl-3 text-sm"
disabled={readOnly}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground w-5 shrink-0 text-center text-xs font-semibold">
{index + 1}
</span>
<DescriptionAutocomplete
value={item.description}
onChange={(v) => onDescriptionChange(index, v)}
onSelect={(s) => onSelectSuggestion(index, s)}
suggestions={suggestions}
placeholder="Description"
className="h-8 flex-1 text-sm"
disabled={readOnly}
/>
</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())}
<div className="flex items-center gap-1.5 pl-7">
<DatePicker
date={item.date}
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
size="sm"
className="w-[92px] shrink-0"
inputClassName="h-8 px-2 text-xs"
disabled={readOnly}
/>
<NumberInput
value={item.hours}
onChange={(value) => onUpdate(index, "hours", value)}
min={0}
step={0.25}
width="full"
className="h-8 w-[88px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-8 [&_input]:text-xs"
suffix="h"
disabled={readOnly}
/>
<NumberInput
value={item.rate}
onChange={(value) => onUpdate(index, "rate", value)}
min={0}
step={1}
prefix="$"
width="full"
className="h-8 w-[84px] shrink-0 font-mono [&_button]:h-7 [&_button]:w-5 [&_input]:min-w-10 [&_input]:text-xs"
disabled={readOnly}
/>
<span className="text-primary ml-auto font-mono text-sm font-semibold tabular-nums">
${(item.hours * item.rate).toFixed(2)}
</span>
{!readOnly ? (
<Button
type="button"
variant="ghost"
size="sm"
inputClassName="h-9"
disabled={readOnly}
/>
</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"
disabled={readOnly}
/>
</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"
className="font-mono"
disabled={readOnly}
/>
</div>
</div>
onClick={() => onRemove(index)}
className="text-muted-foreground hover:text-destructive h-7 w-7 shrink-0 p-0"
disabled={!canRemove}
aria-label="Remove item"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
{/* Bottom section with controls, item name, and total */}
<div className="border-border bg-muted/50 flex items-center justify-between border-t px-4 py-2">
<div className="flex items-center gap-2">
{!readOnly ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemove(index)}
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
disabled={!canRemove}
aria-label="Remove item"
>
<Trash2 className="h-4 w-4" />
</Button>
) : null}
</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>
</motion.div>
</div>
);
}
@@ -405,12 +373,12 @@ export function InvoiceLineItems({
</p>
) : null}
<AnimatePresence>
<div className="space-y-2 md:space-y-0 md:overflow-hidden md:rounded-lg md:border">
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[140px_minmax(200px,1fr)_124px_136px_104px_32px] gap-2 border-b px-3 py-2 text-xs font-medium md:grid">
<div className="space-y-0 md:overflow-hidden md:rounded-lg md:border">
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[108px_minmax(180px,1fr)_96px_108px_88px_28px] gap-1.5 border-b px-2 py-1.5 text-[11px] font-semibold tracking-wide uppercase md:grid">
<span>Date</span>
<span>Description</span>
<span className="text-right">Hours</span>
<span className="text-right">Rate</span>
<span className="text-center">Hours</span>
<span className="text-center">Rate</span>
<span className="text-right">Amount</span>
<span />
</div>
@@ -483,7 +451,7 @@ export function InvoiceLineItems({
type="button"
variant="outline"
onClick={onAddItem}
className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 mt-3 w-full border-dashed py-6 transition-all"
className="border-border text-muted-foreground hover:text-primary hover:bg-accent/50 hover:border-primary/50 mt-2 w-full border-dashed py-3 transition-all"
>
<Plus className="mr-2 h-4 w-4" />
Add Line Item
@@ -0,0 +1,105 @@
"use client";
import { FileText, Loader2 } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import { api } from "~/trpc/react";
export type InvoicePdfPreviewInput = {
invoiceNumber: string;
invoicePrefix: string;
businessId: string;
clientId: string;
issueDate: Date;
dueDate: Date;
status: "draft" | "sent" | "paid";
notes: string;
emailMessage: string;
taxRate: number;
currency: string;
items: Array<{
date: Date;
description: string;
hours: number;
rate: number;
}>;
};
function canPreview(input: InvoicePdfPreviewInput | null): input is InvoicePdfPreviewInput {
if (!input?.clientId) return false;
if (input.items.length === 0) return false;
return input.items.every((item) => item.description.trim().length > 0);
}
type InvoicePdfPreviewPanelProps = {
input: InvoicePdfPreviewInput | null;
enabled?: boolean;
className?: string;
heightClassName?: string;
};
export function InvoicePdfPreviewPanel({
input,
enabled = true,
className,
heightClassName = "h-[min(80vh,760px)]",
}: InvoicePdfPreviewPanelProps) {
const previewReady = canPreview(input);
const { data: pdfPreview, isFetching, error, refetch } =
api.invoices.previewPdf.useQuery(input!, {
enabled: enabled && previewReady,
refetchOnWindowFocus: false,
staleTime: 5_000,
});
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" />
PDF preview
{isFetching ? <Loader2 className="text-muted-foreground h-3.5 w-3.5 animate-spin" /> : null}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div
className={cn(
"bg-muted/20 overflow-hidden border-t",
heightClassName,
)}
>
{!previewReady ? (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
Select a client and add descriptions for all line items to generate the
PDF preview.
</div>
) : error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<p className="text-destructive text-sm">{error.message}</p>
<Button type="button" variant="outline" size="sm" onClick={() => void refetch()}>
Try again
</Button>
</div>
) : isFetching && !pdfPreview ? (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 p-6 text-center text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
Generating preview
</div>
) : pdfPreview ? (
<iframe
title="Invoice PDF preview"
src={`data:${pdfPreview.contentType};base64,${pdfPreview.base64}`}
className="h-full w-full border-0"
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-center text-sm">
PDF preview will appear here.
</div>
)}
</div>
</CardContent>
</Card>
);
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { authClient } from "~/lib/auth-client";
import { Skeleton } from "~/components/ui/skeleton";
import { Button } from "~/components/ui/button";
import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { navigationConfig } from "~/lib/navigation";
import { navigationConfig, isNavLinkActive } from "~/lib/navigation";
import { useSidebar } from "./sidebar-provider";
import { cn } from "~/lib/utils";
import { Logo } from "~/components/branding/logo";
@@ -83,7 +83,7 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
<div className="flex flex-col gap-1">
{section.links.map((link) => {
const Icon = link.icon;
const isActive = pathname === link.href;
const isActive = isNavLinkActive(pathname, link.href);
if (collapsed) {
return (
@@ -6,7 +6,7 @@ import { usePathname } from "next/navigation";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
import { useAuthSession } from "~/hooks/use-auth-session";
import { navigationConfig } from "~/lib/navigation";
import { navigationConfig, isNavLinkActive } from "~/lib/navigation";
interface SidebarTriggerProps {
isOpen: boolean;
@@ -68,10 +68,10 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
key={link.href}
href={link.href}
aria-current={
pathname === link.href ? "page" : undefined
isNavLinkActive(pathname, link.href) ? "page" : undefined
}
className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${
pathname === link.href
isNavLinkActive(pathname, link.href)
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-muted"
}`}
+347 -124
View File
@@ -15,9 +15,29 @@ import {
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Clock, Play, Square, ExternalLink } from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
import { ChevronDown, Clock, ExternalLink, Play, Square } from "lucide-react";
import { toast } from "sonner";
import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock";
import { cn } from "~/lib/utils";
import {
getLastTimeClockClientId,
setLastTimeClockClientId,
} from "~/lib/time-clock-prefs";
import {
describeClockOutOutcome,
formatElapsedSeconds,
resolveClockDescription,
resolveEffectiveHourlyRate,
startedAtFromMinutesAgo,
} from "~/lib/time-clock";
const FEATURED_CLIENT_COUNT = 4;
type StartMode = "now" | "pick" | "ago";
export type TimeClockPanelProps = {
defaultClientId?: string;
@@ -25,6 +45,38 @@ export type TimeClockPanelProps = {
compact?: boolean;
};
function invoiceLabel(inv: {
invoicePrefix: string | null;
invoiceNumber: string;
}) {
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
}
function ClientChip({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"rounded-full border px-3 py-1.5 text-sm font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:bg-muted",
)}
>
{label}
</button>
);
}
export function TimeClockPanel({
defaultClientId = "",
defaultInvoiceId = "",
@@ -37,19 +89,6 @@ export function TimeClockPanel({
);
const { data: clients } = api.clients.getAll.useQuery();
const [clientId, setClientId] = useState(defaultClientId);
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
const [description, setDescription] = useState("");
const [rate, setRate] = useState(0);
const [elapsed, setElapsed] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const draftClientId = running ? (running.clientId ?? "") : clientId;
const { data: billableInvoices } = api.invoices.getBillable.useQuery(
draftClientId ? { clientId: draftClientId } : undefined,
{ enabled: Boolean(draftClientId) },
);
const todayStart = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
@@ -60,6 +99,63 @@ export function TimeClockPanel({
from: todayStart,
});
const [clientId, setClientId] = useState(() => {
if (defaultClientId) return defaultClientId;
return getLastTimeClockClientId() ?? "";
});
const [invoiceId, setInvoiceId] = useState(defaultInvoiceId);
const [title, setTitle] = useState("");
const [stopNote, setStopNote] = useState("");
const [rate, setRate] = useState(0);
const [elapsed, setElapsed] = useState(0);
const [showAllClients, setShowAllClients] = useState(false);
const [optionsOpen, setOptionsOpen] = useState(false);
const [startMode, setStartMode] = useState<StartMode>("now");
const [pickedStart, setPickedStart] = useState("");
const [minutesAgo, setMinutesAgo] = useState("30");
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const draftClientId = running ? (running.clientId ?? "") : clientId;
const { data: billableInvoices } = api.invoices.getBillable.useQuery(
draftClientId ? { clientId: draftClientId } : undefined,
{ enabled: Boolean(draftClientId) },
);
const selectedClient = useMemo(
() => clients?.find((c) => c.id === clientId),
[clients, clientId],
);
const featuredClientIds = useMemo(() => {
const ids: string[] = [];
const last = getLastTimeClockClientId();
if (last) ids.push(last);
for (const entry of todayEntries ?? []) {
if (entry.clientId && !ids.includes(entry.clientId)) {
ids.push(entry.clientId);
}
}
for (const client of clients ?? []) {
if (!ids.includes(client.id)) ids.push(client.id);
if (ids.length >= FEATURED_CLIENT_COUNT) break;
}
return ids;
}, [clients, todayEntries]);
const visibleClients = useMemo(() => {
if (!clients?.length) return [];
if (showAllClients) return clients;
const featured = featuredClientIds
.map((id) => clients.find((c) => c.id === id))
.filter((c): c is NonNullable<typeof c> => Boolean(c));
return featured.length > 0 ? featured : clients.slice(0, FEATURED_CLIENT_COUNT);
}, [clients, featuredClientIds, showAllClients]);
const hiddenClientCount = Math.max(0, (clients?.length ?? 0) - visibleClients.length);
useEffect(() => {
if (intervalRef.current) clearInterval(intervalRef.current);
if (!running) return;
@@ -110,7 +206,8 @@ export function TimeClockPanel({
void utils.invoices.getAll.invalidate();
void utils.invoices.getBillable.invalidate();
void utils.dashboard.getStats.invalidate();
setDescription("");
setTitle("");
setStopNote("");
},
onError: (e) => toast.error(e.message),
});
@@ -118,10 +215,60 @@ export function TimeClockPanel({
function handleClientChange(value: string) {
setClientId(value);
setInvoiceId("");
setLastTimeClockClientId(value);
const client = clients?.find((c) => c.id === value);
setRate(client?.defaultHourlyRate ?? 0);
}
function resolveStartedAt(): Date | undefined {
if (startMode === "now") return undefined;
if (startMode === "pick") {
if (!pickedStart) {
toast.error("Choose a start date and time");
return undefined;
}
const parsed = new Date(pickedStart);
if (Number.isNaN(parsed.getTime())) {
toast.error("Invalid start time");
return undefined;
}
return parsed;
}
const minutes = Number(minutesAgo);
if (!Number.isFinite(minutes) || minutes < 1 || minutes > 24 * 60) {
toast.error("Enter minutes between 1 and 1440");
return undefined;
}
return startedAtFromMinutesAgo(minutes);
}
function selectStartMode(mode: StartMode) {
setStartMode(mode);
if (mode === "pick" && !pickedStart) {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
setPickedStart(now.toISOString().slice(0, 16));
}
}
function handleStart() {
const startedAt = resolveStartedAt();
if (startMode !== "now" && !startedAt) return;
const description = resolveClockDescription(title);
const effectiveRate = resolveEffectiveHourlyRate(rate, selectedClient);
if (clientId) setLastTimeClockClientId(clientId);
clockIn.mutate({
description,
clientId: clientId || "",
invoiceId: invoiceId || undefined,
rate: effectiveRate > 0 ? effectiveRate : undefined,
startedAt,
});
}
if (runningLoading) {
return (
<Card>
@@ -130,61 +277,82 @@ export function TimeClockPanel({
);
}
const invoiceLabel = (inv: {
invoicePrefix: string | null;
invoiceNumber: string;
status: string;
}) => `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
const displayDescription = running ? running.description : description;
const displayRate = running ? (running.rate ?? 0) : rate;
const runningTitle =
running?.description?.trim() ?? resolveClockDescription("");
return (
<div className={compact ? "space-y-4" : "space-y-6"}>
<Card className={running ? "border-primary/30 bg-primary/5" : undefined}>
<CardHeader>
{running ? (
<div className="border-primary/20 bg-primary/5 rounded-2xl border p-6 text-center shadow-sm">
<div className="mb-3 flex items-center justify-center gap-2">
<span className="relative flex h-2.5 w-2.5">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
<span className="bg-primary relative inline-flex h-2.5 w-2.5 rounded-full" />
</span>
<span className="text-primary text-sm font-medium">Timer running</span>
</div>
<p className="text-primary font-mono text-5xl font-bold tracking-tight tabular-nums sm:text-6xl">
{formatElapsedSeconds(elapsed)}
</p>
<p className="mt-3 text-lg font-medium">{runningTitle}</p>
<p className="text-muted-foreground mt-1 text-sm">
{running.client?.name ?? "No client"}
{running.invoice ? ` · ${invoiceLabel(running.invoice)}` : ""}
{displayRate ? ` · $${displayRate}/hr` : ""}
</p>
</div>
) : null}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
{running ? (
<span className="relative flex h-3 w-3">
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
<span className="bg-primary relative inline-flex h-3 w-3 rounded-full" />
</span>
) : (
<Clock className="h-4 w-4" />
)}
{running ? "Timer running" : "Time clock"}
{!running ? <Clock className="h-4 w-4" /> : null}
{running ? "Update & stop" : "Clock in"}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{running ? (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<p className="font-medium">
{displayDescription || (
<span className="text-muted-foreground italic">No description</span>
)}
</p>
<p className="text-muted-foreground text-sm">
{running.client?.name ?? "No client"}
{running.invoice
? ` · ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
: ""}
{displayRate ? ` · $${displayRate}/hr` : ""}
</p>
</div>
<span className="text-primary font-mono text-4xl font-bold tabular-nums">
{formatElapsedSeconds(elapsed)}
</span>
</div>
) : null}
<CardContent className="space-y-5">
{!running ? (
<>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Client</Label>
<div className="space-y-2">
<Label htmlFor="clock-title" className="sr-only">
What are you working on?
</Label>
<Input
id="clock-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What are you working on?"
className="h-12 border-0 bg-transparent px-0 text-lg font-medium shadow-none focus-visible:ring-0"
/>
</div>
<div className="space-y-2">
<Label>Client</Label>
<div className="flex flex-wrap gap-2">
{visibleClients.map((client) => (
<ClientChip
key={client.id}
label={client.name}
active={clientId === client.id}
onClick={() => handleClientChange(client.id)}
/>
))}
{!showAllClients && hiddenClientCount > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
className="rounded-full"
onClick={() => setShowAllClients(true)}
>
+{hiddenClientCount} more
</Button>
) : null}
</div>
{(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && (
<Select value={clientId || undefined} onValueChange={handleClientChange}>
<SelectTrigger>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select client" />
</SelectTrigger>
<SelectContent>
@@ -195,66 +363,122 @@ export function TimeClockPanel({
))}
</SelectContent>
</Select>
</div>
)}
</div>
<div className="space-y-1.5">
<Label>Invoice</Label>
<Select
value={invoiceId || "__none__"}
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
disabled={!clientId}
<div className="space-y-2">
<Label>Invoice</Label>
<Select
value={invoiceId || "__none__"}
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
disabled={!clientId}
>
<SelectTrigger>
<SelectValue
placeholder={
clientId ? "Draft invoice (optional)" : "Choose a client first"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No invoice save entry only</SelectItem>
{billableInvoices?.map((inv) => (
<SelectItem key={inv.id} value={inv.id}>
{invoiceLabel(inv)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Collapsible open={optionsOpen} onOpenChange={setOptionsOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="text-muted-foreground h-auto w-full justify-between px-0 py-1 font-normal hover:bg-transparent"
>
<SelectTrigger>
<SelectValue
placeholder={
clientId ? "Select invoice (optional)" : "Choose a client first"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No invoice save entry only</SelectItem>
{billableInvoices?.map((inv) => (
<SelectItem key={inv.id} value={inv.id}>
{invoiceLabel(inv)}
</SelectItem>
Rate & start time
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 transition-transform",
optionsOpen && "rotate-180",
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
<div className="space-y-2">
<Label>Hourly rate</Label>
<NumberInput
value={rate}
onChange={setRate}
min={0}
step={0.01}
placeholder="0.00"
/>
{clientId && rate === 0 && selectedClient?.defaultHourlyRate ? (
<p className="text-muted-foreground text-xs">
Client default: ${selectedClient.defaultHourlyRate}/hr (used when left at zero).
</p>
) : null}
</div>
<div className="space-y-2">
<Label>When to start</Label>
<div className="flex flex-wrap gap-2">
{(
[
["now", "Now"],
["pick", "Pick time"],
["ago", "Time ago"],
] as const
).map(([mode, label]) => (
<Button
key={mode}
type="button"
size="sm"
variant={startMode === mode ? "default" : "outline"}
className="rounded-full"
onClick={() => selectStartMode(mode)}
>
{label}
</Button>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label>Description</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What are you working on?"
/>
</div>
<div className="space-y-1.5">
<Label>Hourly rate</Label>
<NumberInput
value={rate}
onChange={setRate}
min={0}
step={0.01}
placeholder="0.00"
/>
{clientId && rate === 0 ? (
<p className="text-muted-foreground text-xs">
Set a rate or add a default on the client record.
</p>
) : null}
</div>
</div>
{startMode === "pick" ? (
<Input
type="datetime-local"
value={pickedStart}
onChange={(e) => setPickedStart(e.target.value)}
className="mt-2"
/>
) : null}
{startMode === "ago" ? (
<div className="mt-2 flex items-center gap-2">
<Input
type="number"
min={1}
max={1440}
value={minutesAgo}
onChange={(e) => setMinutesAgo(e.target.value)}
className="w-24"
/>
<span className="text-muted-foreground text-sm">minutes ago</span>
</div>
) : null}
</div>
</CollapsibleContent>
</Collapsible>
</>
) : (
<div className="space-y-1.5">
<Label>Update description on stop (optional)</Label>
<div className="space-y-2">
<Label htmlFor="clock-stop-note">Note on stop (optional)</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={running.description || "What did you work on?"}
id="clock-stop-note"
value={stopNote}
onChange={(e) => setStopNote(e.target.value)}
placeholder={running?.description || "Update description when you stop"}
/>
</div>
)}
@@ -262,8 +486,13 @@ export function TimeClockPanel({
{running ? (
<Button
variant="destructive"
size="lg"
className="w-full"
onClick={() => clockOut.mutate({ description: description || undefined })}
onClick={() =>
clockOut.mutate({
description: stopNote.trim() || undefined,
})
}
disabled={clockOut.isPending}
>
<Square className="mr-2 h-4 w-4" />
@@ -271,15 +500,9 @@ export function TimeClockPanel({
</Button>
) : (
<Button
size="lg"
className="w-full"
onClick={() =>
clockIn.mutate({
description,
clientId: clientId || "",
invoiceId: invoiceId || undefined,
rate: rate || undefined,
})
}
onClick={handleStart}
disabled={clockIn.isPending}
>
<Play className="mr-2 h-4 w-4" />
+12 -3
View File
@@ -3,7 +3,6 @@ import {
LayoutDashboard,
Users,
FileText,
Building,
Receipt,
BarChart2,
Shield,
@@ -22,14 +21,24 @@ export interface NavSection {
links: NavLink[];
}
export function isNavLinkActive(pathname: string, href: string): boolean {
if (href === "/dashboard/entities") {
return (
pathname === href ||
pathname.startsWith("/dashboard/clients") ||
pathname.startsWith("/dashboard/businesses")
);
}
return pathname === href;
}
export const navigationConfig: NavSection[] = [
{
title: "Main",
links: [
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Time clock", href: "/dashboard/time-clock", icon: Clock },
{ name: "Clients", href: "/dashboard/clients", icon: Users },
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
{ name: "Entities", href: "/dashboard/entities", icon: Users },
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
{ name: "Recurring", href: "/dashboard/invoices/recurring", icon: RefreshCw },
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
+11
View File
@@ -0,0 +1,11 @@
const STORAGE_KEY = "beenvoice:time-clock:last-client";
export function getLastTimeClockClientId(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(STORAGE_KEY);
}
export function setLastTimeClockClientId(clientId: string): void {
if (!clientId || typeof window === "undefined") return;
localStorage.setItem(STORAGE_KEY, clientId);
}
+26
View File
@@ -1,3 +1,29 @@
export const DEFAULT_CLOCK_DESCRIPTION = "Professional services";
export function resolveEffectiveHourlyRate(
enteredRate: number,
client?: { defaultHourlyRate?: number | null } | null,
): number {
if (Number.isFinite(enteredRate) && enteredRate > 0) return enteredRate;
const clientRate = client?.defaultHourlyRate ?? 0;
if (Number.isFinite(clientRate) && clientRate > 0) return clientRate;
return 0;
}
export function startedAtFromMinutesAgo(minutes: number): Date {
return new Date(Date.now() - minutes * 60 * 1000);
}
export function resolveClockDescription(
title: string,
existingDescription?: string | null,
): string {
const trimmed = title.trim();
if (trimmed) return trimmed;
if (existingDescription?.trim()) return existingDescription.trim();
return DEFAULT_CLOCK_DESCRIPTION;
}
export type ClockOutOutcome =
| "linked_to_invoice"
| "saved_no_invoice"