Archived
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:
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user