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
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 22,
|
"idx": 23,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1782100000000,
|
"when": 1782200000000,
|
||||||
"tag": "0022_expense_business_receipts",
|
"tag": "0023_invoice_item_time_entry",
|
||||||
"breakpoints": true
|
"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 { eq } from "drizzle-orm";
|
||||||
import { getOptionalServerSession } from "~/lib/auth-server";
|
import { getOptionalServerSession } from "~/lib/auth-server";
|
||||||
import { getObject } from "~/lib/object-storage";
|
import { getObject } from "~/lib/object-storage";
|
||||||
@@ -20,7 +20,7 @@ export async function GET(
|
|||||||
with: { expense: true },
|
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 });
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,15 @@ export function SignInForm({ allowRegistration }: SignInFormProps) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
const message = error.message?.toLowerCase() ?? "";
|
||||||
|
const rateLimited =
|
||||||
|
error.status === 429 ||
|
||||||
|
message.includes("too many") ||
|
||||||
|
message.includes("rate limit");
|
||||||
toast.error(
|
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
|
? error.message
|
||||||
: "Invalid email or password",
|
: "Invalid email or password",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
import { DashboardPageHeader } from "~/components/layout/page-header";
|
||||||
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
|
import { DashboardPage, dashboardStatGridClass } from "~/components/layout/dashboard-page";
|
||||||
@@ -116,12 +116,6 @@ export default function ExpensesPage() {
|
|||||||
[businesses],
|
[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({
|
const create = api.expenses.create.useMutation({
|
||||||
onSuccess: (expense) => {
|
onSuccess: (expense) => {
|
||||||
if (!expense) return;
|
if (!expense) return;
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ export async function fileToBase64(file: File): Promise<string> {
|
|||||||
}
|
}
|
||||||
resolve(base64);
|
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);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function AppearanceProviderSynced({
|
|||||||
if (!serverColorMode?.colorMode) return;
|
if (!serverColorMode?.colorMode) return;
|
||||||
if (serverHydratedRef.current) return;
|
if (serverHydratedRef.current) return;
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setColorMode(serverColorMode.colorMode);
|
setColorMode(serverColorMode.colorMode);
|
||||||
serverHydratedRef.current = true;
|
serverHydratedRef.current = true;
|
||||||
}, [serverColorMode?.colorMode]);
|
}, [serverColorMode?.colorMode]);
|
||||||
|
|||||||
@@ -37,11 +37,68 @@ import {
|
|||||||
} from "~/lib/time-clock";
|
} from "~/lib/time-clock";
|
||||||
import { invoiceLabel } from "~/lib/time-entry-display";
|
import { invoiceLabel } from "~/lib/time-entry-display";
|
||||||
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
|
import { TimeEntryList } from "~/components/time-clock/time-entry-list";
|
||||||
|
import { TimeEntryEditDialog } from "~/components/time-clock/time-entry-edit-dialog";
|
||||||
|
|
||||||
const FEATURED_CLIENT_COUNT = 4;
|
const FEATURED_CLIENT_COUNT = 4;
|
||||||
|
|
||||||
type StartMode = "now" | "pick" | "ago";
|
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 = {
|
export type TimeClockPanelProps = {
|
||||||
defaultClientId?: string;
|
defaultClientId?: string;
|
||||||
defaultInvoiceId?: string;
|
defaultInvoiceId?: string;
|
||||||
@@ -109,6 +166,7 @@ export function TimeClockPanel({
|
|||||||
const [startMode, setStartMode] = useState<StartMode>("now");
|
const [startMode, setStartMode] = useState<StartMode>("now");
|
||||||
const [pickedStart, setPickedStart] = useState("");
|
const [pickedStart, setPickedStart] = useState("");
|
||||||
const [minutesAgo, setMinutesAgo] = useState("30");
|
const [minutesAgo, setMinutesAgo] = useState("30");
|
||||||
|
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
const draftClientId = running ? (running.clientId ?? "") : clientId;
|
const draftClientId = running ? (running.clientId ?? "") : clientId;
|
||||||
@@ -185,6 +243,18 @@ export function TimeClockPanel({
|
|||||||
onError: (e) => toast.error(e.message),
|
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({
|
const clockOut = api.timeEntries.clockOut.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
const message = describeClockOutOutcome({
|
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">
|
<div className="space-y-2">
|
||||||
<Label>Client</Label>
|
<Label>Client</Label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -621,7 +699,10 @@ export function TimeClockPanel({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{todayEntries?.some((e) => e.endedAt) ? (
|
{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">
|
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||||
No entries today.{" "}
|
No entries today.{" "}
|
||||||
@@ -637,6 +718,13 @@ export function TimeClockPanel({
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<TimeEntryEditDialog
|
||||||
|
entryId={editEntryId}
|
||||||
|
open={editEntryId != null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setEditEntryId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -9,9 +9,12 @@ import { EmptyState } from "~/components/layout/page-layout";
|
|||||||
import { Clock, Play } from "lucide-react";
|
import { Clock, Play } from "lucide-react";
|
||||||
import { groupEntriesByDate } from "~/lib/time-entry-display";
|
import { groupEntriesByDate } from "~/lib/time-entry-display";
|
||||||
import { TimeEntryRow } from "~/components/time-clock/time-entry-list";
|
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() {
|
export function TimeEntriesHistory() {
|
||||||
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
|
const { data: entries, isLoading } = api.timeEntries.getAll.useQuery();
|
||||||
|
const [editEntryId, setEditEntryId] = useState<string | null>(null);
|
||||||
|
|
||||||
const completedEntries = useMemo(
|
const completedEntries = useMemo(
|
||||||
() => (entries ?? []).filter((e) => e.endedAt),
|
() => (entries ?? []).filter((e) => e.endedAt),
|
||||||
@@ -57,6 +60,7 @@ export function TimeEntriesHistory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{grouped.map((group) => (
|
{grouped.map((group) => (
|
||||||
<Card key={group.dateKey}>
|
<Card key={group.dateKey}>
|
||||||
@@ -71,11 +75,20 @@ export function TimeEntriesHistory() {
|
|||||||
key={entry.id}
|
key={entry.id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
isLast={index === group.entries.length - 1}
|
isLast={index === group.entries.length - 1}
|
||||||
|
onEdit={(item: TimeEntryListItem) => setEditEntryId(item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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({
|
export function TimeEntryRow({
|
||||||
entry,
|
entry,
|
||||||
isLast,
|
isLast,
|
||||||
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
entry: TimeEntryListItem;
|
entry: TimeEntryListItem;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
|
onEdit?: (entry: TimeEntryListItem) => void;
|
||||||
}) {
|
}) {
|
||||||
const href = entryHref(entry);
|
const href = onEdit ? null : entryHref(entry);
|
||||||
const rowClassName = cn(
|
const rowClassName = cn(
|
||||||
"flex items-start justify-between gap-4 py-3",
|
"flex items-start justify-between gap-4 py-3",
|
||||||
!isLast && "border-border border-b",
|
!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 (
|
return (
|
||||||
<div className={rowClassName}>
|
<div className={rowClassName}>
|
||||||
{content}
|
{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);
|
const completed = entries.filter((e) => e.endedAt);
|
||||||
|
|
||||||
if (completed.length === 0) return null;
|
if (completed.length === 0) return null;
|
||||||
@@ -69,6 +92,7 @@ export function TimeEntryList({ entries }: { entries: TimeEntryListItem[] }) {
|
|||||||
key={entry.id}
|
key={entry.id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
isLast={index === completed.length - 1}
|
isLast={index === completed.length - 1}
|
||||||
|
onEdit={onEdit}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { auth } from "~/lib/auth";
|
|||||||
|
|
||||||
export function hasSessionCookie(headers: Headers): boolean {
|
export function hasSessionCookie(headers: Headers): boolean {
|
||||||
const cookie = headers.get("cookie") ?? "";
|
const cookie = headers.get("cookie") ?? "";
|
||||||
|
if (!cookie.trim()) return false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
cookie.includes("session_token=") ||
|
||||||
|
cookie.includes("session_data=") ||
|
||||||
cookie.includes("better-auth.session_token=") ||
|
cookie.includes("better-auth.session_token=") ||
|
||||||
cookie.includes("__Secure-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() {
|
async function getS3() {
|
||||||
if (!s3ModulePromise) {
|
s3ModulePromise ??= import("@aws-sdk/client-s3");
|
||||||
s3ModulePromise = import("@aws-sdk/client-s3");
|
|
||||||
}
|
|
||||||
const mod = await s3ModulePromise;
|
const mod = await s3ModulePromise;
|
||||||
if (!s3Client) {
|
if (!s3Client) {
|
||||||
logBareMinioEndpointHint();
|
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,
|
putObject,
|
||||||
RECEIPT_MAX_BYTES,
|
RECEIPT_MAX_BYTES,
|
||||||
} from "~/lib/object-storage";
|
} from "~/lib/object-storage";
|
||||||
|
import { parseReceiptText } from "~/lib/receipt-parse";
|
||||||
|
|
||||||
export { EXPENSE_CATEGORIES };
|
export { EXPENSE_CATEGORIES };
|
||||||
|
|
||||||
@@ -431,8 +432,7 @@ export const expensesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!receipt ||
|
receipt?.expense.createdById !== ctx.session.user.id
|
||||||
receipt.expense.createdById !== ctx.session.user.id
|
|
||||||
) {
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
@@ -447,4 +447,16 @@ export const expensesRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return { success: true };
|
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 { z } from "zod";
|
||||||
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
|
import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
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 { TRPCError } from "@trpc/server";
|
||||||
import type { db } from "~/server/db";
|
import type { db } from "~/server/db";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,12 @@ import {
|
|||||||
type ClockOutOutcome,
|
type ClockOutOutcome,
|
||||||
} from "~/lib/time-clock";
|
} from "~/lib/time-clock";
|
||||||
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||||
|
import {
|
||||||
|
insertInvoiceLineForTimeEntry,
|
||||||
|
relinkTimeEntryToInvoice,
|
||||||
|
removeLinkedInvoiceItem,
|
||||||
|
syncLinkedInvoiceItem,
|
||||||
|
} from "~/server/api/lib/time-entry-invoice-sync";
|
||||||
|
|
||||||
type Db = typeof db;
|
type Db = typeof db;
|
||||||
|
|
||||||
@@ -55,37 +61,14 @@ async function addEntryToInvoice(
|
|||||||
rate: number,
|
rate: number,
|
||||||
date: Date,
|
date: Date,
|
||||||
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> {
|
): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string }> {
|
||||||
const amount = hours * rate;
|
return insertInvoiceLineForTimeEntry(database, {
|
||||||
const maxPosition = invoice.items.reduce((m, item) => Math.max(m, item.position), -1);
|
invoice,
|
||||||
|
entryId,
|
||||||
await database.insert(invoiceItems).values({
|
|
||||||
invoiceId: invoice.id,
|
|
||||||
date,
|
|
||||||
description,
|
description,
|
||||||
hours,
|
hours,
|
||||||
rate,
|
rate,
|
||||||
amount,
|
date,
|
||||||
position: maxPosition + 1,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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(
|
async function findOrCreateDraftInvoice(
|
||||||
@@ -338,6 +321,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
clientId: z.string().optional().or(z.literal("")),
|
clientId: z.string().optional().or(z.literal("")),
|
||||||
invoiceId: z.string().optional().or(z.literal("")),
|
invoiceId: z.string().optional().or(z.literal("")),
|
||||||
rate: z.number().min(0).optional(),
|
rate: z.number().min(0).optional(),
|
||||||
|
startedAt: z.date().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -357,9 +341,20 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
clientId?: string | null;
|
clientId?: string | null;
|
||||||
invoiceId?: string | null;
|
invoiceId?: string | null;
|
||||||
rate?: number | null;
|
rate?: number | null;
|
||||||
|
startedAt?: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
} = { updatedAt: new 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) {
|
if (input.description !== undefined) {
|
||||||
updates.description = input.description;
|
updates.description = input.description;
|
||||||
}
|
}
|
||||||
@@ -563,9 +558,13 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
update: protectedProcedure
|
update: protectedProcedure
|
||||||
.input(updateSchema)
|
.input(
|
||||||
|
updateSchema.extend({
|
||||||
|
invoiceId: z.string().optional().or(z.literal("")),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { id, ...data } = input;
|
const { id, invoiceId: nextInvoiceId, ...data } = input;
|
||||||
|
|
||||||
const existing = await ctx.db.query.timeEntries.findFirst({
|
const existing = await ctx.db.query.timeEntries.findFirst({
|
||||||
where: and(
|
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) 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 =
|
const clientId =
|
||||||
data.clientId !== undefined ? data.clientId?.trim() || null : undefined;
|
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" });
|
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
|
await ctx.db
|
||||||
.update(timeEntries)
|
.update(timeEntries)
|
||||||
.set({
|
.set({
|
||||||
...data,
|
...data,
|
||||||
clientId,
|
clientId,
|
||||||
notes: data.notes?.trim() ?? null,
|
hours,
|
||||||
|
notes: data.notes?.trim() ?? undefined,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(timeEntries.id, id));
|
.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 };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -609,6 +638,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Time entry not found" });
|
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));
|
await ctx.db.delete(timeEntries).where(eq(timeEntries.id, input.id));
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -432,6 +432,9 @@ export const invoiceItems = createTable(
|
|||||||
rate: d.real().notNull(),
|
rate: d.real().notNull(),
|
||||||
amount: d.real().notNull(),
|
amount: d.real().notNull(),
|
||||||
position: d.integer().notNull().default(0), // NEW: position for ordering
|
position: d.integer().notNull().default(0), // NEW: position for ordering
|
||||||
|
timeEntryId: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.references(() => timeEntries.id, { onDelete: "set null" }),
|
||||||
createdAt: d
|
createdAt: d
|
||||||
.timestamp()
|
.timestamp()
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
@@ -449,6 +452,10 @@ export const invoiceItemsRelations = relations(invoiceItems, ({ one }) => ({
|
|||||||
fields: [invoiceItems.invoiceId],
|
fields: [invoiceItems.invoiceId],
|
||||||
references: [invoices.id],
|
references: [invoices.id],
|
||||||
}),
|
}),
|
||||||
|
timeEntry: one(timeEntries, {
|
||||||
|
fields: [invoiceItems.timeEntryId],
|
||||||
|
references: [timeEntries.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const expenses = createTable(
|
export const expenses = createTable(
|
||||||
|
|||||||
+44
-34
@@ -8,45 +8,55 @@ import { TRPCClientError } from "@trpc/client";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import SuperJSON from "superjson";
|
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 = () =>
|
export const createQueryClient = () =>
|
||||||
new QueryClient({
|
new QueryClient({
|
||||||
queryCache: new QueryCache({
|
queryCache: new QueryCache({ onError: handleQueryError }),
|
||||||
onError: (error) => {
|
mutationCache: new MutationCache({ onError: handleQueryError }),
|
||||||
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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
// With SSR, we usually want to set some default staleTime
|
|
||||||
// above 0 to avoid refetching immediately on the client
|
|
||||||
staleTime: 30 * 1000,
|
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: {
|
dehydrate: {
|
||||||
serializeData: SuperJSON.serialize,
|
serializeData: SuperJSON.serialize,
|
||||||
|
|||||||
Reference in New Issue
Block a user