"use client"; import { useEffect, useRef, useState } from "react"; import { api } from "~/trpc/react"; import { PageHeader } from "~/components/layout/page-header"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Badge } from "~/components/ui/badge"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "~/components/ui/select"; import { NumberInput } from "~/components/ui/number-input"; import { DatePicker } from "~/components/ui/date-picker"; import { toast } from "sonner"; import { Clock, Play, Square, Plus, Pencil, Trash2 } from "lucide-react"; import { formatCurrency } from "~/lib/currency"; 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 formatDuration(hours: number | null | undefined) { if (!hours) return "—"; const h = Math.floor(hours); const m = Math.round((hours - h) * 60); if (h === 0) return `${m}m`; if (m === 0) return `${h}h`; return `${h}h ${m}m`; } interface ManualEntryForm { description: string; clientId: string; startedAt: Date; endedAt: Date | undefined; rate: number; notes: string; } const defaultManualForm: ManualEntryForm = { description: "", clientId: "", startedAt: new Date(), endedAt: undefined, rate: 0, notes: "", }; export default function TimeClockPage() { const utils = api.useUtils(); const { data: running, isLoading: runningLoading } = api.timeEntries.getRunning.useQuery(undefined, { refetchInterval: 30_000 }); const { data: entries = [], isLoading: entriesLoading } = api.timeEntries.getAll.useQuery(); const { data: summary } = api.timeEntries.getSummary.useQuery(); const { data: clients = [] } = api.clients.getAll.useQuery(); const [clockInDesc, setClockInDesc] = useState(""); const [clockInClientId, setClockInClientId] = useState(""); const [clockInRate, setClockInRate] = useState(0); const [elapsed, setElapsed] = useState(0); const [manualOpen, setManualOpen] = useState(false); const [editId, setEditId] = useState(null); const [manualForm, setManualForm] = useState(defaultManualForm); const [deleteId, setDeleteId] = useState(null); const intervalRef = useRef | null>(null); useEffect(() => { if (running) { 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(); void utils.timeEntries.getAll.invalidate(); setClockInDesc(""); setClockInClientId(""); setClockInRate(0); }, onError: (e) => toast.error(e.message), }); const clockOut = api.timeEntries.clockOut.useMutation({ onSuccess: () => { toast.success("Timer stopped"); void utils.timeEntries.getRunning.invalidate(); void utils.timeEntries.getAll.invalidate(); void utils.timeEntries.getSummary.invalidate(); }, onError: (e) => toast.error(e.message), }); const create = api.timeEntries.create.useMutation({ onSuccess: () => { toast.success("Entry added"); void utils.timeEntries.getAll.invalidate(); void utils.timeEntries.getSummary.invalidate(); setManualOpen(false); setManualForm(defaultManualForm); setEditId(null); }, onError: (e) => toast.error(e.message), }); const update = api.timeEntries.update.useMutation({ onSuccess: () => { toast.success("Entry updated"); void utils.timeEntries.getAll.invalidate(); void utils.timeEntries.getSummary.invalidate(); setManualOpen(false); setManualForm(defaultManualForm); setEditId(null); }, onError: (e) => toast.error(e.message), }); const del = api.timeEntries.delete.useMutation({ onSuccess: () => { toast.success("Entry deleted"); void utils.timeEntries.getAll.invalidate(); void utils.timeEntries.getSummary.invalidate(); setDeleteId(null); }, onError: (e) => toast.error(e.message), }); function handleEdit(entry: (typeof entries)[0]) { setEditId(entry.id); setManualForm({ description: entry.description, clientId: entry.clientId ?? "", startedAt: new Date(entry.startedAt), endedAt: entry.endedAt ? new Date(entry.endedAt) : undefined, rate: entry.rate ?? 0, notes: entry.notes ?? "", }); setManualOpen(true); } function handleManualSubmit() { if (!manualForm.description.trim()) { toast.error("Description is required"); return; } const payload = { description: manualForm.description, clientId: manualForm.clientId || undefined, startedAt: manualForm.startedAt, endedAt: manualForm.endedAt, rate: manualForm.rate || undefined, notes: manualForm.notes || undefined, }; if (editId) update.mutate({ id: editId, ...payload }); else create.mutate(payload); } const completedEntries = entries.filter((e) => e.endedAt !== null); return (
{/* Summary cards */}

Total Hours

{formatDuration(summary?.totalHours)}

Earnings

{formatCurrency(summary?.totalEarnings ?? 0)}

Entries

{summary?.count ?? 0}

{/* Active timer */} {running ? "Timer Running" : "Start Timer"} {runningLoading ? (
Loading…
) : running ? (
{formatElapsed(elapsed)}
{running.description && (

{running.description}

)} {running.client && (

{running.client.name}

)}

Started{" "} {new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", }).format(new Date(running.startedAt))}

) : (
setClockInDesc(e.target.value)} placeholder="e.g. Frontend development" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); clockIn.mutate({ description: clockInDesc, clientId: clockInClientId || undefined, rate: clockInRate || undefined, }); } }} />
setClockInRate(v)} min={0} step={0.01} placeholder="0.00" />
)}
{/* Entry list */} Time Entries {entriesLoading ? (
Loading…
) : completedEntries.length === 0 ? (

No completed entries yet. Start a timer or add a manual entry.

) : (
{completedEntries.map((entry) => (

{entry.description || ( No description )}

{entry.client && ( {entry.client.name} )}

{new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", }).format(new Date(entry.startedAt))} {entry.endedAt ? ` → ${new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit" }).format(new Date(entry.endedAt))}` : ""}

{entry.notes && (

{entry.notes}

)}

{formatDuration(entry.hours)}

{entry.rate && entry.hours ? (

{formatCurrency(entry.hours * entry.rate)}

) : null}
))}
)}
{/* Manual entry dialog */} {editId ? "Edit Entry" : "Add Manual Entry"}
setManualForm((p) => ({ ...p, description: e.target.value })) } placeholder="What did you work on?" />
setManualForm((p) => ({ ...p, startedAt: d ?? new Date() })) } className="w-full" />
setManualForm((p) => ({ ...p, endedAt: d ?? undefined })) } className="w-full" />
setManualForm((p) => ({ ...p, rate: v }))} min={0} step={0.01} placeholder="0.00" />
setManualForm((p) => ({ ...p, notes: e.target.value })) } placeholder="Additional details…" />
{/* Delete dialog */} !o && setDeleteId(null)}> Delete Entry This action cannot be undone.
); }