Midday / apps /dashboard /src /components /canvas /spending-canvas.tsx
Jules
Final deployment with all fixes and verified content
c09f67c
"use client";
import { useArtifact } from "@ai-sdk-tools/artifacts/client";
import { spendingArtifact } from "@api/ai/artifacts/spending";
import { cn } from "@midday/ui/cn";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@midday/ui/table";
import Link from "next/link";
import { parseAsInteger, useQueryState } from "nuqs";
import {
BaseCanvas,
CanvasHeader,
CanvasSection,
} from "@/components/canvas/base";
import { CanvasContent } from "@/components/canvas/base/canvas-content";
import {
Skeleton,
SkeletonCard,
SkeletonLine,
} from "@/components/canvas/base/skeleton";
import { useTransactionParams } from "@/hooks/use-transaction-params";
import { useUserQuery } from "@/hooks/use-user";
import { formatAmount } from "@/utils/format";
export function SpendingCanvas() {
const [version] = useQueryState("version", parseAsInteger.withDefault(0));
const [artifact] = useArtifact(spendingArtifact, { version });
const { data, status } = artifact;
const { data: user } = useUserQuery();
const { setParams } = useTransactionParams();
const _isLoading = status === "loading";
const stage = data?.stage;
const transactions = data?.transactions || [];
const metrics = data?.metrics;
const currency = data?.currency || "USD";
const locale = user?.locale ?? undefined;
const showTransactions =
stage && ["metrics_ready", "analysis_ready"].includes(stage);
const showCards =
stage && ["metrics_ready", "analysis_ready"].includes(stage);
const showSummary = stage === "analysis_ready";
return (
<BaseCanvas>
<CanvasHeader title="Spending" />
<CanvasContent>
<div className="space-y-8">
{/* Largest transactions section */}
{showTransactions ? (
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<h4 className="text-[18px] font-normal font-serif text-black dark:text-white">
Largest transactions
</h4>
<Link
href="/transactions"
className="text-[12px] text-[#707070] dark:text-[#666666] hover:underline"
>
View all transactions
</Link>
</div>
{transactions.length > 0 ? (
<Table>
<TableHeader>
<TableRow className="border-b-0">
<TableHead className="text-[12px] text-[#707070] dark:text-[#666666] font-normal">
Date
</TableHead>
<TableHead className="text-[12px] text-[#707070] dark:text-[#666666] font-normal">
Vendor
</TableHead>
<TableHead className="text-[12px] text-[#707070] dark:text-[#666666] font-normal">
Category
</TableHead>
<TableHead className="text-right text-[12px] text-[#707070] dark:text-[#666666] font-normal">
Amount
</TableHead>
<TableHead className="text-right text-[12px] text-[#707070] dark:text-[#666666] font-normal">
Share
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.slice(0, 10).map((transaction, index) => (
<TableRow
key={transaction.id}
onClick={() =>
setParams({ transactionId: transaction.id })
}
className={cn(
"cursor-pointer hover:bg-[#F2F1EF] dark:hover:bg-[#0f0f0f] transition-colors",
index === transactions.slice(0, 10).length - 1 &&
"border-b-0",
)}
>
<TableCell className="text-[12px] text-black dark:text-white">
{transaction.date}
</TableCell>
<TableCell className="text-[12px] text-black dark:text-white">
{transaction.vendor}
</TableCell>
<TableCell className="text-[12px] text-black dark:text-white">
{transaction.category}
</TableCell>
<TableCell className="text-right text-[12px] text-black dark:text-white font-sans">
{formatAmount({
currency,
amount: transaction.amount,
locale,
})}
</TableCell>
<TableCell className="text-right text-[12px] text-[#707070] dark:text-[#666666]">
{transaction.share.toFixed(1)}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-[12px] text-[#707070] dark:text-[#666666] py-8 text-center">
No transactions found
</div>
)}
</div>
) : (
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<Skeleton width="8rem" height="1.125rem" />
<Skeleton width="6rem" height="0.875rem" />
</div>
<div className="border border-[#e6e6e6] dark:border-[#1d1d1d]">
<div className="p-3 space-y-3">
{Array.from(
{ length: 5 },
(_, i) => `skeleton-transaction-row-${i}`,
).map((key) => (
<SkeletonLine key={key} width="100%" />
))}
</div>
</div>
</div>
)}
{/* Two summary cards */}
{showCards ? (
<div className="grid grid-cols-2 gap-3 mb-6">
<div className="border p-3 bg-white dark:bg-[#0c0c0c] border-[#e6e6e6] dark:border-[#1d1d1d]">
<div className="text-[12px] text-[#707070] dark:text-[#666666] mb-1">
Spending this month
</div>
<div className="text-[18px] font-normal font-sans text-black dark:text-white mb-1">
{metrics?.currentMonthSpending
? formatAmount({
currency,
amount: metrics.currentMonthSpending,
locale,
})
: formatAmount({
currency,
amount: 0,
locale,
})}
</div>
<div className="text-[10px] text-[#707070] dark:text-[#666666]">
Across {transactions.length} high-value transaction
{transactions.length !== 1 ? "s" : ""}
</div>
</div>
<div className="border p-3 bg-white dark:bg-[#0c0c0c] border-[#e6e6e6] dark:border-[#1d1d1d]">
<div className="text-[12px] text-[#707070] dark:text-[#666666] mb-1">
Top category
</div>
<div className="text-[18px] font-normal font-sans text-black dark:text-white mb-1">
{metrics?.topCategory
? `${metrics.topCategory.name} — ${formatAmount({
currency,
amount: metrics.topCategory.amount,
locale,
})}`
: "—"}
</div>
<div className="text-[10px] text-[#707070] dark:text-[#666666]">
Largest share of monthly spend
</div>
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-3 mb-6">
{Array.from(
{ length: 2 },
(_, i) => `skeleton-summary-card-${i}`,
).map((key) => (
<SkeletonCard key={key}>
<SkeletonLine width="5rem" />
<Skeleton width="8rem" height="1.125rem" className="mb-1" />
<SkeletonLine width="6rem" />
</SkeletonCard>
))}
</div>
)}
{/* Summary & Recommendations section */}
<CanvasSection
title="Summary & Recommendations"
isLoading={!showSummary}
>
{data?.analysis?.summary && (
<div className="space-y-3">
<div className="whitespace-pre-wrap">
{data.analysis.summary}
</div>
</div>
)}
</CanvasSection>
</div>
</CanvasContent>
</BaseCanvas>
);
}