diff --git a/drizzle/0023_invoice_item_time_entry.sql b/drizzle/0023_invoice_item_time_entry.sql new file mode 100644 index 0000000..8a488e3 --- /dev/null +++ b/drizzle/0023_invoice_item_time_entry.sql @@ -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; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 243fba6..76e9300 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] diff --git a/src/app/api/receipts/[id]/route.ts b/src/app/api/receipts/[id]/route.ts index 70e137c..125f9a8 100644 --- a/src/app/api/receipts/[id]/route.ts +++ b/src/app/api/receipts/[id]/route.ts @@ -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 }); } diff --git a/src/app/auth/signin/signin-form.tsx b/src/app/auth/signin/signin-form.tsx index 4f09924..6d3cb92 100644 --- a/src/app/auth/signin/signin-form.tsx +++ b/src/app/auth/signin/signin-form.tsx @@ -39,10 +39,17 @@ 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" - ? error.message - : "Invalid email or password", + rateLimited + ? "Too many sign-in attempts. Please wait a moment and try again." + : error.message && error.message !== "Required" + ? error.message + : "Invalid email or password", ); return; } diff --git a/src/app/dashboard/expenses/page.tsx b/src/app/dashboard/expenses/page.tsx index f310795..ca0d0b8 100644 --- a/src/app/dashboard/expenses/page.tsx +++ b/src/app/dashboard/expenses/page.tsx @@ -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; diff --git a/src/components/expenses/receipt-utils.ts b/src/components/expenses/receipt-utils.ts index 5b2908e..cd0030e 100644 --- a/src/components/expenses/receipt-utils.ts +++ b/src/components/expenses/receipt-utils.ts @@ -38,7 +38,8 @@ export async function fileToBase64(file: File): Promise { } 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); }); } diff --git a/src/components/providers/appearance-provider-synced.tsx b/src/components/providers/appearance-provider-synced.tsx index 31d7380..436983e 100644 --- a/src/components/providers/appearance-provider-synced.tsx +++ b/src/components/providers/appearance-provider-synced.tsx @@ -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]); diff --git a/src/components/time-clock/time-clock-panel.tsx b/src/components/time-clock/time-clock-panel.tsx index 4faeb6c..9eb6d39 100644 --- a/src/components/time-clock/time-clock-panel.tsx +++ b/src/components/time-clock/time-clock-panel.tsx @@ -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 ( + <> +
+ + setTitle(e.target.value)} + onBlur={() => onDescriptionCommit(title)} + placeholder="What are you working on?" + /> +
+ +
+ + { + 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} + /> +
+ + ); +} + export type TimeClockPanelProps = { defaultClientId?: string; defaultInvoiceId?: string; @@ -109,6 +166,7 @@ export function TimeClockPanel({ const [startMode, setStartMode] = useState("now"); const [pickedStart, setPickedStart] = useState(""); const [minutesAgo, setMinutesAgo] = useState("30"); + const [editEntryId, setEditEntryId] = useState(null); const intervalRef = useRef | 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({ ) : ( <> + +
@@ -621,7 +699,10 @@ export function TimeClockPanel({ {todayEntries?.some((e) => e.endedAt) ? ( - + setEditEntryId(entry.id)} + /> ) : (

No entries today.{" "} @@ -637,6 +718,13 @@ export function TimeClockPanel({ ) : null} + { + if (!open) setEditEntryId(null); + }} + />

); } diff --git a/src/components/time-clock/time-entries-history.tsx b/src/components/time-clock/time-entries-history.tsx index cd75289..736c566 100644 --- a/src/components/time-clock/time-entries-history.tsx +++ b/src/components/time-clock/time-entries-history.tsx @@ -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(null); const completedEntries = useMemo( () => (entries ?? []).filter((e) => e.endedAt), @@ -57,25 +60,35 @@ export function TimeEntriesHistory() { } return ( -
- {grouped.map((group) => ( - - - - {group.label} - - - - {group.entries.map((entry, index) => ( - - ))} - - - ))} -
+ <> +
+ {grouped.map((group) => ( + + + + {group.label} + + + + {group.entries.map((entry, index) => ( + setEditEntryId(item.id)} + /> + ))} + + + ))} +
+ { + if (!open) setEditEntryId(null); + }} + /> + ); } diff --git a/src/components/time-clock/time-entry-edit-dialog.tsx b/src/components/time-clock/time-entry-edit-dialog.tsx new file mode 100644 index 0000000..64292d0 --- /dev/null +++ b/src/components/time-clock/time-entry-edit-dialog.tsx @@ -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 ( + <> +
+
+ + setDescription(e.target.value)} + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + setStartedAt(e.target.value)} + /> +
+
+ + setEndedAt(e.target.value)} + /> +
+
+ + {hoursPreview != null ? ( +

+ Duration: {hoursPreview.toFixed(2)}h + {rate > 0 ? ` · $${(hoursPreview * rate).toFixed(2)}` : ""} +

+ ) : null} +
+ + + +
+ + +
+
+ + ); +} + +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 ( + + + + Edit time entry + + + {entryQuery.isLoading ? ( +

Loading…

+ ) : entryQuery.data && entryId ? ( + onOpenChange(false)} + /> + ) : ( +

Time entry not found.

+ )} +
+
+ ); +} diff --git a/src/components/time-clock/time-entry-list.tsx b/src/components/time-clock/time-entry-list.tsx index c69ca24..dac69b5 100644 --- a/src/components/time-clock/time-entry-list.tsx +++ b/src/components/time-clock/time-entry-list.tsx @@ -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 ( + + ); + } + return (
{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} /> ))} diff --git a/src/lib/auth-server.ts b/src/lib/auth-server.ts index efc51dd..8ecb793 100644 --- a/src/lib/auth-server.ts +++ b/src/lib/auth-server.ts @@ -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=") ); diff --git a/src/lib/object-storage.ts b/src/lib/object-storage.ts index 9fd6169..f5383b4 100644 --- a/src/lib/object-storage.ts +++ b/src/lib/object-storage.ts @@ -73,9 +73,7 @@ async function withS3Diagnostics(operation: () => Promise): Promise { } async function getS3() { - if (!s3ModulePromise) { - s3ModulePromise = import("@aws-sdk/client-s3"); - } + s3ModulePromise ??= import("@aws-sdk/client-s3"); const mod = await s3ModulePromise; if (!s3Client) { logBareMinioEndpointHint(); diff --git a/src/lib/receipt-parse.ts b/src/lib/receipt-parse.ts new file mode 100644 index 0000000..a2f5e52 --- /dev/null +++ b/src/lib/receipt-parse.ts @@ -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, + }; +} diff --git a/src/server/api/lib/time-entry-invoice-sync.ts b/src/server/api/lib/time-entry-invoice-sync.ts new file mode 100644 index 0000000..31d65ca --- /dev/null +++ b/src/server/api/lib/time-entry-invoice-sync.ts @@ -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, + }); +} diff --git a/src/server/api/routers/expenses.ts b/src/server/api/routers/expenses.ts index 0a6cec1..8b9116e 100644 --- a/src/server/api/routers/expenses.ts +++ b/src/server/api/routers/expenses.ts @@ -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, + }; + }), }); diff --git a/src/server/api/routers/time-entries.ts b/src/server/api/routers/time-entries.ts index 222cfe3..2d46d25 100644 --- a/src/server/api/routers/time-entries.ts +++ b/src/server/api/routers/time-entries.ts @@ -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 }; }), diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index a69444b..d2c9c8d 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -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( diff --git a/src/trpc/query-client.ts b/src/trpc/query-client.ts index 714de43..27eb19c 100644 --- a/src/trpc/query-client.ts +++ b/src/trpc/query-client.ts @@ -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,