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
+1 -14
View File
@@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { brand } from "~/lib/branding";
import { brand, splitLogoText } from "~/lib/branding";
import { cn } from "~/lib/utils";
interface LogoProps {
@@ -10,19 +10,6 @@ interface LogoProps {
animated?: boolean;
}
function splitLogoText(logoText: string) {
const voiceIndex = logoText.toLowerCase().indexOf("voice");
if (voiceIndex > 0) {
return [logoText.slice(0, voiceIndex), logoText.slice(voiceIndex)] as const;
}
return [
logoText.slice(0, Math.ceil(logoText.length / 2)),
logoText.slice(Math.ceil(logoText.length / 2)),
] as const;
}
export function Logo({ className, size = "md", animated = true }: LogoProps) {
const [logoPrefix, logoSuffix] = splitLogoText(brand.logoText);
const sizeClasses = {
+66 -6
View File
@@ -24,10 +24,12 @@ import {
ChevronsRight,
Filter,
Search,
SearchX,
X,
} from "lucide-react";
import * as React from "react";
import { EmptyState } from "~/components/layout/page-layout";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import {
@@ -87,6 +89,41 @@ interface DataTableProps<TData, TValue> {
clearSelection: () => void,
) => React.ReactNode;
initialSorting?: SortingState;
/** Shown when the dataset is empty (no rows in DB). */
emptyTitle?: string;
emptyDescription?: string;
emptyIcon?: React.ReactNode;
emptyAction?: React.ReactNode;
/** Shown when filters/search hide all rows but data exists. */
filteredEmptyTitle?: string;
filteredEmptyDescription?: string;
}
export interface DataTableEmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
}
/** Centered empty state for data tables (reuses page EmptyState). */
export function DataTableEmptyState({
icon,
title,
description,
action,
className,
}: DataTableEmptyStateProps) {
return (
<EmptyState
icon={icon}
title={title}
description={description}
action={action}
className={cn("py-16", className)}
/>
);
}
export function DataTable<TData, TValue>({
@@ -106,6 +143,12 @@ export function DataTable<TData, TValue>({
onRowClick,
selectionActions,
initialSorting = [],
emptyTitle,
emptyDescription,
emptyIcon,
emptyAction,
filteredEmptyTitle = "No matches for your search",
filteredEmptyDescription = "Try adjusting your search or filters.",
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>(initialSorting);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
@@ -190,6 +233,9 @@ export function DataTable<TData, TValue>({
}, [globalFilter]);
const pageSizeOptions = [5, 10, 20, 30, 50, 100];
const filteredRowCount = table.getFilteredRowModel().rows.length;
const isDatasetEmpty = data.length === 0;
const isFilteredEmpty = !isDatasetEmpty && filteredRowCount === 0;
// Handle row click
const handleRowClick = (row: TData, event: React.MouseEvent) => {
@@ -419,12 +465,26 @@ export function DataTable<TData, TValue>({
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
<p className="text-muted-foreground">No results found</p>
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="p-0">
{isDatasetEmpty && emptyTitle ? (
<DataTableEmptyState
icon={emptyIcon}
title={emptyTitle}
description={emptyDescription}
action={emptyAction}
/>
) : isFilteredEmpty ? (
<DataTableEmptyState
icon={<SearchX className="h-6 w-6" />}
title={filteredEmptyTitle}
description={filteredEmptyDescription}
/>
) : (
<div className="text-muted-foreground py-16 text-center text-sm">
No results found
</div>
)}
</TableCell>
</TableRow>
)}
+14 -3
View File
@@ -155,11 +155,22 @@ export function InvoiceList() {
<Eye className="h-4 w-4" />
</Button>
</Link>
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button variant="ghost" size="sm">
{invoice.status === "draft" ? (
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
) : (
<Button
variant="ghost"
size="sm"
disabled
title="Only draft invoices can be edited"
>
<Edit className="h-4 w-4" />
</Button>
</Link>
)}
<Button
variant="ghost"
size="sm"
+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>
);
}
+9 -5
View File
@@ -31,7 +31,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
<div
className={cn(
"dashboard-mobile-header bg-background/80 fixed top-0 right-0 left-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-md md:hidden",
"dashboard-mobile-header bg-background/80 border-border fixed top-0 right-0 left-0 z-50 flex min-h-16 items-center border-b px-3 backdrop-blur-md sm:px-4 md:hidden",
isOnboarding && "hidden",
)}
>
@@ -47,8 +47,8 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<div className="ml-4 flex min-w-0 flex-1 items-center gap-2">
<Logo size="sm" />
<div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4">
<Logo size="sm" className="shrink-0" />
<ActiveTimerWidget compact />
</div>
<SheetContent side="left" className="w-72 p-0">
@@ -67,9 +67,13 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
!isOnboarding && (isCollapsed ? "md:ml-16" : "md:ml-64"),
)}
>
<div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
{isOnboarding ? (
<OnboardingGuard>{children}</OnboardingGuard>
</div>
) : (
<div className="dashboard-content-shell flex flex-col gap-5 md:gap-6">
<OnboardingGuard>{children}</OnboardingGuard>
</div>
)}
</main>
</div>
);
+2 -2
View File
@@ -123,13 +123,13 @@ export function EmptyState({
return (
<div className={cn("py-12 text-center", className)}>
{icon && (
<div className="bg-muted/50 mx-auto mb-4 flex h-16 w-16 items-center justify-center">
<div className="bg-muted mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl p-3 [&_svg]:text-muted-foreground">
{icon}
</div>
)}
<h3 className="mb-2 text-lg font-semibold">{title}</h3>
{description && (
<p className="text-muted-foreground mx-auto mb-4 max-w-sm">
<p className="text-muted-foreground mx-auto mb-4 max-w-sm text-sm">
{description}
</p>
)}
@@ -177,6 +177,7 @@ export function AnimationPreferencesProviderSynced({
serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier;
if (localIsDefault || differs) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time server hydration after local storage
performUpdate(
{
prefersReducedMotion: serverPrefs.prefersReducedMotion,
+72 -39
View File
@@ -20,7 +20,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
import { ChevronDown, Clock, ExternalLink, Play, Square } from "lucide-react";
import { ChevronDown, Clock, Play, Square } from "lucide-react";
import { toast } from "sonner";
import { cn } from "~/lib/utils";
import {
@@ -30,6 +30,7 @@ import {
import {
describeClockOutOutcome,
formatElapsedSeconds,
formatRunningTimerLabel,
resolveClockDescription,
resolveEffectiveHourlyRate,
startedAtFromMinutesAgo,
@@ -52,6 +53,19 @@ function invoiceLabel(inv: {
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
}
function entryHref(entry: {
invoiceId: string | null;
clientId: string | null;
invoice?: { id: string } | null;
client?: { id: string } | null;
}): string | null {
const invoiceId = entry.invoiceId ?? entry.invoice?.id;
if (invoiceId) return `/dashboard/invoices/${invoiceId}`;
const clientId = entry.clientId ?? entry.client?.id;
if (clientId) return `/dashboard/clients/${clientId}`;
return null;
}
function ClientChip({
label,
active,
@@ -278,8 +292,7 @@ export function TimeClockPanel({
}
const displayRate = running ? (running.rate ?? 0) : rate;
const runningTitle =
running?.description?.trim() ?? resolveClockDescription("");
const runningTitle = formatRunningTimerLabel(running?.description);
return (
<div className={compact ? "space-y-4" : "space-y-6"}>
@@ -478,7 +491,11 @@ export function TimeClockPanel({
id="clock-stop-note"
value={stopNote}
onChange={(e) => setStopNote(e.target.value)}
placeholder={running?.description || "Update description when you stop"}
placeholder={
running?.description?.trim()
? running.description
: "Update description when you stop"
}
/>
</div>
)}
@@ -517,49 +534,65 @@ export function TimeClockPanel({
<CardHeader>
<CardTitle className="text-base">Today&apos;s entries</CardTitle>
</CardHeader>
<CardContent className="divide-y">
<CardContent>
{todayEntries
.filter((e) => e.endedAt)
.map((entry) => (
<div
key={entry.id}
className="flex items-start justify-between gap-4 py-3 first:pt-0 last:pb-0"
>
<div className="min-w-0">
<p className="font-medium">
{entry.description || (
<span className="text-muted-foreground italic">No description</span>
.map((entry, index, entries) => {
const href = entryHref(entry);
const isLast = index === entries.length - 1;
const rowClassName = cn(
"flex items-start justify-between gap-4 py-3",
!isLast && "border-border border-b",
);
const content = (
<>
<div className="min-w-0">
<p className="font-medium">
{formatRunningTimerLabel(entry.description)}
</p>
<p className="text-muted-foreground text-sm">
{entry.client?.name ?? "No client"}
{entry.invoice
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: entry.hours
? " · not on invoice"
: ""}
</p>
</div>
<div className="text-right text-sm">
<p className="font-mono font-semibold">{entry.hours ?? "—"}h</p>
{entry.rate ? (
<p className="text-muted-foreground">${entry.rate}/hr</p>
) : null}
</div>
</>
);
if (href) {
return (
<Link
key={entry.id}
href={href}
className={cn(
rowClassName,
"-mx-2 flex w-full cursor-pointer px-2 transition-colors hover:rounded-md hover:bg-muted/60",
)}
</p>
<p className="text-muted-foreground text-sm">
{entry.client?.name ?? "No client"}
{entry.invoice
? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
: entry.hours
? " · not on invoice"
: ""}
</p>
>
{content}
</Link>
);
}
return (
<div key={entry.id} className={rowClassName}>
{content}
</div>
<div className="text-right text-sm">
<p className="font-mono font-semibold">{entry.hours ?? "—"}h</p>
{entry.rate ? (
<p className="text-muted-foreground">${entry.rate}/hr</p>
) : null}
</div>
</div>
))}
);
})}
</CardContent>
</Card>
) : null}
{compact ? (
<Button variant="link" className="h-auto p-0" asChild>
<Link href="/dashboard/time-clock">
Open full time clock
<ExternalLink className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
) : null}
</div>
);
}
+29 -15
View File
@@ -14,18 +14,23 @@ import {
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
const DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
day: "2-digit",
month: "long",
year: "numeric",
};
function formatDate(date: Date | undefined) {
if (!date) {
return "";
}
return date.toLocaleDateString("en-US", {
day: "2-digit",
month: "long",
year: "numeric",
});
return date.toLocaleDateString("en-US", DATE_FORMAT_OPTIONS);
}
// Longest month name in en-US long format (September 30, 2026).
const LONGEST_FORMATTED_DATE = formatDate(new Date(2026, 8, 30));
interface DatePickerProps {
date?: Date;
onDateChange: (date: Date | undefined) => void;
@@ -57,13 +62,7 @@ export function DatePicker({
lg: "h-10 text-sm",
};
const inputWidthClass = className?.includes("w-full")
? "w-full"
: className?.includes("w-32") ||
className?.includes("w-28") ||
className?.includes("w-36")
? className
: "w-full md:w-32 md:min-w-32";
const wantsFullWidth = className?.includes("w-full");
React.useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Keep text input and calendar month synchronized with the controlled date prop.
@@ -72,16 +71,31 @@ export function DatePicker({
}, [date]);
return (
<div className={cn("relative flex gap-2", inputWidthClass, className)}>
<div
className={cn(
"relative min-w-max",
wantsFullWidth ? "w-full" : "w-auto shrink-0",
className,
)}
>
<span
aria-hidden
className={cn(
"invisible block whitespace-nowrap px-3 pr-10",
sizeClasses[size],
inputClassName,
)}
>
{LONGEST_FORMATTED_DATE}
</span>
<Input
id={id}
value={value}
placeholder={placeholder}
disabled={disabled}
className={cn(
"bg-background pr-10",
"bg-background absolute inset-0 w-full pr-10 tabular-nums",
sizeClasses[size],
"w-full",
inputClassName,
)}
onChange={(e) => {
+2 -2
View File
@@ -64,9 +64,9 @@ function SheetContent({
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto rounded-b-xl border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto rounded-t-xl border-t",
className,
)}
{...props}