A screenshot at phone width showed an "INVOICE | LINES | TOTAL | STATUS" header row while the data under LINES was money. The header and its data cell had drifted apart: the earlier pass hid the Lines <td> but not the matching <th>, because the loop that added the hide classes only matched text-left headers and Lines is text-right. So the Total values rendered under a heading that named a column which no longer existed. The same screenshot showed the totals clipped — "41,710.50" reading as "710,50". With only three columns left, the 520px min-width was still forcing a sideways scroll, and the opaque sticky first column then sat on top of the left of the money values. The mobile floor is gone, so the three remaining columns size to their content, and the sticky and status gutters are trimmed at mobile widths to let them fit 375px. Below sm the table is now exactly Invoice, Total, Status — and every header matches a visible cell. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
245 lines
11 KiB
TypeScript
245 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useMemo } from "react";
|
||
import { Wrench, LayoutGrid, Sparkles, ChevronRight } from "lucide-react";
|
||
import { Sparkline } from "@/components/Sparkline";
|
||
import { MONTH_NAMES } from "@/lib/data";
|
||
import type { RunningRow } from "@/lib/rows";
|
||
import { ZAR, ZAR2 } from "@/lib/utils";
|
||
|
||
export function RunningView({
|
||
rows,
|
||
palette,
|
||
monthTotals,
|
||
}: {
|
||
rows: RunningRow[];
|
||
palette: { income: string; profit: string; loss: string };
|
||
monthTotals: number[];
|
||
}) {
|
||
const [view, setView] = useState<"cards" | "grid">("cards");
|
||
const [sort, setSort] = useState<"ytd" | "code">("ytd");
|
||
const [expanded, setExpanded] = useState<string | null>(null);
|
||
|
||
const sorted = useMemo(() => {
|
||
return [...rows].sort((a, b) =>
|
||
sort === "ytd" ? b.ytd - a.ytd : a.code.localeCompare(b.code)
|
||
);
|
||
}, [rows, sort]);
|
||
|
||
const grandTotal = monthTotals.reduce((s, v) => s + v, 0);
|
||
|
||
return (
|
||
<>
|
||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center sm:justify-between gap-3 mb-5">
|
||
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
|
||
<span className="px-2.5 py-1 rounded-md bg-[#EDAE49]/10 text-[#EDAE49] font-medium">
|
||
{rows.filter((r) => r.ytd > 0).length} machines with costs
|
||
</span>
|
||
<span className="hidden md:inline">· sorted by {sort === "ytd" ? "total spend" : "code"}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<div className="flex items-center gap-1 p-0.5 rounded-lg bg-white dark:bg-white/5 border border-gray-200 dark:border-border">
|
||
<button
|
||
onClick={() => setView("cards")}
|
||
aria-label="Cards view"
|
||
className={`min-h-[44px] px-3 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
|
||
view === "cards"
|
||
? "bg-[#003D5B] text-white dark:bg-[#00798C]"
|
||
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||
}`}
|
||
>
|
||
<Sparkles className="w-3.5 h-3.5" /> Cards
|
||
</button>
|
||
<button
|
||
onClick={() => setView("grid")}
|
||
aria-label="Grid view"
|
||
className={`min-h-[44px] px-3 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
|
||
view === "grid"
|
||
? "bg-[#003D5B] text-white dark:bg-[#00798C]"
|
||
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||
}`}
|
||
>
|
||
<LayoutGrid className="w-3.5 h-3.5" /> Grid
|
||
</button>
|
||
</div>
|
||
<div className="hidden sm:flex items-center gap-1">
|
||
<span className="text-xs text-gray-400 dark:text-text-soft mr-1">Sort:</span>
|
||
{[
|
||
{ id: "ytd", label: "Total" },
|
||
{ id: "code", label: "Code" },
|
||
].map((s) => (
|
||
<button
|
||
key={s.id}
|
||
onClick={() => setSort(s.id as typeof sort)}
|
||
className={`min-h-[44px] px-2 text-xs font-medium rounded transition ${
|
||
sort === s.id ? "bg-[#EDAE49] text-white" : "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
|
||
}`}
|
||
>
|
||
{s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="sm:hidden">
|
||
<label className="sr-only" htmlFor="running-sort">Sort by</label>
|
||
<select
|
||
id="running-sort"
|
||
value={sort}
|
||
onChange={(e) => setSort(e.target.value as typeof sort)}
|
||
className="min-h-[44px] px-3 text-xs rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-white/5"
|
||
>
|
||
<option value="ytd">Sort by Total</option>
|
||
<option value="code">Sort by Code</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{view === "cards" ? (
|
||
<CardsView rows={sorted} expanded={expanded} setExpanded={setExpanded} palette={palette} />
|
||
) : (
|
||
<GridView rows={sorted} monthTotals={monthTotals} grandTotal={grandTotal} />
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function CardsView({
|
||
rows, expanded, setExpanded, palette,
|
||
}: {
|
||
rows: RunningRow[];
|
||
expanded: string | null;
|
||
setExpanded: (id: string | null) => void;
|
||
palette: { income: string; profit: string; loss: string };
|
||
}) {
|
||
return (
|
||
<div className="space-y-2">
|
||
{rows.map((r) => {
|
||
const isOpen = expanded === r.id;
|
||
const hasData = r.ytd > 0;
|
||
return (
|
||
<div
|
||
key={r.id}
|
||
className="bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-xl overflow-hidden shadow-sm hover:shadow-md transition"
|
||
>
|
||
<button
|
||
onClick={() => setExpanded(isOpen ? null : r.id)}
|
||
className="w-full flex items-center gap-4 p-4 text-left"
|
||
>
|
||
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold shrink-0">
|
||
{r.code}
|
||
</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 text-sm font-medium text-[#003D5B] dark:text-white">
|
||
<Wrench className="w-3.5 h-3.5 text-[#EDAE49] shrink-0" />
|
||
<span className="truncate">{r.name}</span>
|
||
</div>
|
||
</div>
|
||
<div className="hidden sm:block shrink-0">
|
||
<Sparkline data={r.months} color={palette.income} />
|
||
</div>
|
||
<div className="text-right shrink-0 w-32 hidden md:block">
|
||
<div className="text-[10px] text-gray-400 uppercase tracking-wider">YTD spend</div>
|
||
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">
|
||
{hasData ? ZAR2(r.ytd) : "—"}
|
||
</div>
|
||
</div>
|
||
<div
|
||
className={`shrink-0 px-2.5 py-1.5 rounded-lg text-xs font-semibold ${
|
||
hasData ? "bg-[#EDAE49]/10 text-[#EDAE49] dark:bg-[#EDAE49]/20" : "bg-gray-100 text-gray-400 dark:bg-white/5"
|
||
}`}
|
||
>
|
||
{hasData ? `${r.months.filter((v) => v > 0).length} months` : "—"}
|
||
</div>
|
||
<ChevronRight
|
||
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
||
/>
|
||
</button>
|
||
|
||
{isOpen && (
|
||
<div className="border-t border-gray-100 dark:border-border px-4 py-4 bg-gray-50/50 dark:bg-white/[0.04]">
|
||
{/* Mobile: 3 cols (4 rows × 3 cols = 12 months). md+: 6 cols. lg+: 12 cols. */}
|
||
<div className="grid grid-cols-3 md:grid-cols-6 lg:grid-cols-12 gap-2 text-[11px]">
|
||
{r.months.map((v, i) => (
|
||
<div key={i} className="text-center">
|
||
<div className="text-gray-400 dark:text-text-soft text-[10px] mb-1">{MONTH_NAMES[i]}</div>
|
||
<div className={`py-1.5 rounded tabular-nums ${v > 0 ? "bg-[#EDAE49]/5 text-[#EDAE49] dark:bg-[#EDAE49]/15" : "text-gray-300 dark:text-text-soft"}`}>
|
||
{v > 0 ? ZAR2(v) : "—"}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-border text-xs">
|
||
<span className="text-gray-400 uppercase tracking-wider text-[10px]">YTD total</span>
|
||
<span className="ml-2 font-semibold tabular-nums text-[#EDAE49]">{ZAR(r.ytd)}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GridView({
|
||
rows, monthTotals, grandTotal,
|
||
}: {
|
||
rows: RunningRow[];
|
||
monthTotals: number[];
|
||
grandTotal: number;
|
||
}) {
|
||
return (
|
||
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border shadow-sm overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider border-y border-gray-100 dark:border-border bg-gray-50/50 dark:bg-white/[0.04]">
|
||
<th className="text-left py-3 px-6 font-medium sticky left-0 bg-gray-50/95 dark:bg-surface-muted/95 backdrop-blur z-10">Code</th>
|
||
<th className="text-left py-3 px-3 font-medium">Machine</th>
|
||
{MONTH_NAMES.map((m) => (
|
||
<th key={m} className="text-right py-3 px-2 font-medium min-w-[78px]">{m}</th>
|
||
))}
|
||
<th className="text-right py-3 px-6 font-medium">YTD</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r) => (
|
||
<tr key={r.id} className="border-b border-gray-50 dark:border-border hover:bg-gray-50/40 dark:hover:bg-white/[0.02]">
|
||
<td className="py-2.5 px-6 sticky left-0 bg-white dark:bg-surface">
|
||
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold">
|
||
{r.code}
|
||
</span>
|
||
</td>
|
||
<td className="py-2.5 px-3 font-medium text-[#003D5B] dark:text-white whitespace-nowrap">
|
||
<div className="flex items-center gap-2">
|
||
<Wrench className="w-3 h-3 text-[#EDAE49]" />
|
||
{r.name}
|
||
</div>
|
||
</td>
|
||
{r.months.map((v, i) => (
|
||
<td key={i} className={`py-2.5 px-2 text-right tabular-nums ${v > 0 ? "text-gray-700 dark:text-gray-300" : "text-gray-300 dark:text-text-soft"}`}>
|
||
{v > 0 ? ZAR2(v) : "—"}
|
||
</td>
|
||
))}
|
||
<td className={`py-2.5 px-6 text-right tabular-nums font-semibold whitespace-nowrap ${r.ytd > 0 ? "text-[#EDAE49]" : "text-gray-300 dark:text-text-soft"}`}>
|
||
{r.ytd > 0 ? ZAR(r.ytd) : "—"}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
<tr className="bg-gradient-to-r from-[#EDAE49]/10 dark:from-[#EDAE49]/15 to-transparent border-t-2 border-[#EDAE49]/30">
|
||
<td colSpan={2} className="py-3 px-6 font-semibold text-right text-[#003D5B] dark:text-white">Total</td>
|
||
{monthTotals.map((v, i) => (
|
||
<td key={i} className="py-3 px-2 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white">
|
||
{v > 0 ? ZAR(v) : "—"}
|
||
</td>
|
||
))}
|
||
<td className="py-3 px-6 text-right tabular-nums font-bold text-base text-[#EDAE49]">
|
||
{ZAR(grandTotal)}
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|