2026-09-12 07:35:35 +00:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState, useMemo } from "react";
|
|
|
|
|
import { TrendingUp, TrendingDown, LayoutGrid, Sparkles, ChevronRight } from "lucide-react";
|
|
|
|
|
import { Sparkline } from "@/components/Sparkline";
|
|
|
|
|
import { MONTH_NAMES } from "@/lib/data";
|
|
|
|
|
import type { IncomeRow } from "@/lib/rows";
|
|
|
|
|
import { ZAR, ZAR2 } from "@/lib/utils";
|
|
|
|
|
|
|
|
|
|
export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { income: string; profit: string; loss: string } }) {
|
|
|
|
|
const [view, setView] = useState<"cards" | "grid">("cards");
|
|
|
|
|
const [sort, setSort] = useState<"profit" | "income">("profit");
|
|
|
|
|
const [expanded, setExpanded] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const sorted = useMemo(() => {
|
|
|
|
|
return [...rows].sort((a, b) =>
|
|
|
|
|
sort === "profit" ? b.ytdProfit - a.ytdProfit : b.income - a.income
|
|
|
|
|
);
|
|
|
|
|
}, [rows, sort]);
|
|
|
|
|
|
|
|
|
|
const totals = useMemo(() => {
|
|
|
|
|
const totalIncome = rows.reduce((s, r) => s + r.income, 0);
|
|
|
|
|
const totalProfit = rows.reduce((s, r) => s + r.ytdProfit, 0);
|
|
|
|
|
const profitable = rows.filter((r) => r.ytdProfit > 0).length;
|
|
|
|
|
return { totalIncome, totalProfit, profitable };
|
|
|
|
|
}, [rows]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
Round 4: regressions fixed + mobile standard
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'
Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance
AGENTS.md created with the standing mobile checklist rule.
2026-09-12 10:05:43 +00:00
|
|
|
{/* Round 4 Part B.1/B.3/B.5: toolbar wraps onto two rows on mobile, controls collapse gracefully */}
|
|
|
|
|
<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">
|
2026-09-12 07:35:35 +00:00
|
|
|
<span className="px-2.5 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-medium">
|
|
|
|
|
{totals.profitable} profitable
|
|
|
|
|
</span>
|
|
|
|
|
<span className="px-2.5 py-1 rounded-md bg-[#D1495B]/10 text-[#D1495B] font-medium">
|
|
|
|
|
{rows.length - totals.profitable} loss-making
|
|
|
|
|
</span>
|
|
|
|
|
<span className="hidden md:inline">· sorted by {sort}</span>
|
|
|
|
|
</div>
|
Round 4: regressions fixed + mobile standard
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'
Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance
AGENTS.md created with the standing mobile checklist rule.
2026-09-12 10:05:43 +00:00
|
|
|
{/* Round 4 Part B.3: Sort collapses to a select on mobile. View toggle stays as buttons. */}
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
2026-09-12 07:35:35 +00:00
|
|
|
<div className="flex items-center gap-1 p-0.5 rounded-lg bg-white dark:bg-white/5 border border-gray-200 dark:border-white/5">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setView("cards")}
|
Round 4: regressions fixed + mobile standard
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'
Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance
AGENTS.md created with the standing mobile checklist rule.
2026-09-12 10:05:43 +00:00
|
|
|
aria-label="Cards view"
|
|
|
|
|
className={`min-h-[44px] px-3 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
|
2026-09-12 07:35:35 +00:00
|
|
|
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")}
|
Round 4: regressions fixed + mobile standard
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'
Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance
AGENTS.md created with the standing mobile checklist rule.
2026-09-12 10:05:43 +00:00
|
|
|
aria-label="Grid view"
|
|
|
|
|
className={`min-h-[44px] px-3 text-xs font-medium rounded-md transition flex items-center gap-1.5 ${
|
2026-09-12 07:35:35 +00:00
|
|
|
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>
|
Round 4: regressions fixed + mobile standard
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'
Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance
AGENTS.md created with the standing mobile checklist rule.
2026-09-12 10:05:43 +00:00
|
|
|
<div className="hidden sm:flex items-center gap-1">
|
2026-09-12 07:35:35 +00:00
|
|
|
<span className="text-xs text-gray-400 dark:text-gray-500 mr-1">Sort:</span>
|
|
|
|
|
{[
|
|
|
|
|
{ id: "profit", label: "Profit" },
|
|
|
|
|
{ id: "income", label: "Income" },
|
|
|
|
|
].map((s) => (
|
|
|
|
|
<button
|
|
|
|
|
key={s.id}
|
|
|
|
|
onClick={() => setSort(s.id as typeof sort)}
|
Round 4: regressions fixed + mobile standard
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'
Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance
AGENTS.md created with the standing mobile checklist rule.
2026-09-12 10:05:43 +00:00
|
|
|
className={`min-h-[44px] px-2 text-xs font-medium rounded transition ${
|
2026-09-12 07:35:35 +00:00
|
|
|
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>
|
Round 4: regressions fixed + mobile standard
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'
Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance
AGENTS.md created with the standing mobile checklist rule.
2026-09-12 10:05:43 +00:00
|
|
|
{/* Part B.3: mobile sort dropdown */}
|
|
|
|
|
<div className="sm:hidden">
|
|
|
|
|
<label className="sr-only" htmlFor="income-sort">Sort by</label>
|
|
|
|
|
<select
|
|
|
|
|
id="income-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="profit">Sort by Profit</option>
|
|
|
|
|
<option value="income">Sort by Income</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
2026-09-12 07:35:35 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{view === "cards" ? (
|
|
|
|
|
<CardsView
|
|
|
|
|
rows={sorted}
|
|
|
|
|
expanded={expanded}
|
|
|
|
|
setExpanded={setExpanded}
|
|
|
|
|
palette={palette}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<GridView rows={sorted} />
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CardsView({
|
|
|
|
|
rows, expanded, setExpanded, palette,
|
|
|
|
|
}: {
|
|
|
|
|
rows: IncomeRow[];
|
|
|
|
|
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 positive = r.ytdProfit >= 0;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={r.id}
|
|
|
|
|
className="bg-white dark:bg-[#131a21] border border-gray-100 dark:border-white/5 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition"
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setExpanded(isOpen ? null : r.id)}
|
Round 4: regressions fixed + mobile standard
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'
Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance
AGENTS.md created with the standing mobile checklist rule.
2026-09-12 10:05:43 +00:00
|
|
|
className="w-full text-left p-4"
|
2026-09-12 07:35:35 +00:00
|
|
|
>
|
Round 4: regressions fixed + mobile standard
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'
Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance
AGENTS.md created with the standing mobile checklist rule.
2026-09-12 10:05:43 +00:00
|
|
|
{/* Row 1: code + name + sparkline + YTD + P/L pill + chevron */}
|
|
|
|
|
<div className="flex items-center gap-3 sm:gap-4 min-h-[44px]">
|
|
|
|
|
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold shrink-0">
|
|
|
|
|
{r.code}
|
|
|
|
|
</span>
|
|
|
|
|
{/* Round 4 Part B.2: name gets its own line on mobile, never truncated to a single char */}
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="flex items-center gap-2 text-sm font-medium text-[#003D5B] dark:text-white min-w-0">
|
|
|
|
|
{positive ? (
|
|
|
|
|
<TrendingUp className="w-3.5 h-3.5 text-[#00798C] shrink-0" />
|
|
|
|
|
) : (
|
|
|
|
|
<TrendingDown className="w-3.5 h-3.5 text-[#D1495B] 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 income</div>
|
|
|
|
|
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">
|
|
|
|
|
{ZAR2(r.income)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
className={`shrink-0 hidden md:flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold ${
|
|
|
|
|
positive
|
|
|
|
|
? "bg-[#00798C]/10 text-[#00798C] dark:bg-[#00798C]/20"
|
|
|
|
|
: "bg-[#D1495B]/10 text-[#D1495B] dark:bg-[#D1495B]/20"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{positive ? "+" : ""}{ZAR(r.ytdProfit)}
|
2026-09-12 07:35:35 +00:00
|
|
|
</div>
|
Round 4: regressions fixed + mobile standard
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'
Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance
AGENTS.md created with the standing mobile checklist rule.
2026-09-12 10:05:43 +00:00
|
|
|
<ChevronRight
|
|
|
|
|
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
|
|
|
|
/>
|
2026-09-12 07:35:35 +00:00
|
|
|
</div>
|
Round 4: regressions fixed + mobile standard
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'
Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance
AGENTS.md created with the standing mobile checklist rule.
2026-09-12 10:05:43 +00:00
|
|
|
{/* Row 2 (mobile only): sparkline + YTD + P/L pill */}
|
|
|
|
|
<div className="flex sm:hidden items-center justify-between gap-3 pt-2 text-xs">
|
2026-09-12 07:35:35 +00:00
|
|
|
<Sparkline data={r.months} color={palette.income} />
|
Round 4: regressions fixed + mobile standard
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'
Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance
AGENTS.md created with the standing mobile checklist rule.
2026-09-12 10:05:43 +00:00
|
|
|
<div className="text-right">
|
|
|
|
|
<div className="text-[10px] text-gray-400 uppercase tracking-wider">YTD income</div>
|
|
|
|
|
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(r.income)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
className={`shrink-0 flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold ${
|
|
|
|
|
positive
|
|
|
|
|
? "bg-[#00798C]/10 text-[#00798C] dark:bg-[#00798C]/20"
|
|
|
|
|
: "bg-[#D1495B]/10 text-[#D1495B] dark:bg-[#D1495B]/20"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{positive ? "+" : ""}{ZAR(r.ytdProfit)}
|
2026-09-12 07:35:35 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{isOpen && (
|
|
|
|
|
<div className="border-t border-gray-100 dark:border-white/5 px-4 py-4 bg-gray-50/50 dark:bg-white/[0.02]">
|
|
|
|
|
{/* 12-col on md+, 3-col on mobile — Part 3: unreadable at 375px otherwise */}
|
|
|
|
|
<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-gray-500 text-[10px] mb-1">{MONTH_NAMES[i]}</div>
|
Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
2026-09-12 09:24:18 +00:00
|
|
|
<div className={`py-1.5 rounded tabular-nums ${(v ?? 0) > 0 ? "bg-[#00798C]/5 text-[#00798C] dark:bg-[#00798C]/15" : "text-gray-300 dark:text-gray-600"}`}>
|
|
|
|
|
{v == null ? "—" : (v > 0 ? ZAR2(v) : "R 0")}
|
2026-09-12 07:35:35 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-3 gap-4 mt-4 pt-3 border-t border-gray-100 dark:border-white/5 text-xs">
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-gray-400 uppercase tracking-wider text-[10px]">YTD income</div>
|
|
|
|
|
<div className="font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR(r.income)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-gray-400 uppercase tracking-wider text-[10px]">Fixed + running</div>
|
|
|
|
|
<div className="font-semibold tabular-nums text-[#003D5B] dark:text-white">
|
|
|
|
|
{ZAR(r.fixed + r.running)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-gray-400 uppercase tracking-wider text-[10px]">Profit</div>
|
|
|
|
|
<div className={`font-semibold tabular-nums ${positive ? "text-[#00798C]" : "text-[#D1495B]"}`}>
|
|
|
|
|
{positive ? "+" : ""}{ZAR(r.ytdProfit)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function GridView({ rows }: { rows: IncomeRow[] }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="bg-white dark:bg-[#131a21] rounded-2xl border border-gray-100 dark:border-white/5 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-b border-gray-100 dark:border-white/5 bg-gray-50/50 dark:bg-white/[0.02]">
|
|
|
|
|
<th className="text-left py-3 px-6 font-medium sticky left-0 bg-gray-50/95 dark:bg-[#1a222b]/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) => {
|
|
|
|
|
const positive = r.ytdProfit >= 0;
|
|
|
|
|
return (
|
|
|
|
|
<tr key={r.id} className="border-b border-gray-50 dark:border-white/5 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-[#131a21]">
|
|
|
|
|
<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">
|
|
|
|
|
{positive ? <TrendingUp className="w-3 h-3 text-[#00798C]" /> : <TrendingDown className="w-3 h-3 text-[#D1495B]" />}
|
|
|
|
|
{r.name}
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
{r.months.map((v, i) => (
|
Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
2026-09-12 09:24:18 +00:00
|
|
|
<td key={i} className={`py-2.5 px-2 text-right tabular-nums ${(v ?? 0) > 0 ? "text-gray-700 dark:text-gray-300" : "text-gray-300 dark:text-gray-600"}`}>
|
|
|
|
|
{v == null ? "—" : (v > 0 ? ZAR2(v) : "R 0")}
|
2026-09-12 07:35:35 +00:00
|
|
|
</td>
|
|
|
|
|
))}
|
|
|
|
|
<td className={`py-2.5 px-6 text-right tabular-nums font-semibold whitespace-nowrap ${positive ? "text-[#00798C]" : "text-[#D1495B]"}`}>
|
|
|
|
|
<div className="inline-flex items-center gap-1.5">
|
|
|
|
|
{positive ? "+" : ""}{ZAR(r.ytdProfit)}
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|