Sync time entries with invoice lines and harden auth for mobile.
Link clocked time to invoice items with bidirectional sync, add entry editing on web, broaden session cookie detection for Expo clients, and handle API rate limits without signing users out. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "beenvoice_invoice_item" ADD COLUMN IF NOT EXISTS "timeEntryId" varchar(255);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'beenvoice_invoice_item_timeEntryId_beenvoice_time_entry_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "beenvoice_invoice_item" ADD CONSTRAINT "beenvoice_invoice_item_timeEntryId_beenvoice_time_entry_id_fk" FOREIGN KEY ("timeEntryId") REFERENCES "public"."beenvoice_time_entry"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "invoice_item_time_entry_id_idx" ON "beenvoice_invoice_item" USING btree ("timeEntryId") WHERE "timeEntryId" is not null;
|
||||
@@ -157,10 +157,10 @@
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1782100000000,
|
||||
"tag": "0022_expense_business_receipts",
|
||||
"when": 1782200000000,
|
||||
"tag": "0023_invoice_item_time_entry",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getOptionalServerSession } from "~/lib/auth-server";
|
||||
import { getObject } from "~/lib/object-storage";
|
||||
@@ -20,7 +20,7 @@ export async function GET(
|
||||
with: { expense: true },
|
||||
});
|
||||
|
||||
if (!receipt || receipt.expense.createdById !== session.user.id) {
|
||||
if (receipt?.expense.createdById !== session.user.id) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
|
||||
@@ -39,8 +39,15 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
||||
setLoading(false);
|
||||
|
||||
if (error) {
|
||||
const message = error.message?.toLowerCase() ?? "";
|
||||
const rateLimited =
|
||||
error.status === 429 ||
|
||||
message.includes("too many") ||
|
||||
message.includes("rate limit");
|
||||
toast.error(
|
||||
error.message && error.message !== "Required"
|
||||
rateLimited
|
||||
? "Too many sign-in attempts. Please wait a moment and try again."
|
||||
: error.message && error.message !== "Required"
|
||||
? error.message
|
||||
: "Invalid email or password",
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
|
||||
@@ -116,12 +116,6 @@ export default function ExpensesPage() {
|
||||
[businesses],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || dialogMode !== "create" || !defaultBusinessId || form.businessId)
|
||||
return;
|
||||
setForm((prev) => ({ ...prev, businessId: defaultBusinessId }));
|
||||
}, [open, dialogMode, defaultBusinessId, form.businessId]);
|
||||
|
||||
const create = api.expenses.create.useMutation({
|
||||
onSuccess: (expense) => {
|
||||
if (!expense) return;
|
||||
|
||||
@@ -38,7 +38,8 @@ export async function fileToBase64(file: File): Promise<string> {
|
||||
}
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.onerror = () =>
|
||||
reject(reader.error instanceof Error ? reader.error : new Error("Failed to read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function AppearanceProviderSynced({
|
||||
if (!serverColorMode?.colorMode) return;
|
||||
if (serverHydratedRef.current) return;
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
|
||||
setColorMode(serverColorMode.colorMode);
|
||||
serverHydratedRef.current = true;
|
||||
}, [serverColorMode?.colorMode]);
|
||||
|
||||
@@ -37,11 +37,68 @@ import {
|
||||
} from "~/lib/time-clock";
|
||||
import { invoiceLabel } from "~/lib/time-entry-display";
|
||||
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
|
||||
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
|
||||
|
||||
const FEATURED_CLIENT_COUNT = 4;
|
||||
|
||||
type StartMode = "now" | "pick" | "ago";
|
||||
|
||||
function toDatetimeLocalValue(value: Date | string) {
|
||||
const start = new Date(value);
|
||||
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
|
||||
return start.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function RunningTextFields({
|
||||
running,
|
||||
updateRunningPending,
|
||||
onDescriptionCommit,
|
||||
onStartedAtCommit,
|
||||
}: {
|
||||
running: { id: string; description: string | null; startedAt: Date };
|
||||
updateRunningPending: boolean;
|
||||
onDescriptionCommit: (description: string) => void;
|
||||
onStartedAtCommit: (startedAt: Date) => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState(running.description ?? "");
|
||||
const [runningStartedAt, setRunningStartedAt] = useState(() =>
|
||||
toDatetimeLocalValue(running.startedAt),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clock-running-title">What are you working on?</Label>
|
||||
<Input
|
||||
id="clock-running-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onBlur={() => onDescriptionCommit(title)}
|
||||
placeholder="What are you working on?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clock-running-start">Started at</Label>
|
||||
<Input
|
||||
id="clock-running-start"
|
||||
type="datetime-local"
|
||||
value={runningStartedAt}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setRunningStartedAt(value);
|
||||
if (!value) return;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime()) || parsed > new Date()) return;
|
||||
onStartedAtCommit(parsed);
|
||||
}}
|
||||
disabled={updateRunningPending}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export type TimeClockPanelProps = {
|
||||
defaultClientId?: string;
|
||||
defaultInvoiceId?: string;
|
||||
@@ -109,6 +166,7 @@ export function TimeClockPanel({
|
||||
const [startMode, setStartMode] = useState<StartMode>("now");
|
||||
const [pickedStart, setPickedStart] = useState("");
|
||||
const [minutesAgo, setMinutesAgo] = useState("30");
|
||||
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const draftClientId = running ? (running.clientId ?? "") : clientId;
|
||||
@@ -185,6 +243,18 @@ export function TimeClockPanel({
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
function handleRunningDescriptionCommit(nextTitle: string) {
|
||||
if (!running) return;
|
||||
const next = resolveClockDescription(nextTitle);
|
||||
if (next === (running.description ?? "")) return;
|
||||
updateRunning.mutate({ description: next });
|
||||
}
|
||||
|
||||
function handleRunningStartedAtCommit(parsed: Date) {
|
||||
if (!running) return;
|
||||
updateRunning.mutate({ startedAt: parsed });
|
||||
}
|
||||
|
||||
const clockOut = api.timeEntries.clockOut.useMutation({
|
||||
onSuccess: (data) => {
|
||||
const message = describeClockOutOutcome({
|
||||
@@ -496,6 +566,14 @@ export function TimeClockPanel({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RunningTextFields
|
||||
key={running.id}
|
||||
running={running}
|
||||
updateRunningPending={updateRunning.isPending}
|
||||
onDescriptionCommit={handleRunningDescriptionCommit}
|
||||
onStartedAtCommit={handleRunningStartedAtCommit}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Client</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -621,7 +699,10 @@ export function TimeClockPanel({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{todayEntries?.some((e) => e.endedAt) ? (
|
||||
<TimeEntryList entries={todayEntries} />
|
||||
<TimeEntryList
|
||||
entries={todayEntries}
|
||||
onEdit={(entry) => setEditEntryId(entry.id)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
No entries today.{" "}
|
||||
@@ -637,6 +718,13 @@ export function TimeClockPanel({
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<TimeEntryEditDialog
|
||||
entryId={editEntryId}
|
||||
open={editEntryId != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditEntryId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -9,9 +9,12 @@ import { EmptyState } from "~/components/layout/page-layout";
|
||||
import { Clock, Play } from "lucide-react";
|
||||
import { groupEntriesByDate } from "~/lib/time-entry-display";
|
||||
import { TimeEntryRow } from "~/components/time-clock/time-entry-list";
|
||||
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
|
||||
import type { TimeEntryListItem } from "~/lib/time-entry-display";
|
||||
|
||||
export function TimeEntriesHistory() {
|
||||
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
|
||||
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||
|
||||
const completedEntries = useMemo(
|
||||
() => (entries ?? []).filter((e) => e.endedAt),
|
||||
@@ -57,6 +60,7 @@ export function TimeEntriesHistory() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
{grouped.map((group) => (
|
||||
<Card key={group.dateKey}>
|
||||
@@ -71,11 +75,20 @@ export function TimeEntriesHistory() {
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isLast={index === group.entries.length - 1}
|
||||
onEdit={(item: TimeEntryListItem) => setEditEntryId(item.id)}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<TimeEntryEditDialog
|
||||
entryId={editEntryId}
|
||||
open={editEntryId != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditEntryId(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { invoiceLabel } from "~/lib/time-entry-display";
|
||||
import type { RouterOutputs } from "~/trpc/react";
|
||||
|
||||
type TimeEntry = RouterOutputs["timeEntries"]["getById"];
|
||||
|
||||
function toDatetimeLocalValue(value: Date | string) {
|
||||
const start = new Date(value);
|
||||
start.setMinutes(start.getMinutes() - start.getTimezoneOffset());
|
||||
return start.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export type TimeEntryEditDialogProps = {
|
||||
entryId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
type TimeEntryEditFormProps = {
|
||||
entry: TimeEntry;
|
||||
entryId: string;
|
||||
clients: RouterOutputs["clients"]["getAll"];
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function TimeEntryEditForm({
|
||||
entry,
|
||||
entryId,
|
||||
clients,
|
||||
onClose,
|
||||
}: TimeEntryEditFormProps) {
|
||||
const utils = api.useUtils();
|
||||
const [description, setDescription] = useState(entry.description ?? "");
|
||||
const [clientId, setClientId] = useState(entry.clientId ?? "");
|
||||
const [invoiceId, setInvoiceId] = useState(entry.invoiceId ?? "");
|
||||
const [rate, setRate] = useState(entry.rate ?? 0);
|
||||
const [startedAt, setStartedAt] = useState(() => toDatetimeLocalValue(entry.startedAt));
|
||||
const [endedAt, setEndedAt] = useState(() =>
|
||||
entry.endedAt ? toDatetimeLocalValue(entry.endedAt) : "",
|
||||
);
|
||||
|
||||
const { data: billableInvoices } = api.invoices.getBillable.useQuery(
|
||||
clientId ? { clientId } : undefined,
|
||||
{ enabled: Boolean(clientId) },
|
||||
);
|
||||
|
||||
const hoursPreview = useMemo(() => {
|
||||
if (!startedAt || !endedAt) return null;
|
||||
const start = new Date(startedAt);
|
||||
const end = new Date(endedAt);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return null;
|
||||
return Math.max(0, (end.getTime() - start.getTime()) / 3_600_000);
|
||||
}, [endedAt, startedAt]);
|
||||
|
||||
const updateEntry = api.timeEntries.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success("Time entry updated");
|
||||
await Promise.all([
|
||||
utils.timeEntries.getAll.invalidate(),
|
||||
utils.timeEntries.getById.invalidate(),
|
||||
utils.invoices.getAll.invalidate(),
|
||||
utils.dashboard.getStats.invalidate(),
|
||||
]);
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const deleteEntry = api.timeEntries.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success("Time entry deleted");
|
||||
await Promise.all([
|
||||
utils.timeEntries.getAll.invalidate(),
|
||||
utils.invoices.getAll.invalidate(),
|
||||
utils.dashboard.getStats.invalidate(),
|
||||
]);
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
const start = new Date(startedAt);
|
||||
const end = endedAt ? new Date(endedAt) : undefined;
|
||||
if (Number.isNaN(start.getTime()) || (end && Number.isNaN(end.getTime()))) {
|
||||
toast.error("Invalid start or end time");
|
||||
return;
|
||||
}
|
||||
if (end && end <= start) {
|
||||
toast.error("End time must be after start time");
|
||||
return;
|
||||
}
|
||||
|
||||
updateEntry.mutate({
|
||||
id: entryId,
|
||||
description,
|
||||
clientId: clientId || "",
|
||||
invoiceId: invoiceId || "",
|
||||
rate,
|
||||
startedAt: start,
|
||||
endedAt: end,
|
||||
hours: hoursPreview ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entry-description">Description</Label>
|
||||
<Input
|
||||
id="entry-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Client</Label>
|
||||
<Select
|
||||
value={clientId || "__none__"}
|
||||
onValueChange={(v) => {
|
||||
setClientId(v === "__none__" ? "" : v);
|
||||
setInvoiceId("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No client</SelectItem>
|
||||
{clients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Invoice</Label>
|
||||
<Select
|
||||
value={invoiceId || "__none__"}
|
||||
onValueChange={(v) => setInvoiceId(v === "__none__" ? "" : v)}
|
||||
disabled={!clientId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Not on invoice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Not on invoice</SelectItem>
|
||||
{billableInvoices?.map((inv) => (
|
||||
<SelectItem key={inv.id} value={inv.id}>
|
||||
{invoiceLabel(inv)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Hourly rate</Label>
|
||||
<NumberInput value={rate} onChange={setRate} min={0} step={0.01} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entry-start">Started</Label>
|
||||
<Input
|
||||
id="entry-start"
|
||||
type="datetime-local"
|
||||
value={startedAt}
|
||||
onChange={(e) => setStartedAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="entry-end">Ended</Label>
|
||||
<Input
|
||||
id="entry-end"
|
||||
type="datetime-local"
|
||||
value={endedAt}
|
||||
onChange={(e) => setEndedAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hoursPreview != null ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Duration: {hoursPreview.toFixed(2)}h
|
||||
{rate > 0 ? ` · $${(hoursPreview * rate).toFixed(2)}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={deleteEntry.isPending}
|
||||
onClick={() => deleteEntry.mutate({ id: entryId })}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSave} disabled={updateEntry.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimeEntryEditDialog({
|
||||
entryId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TimeEntryEditDialogProps) {
|
||||
const entryQuery = api.timeEntries.getById.useQuery(
|
||||
{ id: entryId ?? "" },
|
||||
{ enabled: Boolean(entryId) && open },
|
||||
);
|
||||
const { data: clients = [] } = api.clients.getAll.useQuery(undefined, { enabled: open });
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit time entry</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{entryQuery.isLoading ? (
|
||||
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||
) : entryQuery.data && entryId ? (
|
||||
<TimeEntryEditForm
|
||||
key={entryQuery.data.id}
|
||||
entry={entryQuery.data}
|
||||
entryId={entryId}
|
||||
clients={clients}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">Time entry not found.</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,13 @@ import { entryHref, invoiceLabel, type TimeEntryListItem } from "~/lib/time-entr
|
||||
export function TimeEntryRow({
|
||||
entry,
|
||||
isLast,
|
||||
onEdit,
|
||||
}: {
|
||||
entry: TimeEntryListItem;
|
||||
isLast?: boolean;
|
||||
onEdit?: (entry: TimeEntryListItem) => void;
|
||||
}) {
|
||||
const href = entryHref(entry);
|
||||
const href = onEdit ? null : entryHref(entry);
|
||||
const rowClassName = cn(
|
||||
"flex items-start justify-between gap-4 py-3",
|
||||
!isLast && "border-border border-b",
|
||||
@@ -50,6 +52,21 @@ export function TimeEntryRow({
|
||||
);
|
||||
}
|
||||
|
||||
if (onEdit) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(entry)}
|
||||
className={cn(
|
||||
rowClassName,
|
||||
"-mx-2 flex w-full cursor-pointer px-2 text-left transition-colors hover:rounded-md hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={rowClassName}>
|
||||
{content}
|
||||
@@ -57,7 +74,13 @@ export function TimeEntryRow({
|
||||
);
|
||||
}
|
||||
|
||||
export function TimeEntryList({ entries }: { entries: TimeEntryListItem[] }) {
|
||||
export function TimeEntryList({
|
||||
entries,
|
||||
onEdit,
|
||||
}: {
|
||||
entries: TimeEntryListItem[];
|
||||
onEdit?: (entry: TimeEntryListItem) => void;
|
||||
}) {
|
||||
const completed = entries.filter((e) => e.endedAt);
|
||||
|
||||
if (completed.length === 0) return null;
|
||||
@@ -69,6 +92,7 @@ export function TimeEntryList({ entries }: { entries: TimeEntryListItem[] }) {
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isLast={index === completed.length - 1}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -3,7 +3,11 @@ import { auth } from "~/lib/auth";
|
||||
|
||||
export function hasSessionCookie(headers: Headers): boolean {
|
||||
const cookie = headers.get("cookie") ?? "";
|
||||
if (!cookie.trim()) return false;
|
||||
|
||||
return (
|
||||
cookie.includes("session_token=") ||
|
||||
cookie.includes("session_data=") ||
|
||||
cookie.includes("better-auth.session_token=") ||
|
||||
cookie.includes("__Secure-better-auth.session_token=")
|
||||
);
|
||||
|
||||
@@ -73,9 +73,7 @@ async function withS3Diagnostics<T>(operation: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
|
||||
async function getS3() {
|
||||
if (!s3ModulePromise) {
|
||||
s3ModulePromise = import("@aws-sdk/client-s3");
|
||||
}
|
||||
s3ModulePromise ??= import("@aws-sdk/client-s3");
|
||||
const mod = await s3ModulePromise;
|
||||
if (!s3Client) {
|
||||
logBareMinioEndpointHint();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
export type ReceiptParseResult = {
|
||||
amount: number | null;
|
||||
date: Date | null;
|
||||
vendor: string | null;
|
||||
rawLines: string[];
|
||||
};
|
||||
|
||||
const AMOUNT_PATTERNS = [
|
||||
/(?:total|amount due|balance due|grand total)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
|
||||
/\$\s*([\d,]+\.\d{2})\s*(?:total|due)?/i,
|
||||
/(?:USD|CAD|EUR)\s*([\d,]+\.\d{2})/i,
|
||||
];
|
||||
|
||||
const DATE_PATTERNS = [
|
||||
/(\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4})/,
|
||||
/(\d{4}[/.-]\d{1,2}[/.-]\d{1,2})/,
|
||||
];
|
||||
|
||||
function parseAmount(text: string): number | null {
|
||||
for (const pattern of AMOUNT_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (!match?.[1]) continue;
|
||||
const value = Number(match[1].replace(/,/g, ""));
|
||||
if (Number.isFinite(value) && value > 0) return value;
|
||||
}
|
||||
|
||||
const amounts = [...text.matchAll(/\$\s*([\d,]+\.\d{2})/g)]
|
||||
.map((m) => Number(m[1]!.replace(/,/g, "")))
|
||||
.filter((n) => Number.isFinite(n) && n > 0);
|
||||
|
||||
return amounts.length > 0 ? Math.max(...amounts) : null;
|
||||
}
|
||||
|
||||
function parseDate(text: string): Date | null {
|
||||
for (const pattern of DATE_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (!match?.[1]) continue;
|
||||
const parsed = new Date(match[1]);
|
||||
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseVendor(lines: string[]): string | null {
|
||||
const candidate = lines.find((line) => line.trim().length >= 3);
|
||||
return candidate?.trim().slice(0, 120) ?? null;
|
||||
}
|
||||
|
||||
/** Heuristic receipt field extraction from OCR or pasted text. */
|
||||
export function parseReceiptText(text: string): ReceiptParseResult {
|
||||
const normalized = text.replace(/\r/g, "\n").trim();
|
||||
const rawLines = normalized
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
amount: parseAmount(normalized),
|
||||
date: parseDate(normalized),
|
||||
vendor: parseVendor(rawLines),
|
||||
rawLines,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { db } from "~/server/db";
|
||||
import { invoiceItems, invoices, timeEntries } from "~/server/db/schema";
|
||||
import { resolveBillingDescription } from "~/lib/time-clock";
|
||||
|
||||
type Db = typeof db;
|
||||
|
||||
function recalculateInvoiceTotal(
|
||||
items: { amount: number }[],
|
||||
taxRate: number,
|
||||
): number {
|
||||
const subtotal = items.reduce((sum, item) => sum + item.amount, 0);
|
||||
return subtotal + (subtotal * taxRate) / 100;
|
||||
}
|
||||
|
||||
export async function findLinkedInvoiceItem(database: Db, timeEntryId: string) {
|
||||
return database.query.invoiceItems.findFirst({
|
||||
where: eq(invoiceItems.timeEntryId, timeEntryId),
|
||||
with: {
|
||||
invoice: {
|
||||
columns: { id: true, taxRate: true, status: true, createdById: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function insertInvoiceLineForTimeEntry(
|
||||
database: Db,
|
||||
input: {
|
||||
invoice: {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
invoicePrefix: string | null;
|
||||
taxRate: number;
|
||||
items: { amount: number; position: number }[];
|
||||
};
|
||||
entryId: string;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
date: Date;
|
||||
},
|
||||
) {
|
||||
const amount = input.hours * input.rate;
|
||||
const maxPosition = input.invoice.items.reduce(
|
||||
(m, item) => Math.max(m, item.position),
|
||||
-1,
|
||||
);
|
||||
|
||||
await database.insert(invoiceItems).values({
|
||||
invoiceId: input.invoice.id,
|
||||
date: input.date,
|
||||
description: input.description,
|
||||
hours: input.hours,
|
||||
rate: input.rate,
|
||||
amount,
|
||||
position: maxPosition + 1,
|
||||
timeEntryId: input.entryId,
|
||||
});
|
||||
|
||||
const subtotal =
|
||||
input.invoice.items.reduce((s, i) => s + i.amount, 0) + amount;
|
||||
const newTotal = subtotal + (subtotal * input.invoice.taxRate) / 100;
|
||||
|
||||
await database
|
||||
.update(invoices)
|
||||
.set({ totalAmount: newTotal, updatedAt: new Date() })
|
||||
.where(eq(invoices.id, input.invoice.id));
|
||||
|
||||
await database
|
||||
.update(timeEntries)
|
||||
.set({ invoiceId: input.invoice.id, updatedAt: new Date() })
|
||||
.where(eq(timeEntries.id, input.entryId));
|
||||
|
||||
return {
|
||||
id: input.invoice.id,
|
||||
invoiceNumber: input.invoice.invoiceNumber,
|
||||
invoicePrefix: input.invoice.invoicePrefix ?? "#",
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncLinkedInvoiceItem(
|
||||
database: Db,
|
||||
entry: {
|
||||
id: string;
|
||||
description: string | null;
|
||||
hours: number | null;
|
||||
rate: number | null;
|
||||
startedAt: Date;
|
||||
endedAt: Date | null;
|
||||
invoiceId: string | null;
|
||||
},
|
||||
) {
|
||||
const linked = await findLinkedInvoiceItem(database, entry.id);
|
||||
if (!linked?.invoice) return;
|
||||
|
||||
if (linked.invoice.status !== "draft") return;
|
||||
|
||||
const hours =
|
||||
entry.hours ??
|
||||
(entry.endedAt
|
||||
? Math.max(
|
||||
0,
|
||||
(entry.endedAt.getTime() - entry.startedAt.getTime()) / 3_600_000,
|
||||
)
|
||||
: null);
|
||||
|
||||
if (hours == null || hours <= 0) return;
|
||||
|
||||
const rate = entry.rate ?? 0;
|
||||
const amount = hours * rate;
|
||||
const description = resolveBillingDescription(entry.description ?? "");
|
||||
|
||||
await database
|
||||
.update(invoiceItems)
|
||||
.set({
|
||||
description,
|
||||
hours,
|
||||
rate,
|
||||
amount,
|
||||
date: entry.endedAt ?? entry.startedAt,
|
||||
})
|
||||
.where(eq(invoiceItems.id, linked.id));
|
||||
|
||||
const siblings = await database.query.invoiceItems.findMany({
|
||||
where: eq(invoiceItems.invoiceId, linked.invoiceId),
|
||||
columns: { amount: true },
|
||||
});
|
||||
|
||||
await database
|
||||
.update(invoices)
|
||||
.set({
|
||||
totalAmount: recalculateInvoiceTotal(siblings, linked.invoice.taxRate),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, linked.invoiceId));
|
||||
}
|
||||
|
||||
export async function removeLinkedInvoiceItem(database: Db, timeEntryId: string) {
|
||||
const linked = await findLinkedInvoiceItem(database, timeEntryId);
|
||||
if (!linked?.invoice) return;
|
||||
|
||||
await database.delete(invoiceItems).where(eq(invoiceItems.id, linked.id));
|
||||
|
||||
const siblings = await database.query.invoiceItems.findMany({
|
||||
where: eq(invoiceItems.invoiceId, linked.invoiceId),
|
||||
columns: { amount: true },
|
||||
});
|
||||
|
||||
await database
|
||||
.update(invoices)
|
||||
.set({
|
||||
totalAmount: recalculateInvoiceTotal(siblings, linked.invoice.taxRate),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, linked.invoiceId));
|
||||
}
|
||||
|
||||
export async function relinkTimeEntryToInvoice(
|
||||
database: Db,
|
||||
userId: string,
|
||||
entry: {
|
||||
id: string;
|
||||
description: string | null;
|
||||
hours: number | null;
|
||||
rate: number | null;
|
||||
startedAt: Date;
|
||||
endedAt: Date | null;
|
||||
clientId: string | null;
|
||||
},
|
||||
invoiceId: string | null,
|
||||
) {
|
||||
await removeLinkedInvoiceItem(database, entry.id);
|
||||
|
||||
if (!invoiceId || !entry.endedAt || !entry.hours || entry.hours <= 0) {
|
||||
await database
|
||||
.update(timeEntries)
|
||||
.set({ invoiceId: invoiceId ?? null, updatedAt: new Date() })
|
||||
.where(eq(timeEntries.id, entry.id));
|
||||
return null;
|
||||
}
|
||||
|
||||
const invoice = await database.query.invoices.findFirst({
|
||||
where: and(
|
||||
eq(invoices.id, invoiceId),
|
||||
eq(invoices.createdById, userId),
|
||||
eq(invoices.status, "draft"),
|
||||
),
|
||||
with: { items: true },
|
||||
});
|
||||
|
||||
if (!invoice) return null;
|
||||
|
||||
return insertInvoiceLineForTimeEntry(database, {
|
||||
invoice,
|
||||
entryId: entry.id,
|
||||
description: resolveBillingDescription(entry.description ?? ""),
|
||||
hours: entry.hours,
|
||||
rate: entry.rate ?? 0,
|
||||
date: entry.endedAt,
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
putObject,
|
||||
RECEIPT_MAX_BYTES,
|
||||
} from "~/lib/object-storage";
|
||||
import { parseReceiptText } from "~/lib/receipt-parse";
|
||||
|
||||
export { EXPENSE_CATEGORIES };
|
||||
|
||||
@@ -431,8 +432,7 @@ export const expensesRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
if (
|
||||
!receipt ||
|
||||
receipt.expense.createdById !== ctx.session.user.id
|
||||
receipt?.expense.createdById !== ctx.session.user.id
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
@@ -447,4 +447,16 @@ export const expensesRouter = createTRPCRouter({
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
suggestFromReceiptText: protectedProcedure
|
||||
.input(z.object({ text: z.string().min(1).max(20_000) }))
|
||||
.mutation(({ input }) => {
|
||||
const parsed = parseReceiptText(input.text);
|
||||
return {
|
||||
amount: parsed.amount,
|
||||
date: parsed.date,
|
||||
description: parsed.vendor,
|
||||
rawLines: parsed.rawLines,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { timeEntries, clients, invoices, invoiceItems, businesses } from "~/server/db/schema";
|
||||
import { timeEntries, clients, invoices, businesses } from "~/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type { db } from "~/server/db";
|
||||
import {
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
type ClockOutOutcome,
|
||||
} from "~/lib/time-clock";
|
||||
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||
import {
|
||||
insertInvoiceLineForTimeEntry,
|
||||
relinkTimeEntryToInvoice,
|
||||
removeLinkedInvoiceItem,
|
||||
syncLinkedInvoiceItem,
|
||||
} from "~/server/api/lib/time-entry-invoice-sync";
|
||||
|
||||
type Db = typeof db;
|
||||
|
||||
@@ -55,37 +61,14 @@ async function addEntryToInvoice(
|
||||
rate: number,
|
||||
date: Date,
|
||||
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> {
|
||||
const amount = hours * rate;
|
||||
const maxPosition = invoice.items.reduce((m, item) => Math.max(m, item.position), -1);
|
||||
|
||||
await database.insert(invoiceItems).values({
|
||||
invoiceId: invoice.id,
|
||||
date,
|
||||
return insertInvoiceLineForTimeEntry(database, {
|
||||
invoice,
|
||||
entryId,
|
||||
description,
|
||||
hours,
|
||||
rate,
|
||||
amount,
|
||||
position: maxPosition + 1,
|
||||
date,
|
||||
});
|
||||
|
||||
const subtotal = invoice.items.reduce((s, i) => s + i.amount, 0) + amount;
|
||||
const newTotal = subtotal + (subtotal * invoice.taxRate) / 100;
|
||||
|
||||
await database
|
||||
.update(invoices)
|
||||
.set({ totalAmount: newTotal, updatedAt: new Date() })
|
||||
.where(eq(invoices.id, invoice.id));
|
||||
|
||||
await database
|
||||
.update(timeEntries)
|
||||
.set({ invoiceId: invoice.id, updatedAt: new Date() })
|
||||
.where(eq(timeEntries.id, entryId));
|
||||
|
||||
return {
|
||||
id: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
invoicePrefix: invoice.invoicePrefix ?? "#",
|
||||
};
|
||||
}
|
||||
|
||||
async function findOrCreateDraftInvoice(
|
||||
@@ -338,6 +321,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
clientId: z.string().optional().or(z.literal("")),
|
||||
invoiceId: z.string().optional().or(z.literal("")),
|
||||
rate: z.number().min(0).optional(),
|
||||
startedAt: z.date().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -357,9 +341,20 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
clientId?: string | null;
|
||||
invoiceId?: string | null;
|
||||
rate?: number | null;
|
||||
startedAt?: Date;
|
||||
updatedAt: Date;
|
||||
} = { updatedAt: new Date() };
|
||||
|
||||
if (input.startedAt !== undefined) {
|
||||
if (input.startedAt > new Date()) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Start time cannot be in the future",
|
||||
});
|
||||
}
|
||||
updates.startedAt = input.startedAt;
|
||||
}
|
||||
|
||||
if (input.description !== undefined) {
|
||||
updates.description = input.description;
|
||||
}
|
||||
@@ -563,9 +558,13 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(updateSchema)
|
||||
.input(
|
||||
updateSchema.extend({
|
||||
invoiceId: z.string().optional().or(z.literal("")),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
const { id, invoiceId: nextInvoiceId, ...data } = input;
|
||||
|
||||
const existing = await ctx.db.query.timeEntries.findFirst({
|
||||
where: and(
|
||||
@@ -575,6 +574,13 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
|
||||
|
||||
if (existing.endedAt == null) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Use updateRunning to edit the active timer",
|
||||
});
|
||||
}
|
||||
|
||||
const clientId =
|
||||
data.clientId !== undefined ? data.clientId?.trim() || null : undefined;
|
||||
|
||||
@@ -585,16 +591,39 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
||||
}
|
||||
|
||||
let hours = data.hours;
|
||||
const startedAt = data.startedAt ?? existing.startedAt;
|
||||
const endedAt = data.endedAt ?? existing.endedAt;
|
||||
|
||||
if (endedAt && (data.startedAt !== undefined || data.endedAt !== undefined || data.hours === undefined)) {
|
||||
hours = computeHours(startedAt, endedAt);
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(timeEntries)
|
||||
.set({
|
||||
...data,
|
||||
clientId,
|
||||
notes: data.notes?.trim() ?? null,
|
||||
hours,
|
||||
notes: data.notes?.trim() ?? undefined,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(timeEntries.id, id));
|
||||
|
||||
const updated = await ctx.db.query.timeEntries.findFirst({
|
||||
where: eq(timeEntries.id, id),
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Update failed" });
|
||||
}
|
||||
|
||||
if (nextInvoiceId !== undefined) {
|
||||
await relinkTimeEntryToInvoice(ctx.db, ctx.session.user.id, updated, nextInvoiceId.trim() || null);
|
||||
} else {
|
||||
await syncLinkedInvoiceItem(ctx.db, updated);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
@@ -609,6 +638,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
|
||||
|
||||
await removeLinkedInvoiceItem(ctx.db, input.id);
|
||||
await ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id));
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
@@ -432,6 +432,9 @@ export const invoiceItems = createTable(
|
||||
rate: d.real().notNull(),
|
||||
amount: d.real().notNull(),
|
||||
position: d.integer().notNull().default(0), // NEW: position for ordering
|
||||
timeEntryId: d
|
||||
.varchar({ length: 255 })
|
||||
.references(() => timeEntries.id, { onDelete: "set null" }),
|
||||
createdAt: d
|
||||
.timestamp()
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
@@ -449,6 +452,10 @@ export const invoiceItemsRelations = relations(invoiceItems, ({ one }) => ({
|
||||
fields: [invoiceItems.invoiceId],
|
||||
references: [invoices.id],
|
||||
}),
|
||||
timeEntry: one(timeEntries, {
|
||||
fields: [invoiceItems.timeEntryId],
|
||||
references: [timeEntries.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const expenses = createTable(
|
||||
|
||||
+44
-34
@@ -8,45 +8,55 @@ import { TRPCClientError } from "@trpc/client";
|
||||
import { toast } from "sonner";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
function isUnauthorized(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof TRPCClientError &&
|
||||
error.data != null &&
|
||||
typeof error.data === "object" &&
|
||||
"code" in error.data &&
|
||||
(error.data as { code: string }).code === "UNAUTHORIZED"
|
||||
);
|
||||
}
|
||||
|
||||
function isRateLimited(error: unknown): boolean {
|
||||
if (!(error instanceof TRPCClientError)) return false;
|
||||
if (error.data != null && typeof error.data === "object" && "code" in error.data) {
|
||||
if ((error.data as { code: string }).code === "TOO_MANY_REQUESTS") return true;
|
||||
}
|
||||
const message = error.message.toLowerCase();
|
||||
return message.includes("too many") || message.includes("rate limit");
|
||||
}
|
||||
|
||||
function handleQueryError(error: unknown) {
|
||||
if (isRateLimited(error)) {
|
||||
toast.error("Too many requests. Please wait a moment and try again.");
|
||||
return;
|
||||
}
|
||||
if (isUnauthorized(error)) {
|
||||
toast.error("Please sign in to continue");
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/auth/signin";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => {
|
||||
if (
|
||||
error instanceof TRPCClientError &&
|
||||
error.data &&
|
||||
typeof error.data === "object" &&
|
||||
"code" in error.data &&
|
||||
(error.data as { code: string }).code === "UNAUTHORIZED"
|
||||
) {
|
||||
toast.error("Please sign in to continue");
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/auth/signin";
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error) => {
|
||||
if (
|
||||
error instanceof TRPCClientError &&
|
||||
error.data &&
|
||||
typeof error.data === "object" &&
|
||||
"code" in error.data &&
|
||||
(error.data as { code: string }).code === "UNAUTHORIZED"
|
||||
) {
|
||||
toast.error("Please sign in to continue");
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/auth/signin";
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
queryCache: new QueryCache({ onError: handleQueryError }),
|
||||
mutationCache: new MutationCache({ onError: handleQueryError }),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// With SSR, we usually want to set some default staleTime
|
||||
// above 0 to avoid refetching immediately on the client
|
||||
staleTime: 30 * 1000,
|
||||
retry: (failureCount, error) => {
|
||||
if (isUnauthorized(error) || isRateLimited(error)) return false;
|
||||
return failureCount < 1;
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
retry: (failureCount, error) => {
|
||||
if (isRateLimited(error)) return false;
|
||||
return failureCount < 1;
|
||||
},
|
||||
},
|
||||
dehydrate: {
|
||||
serializeData: SuperJSON.serialize,
|
||||
|
||||
Reference in New Issue
Block a user