Integrate time clock directly into invoices, remove standalone page
- Remove "Time Clock" from sidebar navigation - Redirect /dashboard/time-clock to /dashboard/invoices - Add InvoiceTimerCard to invoice detail page (shown for non-paid invoices) - Timer started from an invoice is explicitly linked to that invoice at clock-in - On clock-out, time is added directly to the linked invoice as a line item - Active timer widget on dashboard now shows which invoice is being tracked - Backend: clockIn accepts invoiceId; clockOut prefers explicit invoiceId over searching for the latest draft invoice for the client https://claude.ai/code/session_014126WHVRT8mftmqkU6dajG
This commit is contained in:
@@ -58,6 +58,10 @@ export function ActiveTimerWidget() {
|
|||||||
|
|
||||||
if (isLoading || !running) return null;
|
if (isLoading || !running) return null;
|
||||||
|
|
||||||
|
const invoiceLabel = running.invoice
|
||||||
|
? `${running.invoice.invoicePrefix ?? "#"}${running.invoice.invoiceNumber}`
|
||||||
|
: null;
|
||||||
|
|
||||||
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">
|
||||||
@@ -77,11 +81,23 @@ export function ActiveTimerWidget() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Started{" "}
|
{invoiceLabel ? (
|
||||||
|
<>Tracking for{" "}
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/invoices/${running.invoice!.id}`}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{invoiceLabel}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Started{" "}
|
||||||
{new Intl.DateTimeFormat("en-US", {
|
{new Intl.DateTimeFormat("en-US", {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
}).format(new Date(running.startedAt))}
|
}).format(new Date(running.startedAt))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,7 +105,6 @@ export function ActiveTimerWidget() {
|
|||||||
{formatElapsed(elapsed)}
|
{formatElapsed(elapsed)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -99,10 +114,6 @@ export function ActiveTimerWidget() {
|
|||||||
<Square className="mr-1.5 h-3.5 w-3.5" />
|
<Square className="mr-1.5 h-3.5 w-3.5" />
|
||||||
{clockOut.isPending ? "Stopping…" : "Stop"}
|
{clockOut.isPending ? "Stopping…" : "Stop"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<Link href="/dashboard/time-clock">Details</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, 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 { 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 {
|
||||||
|
invoiceId: string;
|
||||||
|
clientId: string;
|
||||||
|
defaultRate?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvoiceTimerCard({ invoiceId, clientId, defaultRate }: 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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
Track Time
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>What are you working on?</Label>
|
||||||
|
<Input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ import type { StoredInvoiceStatus } from "~/types/invoice";
|
|||||||
import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton";
|
import { InvoiceDetailsSkeleton } from "./_components/invoice-details-skeleton";
|
||||||
import { PDFDownloadButton } from "./_components/pdf-download-button";
|
import { PDFDownloadButton } from "./_components/pdf-download-button";
|
||||||
import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
|
import { EnhancedSendInvoiceButton } from "~/components/forms/enhanced-send-invoice-button";
|
||||||
|
import { InvoiceTimerCard } from "./_components/invoice-timer-card";
|
||||||
|
|
||||||
const PAYMENT_METHODS = [
|
const PAYMENT_METHODS = [
|
||||||
{ value: "cash", label: "Cash" },
|
{ value: "cash", label: "Cash" },
|
||||||
@@ -521,6 +522,14 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
|
|
||||||
{/* Right Column - Actions */}
|
{/* Right Column - Actions */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{effectiveStatus !== "paid" && (
|
||||||
|
<InvoiceTimerCard
|
||||||
|
invoiceId={invoiceId}
|
||||||
|
clientId={invoice.clientId}
|
||||||
|
defaultRate={invoice.client?.defaultHourlyRate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="lg:sticky lg:top-6">
|
<Card className="lg:sticky lg:top-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,620 +1,5 @@
|
|||||||
"use client";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
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, FileText } from "lucide-react";
|
|
||||||
import { formatCurrency } from "~/lib/currency";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
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() {
|
export default function TimeClockPage() {
|
||||||
const utils = api.useUtils();
|
redirect("/dashboard/invoices");
|
||||||
|
|
||||||
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<string | null>(null);
|
|
||||||
const [manualForm, setManualForm] = useState<ManualEntryForm>(defaultManualForm);
|
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | 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: (data) => {
|
|
||||||
if (data.invoice) {
|
|
||||||
const label = `${data.invoice.invoicePrefix}${data.invoice.invoiceNumber}`;
|
|
||||||
toast.success("Timer stopped", {
|
|
||||||
description: `Added to invoice ${label}`,
|
|
||||||
action: {
|
|
||||||
label: "View Invoice",
|
|
||||||
onClick: () => window.location.assign(`/dashboard/invoices/${data.invoice!.id}`),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
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: (data) => {
|
|
||||||
if (data.invoice) {
|
|
||||||
const label = `${data.invoice.invoicePrefix}${data.invoice.invoiceNumber}`;
|
|
||||||
toast.success("Entry added", {
|
|
||||||
description: `Added to invoice ${label}`,
|
|
||||||
action: {
|
|
||||||
label: "View Invoice",
|
|
||||||
onClick: () => window.location.assign(`/dashboard/invoices/${data.invoice!.id}`),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Live estimate for running timer, rounded up to 15-min increments (no minimum while running)
|
|
||||||
const estimatedHours = running ? Math.ceil(elapsed / 900) * 0.25 : 0;
|
|
||||||
const estimatedEarnings = running ? estimatedHours * (running.rate ?? 0) : 0;
|
|
||||||
const displayHours = (summary?.totalHours ?? 0) + estimatedHours;
|
|
||||||
const displayEarnings = (summary?.totalEarnings ?? 0) + estimatedEarnings;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-enter space-y-6 pb-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Time Clock"
|
|
||||||
description="Track billable hours with a live timer"
|
|
||||||
variant="gradient"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setEditId(null);
|
|
||||||
setManualForm(defaultManualForm);
|
|
||||||
setManualOpen(true);
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" /> Add Manual Entry
|
|
||||||
</Button>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
{/* Summary cards */}
|
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
|
||||||
Total Hours
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-2xl font-bold">
|
|
||||||
{formatDuration(displayHours || undefined)}
|
|
||||||
</p>
|
|
||||||
{running && estimatedHours > 0 && (
|
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
||||||
+{formatDuration(estimatedHours)} est.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
|
||||||
Earnings
|
|
||||||
</p>
|
|
||||||
<p className="text-primary mt-1 text-2xl font-bold">
|
|
||||||
{formatCurrency(displayEarnings)}
|
|
||||||
</p>
|
|
||||||
{running && estimatedEarnings > 0 && (
|
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
||||||
+{formatCurrency(estimatedEarnings)} est.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="col-span-2 sm:col-span-1">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
|
||||||
Entries
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-2xl font-bold">{summary?.count ?? 0}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active timer */}
|
|
||||||
<Card className={running ? "border-primary/30 bg-primary/5" : ""}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Clock className="h-5 w-5" />
|
|
||||||
{running ? "Timer Running" : "Start Timer"}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{runningLoading ? (
|
|
||||||
<div className="text-muted-foreground text-sm">Loading…</div>
|
|
||||||
) : running ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
||||||
<span className="text-primary font-mono text-4xl font-bold tabular-nums">
|
|
||||||
{formatElapsed(elapsed)}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
{running.description && (
|
|
||||||
<p className="font-medium">{running.description}</p>
|
|
||||||
)}
|
|
||||||
{running.client && (
|
|
||||||
<p className="text-muted-foreground text-sm">{running.client.name}</p>
|
|
||||||
)}
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Started{" "}
|
|
||||||
{new Intl.DateTimeFormat("en-US", {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
}).format(new Date(running.startedAt))}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => clockOut.mutate({})}
|
|
||||||
disabled={clockOut.isPending}
|
|
||||||
>
|
|
||||||
<Square className="mr-2 h-4 w-4" />
|
|
||||||
{clockOut.isPending ? "Stopping…" : "Stop Timer"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>What are you working on?</Label>
|
|
||||||
<Input
|
|
||||||
value={clockInDesc}
|
|
||||||
onChange={(e) => 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Client (optional)</Label>
|
|
||||||
<Select
|
|
||||||
value={clockInClientId || "none"}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
const id = v === "none" ? "" : v;
|
|
||||||
setClockInClientId(id);
|
|
||||||
if (id) {
|
|
||||||
const client = clients.find((c) => c.id === id);
|
|
||||||
if (client?.defaultHourlyRate && clockInRate === 0) {
|
|
||||||
setClockInRate(client.defaultHourlyRate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<div className="flex items-end gap-3">
|
|
||||||
<div className="w-40 space-y-2">
|
|
||||||
<Label>Hourly Rate (optional)</Label>
|
|
||||||
<NumberInput
|
|
||||||
value={clockInRate}
|
|
||||||
onChange={(v) => setClockInRate(v)}
|
|
||||||
min={0}
|
|
||||||
step={0.01}
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
clockIn.mutate({
|
|
||||||
description: clockInDesc,
|
|
||||||
clientId: clockInClientId || undefined,
|
|
||||||
rate: clockInRate || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={clockIn.isPending}
|
|
||||||
className="hover-lift shadow-md"
|
|
||||||
>
|
|
||||||
<Play className="mr-2 h-4 w-4" />
|
|
||||||
{clockIn.isPending ? "Starting…" : "Start Timer"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Entry list */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Clock className="h-5 w-5" /> Time Entries
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{entriesLoading ? (
|
|
||||||
<div className="text-muted-foreground p-6 text-center text-sm">Loading…</div>
|
|
||||||
) : completedEntries.length === 0 ? (
|
|
||||||
<div className="p-8 text-center">
|
|
||||||
<Clock className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
No completed entries yet. Start a timer or add a manual entry.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y">
|
|
||||||
{completedEntries.map((entry) => (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
className="flex items-start justify-between gap-3 p-4"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<p className="font-medium">
|
|
||||||
{entry.description || (
|
|
||||||
<span className="text-muted-foreground italic">No description</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
{entry.client && (
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{entry.client.name}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{entry.invoice && (
|
|
||||||
<Link href={`/dashboard/invoices/${entry.invoice.id}`}>
|
|
||||||
<Badge variant="outline" className="gap-1 text-xs hover:bg-accent">
|
|
||||||
<FileText className="h-3 w-3" />
|
|
||||||
{entry.invoice.invoicePrefix}{entry.invoice.invoiceNumber}
|
|
||||||
</Badge>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
||||||
{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))}`
|
|
||||||
: ""}
|
|
||||||
</p>
|
|
||||||
{entry.notes && (
|
|
||||||
<p className="text-muted-foreground mt-1 text-xs">{entry.notes}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-shrink-0 items-center gap-2">
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="font-semibold tabular-nums">
|
|
||||||
{formatDuration(entry.hours)}
|
|
||||||
</p>
|
|
||||||
{entry.rate && entry.hours ? (
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
{formatCurrency(entry.hours * entry.rate)}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
onClick={() => handleEdit(entry)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive h-8 w-8 p-0"
|
|
||||||
onClick={() => setDeleteId(entry.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Manual entry dialog */}
|
|
||||||
<Dialog open={manualOpen} onOpenChange={setManualOpen}>
|
|
||||||
<DialogContent className="sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{editId ? "Edit Entry" : "Add Manual Entry"}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Description</Label>
|
|
||||||
<Input
|
|
||||||
value={manualForm.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
setManualForm((p) => ({ ...p, description: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="What did you work on?"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Client (optional)</Label>
|
|
||||||
<Select
|
|
||||||
value={manualForm.clientId || "none"}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setManualForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<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="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Start Date</Label>
|
|
||||||
<DatePicker
|
|
||||||
date={manualForm.startedAt}
|
|
||||||
onDateChange={(d) =>
|
|
||||||
setManualForm((p) => ({ ...p, startedAt: d ?? new Date() }))
|
|
||||||
}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>End Date (optional)</Label>
|
|
||||||
<DatePicker
|
|
||||||
date={manualForm.endedAt}
|
|
||||||
onDateChange={(d) =>
|
|
||||||
setManualForm((p) => ({ ...p, endedAt: d ?? undefined }))
|
|
||||||
}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Hourly Rate (optional)</Label>
|
|
||||||
<NumberInput
|
|
||||||
value={manualForm.rate}
|
|
||||||
onChange={(v) => setManualForm((p) => ({ ...p, rate: v }))}
|
|
||||||
min={0}
|
|
||||||
step={0.01}
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Notes (optional)</Label>
|
|
||||||
<Input
|
|
||||||
value={manualForm.notes}
|
|
||||||
onChange={(e) =>
|
|
||||||
setManualForm((p) => ({ ...p, notes: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Additional details…"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setManualOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleManualSubmit}
|
|
||||||
disabled={create.isPending || update.isPending}
|
|
||||||
>
|
|
||||||
{create.isPending || update.isPending
|
|
||||||
? "Saving…"
|
|
||||||
: editId
|
|
||||||
? "Update"
|
|
||||||
: "Add Entry"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete dialog */}
|
|
||||||
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete Entry</DialogTitle>
|
|
||||||
<DialogDescription>This action cannot be undone.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => deleteId && del.mutate({ id: deleteId })}
|
|
||||||
disabled={del.isPending}
|
|
||||||
>
|
|
||||||
{del.isPending ? "Deleting…" : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
BarChart2,
|
BarChart2,
|
||||||
Shield,
|
Shield,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Clock,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export interface NavLink {
|
export interface NavLink {
|
||||||
@@ -32,7 +31,6 @@ export const navigationConfig: NavSection[] = [
|
|||||||
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
||||||
{ name: "Recurring", href: "/dashboard/invoices/recurring", icon: RefreshCw },
|
{ name: "Recurring", href: "/dashboard/invoices/recurring", icon: RefreshCw },
|
||||||
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
|
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
|
||||||
{ name: "Time Clock", href: "/dashboard/time-clock", icon: Clock },
|
|
||||||
{ name: "Reports", href: "/dashboard/reports", icon: BarChart2 },
|
{ name: "Reports", href: "/dashboard/reports", icon: BarChart2 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,28 +24,15 @@ function computeHours(startedAt: Date, endedAt: Date): number {
|
|||||||
return Math.max(0.25, Math.ceil(seconds / 900) * 0.25);
|
return Math.max(0.25, Math.ceil(seconds / 900) * 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addEntryToLatestInvoice(
|
async function addEntryToInvoice(
|
||||||
database: Db,
|
database: Db,
|
||||||
userId: string,
|
invoice: { id: string; invoiceNumber: string; invoicePrefix: string | null; taxRate: number; items: { amount: number; position: number }[] },
|
||||||
clientId: string,
|
|
||||||
entryId: string,
|
entryId: string,
|
||||||
description: string,
|
description: string,
|
||||||
hours: number,
|
hours: number,
|
||||||
rate: number,
|
rate: number,
|
||||||
date: Date,
|
date: Date,
|
||||||
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
|
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> {
|
||||||
const invoice = await database.query.invoices.findFirst({
|
|
||||||
where: and(
|
|
||||||
eq(invoices.clientId, clientId),
|
|
||||||
eq(invoices.createdById, userId),
|
|
||||||
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
|
|
||||||
),
|
|
||||||
with: { items: true },
|
|
||||||
orderBy: [desc(invoices.createdAt)],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!invoice) return null;
|
|
||||||
|
|
||||||
const amount = hours * rate;
|
const amount = hours * rate;
|
||||||
const maxPosition = invoice.items.reduce((m, item) => Math.max(m, item.position), -1);
|
const maxPosition = invoice.items.reduce((m, item) => Math.max(m, item.position), -1);
|
||||||
|
|
||||||
@@ -79,6 +66,53 @@ async function addEntryToLatestInvoice(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function addEntryToLatestInvoice(
|
||||||
|
database: Db,
|
||||||
|
userId: string,
|
||||||
|
clientId: string,
|
||||||
|
entryId: string,
|
||||||
|
description: string,
|
||||||
|
hours: number,
|
||||||
|
rate: number,
|
||||||
|
date: Date,
|
||||||
|
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
|
||||||
|
const invoice = await database.query.invoices.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(invoices.clientId, clientId),
|
||||||
|
eq(invoices.createdById, userId),
|
||||||
|
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
|
||||||
|
),
|
||||||
|
with: { items: true },
|
||||||
|
orderBy: [desc(invoices.createdAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invoice) return null;
|
||||||
|
return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addEntryToSpecificInvoice(
|
||||||
|
database: Db,
|
||||||
|
userId: string,
|
||||||
|
invoiceId: string,
|
||||||
|
entryId: string,
|
||||||
|
description: string,
|
||||||
|
hours: number,
|
||||||
|
rate: number,
|
||||||
|
date: Date,
|
||||||
|
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> {
|
||||||
|
const invoice = await database.query.invoices.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(invoices.id, invoiceId),
|
||||||
|
eq(invoices.createdById, userId),
|
||||||
|
or(eq(invoices.status, "draft"), eq(invoices.status, "sent")),
|
||||||
|
),
|
||||||
|
with: { items: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invoice) return null;
|
||||||
|
return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date);
|
||||||
|
}
|
||||||
|
|
||||||
export const timeEntriesRouter = createTRPCRouter({
|
export const timeEntriesRouter = createTRPCRouter({
|
||||||
getAll: protectedProcedure
|
getAll: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -123,7 +157,10 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
eq(timeEntries.createdById, ctx.session.user.id),
|
eq(timeEntries.createdById, ctx.session.user.id),
|
||||||
isNull(timeEntries.endedAt),
|
isNull(timeEntries.endedAt),
|
||||||
),
|
),
|
||||||
with: { client: true },
|
with: {
|
||||||
|
client: true,
|
||||||
|
invoice: { columns: { id: true, invoiceNumber: true, invoicePrefix: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -132,6 +169,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
z.object({
|
z.object({
|
||||||
description: z.string().max(500).default(""),
|
description: z.string().max(500).default(""),
|
||||||
clientId: z.string().optional().or(z.literal("")),
|
clientId: z.string().optional().or(z.literal("")),
|
||||||
|
invoiceId: z.string().optional(),
|
||||||
rate: z.number().min(0).optional(),
|
rate: z.number().min(0).optional(),
|
||||||
startedAt: z.date().optional(),
|
startedAt: z.date().optional(),
|
||||||
}),
|
}),
|
||||||
@@ -158,6 +196,14 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invoiceId = input.invoiceId ?? null;
|
||||||
|
if (invoiceId) {
|
||||||
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
|
where: and(eq(invoices.id, invoiceId), eq(invoices.createdById, ctx.session.user.id)),
|
||||||
|
});
|
||||||
|
if (!invoice) throw new TRPCError({ code: "FORBIDDEN", message: "Invoice not found" });
|
||||||
|
}
|
||||||
|
|
||||||
const startedAt = input.startedAt ?? new Date();
|
const startedAt = input.startedAt ?? new Date();
|
||||||
if (startedAt > new Date()) {
|
if (startedAt > new Date()) {
|
||||||
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" });
|
||||||
@@ -168,6 +214,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
.values({
|
.values({
|
||||||
description: input.description,
|
description: input.description,
|
||||||
clientId,
|
clientId,
|
||||||
|
invoiceId,
|
||||||
startedAt,
|
startedAt,
|
||||||
rate: input.rate ?? null,
|
rate: input.rate ?? null,
|
||||||
createdById: ctx.session.user.id,
|
createdById: ctx.session.user.id,
|
||||||
@@ -212,7 +259,19 @@ 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;
|
||||||
if (entry.clientId && hours > 0) {
|
if (hours > 0) {
|
||||||
|
if (entry.invoiceId) {
|
||||||
|
linkedInvoice = await addEntryToSpecificInvoice(
|
||||||
|
ctx.db,
|
||||||
|
ctx.session.user.id,
|
||||||
|
entry.invoiceId,
|
||||||
|
updated.id,
|
||||||
|
description,
|
||||||
|
hours,
|
||||||
|
entry.rate ?? 0,
|
||||||
|
endedAt,
|
||||||
|
);
|
||||||
|
} else if (entry.clientId) {
|
||||||
linkedInvoice = await addEntryToLatestInvoice(
|
linkedInvoice = await addEntryToLatestInvoice(
|
||||||
ctx.db,
|
ctx.db,
|
||||||
ctx.session.user.id,
|
ctx.session.user.id,
|
||||||
@@ -224,6 +283,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
endedAt,
|
endedAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { entry: updated, invoice: linkedInvoice };
|
return { entry: updated, invoice: linkedInvoice };
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user