Polish onboarding, invoices, and time clock while promoting the first registrant to admin.

Refresh onboarding wizard and shell, tighten invoice edit/detail flows, align timer widgets with the redesigned clock panel, and assign admin role on first signup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 03:40:25 -04:00
co-authored by Cursor
parent c53f2e6c4d
commit 3fb61ff4dd
37 changed files with 1135 additions and 481 deletions
+91 -107
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Label } from "~/components/ui/label";
@@ -11,11 +11,9 @@ import {
PageTabsContent,
PageTabsList,
PageTabsTrigger,
pageTabsGridClass,
} from "~/components/layout/page-tabs";
import {
DashboardPage,
dashboardGridClass,
} from "~/components/layout/dashboard-page";
import { DashboardPage } from "~/components/layout/dashboard-page";
import { DashboardPageHeader } from "~/components/layout/page-header";
import { cn } from "~/lib/utils";
import {
@@ -72,13 +70,13 @@ interface InvoiceFormProps {
function InvoiceFormSkeleton() {
return (
<DashboardPage className="pb-8">
<DashboardPage>
<DashboardPageHeader
title="Loading..."
description="Loading invoice form"
/>
<div className="bg-muted h-12 w-full animate-pulse rounded-xl p-1" />
<div className={cn(dashboardGridClass, "lg:grid-cols-2")}>
<div className="bg-muted h-10 w-full animate-pulse rounded-xl p-1" />
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
<div className="bg-muted h-[200px] animate-pulse rounded-xl" />
</div>
@@ -103,7 +101,7 @@ function plainTextToHtml(value: string) {
.replace(/\n/g, "<br>");
}
function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
function createDefaultInvoiceFormData(): InvoiceFormData {
return {
invoiceNumber: `INV-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${Date.now().toString().slice(-6)}`,
invoicePrefix: "#",
@@ -117,30 +115,26 @@ function createDefaultInvoiceFormData(blank = false): InvoiceFormData {
taxRate: 0,
currency: "USD",
defaultHourlyRate: null,
items: blank
? []
: [
{
id: crypto.randomUUID(),
date: new Date(),
description: "",
hours: 1,
rate: 0,
amount: 0,
},
],
items: [
{
id: crypto.randomUUID(),
date: new Date(),
description: "",
hours: 1,
rate: 0,
amount: 0,
},
],
};
}
export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const isBlank = searchParams.get("blank") === "1";
const utils = api.useUtils();
// State
const [formData, setFormData] = useState<InvoiceFormData>(() =>
createDefaultInvoiceFormData(isBlank),
createDefaultInvoiceFormData(),
);
const [loading, setLoading] = useState(false);
@@ -148,15 +142,6 @@ 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 } =
@@ -187,7 +172,6 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
}, [invoiceId]);
useEffect(() => {
if (invoiceId && invoiceId !== "new" && existingInvoice && !initialized) {
// ... (Mapping logic same as before)
const mappedItems: InvoiceItem[] =
existingInvoice.items?.map((item) => ({
id: crypto.randomUUID(),
@@ -239,6 +223,19 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
}
}, [invoiceId, existingInvoice, businesses, initialized]);
useEffect(() => {
if (
invoiceId &&
invoiceId !== "new" &&
existingInvoice &&
!loadingInvoice &&
existingInvoice.status !== "draft"
) {
toast.error("Only draft invoices can be edited");
router.replace(`/dashboard/invoices/${invoiceId}`);
}
}, [invoiceId, existingInvoice, loadingInvoice, router]);
const totals = React.useMemo(() => {
const subtotal = formData.items.reduce(
(sum, item) => sum + item.hours * item.rate,
@@ -464,26 +461,20 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
!initialized ||
loadingClients ||
loadingBusinesses ||
(invoiceId && invoiceId !== "new" && loadingInvoice)
(invoiceId && invoiceId !== "new" && loadingInvoice) ||
(invoiceId &&
invoiceId !== "new" &&
existingInvoice &&
existingInvoice.status !== "draft")
)
return <InvoiceFormSkeleton />;
return (
<>
<DashboardPage className="pb-8">
<DashboardPage>
<DashboardPageHeader
title={
invoiceId !== "new"
? "Edit Invoice"
: isBlank
? "Blank Invoice"
: "Create Invoice"
}
description={
isBlank
? "Set up a draft to clock time into later"
: "Manage your invoice"
}
title={invoiceId !== "new" ? "Edit Invoice" : "Create Invoice"}
description="Manage your invoice"
>
{invoiceId !== "new" && (
<Button
@@ -500,27 +491,17 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</Button>
</DashboardPageHeader>
<div
className={cn(
dashboardGridClass,
"lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]",
)}
>
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
<PageTabs value={activeTab} className="w-full" onValueChange={setActiveTab}>
<PageTabsList>
<PageTabsTrigger value="details">Details</PageTabsTrigger>
<PageTabsTrigger value="items">Items</PageTabsTrigger>
<PageTabsTrigger value="timesheet">Timesheet</PageTabsTrigger>
<PageTabsTrigger value="preview" className="lg:hidden">
Preview
</PageTabsTrigger>
<PageTabsTrigger value="preview">Preview</PageTabsTrigger>
</PageTabsList>
{/* DETAILS TAB */}
<PageTabsContent
value="details"
className={cn(dashboardGridClass, "lg:grid-cols-2")}
>
<PageTabsContent value="details">
<div className={cn(pageTabsGridClass, "lg:grid-cols-2")}>
<Card className="h-full">
<CardHeader>
<CardTitle className="flex gap-2 text-base">
@@ -763,11 +744,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
/>
</CardContent>
</Card>
</div>
</PageTabsContent>
{/* ITEMS TAB */}
<PageTabsContent value="items">
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
<div className={cn(pageTabsGridClass, "md:grid-cols-3")}>
<Card className="bg-primary/5 border-primary/20">
<CardContent className="flex items-center justify-between p-4">
<span className="text-muted-foreground">Total</span>
@@ -840,28 +822,48 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
</PageTabsContent>
<PageTabsContent value="preview">
<PageTabs
value={previewTab}
onValueChange={setPreviewTab}
className="w-full"
>
<PageTabsList>
<PageTabsTrigger value="pdf">PDF</PageTabsTrigger>
<PageTabsTrigger value="email">Email</PageTabsTrigger>
</PageTabsList>
<PageTabsContent value="pdf">
<InvoicePdfPreviewPanel input={pdfPreviewInput} />
</PageTabsContent>
<PageTabsContent value="email">
<Card>
<CardHeader>
<CardTitle className="flex gap-2">
<Mail className="h-5 w-5" /> Email Preview
</CardTitle>
</CardHeader>
<CardContent>
<Card className="overflow-hidden">
<CardHeader className="flex flex-row items-center gap-3 space-y-0 pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" />
Preview
</CardTitle>
<div className="bg-muted flex rounded-lg p-1 text-sm">
<button
type="button"
onClick={() => setPreviewTab("pdf")}
className={cn(
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
previewTab === "pdf"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
)}
>
PDF
</button>
<button
type="button"
onClick={() => setPreviewTab("email")}
className={cn(
"rounded-md px-3 py-1.5 text-center font-medium transition-all",
previewTab === "email"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground",
)}
>
Email
</button>
</div>
</CardHeader>
<CardContent className="p-0">
{previewTab === "pdf" ? (
<InvoicePdfPreviewPanel
embedded
input={pdfPreviewInput}
enabled={activeTab === "preview" && previewTab === "pdf"}
/>
) : (
<div className="border-t p-6">
<EmailPreview
subject={`Invoice ${formData.invoiceNumber} from ${
selectedBusiness?.name ?? "Your Business"
@@ -900,30 +902,12 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
})),
}}
/>
</CardContent>
</Card>
</PageTabsContent>
</PageTabs>
</div>
)}
</CardContent>
</Card>
</PageTabsContent>
</PageTabs>
<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>
</DashboardPage>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+3 -3
View File
@@ -155,7 +155,7 @@ const LineItemCard = React.forwardRef<HTMLDivElement, LineItemRowProps>(
<div
ref={ref}
className={cn(
"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",
"group hover:bg-muted/30 hidden min-h-11 grid-cols-[minmax(11.5rem,auto)_minmax(180px,1fr)_96px_108px_88px_28px] items-center gap-1.5 border-b px-2 py-1.5 transition-colors md:grid",
)}
>
<DatePicker
@@ -260,7 +260,7 @@ function MobileLineItem({
date={item.date}
onDateChange={(date) => onUpdate(index, "date", date ?? new Date())}
size="sm"
className="w-[92px] shrink-0"
className="w-auto shrink-0"
inputClassName="h-8 px-2 text-xs"
disabled={readOnly}
/>
@@ -374,7 +374,7 @@ export function InvoiceLineItems({
) : null}
<AnimatePresence>
<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">
<div className="bg-muted/60 text-muted-foreground hidden grid-cols-[minmax(11.5rem,auto)_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-center">Hours</span>
@@ -37,6 +37,8 @@ type InvoicePdfPreviewPanelProps = {
enabled?: boolean;
className?: string;
heightClassName?: string;
/** Renders only the preview body (no card/header) for embedding in a parent pane. */
embedded?: boolean;
};
export function InvoicePdfPreviewPanel({
@@ -44,6 +46,7 @@ export function InvoicePdfPreviewPanel({
enabled = true,
className,
heightClassName = "h-[min(80vh,760px)]",
embedded = false,
}: InvoicePdfPreviewPanelProps) {
const previewReady = canPreview(input);
@@ -54,6 +57,48 @@ export function InvoicePdfPreviewPanel({
staleTime: 5_000,
});
const previewBody = (
<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>
);
if (embedded) {
return <div className={cn("overflow-hidden", className)}>{previewBody}</div>;
}
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader className="pb-3">
@@ -63,43 +108,7 @@ export function InvoicePdfPreviewPanel({
{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>
<CardContent className="p-0">{previewBody}</CardContent>
</Card>
);
}