Archived
Unify time tracking on server-backed entries with updateRunning.
Consolidates dashboard and invoice timers onto shared time-entry APIs so clock-in state, invoice linking, and clock-out flow stay consistent across surfaces. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -806,6 +806,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
onUpdateItem={updateItem}
|
||||
onAddItemWithValues={addItemWithValues}
|
||||
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined}
|
||||
clientId={formData.clientId || undefined}
|
||||
defaultRate={formData.items[0]?.rate}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Plus, Timer, Trash2, Zap } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { Input } from "~/components/ui/input";
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
useLineItemSuggestions,
|
||||
type LineItemSuggestion,
|
||||
} from "~/hooks/use-line-item-suggestions";
|
||||
import { TimeTracker } from "~/components/invoice/time-tracker";
|
||||
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
@@ -37,6 +37,7 @@ interface InvoiceLineItemsProps {
|
||||
) => void;
|
||||
onAddItemWithValues?: (parsed: ParsedLineItem) => void;
|
||||
invoiceId?: string;
|
||||
clientId?: string;
|
||||
defaultRate?: number;
|
||||
className?: string;
|
||||
}
|
||||
@@ -348,7 +349,8 @@ export function InvoiceLineItems({
|
||||
onUpdateItem,
|
||||
onAddItemWithValues,
|
||||
invoiceId,
|
||||
defaultRate,
|
||||
clientId,
|
||||
defaultRate: _defaultRate,
|
||||
className,
|
||||
}: InvoiceLineItemsProps) {
|
||||
const canRemoveItems = items.length > 1;
|
||||
@@ -422,18 +424,24 @@ export function InvoiceLineItems({
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{onAddItemWithValues && invoiceId && (
|
||||
{invoiceId && (
|
||||
<div className="border-t p-3 space-y-2">
|
||||
<p className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
|
||||
<Timer className="h-3.5 w-3.5" /> Time tracker
|
||||
<Timer className="h-3.5 w-3.5" /> Time clock
|
||||
</p>
|
||||
<TimeTracker
|
||||
invoiceId={invoiceId}
|
||||
defaultRate={defaultRate}
|
||||
onStop={(hours, description) => {
|
||||
onAddItemWithValues({ description, hours, rate: defaultRate ?? 0 });
|
||||
}}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Track time on the dedicated time clock — entries sync across devices and
|
||||
bill directly to an invoice.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="w-full" asChild>
|
||||
<Link
|
||||
href={`/dashboard/time-clock?invoiceId=${invoiceId}${
|
||||
clientId ? `&clientId=${clientId}` : ""
|
||||
}`}
|
||||
>
|
||||
Open time clock
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{onAddItemWithValues && (
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Play, Square } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
|
||||
interface TimeTrackerProps {
|
||||
invoiceId: string;
|
||||
onStop: (hours: number, description: string) => void;
|
||||
defaultRate?: number;
|
||||
}
|
||||
|
||||
interface PersistedState {
|
||||
running: boolean;
|
||||
startedAt: number | null;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function storageKey(invoiceId: string) {
|
||||
return `time-tracker-${invoiceId}`;
|
||||
}
|
||||
|
||||
function formatElapsed(seconds: number) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
return [h, m, s].map((v) => String(v).padStart(2, "0")).join(":");
|
||||
}
|
||||
|
||||
function readPersistedState(invoiceId: string): PersistedState {
|
||||
if (typeof window === "undefined") return { running: false, startedAt: null, description: "" };
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(invoiceId));
|
||||
if (raw) return JSON.parse(raw) as PersistedState;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { running: false, startedAt: null, description: "" };
|
||||
}
|
||||
|
||||
export function TimeTracker({ invoiceId, onStop }: TimeTrackerProps) {
|
||||
const [running, setRunning] = useState(() => readPersistedState(invoiceId).running);
|
||||
const [startedAt, setStartedAt] = useState<number | null>(
|
||||
() => readPersistedState(invoiceId).startedAt,
|
||||
);
|
||||
const [elapsed, setElapsed] = useState(() => {
|
||||
const s = readPersistedState(invoiceId);
|
||||
if (s.running && s.startedAt) {
|
||||
return Math.max(0, Math.floor((Date.now() - s.startedAt) / 1000));
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
const [description, setDescription] = useState(
|
||||
() => readPersistedState(invoiceId).description,
|
||||
);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (running && startedAt !== null) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setElapsed(Math.floor((Date.now() - startedAt) / 1000));
|
||||
}, 1000);
|
||||
}
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [running, startedAt]);
|
||||
|
||||
function persist(state: PersistedState) {
|
||||
localStorage.setItem(storageKey(invoiceId), JSON.stringify(state));
|
||||
}
|
||||
|
||||
function handleStart() {
|
||||
const now = Date.now();
|
||||
setStartedAt(now);
|
||||
setElapsed(0);
|
||||
setRunning(true);
|
||||
persist({ running: true, startedAt: now, description });
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
const hours = Math.max(0.25, Math.ceil(elapsed / 900) * 0.25);
|
||||
setRunning(false);
|
||||
setStartedAt(null);
|
||||
setElapsed(0);
|
||||
localStorage.removeItem(storageKey(invoiceId));
|
||||
onStop(hours, description);
|
||||
setDescription("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-secondary flex flex-col gap-3 rounded-lg p-3 sm:flex-row sm:items-center">
|
||||
{running ? (
|
||||
<>
|
||||
<span className="text-primary font-mono text-xl font-bold tabular-nums">
|
||||
{formatElapsed(elapsed)}
|
||||
</span>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
persist({ running: true, startedAt, description: e.target.value });
|
||||
}}
|
||||
placeholder="What are you working on?"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="button" variant="default" size="sm" onClick={handleStop} className="shrink-0">
|
||||
<Square className="mr-1 h-3.5 w-3.5" />
|
||||
Stop & add
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What will you work on?"
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleStart();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={handleStart} className="shrink-0">
|
||||
<Play className="mr-1 h-3.5 w-3.5" />
|
||||
Start timer
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { Logo } from "~/components/branding/logo";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
||||
|
||||
function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
const { isCollapsed } = useSidebar();
|
||||
@@ -68,10 +69,12 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
>
|
||||
<div className="dashboard-content-shell p-4 pt-16 md:pt-4">
|
||||
{/* Mobile header spacer is handled by pt-16 on mobile */}
|
||||
<div className="mb-4 md:hidden">
|
||||
{/* Mobile Breadcrumbs could go here or be part of the page */}
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<ActiveTimerWidget />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Clock, Play, Square, ExternalLink } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock";
|
||||
|
||||
export type TimeClockPanelProps = {
|
||||
defaultClientId?: string;
|
||||
defaultInvoiceId?: string;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function TimeClockPanel({
|
||||
defaultClientId = "",
|
||||
defaultInvoiceId = "",
|
||||
compact = false,
|
||||
}: TimeClockPanelProps) {
|
||||
const utils = api.useUtils();
|
||||
const { data: running, isLoading: runningLoading } = api.timeEntries.getRunning.useQuery(
|
||||
undefined,
|
||||
{ refetchInterval: 30_000 },
|
||||
);
|
||||
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);
|
||||
return d;
|
||||
}, []);
|
||||
|
||||
const { data: todayEntries } = api.timeEntries.getAll.useQuery({
|
||||
from: todayStart,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
if (!running) return;
|
||||
|
||||
const tick = () =>
|
||||
setElapsed(Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000));
|
||||
tick();
|
||||
intervalRef.current = setInterval(tick, 1000);
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [running]);
|
||||
|
||||
const clockIn = api.timeEntries.clockIn.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Timer started");
|
||||
void utils.timeEntries.getRunning.invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const clockOut = api.timeEntries.clockOut.useMutation({
|
||||
onSuccess: (data) => {
|
||||
const message = describeClockOutOutcome({
|
||||
outcome: data.outcome,
|
||||
hours: data.hours,
|
||||
rate: data.rate,
|
||||
invoice: data.invoice,
|
||||
});
|
||||
|
||||
if (data.outcome === "linked_to_invoice" && data.invoice) {
|
||||
toast.success("Time logged", {
|
||||
description: message,
|
||||
action: {
|
||||
label: "View invoice",
|
||||
onClick: () =>
|
||||
window.location.assign(`/dashboard/invoices/${data.invoice!.id}`),
|
||||
},
|
||||
});
|
||||
} else if (data.outcome === "saved_no_invoice" || data.outcome === "saved_no_client") {
|
||||
toast.warning("Time saved", { description: message });
|
||||
} else {
|
||||
toast.success(message);
|
||||
}
|
||||
|
||||
void utils.timeEntries.getRunning.invalidate();
|
||||
void utils.timeEntries.getAll.invalidate();
|
||||
void utils.invoices.getAll.invalidate();
|
||||
void utils.invoices.getBillable.invalidate();
|
||||
void utils.dashboard.getStats.invalidate();
|
||||
setDescription("");
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
function handleClientChange(value: string) {
|
||||
setClientId(value);
|
||||
setInvoiceId("");
|
||||
const client = clients?.find((c) => c.id === value);
|
||||
setRate(client?.defaultHourlyRate ?? 0);
|
||||
}
|
||||
|
||||
if (runningLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="text-muted-foreground p-6 text-sm">Loading timer…</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const invoiceLabel = (inv: {
|
||||
invoicePrefix: string | null;
|
||||
invoiceNumber: string;
|
||||
status: string;
|
||||
}) => `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber} (${inv.status})`;
|
||||
|
||||
const displayDescription = running ? running.description : description;
|
||||
const displayRate = running ? (running.rate ?? 0) : rate;
|
||||
|
||||
return (
|
||||
<div className={compact ? "space-y-4" : "space-y-6"}>
|
||||
<Card className={running ? "border-primary/30 bg-primary/5" : undefined}>
|
||||
<CardHeader>
|
||||
<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"}
|
||||
</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}
|
||||
|
||||
{!running ? (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Client</Label>
|
||||
<Select value={clientId || undefined} onValueChange={handleClientChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients?.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Invoice</Label>
|
||||
<Select
|
||||
value={invoiceId || "__none__"}
|
||||
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
|
||||
disabled={!clientId}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</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 className="space-y-1.5">
|
||||
<Label>Update description on stop (optional)</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={running.description || "What did you work on?"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{running ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={() => clockOut.mutate({ description: description || undefined })}
|
||||
disabled={clockOut.isPending}
|
||||
>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
{clockOut.isPending ? "Stopping…" : "Stop & save"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
clockIn.mutate({
|
||||
description,
|
||||
clientId: clientId || "",
|
||||
invoiceId: invoiceId || undefined,
|
||||
rate: rate || undefined,
|
||||
})
|
||||
}
|
||||
disabled={clockIn.isPending}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{clockIn.isPending ? "Starting…" : "Start timer"}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!compact && todayEntries && todayEntries.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Today's entries</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y">
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user