diff --git a/README.md b/README.md index 99965146..9c56d5bd 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,17 @@ For Cloudflare Workers hosting with OpenNext, follow the [deployment guide](docs yarn dev ``` +## Financial Dashboard + +Open **Dashboard** in the sidebar to view local invoices and, when signed in, your saved server invoices. Select a currency to see collected, outstanding, and overdue totals, six- or twelve-month trends, and the top five clients by all-time billed amount. Currencies are never combined or converted. + +- Collected includes invoices marked `success`. Monthly collections use the recorded payment date; paid invoices without one remain in all-time totals. +- Outstanding includes `pending`, `error`, and `expired` invoices. Overdue is the subset past its due date or marked `expired`; an invoice due today is not overdue. +- Refunded invoices are excluded from amounts. Monthly billed amounts use the invoice date, and clients are grouped by normalized name and address. +- Amounts use decimal arithmetic and round each invoice to two decimals, matching invoice display precision. Partial payments and a payment ledger are not supported. + +The dashboard uses existing invoice fields and requires **no new database migration**, environment variable, or dependency. + ## 🛠️ Tech Stack ### Core Framework diff --git a/apps/web/src/app/(dashboard)/dashboard/dashboard-panels.tsx b/apps/web/src/app/(dashboard)/dashboard/dashboard-panels.tsx new file mode 100644 index 00000000..881e86eb --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/dashboard-panels.tsx @@ -0,0 +1,153 @@ +import { formatDashboardAmount, type DashboardCurrency } from "@/lib/dashboard/financial-dashboard"; +import { ArrowDownLeft, Clock3, CircleAlert } from "lucide-react"; + +interface DashboardPanelProps { + data: DashboardCurrency; +} + +function DashboardOverview({ data }: DashboardPanelProps) { + const metrics = [ + { + title: "Collected", + amount: data.collected, + count: data.collectedCount, + detail: "Invoices marked as paid", + icon: ArrowDownLeft, + color: "text-emerald-700 dark:text-emerald-400", + background: "bg-emerald-500/10", + }, + { + title: "Outstanding", + amount: data.outstanding, + count: data.outstandingCount, + detail: "All unpaid invoices, including overdue", + icon: Clock3, + color: "text-foreground", + background: "bg-muted", + }, + { + title: "Overdue", + amount: data.overdue, + count: data.overdueCount, + detail: "Past due date or marked expired", + icon: CircleAlert, + color: "text-amber-700 dark:text-amber-400", + background: "bg-amber-500/10", + }, + ]; + + return ( +
+
+

+ All-time overview +

+ {data.currency} +
+
+ {metrics.map(({ title, amount, count, detail, icon: Icon, color, background }) => ( +
+
+

{title}

+ + +
+

+ {formatDashboardAmount(amount, data.currency)} +

+

+ {count} {count === 1 ? "invoice" : "invoices"} +

+

{detail}

+
+ ))} +
+

+ Pending, error, and expired invoices are outstanding. Overdue is included in outstanding. Refunded invoices are + excluded from amounts. +

+
+ ); +} + +function TopClients({ data }: DashboardPanelProps) { + return ( +
+
+
+

+ Top clients +

+

+ Top 5 by all-time billed amount in {data.currency}. Matched by name and address. +

+
+ All time +
+ {data.topClients.length ? ( +
+ + + + + + + + + + + + + {data.topClients.map((client, index) => ( + + + + + + + + ))} + +
Top clients in {data.currency}, ranked by total billed
+ Client + + Invoices + + Billed + + Collected + + Outstanding +
+
+ + {String(index + 1).padStart(2, "0")} + +
+

{client.name}

+ {client.address ? ( +

+ {client.address} +

+ ) : null} +
+
+
{client.invoiceCount} + {formatDashboardAmount(client.billed, data.currency)} + + {formatDashboardAmount(client.collected, data.currency)} + + {formatDashboardAmount(client.outstanding, data.currency)} +
+
+ ) : ( +

+ No non-refunded invoices to rank in this currency yet. +

+ )} +
+ ); +} + +export { DashboardOverview, TopClients }; diff --git a/apps/web/src/app/(dashboard)/dashboard/dashboard.client.tsx b/apps/web/src/app/(dashboard)/dashboard/dashboard.client.tsx new file mode 100644 index 00000000..1ffb6bfe --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/dashboard.client.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { buildFinancialDashboard, mergeDashboardInvoices } from "@/lib/dashboard/financial-dashboard"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ArrowUpRight, Plus, RefreshCw, Wallet } from "lucide-react"; +import { DashboardOverview, TopClients } from "./dashboard-panels"; +import { getAllInvoices } from "@/lib/indexdb-queries/invoice"; +import { MonthlyTrend } from "./monthly-trend.client"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { useSession } from "@/lib/client-auth"; +import { useTRPCClient } from "@/trpc/client"; +import { LINKS } from "@/constants/links"; +import { useMemo, useState } from "react"; +import Link from "next/link"; + +function FinancialDashboard() { + const trpcClient = useTRPCClient(); + const { data: session, isPending: isSessionPending, error: sessionError, refetch: refetchSession } = useSession(); + const [selectedCurrency, setSelectedCurrency] = useState(""); + const [months, setMonths] = useState<6 | 12>(6); + + const localInvoices = useQuery({ + queryKey: ["idb-invoices"], + queryFn: getAllInvoices, + staleTime: 0, + }); + const serverInvoices = useQuery({ + // Scope cached financial data to the signed-in account. + queryKey: ["dashboard-invoices", session?.user.id], + queryFn: () => trpcClient.invoice.dashboard.query(), + enabled: !!session?.user && !isSessionPending && !sessionError, + staleTime: 0, + }); + + const serverData = session?.user && !sessionError ? serverInvoices.data : undefined; + const today = new Date().setHours(0, 0, 0, 0); + const dashboard = useMemo( + () => + buildFinancialDashboard(mergeDashboardInvoices(localInvoices.data ?? [], serverData ?? []), { + months, + now: new Date(today), + }), + // Refresh the date-sensitive overdue calculation when the page rerenders on a new day. + [localInvoices.data, serverData, months, today], + ); + const currency = dashboard.currencies.find((entry) => entry.currency === selectedCurrency) ?? dashboard.currencies[0]; + const isLoading = isSessionPending || localInvoices.isLoading || (!!session?.user && serverInvoices.isLoading); + const isRefreshing = localInvoices.isFetching || (!!session?.user && serverInvoices.isFetching); + const hasError = localInvoices.isError || (!!session?.user && serverInvoices.isError) || !!sessionError; + + function refresh() { + void localInvoices.refetch(); + if (sessionError) void refetchSession(); + else if (session?.user) void serverInvoices.refetch(); + } + + return ( +
+
+
+

Financial overview

+

Dashboard

+

+ A clear view of what’s paid, what’s due, and who you work with. +

+
+
+ + +
+
+ + {hasError ? ( + + Some invoice data is unavailable + +

+ {sessionError ? "We couldn’t check your sign-in status. " : ""} + {localInvoices.isError ? "Invoices on this device couldn’t be loaded. " : ""} + {session?.user && serverInvoices.isError ? "Saved server invoices couldn’t be refreshed. " : ""} + Figures below may be incomplete or out of date. Use Refresh to try again. +

+
+
+ ) : null} + + {isLoading ? ( +
+

Loading your financial overview…

+
+ ) : !currency ? ( +
+
+
+

+ {hasError ? "Your overview is unavailable" : "Your finances, at a glance"} +

+

+ {hasError + ? "Refresh to load your invoices and see your financial overview." + : "Create your first invoice to start tracking collections, outstanding amounts, and your top clients."} +

+ {!hasError ? ( + + ) : null} +
+ ) : ( + <> +
+
+ + +
+

+ {dashboard.invoiceCount} {dashboard.invoiceCount === 1 ? "invoice" : "invoices"} across{" "} + {dashboard.currencies.length} {dashboard.currencies.length === 1 ? "currency" : "currencies"} +
+ Each currency is reported separately. No conversion or combined totals. +

+
+ + + +
+
+
+ +

+ Billed by invoice date. Collected by payment date. Amounts in {currency.currency}. +

+
+
+ + +
+
+ + {currency.undatedCollectedCount > 0 ? ( +

+ {currency.undatedCollectedCount} paid{" "} + {currency.undatedCollectedCount === 1 ? "invoice has" : "invoices have"} no payment date. Included in + collected totals, excluded from monthly collections. +

+ ) : null} +
+ + + +
+

+ {session?.user + ? "Includes invoices on this device and in your account." + : "Showing invoices on this device. Sign in to include your saved server invoices."} +

+ + Manage invoices +
+ + )} +
+ ); +} + +export { FinancialDashboard }; diff --git a/apps/web/src/app/(dashboard)/dashboard/monthly-trend.client.tsx b/apps/web/src/app/(dashboard)/dashboard/monthly-trend.client.tsx new file mode 100644 index 00000000..3463bfca --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/monthly-trend.client.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { formatDashboardAmount, type DashboardCurrency } from "@/lib/dashboard/financial-dashboard"; +import Decimal from "decimal.js"; +import { useId } from "react"; + +function monthLabel(month: string, short = false) { + const [year, monthNumber] = month.split("-").map(Number); + return new Intl.DateTimeFormat("en-US", { + month: short ? "short" : "long", + year: short ? "2-digit" : "numeric", + }).format(new Date(year, monthNumber - 1, 1)); +} + +function compactAmount(value: Decimal) { + for (const [divisor, suffix] of [ + ["1000000000000", "T"], + ["1000000000", "B"], + ["1000000", "M"], + ["1000", "K"], + ]) { + if (value.abs().gte(divisor)) return `${value.div(divisor).toDecimalPlaces(1).toString()}${suffix}`; + } + return value.toDecimalPlaces(2).toString(); +} + +function MonthlyTrend({ data }: { data: DashboardCurrency }) { + const chartId = useId(); + const values = data.monthly.flatMap((month) => [new Decimal(month.billed), new Decimal(month.collected)]); + const maximum = Decimal.max(0, ...values); + const minimum = Decimal.min(0, ...values); + const range = maximum.minus(minimum); + const hasActivity = !range.isZero(); + const scale = hasActivity ? range : new Decimal(1); + const chartTop = 16; + const chartHeight = 200; + const chartLeft = 64; + const plotWidth = 736; + const step = plotWidth / data.monthly.length; + const barWidth = Math.min(26, step * 0.28); + + function yPosition(value: Decimal) { + // Only chart coordinates use JS numbers. Money stays in Decimal/string form. + return chartTop + maximum.minus(value).div(scale).toNumber() * chartHeight; + } + + const zeroY = hasActivity ? yPosition(new Decimal(0)) : chartTop + chartHeight; + + return ( +
+
+ + + Billed + + + + Collected + +
+ {!hasActivity ? ( +
+ No billed or collected amounts in this period. +
+ ) : null} +
+ + Monthly billed and collected amounts in {data.currency} + + Comparison for the last {data.monthly.length} months. Exact amounts are available in the monthly figures + table below. + + {[0, 0.5, 1].map((fraction) => { + const amount = maximum.minus(scale.times(fraction)); + const y = chartTop + chartHeight * fraction; + return ( + + + + {hasActivity ? compactAmount(amount) : fraction === 1 ? "0" : ""} + + + ); + })} + + {data.monthly.map((month, index) => { + const center = chartLeft + step * (index + 0.5); + return ( + + {(["billed", "collected"] as const).map((key, seriesIndex) => { + const amount = new Decimal(month[key]); + const valueY = hasActivity ? yPosition(amount) : zeroY; + return ( + + + {monthLabel(month.month)} — {key === "billed" ? "Billed" : "Collected"}:{" "} + {formatDashboardAmount(month[key], data.currency)} + + + ); + })} + + {monthLabel(month.month, true)} + + + ); + })} + +
+
+ + View monthly figures + +
+ + + + + + + + + + + {data.monthly.map((month) => ( + + + + + + ))} + +
Monthly figures in {data.currency}
+ Month + + Billed + + Collected +
+ {monthLabel(month.month)} + + {formatDashboardAmount(month.billed, data.currency)} + + {formatDashboardAmount(month.collected, data.currency)} +
+
+
+
+ ); +} + +export { MonthlyTrend }; diff --git a/apps/web/src/app/(dashboard)/dashboard/page.tsx b/apps/web/src/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 00000000..b8600aee --- /dev/null +++ b/apps/web/src/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,13 @@ +import { generateWebsiteMetadata } from "@/constants/meta-data"; +import { FinancialDashboard } from "./dashboard.client"; +import type { Metadata } from "next"; + +export const metadata: Metadata = generateWebsiteMetadata({ + title: "Financial Dashboard | Invoicely", +}); + +function DashboardPage() { + return ; +} + +export default DashboardPage; diff --git a/apps/web/src/assets/icons/dashboard.tsx b/apps/web/src/assets/icons/dashboard.tsx new file mode 100644 index 00000000..6005bafd --- /dev/null +++ b/apps/web/src/assets/icons/dashboard.tsx @@ -0,0 +1,31 @@ +import type { IconProps } from "@/types"; + +function DashboardIcon({ fill = "currentColor", secondaryfill = fill, ...props }: IconProps) { + return ( + + ); +} + +export { DashboardIcon }; diff --git a/apps/web/src/assets/icons/index.ts b/apps/web/src/assets/icons/index.ts index 2b3dd7b1..9aa70145 100644 --- a/apps/web/src/assets/icons/index.ts +++ b/apps/web/src/assets/icons/index.ts @@ -1,4 +1,5 @@ // Follow the naming convention: NameIcon +export { DashboardIcon } from "./dashboard"; export { default as SidebarMenuIcon } from "./sidebar-menu"; export { default as SquareWandSparkleIcon } from "./square-wand-sparkle"; export { default as ReceiptIcon } from "./recipt"; diff --git a/apps/web/src/constants/sidebar.tsx b/apps/web/src/constants/sidebar.tsx index 98379bdb..d0a42f52 100644 --- a/apps/web/src/constants/sidebar.tsx +++ b/apps/web/src/constants/sidebar.tsx @@ -1,9 +1,14 @@ -import { FolderFeatherIcon, ReceiptIcon, VersionsIcon } from "@/assets/icons"; +import { DashboardIcon, FolderFeatherIcon, ReceiptIcon, VersionsIcon } from "@/assets/icons"; import type { ISidebar } from "@/types"; import { LINKS } from "./links"; export const SIDEBAR_ITEMS: ISidebar = { Navigation: [ + { + name: "Dashboard", + url: LINKS.DASHBOARD, + icon: , + }, { name: "Invoices", url: LINKS.INVOICES, diff --git a/apps/web/src/lib/dashboard/financial-dashboard.ts b/apps/web/src/lib/dashboard/financial-dashboard.ts new file mode 100644 index 00000000..75a80cb7 --- /dev/null +++ b/apps/web/src/lib/dashboard/financial-dashboard.ts @@ -0,0 +1,253 @@ +import Decimal from "decimal.js"; + +// Keep calculations independent of Decimal configuration used elsewhere in the app. +const Money = Decimal.clone({ precision: 64, rounding: Decimal.ROUND_HALF_UP }); + +type DashboardDate = Date | string; + +/** The dashboard query and locally saved invoices both satisfy this minimal shape. */ +export interface DashboardInvoice { + id: string; + type: "local" | "server"; + status: "pending" | "success" | "error" | "expired" | "refunded"; + paidAt?: DashboardDate | null; + invoiceFields: { + clientDetails: { name: string; address: string }; + invoiceDetails: { + currency: string; + date: DashboardDate; + dueDate?: DashboardDate | null; + billingDetails: { type: "fixed" | "percentage"; value: Decimal.Value }[]; + }; + items: { quantity: Decimal.Value; unitPrice: Decimal.Value }[]; + }; +} + +export interface DashboardMonth { + month: string; + billed: string; + collected: string; +} + +export interface DashboardClient { + id: string; + name: string; + address: string; + invoiceCount: number; + billed: string; + collected: string; + outstanding: string; +} + +export interface DashboardCurrency { + currency: string; + invoiceCount: number; + collected: string; + outstanding: string; + overdue: string; + collectedCount: number; + outstandingCount: number; + overdueCount: number; + undatedCollectedCount: number; + monthly: DashboardMonth[]; + topClients: DashboardClient[]; +} + +export interface FinancialDashboard { + invoiceCount: number; + currencies: DashboardCurrency[]; +} + +/** Prefer the server record when a locally cached copy has the same invoice ID. */ +export function mergeDashboardInvoices( + localInvoices: readonly DashboardInvoice[], + serverInvoices: readonly DashboardInvoice[], +): DashboardInvoice[] { + return Array.from(new Map([...localInvoices, ...serverInvoices].map((invoice) => [invoice.id, invoice])).values()); +} + +/** Same formula as the PDF: percentages apply to the subtotal, then round the invoice to two decimals. */ +export function getDashboardInvoiceTotal(invoice: DashboardInvoice): string { + const subtotal = invoice.invoiceFields.items.reduce( + (sum, item) => sum.plus(new Money(item.quantity).times(item.unitPrice)), + new Money(0), + ); + + return invoice.invoiceFields.invoiceDetails.billingDetails + .reduce( + (total, rate) => total.plus(rate.type === "percentage" ? subtotal.times(rate.value).dividedBy(100) : rate.value), + subtotal, + ) + .toDecimalPlaces(2) + .toFixed(); +} + +function asDate(value: DashboardDate | null | undefined): Date | null { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function monthKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; +} + +function dayStart(date: Date): number { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); +} + +function add(left: string, right: string): string { + return new Money(left).plus(right).toFixed(); +} + +function clientKey(name: string, address: string): string { + const normalize = (value: string) => value.trim().replace(/\s+/g, " ").toLowerCase(); + return JSON.stringify([normalize(name), normalize(address)]); +} + +/** + * Summaries and client rankings cover all time; only monthly trends use the window. + * Pending, failed, and expired invoices remain outstanding. Overdue is a subset of + * outstanding, based on a past calendar due date or explicit expired status. + * Refunded invoices do not contribute to financial totals. Successful invoices + * without a payment date count toward collected, but never an invented payment month. + */ +export function buildFinancialDashboard( + invoices: readonly DashboardInvoice[], + { now = new Date(), months = 6, topClientLimit = 5 }: { now?: Date; months?: 6 | 12; topClientLimit?: number } = {}, +): FinancialDashboard { + const monthKeys = Array.from({ length: months }, (_, index) => + monthKey(new Date(now.getFullYear(), now.getMonth() - months + index + 1, 1)), + ); + const currencies = new Map }>(); + const today = dayStart(now); + + for (const invoice of invoices) { + const details = invoice.invoiceFields.invoiceDetails; + const currency = details.currency.trim().toUpperCase(); + let group = currencies.get(currency); + + if (!group) { + group = { + summary: { + currency, + invoiceCount: 0, + collected: "0", + outstanding: "0", + overdue: "0", + collectedCount: 0, + outstandingCount: 0, + overdueCount: 0, + undatedCollectedCount: 0, + monthly: monthKeys.map((month) => ({ month, billed: "0", collected: "0" })), + topClients: [], + }, + clients: new Map(), + }; + currencies.set(currency, group); + } + + const { summary, clients } = group; + summary.invoiceCount += 1; + if (invoice.status === "refunded") continue; + + const amount = getDashboardInvoiceTotal(invoice); + const paid = invoice.status === "success"; + const dueDate = asDate(details.dueDate); + const overdue = !paid && (invoice.status === "expired" || (dueDate !== null && dayStart(dueDate) < today)); + + if (paid) { + summary.collected = add(summary.collected, amount); + summary.collectedCount += 1; + const paidAt = asDate(invoice.paidAt); + if (paidAt) { + const month = summary.monthly.find((entry) => entry.month === monthKey(paidAt)); + if (month) month.collected = add(month.collected, amount); + } else { + summary.undatedCollectedCount += 1; + } + } else { + summary.outstanding = add(summary.outstanding, amount); + summary.outstandingCount += 1; + } + + if (overdue) { + summary.overdue = add(summary.overdue, amount); + summary.overdueCount += 1; + } + + const invoiceDate = asDate(details.date); + const month = invoiceDate ? summary.monthly.find((entry) => entry.month === monthKey(invoiceDate)) : undefined; + if (month) month.billed = add(month.billed, amount); + + const { name, address } = invoice.invoiceFields.clientDetails; + const id = clientKey(name, address); + const client = clients.get(id) ?? { + id, + name: name.trim(), + address: address.trim(), + invoiceCount: 0, + billed: "0", + collected: "0", + outstanding: "0", + }; + client.invoiceCount += 1; + client.billed = add(client.billed, amount); + if (paid) client.collected = add(client.collected, amount); + else client.outstanding = add(client.outstanding, amount); + clients.set(id, client); + } + + return { + invoiceCount: invoices.length, + currencies: Array.from(currencies.values()) + .map(({ summary, clients }) => ({ + ...summary, + topClients: Array.from(clients.values()) + .sort( + (left, right) => + new Money(right.billed).comparedTo(left.billed) || + left.name.localeCompare(right.name) || + left.address.localeCompare(right.address), + ) + .slice(0, Math.max(0, topClientLimit)), + })) + .sort((left, right) => left.currency.localeCompare(right.currency)), + }; +} + +/** Format exact decimal strings without converting money to a JavaScript number. */ +export function formatDashboardAmount(amount: string, currency: string, locale = "en-US"): string { + let formatter: Intl.NumberFormat; + try { + // Match the two-decimal invoice amounts displayed in the app and generated PDFs. + formatter = new Intl.NumberFormat(locale, { + style: "currency", + currency, + currencyDisplay: "code", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + } catch { + // Existing invoices may contain a custom currency label instead of an ISO code. + return `${currency} ${new Money(amount).toFixed(2)}`; + } + + const decimals = formatter.resolvedOptions().maximumFractionDigits ?? 2; + const rounded = new Money(amount).toDecimalPlaces(decimals); + const [integer, fraction] = rounded.abs().toFixed(decimals).split("."); + const wholeParts = formatter.formatToParts(BigInt(integer!)); + const groupedInteger = wholeParts + .filter((part) => part.type === "integer" || part.type === "group") + .map((part) => part.value) + .join(""); + const template = formatter.formatToParts(BigInt(rounded.isNegative() && !rounded.isZero() ? -1 : 1)); + + return template + .map((part) => { + if (part.type === "integer") return groupedInteger; + if (part.type === "fraction") return fraction; + return part.value; + }) + .join(""); +} diff --git a/apps/web/src/lib/db-queries/invoice/getDashboardInvoices.ts b/apps/web/src/lib/db-queries/invoice/getDashboardInvoices.ts new file mode 100644 index 00000000..3bc81eac --- /dev/null +++ b/apps/web/src/lib/db-queries/invoice/getDashboardInvoices.ts @@ -0,0 +1,36 @@ +import { db, schema } from "@invoicely/db"; +import { eq } from "drizzle-orm"; + +/** Fetch only the financial fields needed to summarize the current user's invoices. */ +export const getDashboardInvoicesQuery = async (userId: string) => { + return db.query.invoices.findMany({ + where: eq(schema.invoices.userId, userId), + columns: { + id: true, + type: true, + status: true, + paidAt: true, + }, + with: { + invoiceFields: { + columns: {}, + with: { + clientDetails: { + columns: { name: true, address: true }, + }, + invoiceDetails: { + columns: { currency: true, date: true, dueDate: true }, + with: { + billingDetails: { + columns: { type: true, value: true }, + }, + }, + }, + items: { + columns: { quantity: true, unitPrice: true }, + }, + }, + }, + }, + }); +}; diff --git a/apps/web/src/trpc/services/invoice/getDashboard.ts b/apps/web/src/trpc/services/invoice/getDashboard.ts new file mode 100644 index 00000000..a3547686 --- /dev/null +++ b/apps/web/src/trpc/services/invoice/getDashboard.ts @@ -0,0 +1,43 @@ +import { getDashboardInvoicesQuery } from "@/lib/db-queries/invoice/getDashboardInvoices"; +import { authorizedProcedure } from "@/trpc/procedures/authorizedProcedure"; +import { parseCatchError } from "@/lib/neverthrow/parseCatchError"; +import { InternalServerError } from "@/lib/effect/error/trpc"; +import { TRPCError } from "@trpc/server"; +import { Effect } from "effect"; + +export const getDashboard = authorizedProcedure.query(async ({ ctx }) => { + const dashboardEffect = Effect.gen(function* () { + const invoices = yield* Effect.tryPromise({ + try: () => getDashboardInvoicesQuery(ctx.auth.user.id), + catch: (error) => new InternalServerError({ message: parseCatchError(error) }), + }); + + // Decimal strings retain database precision across the API boundary. + return invoices.map((invoice) => ({ + ...invoice, + invoiceFields: { + ...invoice.invoiceFields, + items: invoice.invoiceFields.items.map((item) => ({ + ...item, + unitPrice: item.unitPrice.toString(), + })), + invoiceDetails: { + ...invoice.invoiceFields.invoiceDetails, + billingDetails: invoice.invoiceFields.invoiceDetails.billingDetails.map((detail) => ({ + ...detail, + value: detail.value.toString(), + })), + }, + }, + })); + }); + + return Effect.runPromise( + dashboardEffect.pipe( + Effect.catchTags({ + InternalServerError: (error) => + Effect.fail(new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: error.message })), + }), + ), + ); +}); diff --git a/apps/web/src/trpc/services/invoice/index.ts b/apps/web/src/trpc/services/invoice/index.ts index 66c0ba1b..5ae766da 100644 --- a/apps/web/src/trpc/services/invoice/index.ts +++ b/apps/web/src/trpc/services/invoice/index.ts @@ -3,11 +3,13 @@ import { insertInvoice } from "./insertInvoice"; import { deleteInvoice } from "./deleteInvoice"; import { createTRPCRouter } from "@/trpc/init"; import { listInvoices } from "./listInvoices"; +import { getDashboard } from "./getDashboard"; import { migrateToDb } from "./migrateToDb"; import { editInvoice } from "./editInvoice"; import { getInvoice } from "./getInvoice"; export const invoiceRouter = createTRPCRouter({ + dashboard: getDashboard, list: listInvoices, insert: insertInvoice, updateStatus: updateInvoiceStatus,