Two things this question turned up. The upload DID store the PDF, but on the intake record, never on the invoice — so "Open invoice PDF" 404'd for every invoice imported through the app while working for the older ones. The file now lives on invoices.source_pdf and the intake record keeps only the provenance (who sent it, what was parsed), rather than a second copy of the file. The document endpoint also falls back to the intake record, which makes the seven already-stored documents reachable again without a migration. All 10 now open. The machine combobox searched only the register's own words, so an invoice line reading "CB22 2.2 TON Roller Vibration" could never be matched to the asset filed as "CAT 2,4TON RV" — the words simply were not in the list. The line's own invoice code and description are now searchable too, so the two sides of the same machine can actually be found from either direction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
49 lines
1.9 KiB
TypeScript
49 lines
1.9 KiB
TypeScript
"use client";
|
|
|
|
import { ResponsiveContainer, AreaChart, Area } from "recharts";
|
|
|
|
type Props = {
|
|
data: (number | null)[];
|
|
color: string;
|
|
height?: number;
|
|
// Sites renders a narrower sparkline on mobile so the 375px table row keeps its
|
|
// budget. Defaults to the width /income has always used.
|
|
width?: number;
|
|
};
|
|
|
|
export function Sparkline({ data, color, height = 36, width = 120 }: Props) {
|
|
const points = data.map((v, i) => ({ i, v: v ?? 0 }));
|
|
const max = Math.max(0.001, ...data.filter((v): v is number => v != null));
|
|
// Pad values: if all 0, draw a flat line at 0 for visual presence
|
|
const flat = data.every((v) => !v);
|
|
const displayData = flat ? points.map((p) => ({ ...p, v: 1 })) : points;
|
|
const displayMax = flat ? 1 : max;
|
|
|
|
return (
|
|
<div style={{ width, height }} className="shrink-0">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={displayData} margin={{ top: 2, right: 2, left: 2, bottom: 2 }}>
|
|
<defs>
|
|
{/* Keyed by colour AND width: /sites and /income can both mount a sparkline
|
|
with the same colour in one document, and identical ids would make the
|
|
second one reuse the first one's gradient. */}
|
|
<linearGradient id={`spark-${color.replace("#", "")}-${width}`} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor={color} stopOpacity={0.4} />
|
|
<stop offset="100%" stopColor={color} stopOpacity={0.02} />
|
|
</linearGradient>
|
|
</defs>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="v"
|
|
stroke={color}
|
|
strokeWidth={1.5}
|
|
fill={`url(#spark-${color.replace("#", "")}-${width})`}
|
|
isAnimationActive={false}
|
|
baseValue={0}
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
<div className="hidden">{displayMax}</div>
|
|
</div>
|
|
);
|
|
}
|