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:
@@ -1,19 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
import { Card, CardContent } from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Square } from "lucide-react";
|
import { Square, Clock } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import Link from "next/link";
|
import { describeClockOutOutcome, formatElapsedSeconds } from "~/lib/time-clock";
|
||||||
|
|
||||||
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(":");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActiveTimerWidget() {
|
export function ActiveTimerWidget() {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
@@ -39,19 +33,32 @@ export function ActiveTimerWidget() {
|
|||||||
|
|
||||||
const clockOut = api.timeEntries.clockOut.useMutation({
|
const clockOut = api.timeEntries.clockOut.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.invoice) {
|
const message = describeClockOutOutcome({
|
||||||
const label = `${data.invoice.invoicePrefix}${data.invoice.invoiceNumber}`;
|
outcome: data.outcome,
|
||||||
|
hours: data.hours,
|
||||||
|
rate: data.rate,
|
||||||
|
invoice: data.invoice,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.outcome === "linked_to_invoice" && data.invoice) {
|
||||||
toast.success("Timer stopped", {
|
toast.success("Timer stopped", {
|
||||||
description: `Added to invoice ${label}`,
|
description: message,
|
||||||
action: {
|
action: {
|
||||||
label: "View Invoice",
|
label: "View invoice",
|
||||||
onClick: () => window.location.assign(`/dashboard/invoices/${data.invoice!.id}`),
|
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 {
|
} else {
|
||||||
toast.success("Timer stopped");
|
toast.success(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
void utils.timeEntries.getRunning.invalidate();
|
void utils.timeEntries.getRunning.invalidate();
|
||||||
|
void utils.timeEntries.getAll.invalidate();
|
||||||
|
void utils.invoices.getAll.invalidate();
|
||||||
|
void utils.dashboard.getStats.invalidate();
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
@@ -65,7 +72,6 @@ export function ActiveTimerWidget() {
|
|||||||
return (
|
return (
|
||||||
<Card className="border-primary/30 bg-primary/5">
|
<Card className="border-primary/30 bg-primary/5">
|
||||||
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
|
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
|
||||||
{/* Pulse indicator */}
|
|
||||||
<span className="relative flex h-3 w-3 flex-shrink-0">
|
<span className="relative flex h-3 w-3 flex-shrink-0">
|
||||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75" />
|
<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 className="bg-primary relative inline-flex h-3 w-3 rounded-full" />
|
||||||
@@ -82,7 +88,8 @@ export function ActiveTimerWidget() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{invoiceLabel ? (
|
{invoiceLabel ? (
|
||||||
<>Tracking for{" "}
|
<>
|
||||||
|
Billing to{" "}
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/invoices/${running.invoice!.id}`}
|
href={`/dashboard/invoices/${running.invoice!.id}`}
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
@@ -91,29 +98,36 @@ export function ActiveTimerWidget() {
|
|||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>Started{" "}
|
<>No invoice selected — open time clock to assign</>
|
||||||
{new Intl.DateTimeFormat("en-US", {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
}).format(new Date(running.startedAt))}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
{" · "}
|
||||||
|
<Link href="/dashboard/time-clock" className="text-primary hover:underline">
|
||||||
|
Time clock
|
||||||
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-primary font-mono text-2xl font-bold tabular-nums">
|
<span className="text-primary font-mono text-2xl font-bold tabular-nums">
|
||||||
{formatElapsed(elapsed)}
|
{formatElapsedSeconds(elapsed)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
variant="destructive"
|
<Button variant="outline" size="sm" asChild>
|
||||||
size="sm"
|
<Link href="/dashboard/time-clock">
|
||||||
onClick={() => clockOut.mutate({})}
|
<Clock className="mr-1.5 h-3.5 w-3.5" />
|
||||||
disabled={clockOut.isPending}
|
Open
|
||||||
>
|
</Link>
|
||||||
<Square className="mr-1.5 h-3.5 w-3.5" />
|
</Button>
|
||||||
{clockOut.isPending ? "Stopping…" : "Stop"}
|
<Button
|
||||||
</Button>
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => clockOut.mutate({})}
|
||||||
|
disabled={clockOut.isPending}
|
||||||
|
>
|
||||||
|
<Square className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
{clockOut.isPending ? "Stopping…" : "Stop"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,178 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import Link from "next/link";
|
||||||
import { api } from "~/trpc/react";
|
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { ExternalLink } from "lucide-react";
|
||||||
import { NumberInput } from "~/components/ui/number-input";
|
|
||||||
import { Label } from "~/components/ui/label";
|
|
||||||
import { Clock, Play, Square } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
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(":");
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InvoiceTimerCardProps {
|
interface InvoiceTimerCardProps {
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
defaultRate?: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvoiceTimerCard({ invoiceId, clientId, defaultRate }: InvoiceTimerCardProps) {
|
export function InvoiceTimerCard({ invoiceId, clientId }: InvoiceTimerCardProps) {
|
||||||
const utils = api.useUtils();
|
|
||||||
const { data: running, isLoading } = api.timeEntries.getRunning.useQuery(undefined, {
|
|
||||||
refetchInterval: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [rate, setRate] = useState(defaultRate ?? 0);
|
|
||||||
const [elapsed, setElapsed] = useState(0);
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
const isThisInvoice = running?.invoiceId === invoiceId;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
||||||
if (isThisInvoice && 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);
|
|
||||||
};
|
|
||||||
}, [isThisInvoice, running]);
|
|
||||||
|
|
||||||
const clockIn = api.timeEntries.clockIn.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
void utils.timeEntries.getRunning.invalidate();
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e.message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const clockOut = api.timeEntries.clockOut.useMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data.invoice) {
|
|
||||||
toast.success("Time added to invoice");
|
|
||||||
} else {
|
|
||||||
toast.success("Timer stopped");
|
|
||||||
}
|
|
||||||
void utils.timeEntries.getRunning.invalidate();
|
|
||||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e.message),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) return null;
|
|
||||||
|
|
||||||
// Another timer is running for a different invoice
|
|
||||||
if (running && !isThisInvoice) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<Clock className="h-4 w-4" />
|
|
||||||
Timer
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
A timer is already running
|
|
||||||
{running.invoice
|
|
||||||
? ` for ${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
|
||||||
: ""}
|
|
||||||
. Stop it before starting a new one.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isThisInvoice && running) {
|
|
||||||
return (
|
|
||||||
<Card className="border-primary/30 bg-primary/5">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<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>
|
|
||||||
Timer Running
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{running.description || <span className="text-muted-foreground italic">No description</span>}</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-primary font-mono text-3xl font-bold tabular-nums">
|
|
||||||
{formatElapsed(elapsed)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => clockOut.mutate({})}
|
|
||||||
disabled={clockOut.isPending}
|
|
||||||
>
|
|
||||||
<Square className="mr-2 h-4 w-4" />
|
|
||||||
{clockOut.isPending ? "Stopping…" : "Stop & Add to Invoice"}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="space-y-3">
|
||||||
<CardHeader>
|
<TimeClockPanel
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
compact
|
||||||
<Clock className="h-4 w-4" />
|
defaultClientId={clientId}
|
||||||
Track Time
|
defaultInvoiceId={invoiceId}
|
||||||
</CardTitle>
|
/>
|
||||||
</CardHeader>
|
<Button variant="outline" size="sm" className="w-full" asChild>
|
||||||
<CardContent className="space-y-3">
|
<Link href={`/dashboard/time-clock?clientId=${clientId}&invoiceId=${invoiceId}`}>
|
||||||
<div className="space-y-1.5">
|
Open full time clock
|
||||||
<Label>What are you working on?</Label>
|
<ExternalLink className="ml-2 h-3.5 w-3.5" />
|
||||||
<Input
|
</Link>
|
||||||
value={description}
|
</Button>
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
</div>
|
||||||
placeholder="e.g. Frontend development"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
clockIn.mutate({ description, clientId, invoiceId, rate: rate || undefined });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Hourly rate</Label>
|
|
||||||
<NumberInput
|
|
||||||
value={rate}
|
|
||||||
onChange={(v) => setRate(v)}
|
|
||||||
min={0}
|
|
||||||
step={0.01}
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
onClick={() =>
|
|
||||||
clockIn.mutate({ description, clientId, invoiceId, rate: rate || undefined })
|
|
||||||
}
|
|
||||||
disabled={clockIn.isPending}
|
|
||||||
>
|
|
||||||
<Play className="mr-2 h-4 w-4" />
|
|
||||||
{clockIn.isPending ? "Starting…" : "Start Timer"}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -523,11 +523,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
{/* Right Column - Actions */}
|
{/* Right Column - Actions */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{effectiveStatus !== "paid" && (
|
{effectiveStatus !== "paid" && (
|
||||||
<InvoiceTimerCard
|
<InvoiceTimerCard invoiceId={invoiceId} clientId={invoice.clientId} />
|
||||||
invoiceId={invoiceId}
|
|
||||||
clientId={invoice.clientId}
|
|
||||||
defaultRate={invoice.client?.defaultHourlyRate}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className="lg:sticky lg:top-6">
|
<Card className="lg:sticky lg:top-6">
|
||||||
|
|||||||
@@ -490,7 +490,6 @@ function CardSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
|
||||||
|
|
||||||
// ... imports
|
// ... imports
|
||||||
|
|
||||||
@@ -511,10 +510,6 @@ export default async function DashboardPage() {
|
|||||||
description="Here's what's happening with your business today"
|
description="Here's what's happening with your business today"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HydrateClient>
|
|
||||||
<ActiveTimerWidget />
|
|
||||||
</HydrateClient>
|
|
||||||
|
|
||||||
<HydrateClient>
|
<HydrateClient>
|
||||||
<Suspense fallback={<StatsSkeleton />}>
|
<Suspense fallback={<StatsSkeleton />}>
|
||||||
<DashboardStats stats={stats} />
|
<DashboardStats stats={stats} />
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { HydrateClient, api } from "~/trpc/server";
|
||||||
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
|
import { TimeClockPanel } from "~/components/time-clock/time-clock-panel";
|
||||||
|
|
||||||
export default function TimeClockPage() {
|
export default async function TimeClockPage({
|
||||||
redirect("/dashboard/invoices");
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ clientId?: string; invoiceId?: string }>;
|
||||||
|
}) {
|
||||||
|
const params = await searchParams;
|
||||||
|
void api.timeEntries.getRunning.prefetch();
|
||||||
|
void api.clients.getAll.prefetch();
|
||||||
|
if (params.clientId) {
|
||||||
|
void api.invoices.getBillable.prefetch({ clientId: params.clientId });
|
||||||
|
} else {
|
||||||
|
void api.invoices.getBillable.prefetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-enter mx-auto max-w-3xl space-y-6">
|
||||||
|
<DashboardPageHeader
|
||||||
|
title="Time clock"
|
||||||
|
description="Track billable hours and save them directly to an invoice"
|
||||||
|
/>
|
||||||
|
<HydrateClient>
|
||||||
|
<TimeClockPanel
|
||||||
|
defaultClientId={params.clientId}
|
||||||
|
defaultInvoiceId={params.invoiceId}
|
||||||
|
/>
|
||||||
|
</HydrateClient>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -806,6 +806,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
|||||||
onUpdateItem={updateItem}
|
onUpdateItem={updateItem}
|
||||||
onAddItemWithValues={addItemWithValues}
|
onAddItemWithValues={addItemWithValues}
|
||||||
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined}
|
invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined}
|
||||||
|
clientId={formData.clientId || undefined}
|
||||||
defaultRate={formData.items[0]?.rate}
|
defaultRate={formData.items[0]?.rate}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Plus, Timer, Trash2, Zap } from "lucide-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import Link from "next/link";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { DatePicker } from "~/components/ui/date-picker";
|
import { DatePicker } from "~/components/ui/date-picker";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
@@ -15,7 +16,6 @@ import {
|
|||||||
useLineItemSuggestions,
|
useLineItemSuggestions,
|
||||||
type LineItemSuggestion,
|
type LineItemSuggestion,
|
||||||
} from "~/hooks/use-line-item-suggestions";
|
} from "~/hooks/use-line-item-suggestions";
|
||||||
import { TimeTracker } from "~/components/invoice/time-tracker";
|
|
||||||
|
|
||||||
interface InvoiceItem {
|
interface InvoiceItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,6 +37,7 @@ interface InvoiceLineItemsProps {
|
|||||||
) => void;
|
) => void;
|
||||||
onAddItemWithValues?: (parsed: ParsedLineItem) => void;
|
onAddItemWithValues?: (parsed: ParsedLineItem) => void;
|
||||||
invoiceId?: string;
|
invoiceId?: string;
|
||||||
|
clientId?: string;
|
||||||
defaultRate?: number;
|
defaultRate?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -348,7 +349,8 @@ export function InvoiceLineItems({
|
|||||||
onUpdateItem,
|
onUpdateItem,
|
||||||
onAddItemWithValues,
|
onAddItemWithValues,
|
||||||
invoiceId,
|
invoiceId,
|
||||||
defaultRate,
|
clientId,
|
||||||
|
defaultRate: _defaultRate,
|
||||||
className,
|
className,
|
||||||
}: InvoiceLineItemsProps) {
|
}: InvoiceLineItemsProps) {
|
||||||
const canRemoveItems = items.length > 1;
|
const canRemoveItems = items.length > 1;
|
||||||
@@ -422,18 +424,24 @@ export function InvoiceLineItems({
|
|||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
{onAddItemWithValues && invoiceId && (
|
{invoiceId && (
|
||||||
<div className="border-t p-3 space-y-2">
|
<div className="border-t p-3 space-y-2">
|
||||||
<p className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
|
<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>
|
</p>
|
||||||
<TimeTracker
|
<p className="text-muted-foreground text-xs">
|
||||||
invoiceId={invoiceId}
|
Track time on the dedicated time clock — entries sync across devices and
|
||||||
defaultRate={defaultRate}
|
bill directly to an invoice.
|
||||||
onStop={(hours, description) => {
|
</p>
|
||||||
onAddItemWithValues({ description, hours, rate: defaultRate ?? 0 });
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{onAddItemWithValues && (
|
{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 { Button } from "~/components/ui/button";
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
||||||
import { useAppearance } from "~/components/providers/appearance-provider";
|
import { useAppearance } from "~/components/providers/appearance-provider";
|
||||||
|
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
||||||
|
|
||||||
function DashboardContent({ children }: { children: React.ReactNode }) {
|
function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||||
const { isCollapsed } = useSidebar();
|
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">
|
<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">
|
<div className="mb-4 md:hidden">
|
||||||
{/* Mobile Breadcrumbs could go here or be part of the page */}
|
{/* Mobile Breadcrumbs could go here or be part of the page */}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<ActiveTimerWidget />
|
||||||
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
BarChart2,
|
BarChart2,
|
||||||
Shield,
|
Shield,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Clock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export interface NavLink {
|
export interface NavLink {
|
||||||
@@ -26,6 +27,7 @@ export const navigationConfig: NavSection[] = [
|
|||||||
title: "Main",
|
title: "Main",
|
||||||
links: [
|
links: [
|
||||||
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||||
|
{ name: "Time clock", href: "/dashboard/time-clock", icon: Clock },
|
||||||
{ name: "Clients", href: "/dashboard/clients", icon: Users },
|
{ name: "Clients", href: "/dashboard/clients", icon: Users },
|
||||||
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
|
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
|
||||||
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export type ClockOutOutcome =
|
||||||
|
| "linked_to_invoice"
|
||||||
|
| "saved_no_invoice"
|
||||||
|
| "saved_no_client"
|
||||||
|
| "zero_hours";
|
||||||
|
|
||||||
|
export function computeTrackedHours(startedAt: Date, endedAt: Date): number {
|
||||||
|
const seconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
|
||||||
|
return Math.max(0.25, Math.ceil(seconds / 900) * 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatElapsedSeconds(seconds: number): string {
|
||||||
|
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(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeClockOutOutcome(input: {
|
||||||
|
outcome: ClockOutOutcome;
|
||||||
|
hours: number;
|
||||||
|
rate: number;
|
||||||
|
invoice?: { invoicePrefix: string; invoiceNumber: string } | null;
|
||||||
|
}): string {
|
||||||
|
const amount = input.hours * input.rate;
|
||||||
|
|
||||||
|
switch (input.outcome) {
|
||||||
|
case "linked_to_invoice":
|
||||||
|
if (input.invoice) {
|
||||||
|
const label = `${input.invoice.invoicePrefix}${input.invoice.invoiceNumber}`;
|
||||||
|
return `Added ${input.hours}h @ $${input.rate}/hr ($${amount.toFixed(2)}) to invoice ${label}`;
|
||||||
|
}
|
||||||
|
return `Added ${input.hours}h to invoice`;
|
||||||
|
case "saved_no_invoice":
|
||||||
|
return `Saved ${input.hours}h — no open invoice found for this client. Pick an invoice on the time clock or create one.`;
|
||||||
|
case "saved_no_client":
|
||||||
|
return `Saved ${input.hours}h — assign a client and invoice to bill this time.`;
|
||||||
|
case "zero_hours":
|
||||||
|
return "Timer stopped (less than minimum billable increment).";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -155,6 +155,38 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Draft and sent invoices available for time-clock billing. */
|
||||||
|
getBillable: protectedProcedure
|
||||||
|
.input(z.object({ clientId: z.string().optional() }).optional())
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const conditions = [
|
||||||
|
eq(invoices.createdById, ctx.session.user.id),
|
||||||
|
inArray(invoices.status, ["draft", "sent"]),
|
||||||
|
];
|
||||||
|
if (input?.clientId) conditions.push(eq(invoices.clientId, input.clientId));
|
||||||
|
|
||||||
|
return ctx.db.query.invoices.findMany({
|
||||||
|
where: and(...conditions),
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
invoiceNumber: true,
|
||||||
|
invoicePrefix: true,
|
||||||
|
status: true,
|
||||||
|
clientId: true,
|
||||||
|
issueDate: true,
|
||||||
|
totalAmount: true,
|
||||||
|
},
|
||||||
|
with: {
|
||||||
|
client: { columns: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
orderBy: (invoices, { desc }) => [
|
||||||
|
desc(invoices.issueDate),
|
||||||
|
desc(invoices.dueDate),
|
||||||
|
desc(invoices.invoiceNumber),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
getLineItemHistory: protectedProcedure.query(async ({ ctx }) => {
|
getLineItemHistory: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const userInvoices = await ctx.db
|
const userInvoices = await ctx.db
|
||||||
.select({ id: invoices.id })
|
.select({ id: invoices.id })
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { createTRPCRouter, protectedProcedure } from "../trpc";
|
|||||||
import { timeEntries, clients, invoices, invoiceItems } from "~/server/db/schema";
|
import { timeEntries, clients, invoices, invoiceItems } from "~/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import type { db } from "~/server/db";
|
import type { db } from "~/server/db";
|
||||||
|
import {
|
||||||
|
computeTrackedHours,
|
||||||
|
type ClockOutOutcome,
|
||||||
|
} from "~/lib/time-clock";
|
||||||
|
|
||||||
type Db = typeof db;
|
type Db = typeof db;
|
||||||
|
|
||||||
@@ -19,9 +23,25 @@ const createSchema = z.object({
|
|||||||
|
|
||||||
const updateSchema = createSchema.partial().extend({ id: z.string() });
|
const updateSchema = createSchema.partial().extend({ id: z.string() });
|
||||||
|
|
||||||
|
async function resolveHourlyRate(
|
||||||
|
database: Db,
|
||||||
|
userId: string,
|
||||||
|
clientId: string | null,
|
||||||
|
explicitRate?: number | null,
|
||||||
|
): Promise<number | null> {
|
||||||
|
if (explicitRate != null && explicitRate > 0) return explicitRate;
|
||||||
|
if (!clientId) return explicitRate ?? null;
|
||||||
|
|
||||||
|
const client = await database.query.clients.findFirst({
|
||||||
|
where: and(eq(clients.id, clientId), eq(clients.createdById, userId)),
|
||||||
|
columns: { defaultHourlyRate: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return client?.defaultHourlyRate ?? explicitRate ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function computeHours(startedAt: Date, endedAt: Date): number {
|
function computeHours(startedAt: Date, endedAt: Date): number {
|
||||||
const seconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
|
return computeTrackedHours(startedAt, endedAt);
|
||||||
return Math.max(0.25, Math.ceil(seconds / 900) * 0.25);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addEntryToInvoice(
|
async function addEntryToInvoice(
|
||||||
@@ -156,7 +176,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getRunning: protectedProcedure.query(async ({ ctx }) => {
|
getRunning: protectedProcedure.query(async ({ ctx }) => {
|
||||||
return ctx.db.query.timeEntries.findFirst({
|
const entry = await ctx.db.query.timeEntries.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(timeEntries.createdById, ctx.session.user.id),
|
eq(timeEntries.createdById, ctx.session.user.id),
|
||||||
isNull(timeEntries.endedAt),
|
isNull(timeEntries.endedAt),
|
||||||
@@ -166,6 +186,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
invoice: { columns: { id: true, invoiceNumber: true, invoicePrefix: true } },
|
invoice: { columns: { id: true, invoiceNumber: true, invoicePrefix: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return entry ?? null;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
clockIn: protectedProcedure
|
clockIn: protectedProcedure
|
||||||
@@ -193,19 +214,40 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clientId = input.clientId?.trim() ?? null;
|
const clientId = input.clientId?.trim() ?? null;
|
||||||
|
let clientRecord: { defaultHourlyRate: number | null } | null = null;
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
const client = await ctx.db.query.clients.findFirst({
|
const found = await ctx.db.query.clients.findFirst({
|
||||||
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
|
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
|
||||||
|
columns: { defaultHourlyRate: true },
|
||||||
});
|
});
|
||||||
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
if (!found) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
||||||
|
clientRecord = found;
|
||||||
}
|
}
|
||||||
|
|
||||||
const invoiceId = input.invoiceId ?? null;
|
const invoiceId = input.invoiceId ?? null;
|
||||||
|
let resolvedClientId = clientId;
|
||||||
if (invoiceId) {
|
if (invoiceId) {
|
||||||
const invoice = await ctx.db.query.invoices.findFirst({
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
where: and(eq(invoices.id, invoiceId), eq(invoices.createdById, ctx.session.user.id)),
|
where: and(
|
||||||
|
eq(invoices.id, invoiceId),
|
||||||
|
eq(invoices.createdById, ctx.session.user.id),
|
||||||
|
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
|
||||||
|
),
|
||||||
|
columns: { id: true, clientId: true },
|
||||||
});
|
});
|
||||||
if (!invoice) throw new TRPCError({ code: "FORBIDDEN", message: "Invoice not found" });
|
if (!invoice) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Invoice not found or not open for time tracking",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (resolvedClientId && invoice.clientId !== resolvedClientId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Selected invoice does not belong to this client",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resolvedClientId = resolvedClientId ?? invoice.clientId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startedAt = input.startedAt ?? new Date();
|
const startedAt = input.startedAt ?? new Date();
|
||||||
@@ -213,14 +255,32 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "startedAt cannot be in the future" });
|
throw new TRPCError({ code: "BAD_REQUEST", message: "startedAt cannot be in the future" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!clientRecord && resolvedClientId) {
|
||||||
|
const found = await ctx.db.query.clients.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(clients.id, resolvedClientId),
|
||||||
|
eq(clients.createdById, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
columns: { defaultHourlyRate: true },
|
||||||
|
});
|
||||||
|
clientRecord = found ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rate = await resolveHourlyRate(
|
||||||
|
ctx.db,
|
||||||
|
ctx.session.user.id,
|
||||||
|
resolvedClientId,
|
||||||
|
input.rate ?? clientRecord?.defaultHourlyRate,
|
||||||
|
);
|
||||||
|
|
||||||
const [entry] = await ctx.db
|
const [entry] = await ctx.db
|
||||||
.insert(timeEntries)
|
.insert(timeEntries)
|
||||||
.values({
|
.values({
|
||||||
description: input.description,
|
description: input.description,
|
||||||
clientId,
|
clientId: resolvedClientId,
|
||||||
invoiceId,
|
invoiceId,
|
||||||
startedAt,
|
startedAt,
|
||||||
rate: input.rate ?? null,
|
rate,
|
||||||
createdById: ctx.session.user.id,
|
createdById: ctx.session.user.id,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -228,6 +288,109 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
return entry;
|
return entry;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
updateRunning: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
clientId: z.string().optional().or(z.literal("")),
|
||||||
|
invoiceId: z.string().optional().or(z.literal("")),
|
||||||
|
rate: z.number().min(0).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const entry = await ctx.db.query.timeEntries.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(timeEntries.createdById, ctx.session.user.id),
|
||||||
|
isNull(timeEntries.endedAt),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "No running timer found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: {
|
||||||
|
description?: string;
|
||||||
|
clientId?: string | null;
|
||||||
|
invoiceId?: string | null;
|
||||||
|
rate?: number | null;
|
||||||
|
updatedAt: Date;
|
||||||
|
} = { updatedAt: new Date() };
|
||||||
|
|
||||||
|
if (input.description !== undefined) {
|
||||||
|
updates.description = input.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedClientId = entry.clientId;
|
||||||
|
|
||||||
|
if (input.clientId !== undefined) {
|
||||||
|
const clientId = input.clientId.trim() || null;
|
||||||
|
if (clientId) {
|
||||||
|
const found = await ctx.db.query.clients.findFirst({
|
||||||
|
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
|
||||||
|
});
|
||||||
|
if (!found) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
||||||
|
}
|
||||||
|
resolvedClientId = clientId;
|
||||||
|
updates.clientId = clientId;
|
||||||
|
if (input.invoiceId === undefined) {
|
||||||
|
updates.invoiceId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.invoiceId !== undefined) {
|
||||||
|
const invoiceId = input.invoiceId.trim() || null;
|
||||||
|
if (invoiceId) {
|
||||||
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(invoices.id, invoiceId),
|
||||||
|
eq(invoices.createdById, ctx.session.user.id),
|
||||||
|
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
|
||||||
|
),
|
||||||
|
columns: { id: true, clientId: true },
|
||||||
|
});
|
||||||
|
if (!invoice) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Invoice not found or not open for time tracking",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (resolvedClientId && invoice.clientId !== resolvedClientId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Selected invoice does not belong to this client",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resolvedClientId = resolvedClientId ?? invoice.clientId;
|
||||||
|
updates.clientId = resolvedClientId;
|
||||||
|
}
|
||||||
|
updates.invoiceId = invoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.rate !== undefined) {
|
||||||
|
updates.rate = input.rate;
|
||||||
|
} else if (input.clientId !== undefined && resolvedClientId) {
|
||||||
|
updates.rate = await resolveHourlyRate(
|
||||||
|
ctx.db,
|
||||||
|
ctx.session.user.id,
|
||||||
|
resolvedClientId,
|
||||||
|
entry.rate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await ctx.db
|
||||||
|
.update(timeEntries)
|
||||||
|
.set(updates)
|
||||||
|
.where(eq(timeEntries.id, entry.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Update failed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
|
||||||
clockOut: protectedProcedure
|
clockOut: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -253,6 +416,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
const endedAt = new Date();
|
const endedAt = new Date();
|
||||||
const hours = computeHours(entry.startedAt, endedAt);
|
const hours = computeHours(entry.startedAt, endedAt);
|
||||||
const description = input?.description?.trim() ?? entry.description;
|
const description = input?.description?.trim() ?? entry.description;
|
||||||
|
const rate = entry.rate ?? 0;
|
||||||
|
|
||||||
const [updated] = await ctx.db
|
const [updated] = await ctx.db
|
||||||
.update(timeEntries)
|
.update(timeEntries)
|
||||||
@@ -263,6 +427,8 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
if (!updated) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Clock out failed" });
|
if (!updated) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Clock out failed" });
|
||||||
|
|
||||||
let linkedInvoice: { id: string; invoiceNumber: string; invoicePrefix: string } | null = null;
|
let linkedInvoice: { id: string; invoiceNumber: string; invoicePrefix: string } | null = null;
|
||||||
|
let outcome: ClockOutOutcome = "zero_hours";
|
||||||
|
|
||||||
if (hours > 0) {
|
if (hours > 0) {
|
||||||
if (entry.invoiceId) {
|
if (entry.invoiceId) {
|
||||||
linkedInvoice = await addEntryToSpecificInvoice(
|
linkedInvoice = await addEntryToSpecificInvoice(
|
||||||
@@ -272,9 +438,10 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
updated.id,
|
updated.id,
|
||||||
description,
|
description,
|
||||||
hours,
|
hours,
|
||||||
entry.rate ?? 0,
|
rate,
|
||||||
endedAt,
|
endedAt,
|
||||||
);
|
);
|
||||||
|
outcome = linkedInvoice ? "linked_to_invoice" : "saved_no_invoice";
|
||||||
} else if (entry.clientId) {
|
} else if (entry.clientId) {
|
||||||
linkedInvoice = await addEntryToLatestInvoice(
|
linkedInvoice = await addEntryToLatestInvoice(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
@@ -283,13 +450,23 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
updated.id,
|
updated.id,
|
||||||
description,
|
description,
|
||||||
hours,
|
hours,
|
||||||
entry.rate ?? 0,
|
rate,
|
||||||
endedAt,
|
endedAt,
|
||||||
);
|
);
|
||||||
|
outcome = linkedInvoice ? "linked_to_invoice" : "saved_no_invoice";
|
||||||
|
} else {
|
||||||
|
outcome = "saved_no_client";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { entry: updated, invoice: linkedInvoice };
|
return {
|
||||||
|
entry: updated,
|
||||||
|
invoice: linkedInvoice,
|
||||||
|
outcome,
|
||||||
|
hours,
|
||||||
|
rate,
|
||||||
|
amount: hours * rate,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { relations, sql } from "drizzle-orm";
|
import { relations, sql } from "drizzle-orm";
|
||||||
import { index, pgTableCreator } from "drizzle-orm/pg-core";
|
import { index, pgTableCreator, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||||
@@ -720,6 +720,9 @@ export const timeEntries = createTable(
|
|||||||
index("time_entry_client_id_idx").on(t.clientId),
|
index("time_entry_client_id_idx").on(t.clientId),
|
||||||
index("time_entry_started_at_idx").on(t.startedAt),
|
index("time_entry_started_at_idx").on(t.startedAt),
|
||||||
index("time_entry_ended_at_idx").on(t.endedAt),
|
index("time_entry_ended_at_idx").on(t.endedAt),
|
||||||
|
uniqueIndex("time_entry_one_running_per_user_idx")
|
||||||
|
.on(t.createdById)
|
||||||
|
.where(sql`${t.endedAt} is null`),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user