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
co-authored by Cursor
parent 0b7ffac4e7
commit 480c50981d
19 changed files with 734 additions and 364 deletions
+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" />