Archived
Sync time entries with invoice lines and harden auth for mobile.
Link clocked time to invoice items with bidirectional sync, add entry editing on web, broaden session cookie detection for Expo clients, and handle API rate limits without signing users out. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -37,11 +37,68 @@ import {
|
||||
} 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 (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clock-running-title">What are you working on?</Label>
|
||||
<Input
|
||||
id="clock-running-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onBlur={() => onDescriptionCommit(title)}
|
||||
placeholder="What are you working on?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clock-running-start">Started at</Label>
|
||||
<Input
|
||||
id="clock-running-start"
|
||||
type="datetime-local"
|
||||
value={runningStartedAt}
|
||||
onChange={(e) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export type TimeClockPanelProps = {
|
||||
defaultClientId?: string;
|
||||
defaultInvoiceId?: string;
|
||||
@@ -109,6 +166,7 @@ export function TimeClockPanel({
|
||||
const [startMode, setStartMode] = useState<StartMode>("now");
|
||||
const [pickedStart, setPickedStart] = useState("");
|
||||
const [minutesAgo, setMinutesAgo] = useState("30");
|
||||
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const draftClientId = running ? (running.clientId ?? "") : clientId;
|
||||
@@ -185,6 +243,18 @@ export function TimeClockPanel({
|
||||
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({
|
||||
@@ -496,6 +566,14 @@ export function TimeClockPanel({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RunningTextFields
|
||||
key={running.id}
|
||||
running={running}
|
||||
updateRunningPending={updateRunning.isPending}
|
||||
onDescriptionCommit={handleRunningDescriptionCommit}
|
||||
onStartedAtCommit={handleRunningStartedAtCommit}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Client</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -621,7 +699,10 @@ export function TimeClockPanel({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{todayEntries?.some((e) => e.endedAt) ? (
|
||||
<TimeEntryList entries={todayEntries} />
|
||||
<TimeEntryList
|
||||
entries={todayEntries}
|
||||
onEdit={(entry) => setEditEntryId(entry.id)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
No entries today.{" "}
|
||||
@@ -637,6 +718,13 @@ export function TimeClockPanel({
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<TimeEntryEditDialog
|
||||
entryId={editEntryId}
|
||||
open={editEntryId != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditEntryId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -9,9 +9,12 @@ import { EmptyState } from "~/components/layout/page-layout";
|
||||
import { Clock, Play } from "lucide-react";
|
||||
import { groupEntriesByDate } from "~/lib/time-entry-display";
|
||||
import { TimeEntryRow } from "~/components/time-clock/time-entry-list";
|
||||
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
|
||||
import type { TimeEntryListItem } from "~/lib/time-entry-display";
|
||||
|
||||
export function TimeEntriesHistory() {
|
||||
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
|
||||
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||
|
||||
const completedEntries = useMemo(
|
||||
() => (entries ?? []).filter((e) => e.endedAt),
|
||||
@@ -57,25 +60,35 @@ export function TimeEntriesHistory() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{grouped.map((group) => (
|
||||
<Card key={group.dateKey}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||
{group.label}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{group.entries.map((entry, index) => (
|
||||
<TimeEntryRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isLast={index === group.entries.length - 1}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
{grouped.map((group) => (
|
||||
<Card key={group.dateKey}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-muted-foreground text-sm font-medium">
|
||||
{group.label}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{group.entries.map((entry, index) => (
|
||||
<TimeEntryRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isLast={index === group.entries.length - 1}
|
||||
onEdit={(item: TimeEntryListItem) => setEditEntryId(item.id)}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<TimeEntryEditDialog
|
||||
entryId={editEntryId}
|
||||
open={editEntryId != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditEntryId(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { invoiceLabel } from "~/lib/time-entry-display";
|
||||
import type { RouterOutputs } from "~/trpc/react";
|
||||
|
||||
type TimeEntry = RouterOutputs["timeEntries"]["getById"];
|
||||
|
||||
function toDatetimeLocalValue(value: Date | string) {
|
||||
const start = new Date(value);
|
||||
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
|
||||
return start.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export type TimeEntryEditDialogProps = {
|
||||
entryId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
type TimeEntryEditFormProps = {
|
||||
entry: TimeEntry;
|
||||
entryId: string;
|
||||
clients: RouterOutputs["clients"]["getAll"];
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function TimeEntryEditForm({
|
||||
entry,
|
||||
entryId,
|
||||
clients,
|
||||
onClose,
|
||||
}: TimeEntryEditFormProps) {
|
||||
const utils = api.useUtils();
|
||||
const [description, setDescription] = useState(entry.description ?? "");
|
||||
const [clientId, setClientId] = useState(entry.clientId ?? "");
|
||||
const [invoiceId, setInvoiceId] = useState(entry.invoiceId ?? "");
|
||||
const [rate, setRate] = useState(entry.rate ?? 0);
|
||||
const [startedAt, setStartedAt] = useState(() => toDatetimeLocalValue(entry.startedAt));
|
||||
const [endedAt, setEndedAt] = useState(() =>
|
||||
entry.endedAt ? toDatetimeLocalValue(entry.endedAt) : "",
|
||||
);
|
||||
|
||||
const { data: billableInvoices } = api.invoices.getBillable.useQuery(
|
||||
clientId ? { clientId } : undefined,
|
||||
{ enabled: Boolean(clientId) },
|
||||
);
|
||||
|
||||
const hoursPreview = useMemo(() => {
|
||||
if (!startedAt || !endedAt) return null;
|
||||
const start = new Date(startedAt);
|
||||
const end = new Date(endedAt);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null;
|
||||
return Math.max(0, (end.getTime() - start.getTime()) / 3_600_000);
|
||||
}, [endedAt, startedAt]);
|
||||
|
||||
const updateEntry = api.timeEntries.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success("Time entry updated");
|
||||
await Promise.all([
|
||||
utils.timeEntries.getAll.invalidate(),
|
||||
utils.timeEntries.getById.invalidate(),
|
||||
utils.invoices.getAll.invalidate(),
|
||||
utils.dashboard.getStats.invalidate(),
|
||||
]);
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const deleteEntry = api.timeEntries.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success("Time entry deleted");
|
||||
await Promise.all([
|
||||
utils.timeEntries.getAll.invalidate(),
|
||||
utils.invoices.getAll.invalidate(),
|
||||
utils.dashboard.getStats.invalidate(),
|
||||
]);
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
const start = new Date(startedAt);
|
||||
const end = endedAt ? new Date(endedAt) : undefined;
|
||||
if (Number.isNaN(start.getTime()) || (end && Number.isNaN(end.getTime()))) {
|
||||
toast.error("Invalid start or end time");
|
||||
return;
|
||||
}
|
||||
if (end && end <= start) {
|
||||
toast.error("End time must be after start time");
|
||||
return;
|
||||
}
|
||||
|
||||
updateEntry.mutate({
|
||||
id: entryId,
|
||||
description,
|
||||
clientId: clientId || "",
|
||||
invoiceId: invoiceId || "",
|
||||
rate,
|
||||
startedAt: start,
|
||||
endedAt: end,
|
||||
hours: hoursPreview ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entry-description">Description</Label>
|
||||
<Input
|
||||
id="entry-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Client</Label>
|
||||
<Select
|
||||
value={clientId || "__none__"}
|
||||
onValueChange={(v) => {
|
||||
setClientId(v === "__none__" ? "" : v);
|
||||
setInvoiceId("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No client</SelectItem>
|
||||
{clients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Invoice</Label>
|
||||
<Select
|
||||
value={invoiceId || "__none__"}
|
||||
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
|
||||
disabled={!clientId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Not on invoice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Not on invoice</SelectItem>
|
||||
{billableInvoices?.map((inv) => (
|
||||
<SelectItem key={inv.id} value={inv.id}>
|
||||
{invoiceLabel(inv)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Hourly rate</Label>
|
||||
<NumberInput value={rate} onChange={setRate} min={0} step={0.01} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entry-start">Started</Label>
|
||||
<Input
|
||||
id="entry-start"
|
||||
type="datetime-local"
|
||||
value={startedAt}
|
||||
onChange={(e) => setStartedAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entry-end">Ended</Label>
|
||||
<Input
|
||||
id="entry-end"
|
||||
type="datetime-local"
|
||||
value={endedAt}
|
||||
onChange={(e) => setEndedAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hoursPreview != null ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Duration: {hoursPreview.toFixed(2)}h
|
||||
{rate > 0 ? ` · $${(hoursPreview * rate).toFixed(2)}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={deleteEntry.isPending}
|
||||
onClick={() => deleteEntry.mutate({ id: entryId })}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSave} disabled={updateEntry.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimeEntryEditDialog({
|
||||
entryId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TimeEntryEditDialogProps) {
|
||||
const entryQuery = api.timeEntries.getById.useQuery(
|
||||
{ id: entryId ?? "" },
|
||||
{ enabled: Boolean(entryId) && open },
|
||||
);
|
||||
const { data: clients = [] } = api.clients.getAll.useQuery(undefined, { enabled: open });
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit time entry</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{entryQuery.isLoading ? (
|
||||
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||
) : entryQuery.data && entryId ? (
|
||||
<TimeEntryEditForm
|
||||
key={entryQuery.data.id}
|
||||
entry={entryQuery.data}
|
||||
entryId={entryId}
|
||||
clients={clients}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">Time entry not found.</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,13 @@ import { entryHref, invoiceLabel, type TimeEntryListItem } from "~/lib/time-entr
|
||||
export function TimeEntryRow({
|
||||
entry,
|
||||
isLast,
|
||||
onEdit,
|
||||
}: {
|
||||
entry: TimeEntryListItem;
|
||||
isLast?: boolean;
|
||||
onEdit?: (entry: TimeEntryListItem) => void;
|
||||
}) {
|
||||
const href = entryHref(entry);
|
||||
const href = onEdit ? null : entryHref(entry);
|
||||
const rowClassName = cn(
|
||||
"flex items-start justify-between gap-4 py-3",
|
||||
!isLast && "border-border border-b",
|
||||
@@ -50,6 +52,21 @@ export function TimeEntryRow({
|
||||
);
|
||||
}
|
||||
|
||||
if (onEdit) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(entry)}
|
||||
className={cn(
|
||||
rowClassName,
|
||||
"-mx-2 flex w-full cursor-pointer px-2 text-left transition-colors hover:rounded-md hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={rowClassName}>
|
||||
{content}
|
||||
@@ -57,7 +74,13 @@ export function TimeEntryRow({
|
||||
);
|
||||
}
|
||||
|
||||
export function TimeEntryList({ entries }: { entries: TimeEntryListItem[] }) {
|
||||
export function TimeEntryList({
|
||||
entries,
|
||||
onEdit,
|
||||
}: {
|
||||
entries: TimeEntryListItem[];
|
||||
onEdit?: (entry: TimeEntryListItem) => void;
|
||||
}) {
|
||||
const completed = entries.filter((e) => e.endedAt);
|
||||
|
||||
if (completed.length === 0) return null;
|
||||
@@ -69,6 +92,7 @@ export function TimeEntryList({ entries }: { entries: TimeEntryListItem[] }) {
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isLast={index === completed.length - 1}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user