"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 { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "~/components/ui/collapsible"; import { ChevronDown, Clock, Play, Square } from "lucide-react"; import { toast } from "sonner"; import { cn } from "~/lib/utils"; import { getLastTimeClockClientId, setLastTimeClockClientId, } from "~/lib/time-clock-prefs"; import { describeClockOutOutcome, formatElapsedSeconds, formatRunningTimerLabel, resolveClockDescription, resolveEffectiveHourlyRate, startedAtFromMinutesAgo, } from "~/lib/time-clock"; import { invoiceLabel } from "~/lib/time-entry-display"; import { TimeEntryList } from "~/components/time-clock/time-entry-list"; import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog"; const FEATURED_CLIENT_COUNT = 4; type StartMode = "now" | "pick" | "ago"; function toDatetimeLocalValue(value: Date | string) { const start = new Date(value); start.setMinutes(start.getMinutes() - start.getTimezoneOffset()); return start.toISOString().slice(0, 16); } function RunningTextFields({ running, updateRunningPending, onDescriptionCommit, onStartedAtCommit, }: { running: { id: string; description: string | null; startedAt: Date }; updateRunningPending: boolean; onDescriptionCommit: (description: string) => void; onStartedAtCommit: (startedAt: Date) => void; }) { const [title, setTitle] = useState(running.description ?? ""); const [runningStartedAt, setRunningStartedAt] = useState(() => toDatetimeLocalValue(running.startedAt), ); return ( <>
setTitle(e.target.value)} onBlur={() => onDescriptionCommit(title)} placeholder="What are you working on?" />
{ const value = e.target.value; setRunningStartedAt(value); if (!value) return; const parsed = new Date(value); if (Number.isNaN(parsed.getTime()) || parsed > new Date()) return; onStartedAtCommit(parsed); }} disabled={updateRunningPending} />
); } export type TimeClockPanelProps = { defaultClientId?: string; defaultInvoiceId?: string; compact?: boolean; }; function ClientChip({ label, active, onClick, }: { label: string; active: boolean; onClick: () => void; }) { return ( ); } 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 todayStart = useMemo(() => { const d = new Date(); d.setHours(0, 0, 0, 0); return d; }, []); const { data: todayEntries } = api.timeEntries.getAll.useQuery({ 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("now"); const [pickedStart, setPickedStart] = useState(""); const [minutesAgo, setMinutesAgo] = useState("30"); const [editEntryId, setEditEntryId] = useState(null); const intervalRef = useRef | 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); if (running?.clientId && !ids.includes(running.clientId)) { ids.unshift(running.clientId); } 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, running]); 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 => 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; 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 updateRunning = api.timeEntries.updateRunning.useMutation({ onSuccess: () => { void utils.timeEntries.getRunning.invalidate(); void utils.invoices.getBillable.invalidate(); }, onError: (e) => toast.error(e.message), }); function handleRunningDescriptionCommit(nextTitle: string) { if (!running) return; const next = resolveClockDescription(nextTitle); if (next === (running.description ?? "")) return; updateRunning.mutate({ description: next }); } function handleRunningStartedAtCommit(parsed: Date) { if (!running) return; updateRunning.mutate({ startedAt: parsed }); } 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(); setTitle(""); setStopNote(""); }, onError: (e) => toast.error(e.message), }); function handleClientChange(value: string) { if (running) { updateRunning.mutate({ clientId: value, invoiceId: "" }); return; } setClientId(value); setInvoiceId(""); setLastTimeClockClientId(value); const client = clients?.find((c) => c.id === value); setRate(client?.defaultHourlyRate ?? 0); } function handleInvoiceChange(value: string) { const next = value === "__none__" ? "" : value; if (running) { updateRunning.mutate({ invoiceId: next }); return; } setInvoiceId(next); } 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 ( Loading timer… ); } const displayRate = running ? (running.rate ?? 0) : rate; const runningTitle = formatRunningTimerLabel(running?.description); const activeClientId = running ? (running.clientId ?? "") : clientId; const activeInvoiceId = running ? (running.invoiceId ?? "") : invoiceId; return (
{running ? (
Timer running

{formatElapsedSeconds(elapsed)}

{runningTitle}

{running.client?.name ?? "No client"} {running.invoice ? ` · ${invoiceLabel(running.invoice)}` : ""} {displayRate ? ` · $${displayRate}/hr` : ""}

) : null} {!running ? : null} {running ? "Update & stop" : "Clock in"} {!running ? ( <>
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" />
{visibleClients.map((client) => ( handleClientChange(client.id)} /> ))} {!showAllClients && hiddenClientCount > 0 ? ( ) : null}
{(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && ( )}
{clientId && rate === 0 && selectedClient?.defaultHourlyRate ? (

Client default: ${selectedClient.defaultHourlyRate}/hr (used when left at zero).

) : null}
{( [ ["now", "Now"], ["pick", "Pick time"], ["ago", "Time ago"], ] as const ).map(([mode, label]) => ( ))}
{startMode === "pick" ? ( setPickedStart(e.target.value)} className="mt-2" /> ) : null} {startMode === "ago" ? (
setMinutesAgo(e.target.value)} className="w-24" /> minutes ago
) : null}
) : ( <>
{visibleClients.map((client) => ( handleClientChange(client.id)} /> ))} {!showAllClients && hiddenClientCount > 0 ? ( ) : null}
{(showAllClients || (clients?.length ?? 0) > FEATURED_CLIENT_COUNT) && ( )}
setStopNote(e.target.value)} placeholder={ running?.description?.trim() ? running.description : "Update description when you stop" } />
)} {running ? ( ) : ( )}
{!compact ? ( Today's entries {todayEntries?.some((e) => e.endedAt) ? ( setEditEntryId(entry.id)} /> ) : (

No entries today.{" "} View history

)}
) : null} { if (!open) setEditEntryId(null); }} />
); }