import { useEffect, useMemo, useState } from 'react';
import {
    ArrowDown, ArrowUp, ChevronDown, ChevronRight, ChevronsUpDown, GripVertical, Info, Plus, Sigma, X,
} from 'lucide-react';
import {
    Area, AreaChart, Bar, BarChart, CartesianGrid, Legend, Line, LineChart,
    ResponsiveContainer, Scatter, ScatterChart, Tooltip, XAxis, YAxis,
} from 'recharts';
import { cn } from '@/lib/utils';
// ⚠️ recharts also exports `Tooltip` (imported above for the chart renderers), so the shadcn one
// MUST be aliased — importing it bare is a duplicate-symbol build error, not a runtime surprise.
import { Tooltip as UiTooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/Components/ui/tooltip';
import { SELECTED_HOVER_TR, SELECTED_HOVER_CELL } from '@/lib/rowTint';
import useTheme from '@/Hooks/useTheme';
import {
    DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from '@/Components/ui/dropdown-menu';
import { NativeSelect } from '@/Components/ui/native-select';
import { FilterPill } from '@/Components/ui/filter-pill';
import {
    AGGS, ORDERS, RENDERERS, VALUES_FIELD, VALUES_LABEL,
    applyFilters, buildPivot, distinctValues, formatValue, headerLevels,
    heatScales, nextValueField, normalizeValues, placeField, removeField, treeRowLabels, valueLabel,
} from '@/lib/pivotModel';

/**
 * PivotBoard — Excel's four-zone pivot builder in the app design system.
 *
 * Zones follow the layout the user supplied on 2026-08-26 (an Excel "Drag fields between areas
 * below" screenshot): Filters and Columns on top, Rows and Values beneath, with `Σ Values`
 * appearing as a draggable chip inside whichever axis lays the measures out. The FIELDS card on
 * the left and the grid's visual language are unchanged from the 2026-08-05 "Pivot Builder"
 * reference — brand tokens only, so dark mode flips from one switch.
 *
 * ⚠️ ALL THE LOGIC LIVES IN `@/lib/pivotModel`, not here. That is deliberate: this project has
 * no DOM test harness (vitest runs `environment: 'node'`, no jsdom, no testing-library), so
 * anything left in this file is untestable. Bucketing, the ×N expansion for multiple value
 * fields, header grouping, the totals band and the heat scales are all pure functions over
 * plain data, covered by tests/js/pivotModel.test.js. Keep new logic there.
 *
 * The aggregators and renderers were verified line-by-line against the real PivotTable.js,
 * vendored at `public/referenceonly/pivot/` — see the citations in pivotModel.js.
 *
 * Props:
 *   rows      — flat fact objects (one per detail line)
 *   fields    — [{id,label}] draggable dimensions (id = fact key)
 *   measures  — [{id,label}] fact keys offered as a measure
 *   initial   — {rows:[], cols:[], measure, agg, renderer} — or {values:[{agg,measure}]} for
 *               several. The single-measure form is what both existing callers pass and it
 *               keeps working untouched; `normalizeValues` widens it.
 *   toolbar   — node rendered above the zones (the report's server-side filters)
 *   placeholder — node shown IN PLACE of the grid; the zones stay interactive so the user can
 *               pre-arrange a layout before any data exists
 *
 * ⚠️ THE FILTERS ZONE REACHES THE PIVOT AND NOTHING ELSE. There used to be an
 * `onFilteredRowsChange` callback so a host could narrow its own detail table to match; the user
 * removed it on 2026-08-27 (Details Inv-Analysis, View Company) — the detail list is the audit
 * trail you go to precisely BECAUSE the pivot above it is narrowed, and narrowing both leaves
 * nothing showing the whole. Do not re-add it: a host that wants a filtered table can filter its
 * own rows with `applyFilters`.
 *
 * ── Rows are a DRILL-DOWN, columns are not (user decision 2026-08-28) ────────────────────────
 * Asked for against the WebDataRocks demo — *"masih belom bisa di berikan seperti accordion untuk
 * group data banyak nya"*. A second row dimension used to print every leaf combination at once,
 * so the answer to "too many rows" was to drag the dimension back out; past MAX_CELLS the grid
 * disappeared entirely, which is the worst moment to lose it. Now each group is one row carrying
 * its own subtotal, opened on demand, and the cell budget is spent on the rows that are VISIBLE.
 *
 * `openGroups` holds the groups the user has OPENED, so the empty set is the opening state and
 * nothing has to be seeded from data that has not arrived yet.
 *
 * ⚠️ The COLUMN axis stays flat, deliberately. Its header cells already own a click — it sorts
 * the rows by that column — and hanging a second meaning on the same target makes both hard to
 * hit. If column drill-down is ever wanted, it needs its own affordance, not a shared one.
 */

// Categorical palette — the same 8 hues as Components/MenuCompanies/CompanyGraphSection.jsx,
// deliberately shared rather than re-picked: one chart vocabulary across the app, and it is the
// instance the dataviz validator has passed (re-run 2026-08-26, ALL CHECKS PASS both modes).
// NEVER cycled past 8 — see CHART_MAX.
const PALETTE = {
    light: ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948'],
    dark: ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767'],
};

const RENDERER_BY_ID = Object.fromEntries(RENDERERS.map((r) => [r.id, r]));

const ORDER_LABEL = {
    key: 'Sort by label (A→Z)',
    keyDesc: 'Sort by label (Z→A)',
    asc: 'Sort by value (low → high)',
    desc: 'Sort by value (high → low)',
};

// ⚠️ The BUTTON TEXT carries the basis, because the icon cannot.
// .claude/rules/ui-conventions.md locks exactly three sort glyphs — neutral ChevronsUpDown,
// active ArrowUp, active ArrowDown — and says "DO NOT use any other icon for sort state". A
// fourth order state therefore has no fourth glyph available: label-Z→A and value-high→low both
// have to draw ArrowDown. Rather than invent a glyph (which would break a locked ruling), the
// icon keeps meaning DIRECTION only and this word says what is being ordered.
const ORDER_TEXT = { key: 'A–Z', keyDesc: 'Z–A', asc: 'Value', desc: 'Value' };

// The palette is 8 hues and the dataviz rule forbids cycling it, so a 9th series has no honest
// colour. Legacy's c3 wrapped around and drew two series identically; this says so instead.
const CHART_MAX = 8;

// NativeSelect enforces `appearance-none pr-8` on top of this, which keeps the chevron off the
// value. A bare <select> would draw the browser's arrow inside px-3, and `rounded-full` curves
// into that same 12px, landing the arrow on the border.
const SELECT =
    'h-8 rounded-full border border-input bg-card px-3 text-[12px] font-semibold text-foreground outline-none transition-colors hover:border-primary focus:border-primary focus:ring-1 focus:ring-primary';

/**
 * A tiny (i) that reveals its note on hover OR focus — copied shape-for-shape from the `InfoHint`
 * in CompanyRebate/CreateRebatePage so hints look the same everywhere.
 *
 * ⚠️ The note it carries is NOT decoration (see the Filters docblock below): this zone filters in
 * the BROWSER, over rows already fetched, while the toolbar above filters on the SERVER. Someone
 * who confuses the two reads a total as "all of 2026" when it is "all of 2026 this page happened
 * to load". Behind an icon that warning is only seen by someone who goes looking — that is the
 * trade the user accepted on 2026-08-27 to get the line back. `aria-label` carries the full text
 * so screen readers and keyboard focus still reach it.
 */
const InfoHint = ({ text }) => (
    <TooltipProvider delayDuration={150}>
        <UiTooltip>
            <TooltipTrigger asChild>
                <button type="button" aria-label={text} className="inline-grid size-4 place-items-center rounded-full text-muted-foreground/70 transition-colors hover:text-primary">
                    <Info className="size-3.5" aria-hidden="true" />
                </button>
            </TooltipTrigger>
            <TooltipContent className="max-w-[280px] text-[11.5px] leading-snug">{text}</TooltipContent>
        </UiTooltip>
    </TooltipProvider>
);

export function PivotBoard({
    rows = [],
    fields = [],
    measures = [],
    initial = {},
    toolbar = null,
    placeholder = null,
}) {
    const { theme } = useTheme();
    const hues = PALETTE[theme === 'dark' ? 'dark' : 'light'];

    // ONE state for the three zones, not three. Since 2026-08-27 a field can sit in Filters AND
    // on an axis, so "where does X live" is a single answer — computed in one shot by
    // placeField/removeField, which three independent setters could only ever know half of.
    const [zones, setZones] = useState(() => ({
        filters: initial.filters ?? [],
        rows: initial.rows ?? [],
        cols: initial.cols ?? [],
    }));
    const { filters: filterDims, rows: rowDims, cols: colDims } = zones;
    const [filterSel, setFilterSel] = useState({});
    const [values, setValues] = useState(() => normalizeValues(initial, measures));
    const [editing, setEditing] = useState(null);
    const [rowOrder, setRowOrder] = useState('key');
    const [colOrder, setColOrder] = useState('key');
    // Which COLUMN the rows are ordered by — `{key, vi}` from a header cell's own sortKey, or
    // null for the row total. Direction is NOT stored here; it stays in `rowOrder`, so there is
    // exactly one source of truth for "which way round".
    const [rowSortCol, setRowSortCol] = useState(null);
    const [renderer, setRenderer] = useState(initial.renderer ?? 'heatmap');

    // Which row GROUPS are open. An empty set is the opening state — collapsed to the top level —
    // so nothing has to be seeded from data that does not exist yet, and a filter or a dragged
    // dimension can never arrive pre-expanded. Keys are bucket prefixes, so a stale one (from a
    // layout the user has since changed) simply matches nothing.
    const [openGroups, setOpenGroups] = useState(() => new Set());

    const toggleGroup = (key) => setOpenGroups((prev) => {
        const next = new Set(prev);
        if (next.has(key)) next.delete(key);
        else next.add(key);
        return next;
    });

    // `Σ Values` is only meaningful with more than one measure, so it appears and disappears on
    // its own — exactly as Excel hides it for a single value. It lives INSIDE rowDims/colDims,
    // which is what makes drag, drop, remove and position-in-the-axis work with no special case.
    useEffect(() => {
        setZones((z) => {
            const inRows = z.rows.includes(VALUES_FIELD);
            const inCols = z.cols.includes(VALUES_FIELD);
            if (values.length >= 2 && !inRows && !inCols) return { ...z, cols: [...z.cols, VALUES_FIELD] };
            if (values.length < 2 && (inRows || inCols)) {
                return {
                    ...z,
                    rows: z.rows.filter((x) => x !== VALUES_FIELD),
                    cols: z.cols.filter((x) => x !== VALUES_FIELD),
                };
            }
            // The SAME object back, so React bails out of the render — which is what makes it
            // safe to depend on `zones` here (the axes must be re-checked after every drag,
            // because Σ Values can be dragged onto the Fields card).
            return z;
        });
    }, [values.length, zones]);

    const fieldById = useMemo(
        () => ({
            ...Object.fromEntries(fields.map((f) => [f.id, f])),
            [VALUES_FIELD]: { id: VALUES_FIELD, label: VALUES_LABEL },
        }),
        [fields],
    );
    const used = new Set([...rowDims, ...colDims, ...filterDims]);
    const available = fields.filter((f) => !used.has(f.id));

    // ── Filters ─────────────────────────────────────────────────────────────────────────
    const filteredRows = useMemo(
        () => applyFilters(rows, filterDims, filterSel),
        [rows, filterDims, filterSel],
    );

    const filterOptions = useMemo(
        () => Object.fromEntries(filterDims.map((d) => [d, distinctValues(rows, d).map((v) => ({ id: v, name: v }))])),
        [rows, filterDims],
    );

    // ── Zones ───────────────────────────────────────────────────────────────────────────
    // `from` names the chip's OWN zone. A field can be filtering and grouping at the same time
    // now, so a drag that only said "division" could not express which of the two chips moved.
    const onDragStart = (e, id, from) => {
        e.dataTransfer.setData('text/plain', JSON.stringify({ id, from }));
        e.dataTransfer.effectAllowed = 'move';
    };

    const clearSelection = (id) => setFilterSel((prev) => { const { [id]: _drop, ...rest } = prev; return rest; });

    /**
     * Put a field in a zone ('filters' | 'rows' | 'cols' | 'available').
     *
     * Shared by the drag handler AND the dropdowns, deliberately: two code paths that each
     * decided what "put field X in Rows" means would eventually disagree. The rules themselves
     * live in `placeField` — where a test can reach them.
     */
    const moveTo = (id, zone) => {
        if (!fieldById[id]) return;
        setZones((z) => placeField(z, id, zone));
        // Only leaving the board entirely drops the tick list. Moving a field between axes keeps
        // it: the filter and the layout are independent placements of the same field.
        if (zone === 'available') clearSelection(id);
    };

    /** A chip's own × — or dragging that chip away. Takes the field out of THAT zone only. */
    const removeFrom = (id, zone) => {
        if (!fieldById[id]) return;
        setZones((z) => removeField(z, id, zone));
        if (zone === 'filters' || zone === 'available') clearSelection(id);
    };

    const dropTo = (zone) => (e) => {
        e.preventDefault();
        let payload;
        try { payload = JSON.parse(e.dataTransfer.getData('text/plain')); } catch { return; }
        if (!payload?.id) return;
        // Dropped on the Fields card = "out of the zone I came from", not out of everything.
        if (zone === 'available') removeFrom(payload.id, payload.from ?? 'available');
        else moveTo(payload.id, zone);
    };

    // ── Values ──────────────────────────────────────────────────────────────────────────
    const addValue = () => {
        // Which field to add is `nextValueField`'s call, in pivotModel where a test can reach it.
        // Deciding it here is what let "Add" ship a byte-identical duplicate for months.
        setValues((prev) => [...prev, nextValueField(prev, measures, `v${Date.now()}`)]);
        setEditing(values.length);
    };
    const patchValue = (i, patch) => setValues((prev) => prev.map((v, j) => (j === i ? { ...v, ...patch } : v)));
    const dropValue = (i) => {
        // Never leave the Values zone empty — a pivot with no measure has nothing to show, and
        // Excel does not allow it either.
        setValues((prev) => (prev.length <= 1 ? prev : prev.filter((_, j) => j !== i)));
        setEditing(null);
    };

    // ── The pivot ───────────────────────────────────────────────────────────────────────
    // ⚠️ `rowSortCol` MUST be in the dep list. Leave it out and clicking a header updates state,
    // React re-renders, this memo serves the cached pivot, and the grid never moves — no error,
    // no warning, just a control that appears dead.
    const pivot = useMemo(
        () => buildPivot({
            rows: filteredRows, rowDims, colDims, values, rowOrder, colOrder, measures,
            rowSortBy: rowSortCol, rowExpand: openGroups,
        }),
        [filteredRows, rowDims, colDims, values, rowOrder, colOrder, measures, rowSortCol, openGroups],
    );

    const rendDef = RENDERER_BY_ID[renderer] ?? RENDERER_BY_ID.heatmap;
    const rowDepth = Math.max(1, rowDims.length);
    const colDepth = Math.max(1, colDims.length);

    // `colDims` is passed so each header cell can carry the COLUMN it stands for — Σ Values owns
    // a slot of the display path but none of the bucket key, so the level index alone is wrong.
    const colLevels = useMemo(
        () => (pivot.tooBig ? [] : headerLevels(pivot.displayCols, colDepth, colDims)),
        [pivot, colDepth, colDims],
    );
    const rowLabelCells = useMemo(
        () => (pivot.tooBig ? [] : treeRowLabels(pivot.displayRows, rowDims, pivot.valueLabels)),
        [pivot, rowDims],
    );
    const scale = useMemo(
        () => (pivot.tooBig ? null : heatScales(pivot.grid, pivot.displayRows, pivot.displayCols, rendDef.heat ?? rendDef.bar)),
        [pivot, rendDef],
    );

    /** The aggregator governing one cell — its own value field's, never a grid-wide one. */
    const aggAt = (ri, ci) => {
        const i = pivot.displayRows[ri]?.vi ?? pivot.displayCols[ci]?.vi ?? 0;
        return values[i]?.agg ?? 'sum';
    };
    const isTextAt = (ri, ci) => {
        const f = (AGGS[aggAt(ri, ci)] ?? AGGS.sum).format;
        return f === 'text' || f === 'auto';
    };

    // ── Chart shaping ───────────────────────────────────────────────────────────────────
    // Totals are excluded: a grand-total series dwarfs every other and turns a chart into one
    // tall bar beside a row of stubs. An OPEN group row is excluded for a harder reason — it is
    // the sum of the children plotted beside it, so charting both draws the same money twice.
    const chartRows = useMemo(
        () => (pivot.displayRows ?? []).map((r, i) => ({ r, i })).filter(({ r }) => !r.total && !r.open),
        [pivot],
    );
    const chartCols = useMemo(() => (pivot.displayCols ?? []).map((c, i) => ({ c, i })).filter(({ c }) => !c.total), [pivot]);
    const chartSeries = chartCols.map(({ c }) => c.path.join(' · ') || 'Value');
    const chartData = useMemo(
        () => chartRows.map(({ r, i: ri }) => {
            const point = { name: r.path.join(' · ') || 'Total' };
            chartCols.forEach(({ i: ci }, s) => {
                const v = pivot.grid[ri]?.[ci];
                point[chartSeries[s]] = typeof v === 'number' ? v : 0;
            });
            return point;
        }),
        [chartRows, chartCols, pivot], // eslint-disable-line react-hooks/exhaustive-deps
    );

    const AXIS = { tick: { fill: 'var(--color-muted-foreground)', fontSize: 11 }, stroke: 'var(--color-border)' };
    const chartFmt = (v) => formatValue(v, values[0]?.agg ?? 'sum');
    const TOOLTIP = {
        contentStyle: {
            background: 'var(--color-card)',
            border: '1px solid var(--color-border)',
            borderRadius: 10,
            fontSize: 12,
            color: 'var(--color-card-foreground)',
        },
        formatter: (v) => chartFmt(v),
    };
    const legend = chartSeries.length > 1
        ? <Legend wrapperStyle={{ fontSize: 11, color: 'var(--color-muted-foreground)' }} />
        : null;

    const chart = () => {
        const common = { data: chartData, margin: { top: 8, right: 16, bottom: 4, left: 4 } };
        // ⚠️ Recharts calls a HORIZONTAL bar chart `layout="vertical"`: its name describes the
        // axis the bars run along, c3's describes the axis the categories run along. Opposites,
        // and picking the wrong one silently renders the other chart.
        const flip = Boolean(rendDef.horizontal);
        const axes = (
            <>
                <CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" vertical={flip} horizontal={!flip} />
                {flip
                    ? <YAxis type="category" dataKey="name" {...AXIS} width={128} />
                    : <XAxis type="category" dataKey="name" {...AXIS} interval="preserveStartEnd" />}
                {flip
                    ? <XAxis type="number" {...AXIS} height={28} tickFormatter={chartFmt} />
                    : <YAxis type="number" {...AXIS} width={72} tickFormatter={chartFmt} />}
                <Tooltip {...TOOLTIP} />
                {legend}
            </>
        );

        if (renderer === 'bar' || renderer === 'stackedBar' || renderer === 'hbar' || renderer === 'hStackedBar') {
            return (
                <BarChart {...common} layout={flip ? 'vertical' : 'horizontal'}>
                    {axes}
                    {chartSeries.map((s, i) => (
                        // 2px surface gap between fills, 4px rounded data-end (dataviz mark spec).
                        <Bar
                            key={s}
                            dataKey={s}
                            fill={hues[i]}
                            stackId={rendDef.stacked ? 'stack' : undefined}
                            radius={flip ? [0, 4, 4, 0] : [4, 4, 0, 0]}
                            stroke="var(--color-card)"
                            strokeWidth={2}
                        />
                    ))}
                </BarChart>
            );
        }
        if (renderer === 'line') {
            return (
                <LineChart {...common}>
                    {axes}
                    {chartSeries.map((s, i) => (
                        <Line key={s} type="monotone" dataKey={s} stroke={hues[i]} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 5 }} />
                    ))}
                </LineChart>
            );
        }
        if (renderer === 'area') {
            return (
                <AreaChart {...common}>
                    {axes}
                    {chartSeries.map((s, i) => (
                        // Legacy's Area Chart is `stacked: true` (c3_renderers.js:316-319), so the
                        // areas add up rather than overlapping and hiding each other.
                        <Area key={s} type="monotone" dataKey={s} stackId="stack" stroke={hues[i]} strokeWidth={2} fill={hues[i]} fillOpacity={0.18} />
                    ))}
                </AreaChart>
            );
        }
        return (
            <ScatterChart {...common}>
                {axes}
                {chartSeries.map((s, i) => (
                    <Scatter key={s} name={s} data={chartData.map((d) => ({ name: d.name, value: d[s] }))} dataKey="value" fill={hues[i]} />
                ))}
            </ScatterChart>
        );
    };

    // ── Presentation ────────────────────────────────────────────────────────────────────
    // Readability pass (Pak David via user, 2026-08-27): tables "ditebelin", thin zebra, and on
    // a pivot "yang paling atas ama paling kiri beda warna".
    //
    // ⚠️ Every grey here is a DESIGN-SYSTEM grey, and that is a correction, not a preference
    // (user 2026-08-27: "abunya jangan beda dari abu design sistem"). The first pass used
    // `border-border-strong` for the gridline — a real token, but the only COOL grey in the
    // ladder (#e5e7eb, R≠G≠B) next to the neutral #f1f1f1/#f5f5f5 — and a full `bg-secondary`
    // band, darker than the header of every other table in the app. The Details grid directly
    // below this one is white-headed with `border-border` lines, so the two sat side by side
    // speaking different languages.
    //
    //   band  — `color-mix(secondary 50%, card)`, the SAME recipe as 82 other table headers.
    //   zebra — `bg-secondary/25`, the canonical detail-table stripe (design-system.md).
    //   line  — `border-border`, the app's line grey.
    const BAND = 'bg-[color-mix(in_srgb,var(--color-secondary)_50%,var(--color-card))]';
    const TH = `border border-[color-mix(in_srgb,var(--color-card-foreground)_14%,var(--color-card))] ${BAND} px-3 py-2.5 text-left text-[11px] font-extrabold uppercase tracking-wide text-card-foreground`;
    const TD = 'border border-[color-mix(in_srgb,var(--color-card-foreground)_14%,var(--color-card))] px-3 py-2 text-right text-[12px] tabular-nums text-card-foreground';
    // The LEFT band. A td background paints over its row, which also keeps this column out of
    // the zebra — that is what makes it read as an axis rather than one more striped column.
    const TD_AXIS = BAND;
    // Zebra decided in JS, not with `even:`: a TOTAL row must keep its own band, and Tailwind
    // orders the `even:` variant AFTER a plain utility, so the stripe would win.
    const rowBand = (dr, ri) => (dr.total ? BAND : ri % 2 === 1 ? 'bg-secondary/25' : 'bg-card');

    const cellContent = (val, ri, ci) => {
        if (val === null || val === undefined) return null;
        const text = <span className={cn(!isTextAt(ri, ci) && 'tabular-nums')}>{formatValue(val, aggAt(ri, ci))}</span>;
        const max = rendDef.heat && scale ? scale(ri, ci) : 0;
        if (!rendDef.heat || !max || typeof val !== 'number') return text;
        // The cell TINT (cellTint below) already encodes magnitude. A dot in the same hue said the
        // same thing a second time on every populated cell — two layers per number, and the
        // varying dot opacity left a ragged edge to the left of right-aligned figures. Dropping it
        // removes a glyph from every cell and loses nothing (user 2026-08-27: "ga clean").
        return text;
    };
    const cellTint = (val, ri, ci) => {
        if (!rendDef.heat || typeof val !== 'number' || !scale) return {};
        const max = scale(ri, ci);
        if (!max) return {};
        const pct = Math.round((val / max) * 26);
        return pct < 3 ? {} : { background: `color-mix(in srgb, var(--color-primary) ${pct}%, var(--color-card))` };
    };
    /** Table Barchart: a proportional bar behind the number, scaled within its ROW (pivot.js:1829). */
    const cellBar = (val, ri, ci) => {
        if (!rendDef.bar || typeof val !== 'number' || !scale) return null;
        const max = scale(ri, ci);
        if (!max || val <= 0) return null;
        return (
            <span
                aria-hidden="true"
                className="absolute inset-y-1 left-1 rounded-[3px] bg-primary/15"
                style={{ width: `calc(${Math.min(100, (val / max) * 100)}% - 8px)` }}
            />
        );
    };

    const zoneChip = (id, zone) => (
        <span
            key={id}
            draggable
            onDragStart={(e) => onDragStart(e, id, zone)}
            className={cn(
                'inline-flex h-7 cursor-grab select-none items-center gap-1 rounded-full border px-2.5 text-[11.5px] font-semibold active:cursor-grabbing',
                id === VALUES_FIELD
                    ? 'border-dashed border-primary/50 bg-primary/10 text-primary'
                    : 'border-primary/40 bg-accent text-primary',
            )}
        >
            {id === VALUES_FIELD && <Sigma className="size-3" aria-hidden="true" />}
            {fieldById[id]?.label}
            {id !== VALUES_FIELD && (
                <button
                    type="button"
                    onClick={() => removeFrom(id, zone)}
                    aria-label={`Remove ${fieldById[id]?.label} from ${zone === 'cols' ? 'Columns' : 'Rows'}`}
                    className="grid size-3.5 place-items-center rounded-full text-primary/70 hover:bg-primary/15 hover:text-primary"
                >
                    <X className="size-3" />
                </button>
            )}
        </span>
    );

    /** Fields this zone can still take. Fields in ANOTHER zone are listed too — picking one does
     *  exactly what dragging it across does: it MOVES between Rows and Columns, and it ADDS to
     *  Filters (which no longer takes a field away from the axis it is laid out on). */
    const addable = (zone) => {
        const here = new Set(zone === 'rows' ? rowDims : zone === 'cols' ? colDims : filterDims);
        return fields.filter((f) => !here.has(f.id));
    };

    const addMenu = (title, zone) => (
        <DropdownMenu>
            <DropdownMenuTrigger asChild>
                <button
                    type="button"
                    disabled={addable(zone).length === 0}
                    aria-label={`Add a field to ${title}`}
                    className="inline-flex h-6 items-center gap-1 rounded-full border border-border px-2 text-[11px] font-bold text-muted-foreground transition-colors hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
                >
                    <Plus className="size-3" aria-hidden="true" />
                    Add
                    <ChevronDown className="size-3" aria-hidden="true" />
                </button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="start" className="max-h-[260px] overflow-y-auto">
                {addable(zone).map((f) => (
                    <DropdownMenuItem key={f.id} onSelect={() => moveTo(f.id, zone)} className="text-[12px]">
                        {f.label}
                    </DropdownMenuItem>
                ))}
            </DropdownMenuContent>
        </DropdownMenu>
    );

    /**
     * PivotTable.js's order toggle, one per axis — now a FOUR-state cycle (A→Z, Z→A, value up,
     * value down). Icons are the ONE sort language .claude/rules/ui-conventions.md locks:
     * neutral ChevronsUpDown for the untouched default, ArrowUp/ArrowDown once the axis has been
     * given an order. ArrowDown necessarily serves both Z→A and value-high→low, so the button's
     * WORD (ORDER_TEXT) is what separates them — see the note there.
     */
    const orderToggle = (order, setOrder, zoneTitle) => {
        const Icon = order === 'key' ? ChevronsUpDown : order === 'asc' ? ArrowUp : ArrowDown;
        return (
            <button
                type="button"
                onClick={() => setOrder(ORDERS[(ORDERS.indexOf(order) + 1) % ORDERS.length])}
                title={`${zoneTitle}: ${ORDER_LABEL[order]}`}
                aria-label={`${zoneTitle} order — ${ORDER_LABEL[order]}`}
                // No pill, no border — bare icon + word, per the reference the user sent
                // (2026-08-27: "tulisan yang sort ngikuti gambar itu, jadi gausa ada teksboxnya").
                // State is carried by COLOUR alone: muted while the axis is in its natural key
                // order, primary once a sort is actually applied. A box here competed with the
                // zone box right below it, which is the real control.
                className={cn(
                    'inline-flex h-6 items-center gap-1 text-[11px] font-bold transition-colors',
                    order === 'key' ? 'text-muted-foreground hover:text-primary' : 'text-primary',
                )}
            >
                <Icon className="size-3" aria-hidden="true" />
                {ORDER_TEXT[order]}
            </button>
        );
    };

    // ── Sorting the rows by ONE column ──────────────────────────────────────────────────
    // The Rows toggle above orders by the row TOTAL. That cannot answer "which product was
    // biggest IN 2026" — a total blends every year. Clicking a column header does.
    //
    // There is deliberately no mirror control for ordering COLUMNS by a row: the row-label
    // columns have no <th> of their own, they share one spanning corner cell, so there would be
    // nothing to click.
    const sameSortCol = (sk) => Boolean(rowSortCol) && Boolean(sk)
        && rowSortCol.key === sk.key && rowSortCol.vi === sk.vi;

    /**
     * Which header CELL carries the arrow — identified by POSITION, not by its {key, vi}.
     *
     * ⚠️ Two cells can share one sortKey. Order the Columns axis by value and a group fragments
     * into separate runs, so "2025" is emitted twice; Σ Values leading the axis does it too.
     * Matching on the key alone lights an arrow on every one of them, and a narrow fragment then
     * claims to order the rows by columns that are not underneath it. First match wins.
     */
    const activeCell = useMemo(() => {
        if (! rowSortCol || ! (rowOrder === 'asc' || rowOrder === 'desc')) return null;
        for (let li = 0; li < colLevels.length; li++) {
            const ci = colLevels[li].findIndex(
                (c) => c.sortKey && c.sortKey.key === rowSortCol.key && c.sortKey.vi === rowSortCol.vi,
            );
            if (ci >= 0) return `${li}:${ci}`;
        }
        return null;
    }, [colLevels, rowSortCol, rowOrder]);

    /**
     * A chosen column can be taken out from under the sort — drag the Columns dimension away,
     * drop the value field it belongs to (which reindexes every `vi` after it), or filter its
     * values out. `buildPivot` then falls back to the row total, so the grid is honestly ordered
     * but NOTHING on screen says by what, and clicking that header again moves no rows.
     *
     * ⚠️ Guarded on tooBig: there `colLevels` is empty for every column, so without the guard
     * the user's choice would be wiped the moment the grid grew past MAX_CELLS — and wiped
     * exactly when they are removing a dimension to get back under it.
     */
    useEffect(() => {
        if (! rowSortCol || pivot.tooBig || activeCell !== null) return;
        setRowSortCol(null);
        setRowOrder('key');
    }, [rowSortCol, pivot.tooBig, activeCell]);

    /** Descending first — "which is biggest" is the question people bring to a heatmap. */
    const sortByColumn = (sk) => {
        if (! sk) return;
        if (! sameSortCol(sk)) {
            setRowSortCol(sk);
            setRowOrder('desc');
            return;
        }
        if (rowOrder === 'desc') {
            setRowOrder('asc');
            return;
        }
        setRowSortCol(null);
        setRowOrder('key');
    };

    /** Picking a LABEL order from the Rows toggle drops the chosen column, so the header arrow
     *  goes with it. A value order keeps it — the toggle then just flips its direction. */
    const cycleRowOrder = (next) => {
        setRowOrder(next);
        if (next === 'key' || next === 'keyDesc') setRowSortCol(null);
    };
    /**
     * Dashed means "empty drop target"; SOLID means "this zone holds something" (user
     * 2026-08-27: "garis rows columns values jangan cuman garis-garis. mau solid").
     *
     * Rows, Columns and Values normally carry chips, so they read as real containers — which is
     * also what the Values zone already did, alone, at the bottom of this file. An EMPTY Filters
     * box keeps the dashed outline, because there a dash is doing a job: it is the only thing
     * saying "you may drop a field here". Making every box solid would delete that hint.
     */
    const zoneBox = (filled) => cn(
        'flex min-h-[52px] flex-wrap content-center items-center gap-1.5 rounded-lg border px-3 py-2',
        filled ? 'border-border' : 'border-dashed border-border',
    );

    const axisZone = (title, zone, dims, order, setOrder) => (
        <div>
            <div className="mb-1.5 flex items-center gap-2">
                <p className="m-0 text-[12px] font-bold text-foreground">{title}</p>
                {addMenu(title, zone)}
                <span className="ml-auto">{orderToggle(order, setOrder, title)}</span>
            </div>
            <div onDragOver={(e) => e.preventDefault()} onDrop={dropTo(zone)} className={zoneBox(dims.length > 0)}>
                {dims.length ? dims.map((d) => zoneChip(d, zone)) : <span className="w-full text-center text-[12px] text-muted-foreground/60">Drop zone</span>}
            </div>
        </div>
    );

    /**
     * Filters — Excel's fourth area, with one deliberate divergence: a field dropped here
     * narrows the data AND lands on Rows, so the values that were picked are broken out instead
     * of blended into one figure. See `placeField` for the report that forced it.
     *
     * ⚠️ The label is not decoration. This screen already has a filter toolbar above that goes
     * to the SERVER and changes which rows are fetched; this one runs in the browser over rows
     * already fetched. Someone who mistakes one for the other will read a number as "all of
     * 2026" when it is "all of 2026 that this page happened to load".
     */
    const filtersZone = (
        <div>
            <div className="mb-1.5 flex items-center gap-2">
                <p className="m-0 text-[12px] font-bold text-foreground">Filters</p>
                {addMenu('Filters', 'filters')}
                <span className="ml-auto">
                    <InfoHint text="Narrows and groups the pivot — not the query, and not the details table below." />
                </span>
            </div>
            <div onDragOver={(e) => e.preventDefault()} onDrop={dropTo('filters')} className={zoneBox(filterDims.length > 0)}>
                {filterDims.length ? filterDims.map((d) => (
                    <FilterPill
                        key={d}
                        label={fieldById[d]?.label ?? d}
                        value={filterSel[d] ?? []}
                        options={filterOptions[d] ?? []}
                        onChange={(v) => setFilterSel((prev) => ({ ...prev, [d]: v }))}
                        // Filters only — the grouping it added to Rows stays until the user drags
                        // that chip out themselves.
                        onRemove={() => removeFrom(d, 'filters')}
                    />
                )) : <span className="w-full text-center text-[12px] text-muted-foreground/60">Drop a field to filter on</span>}
            </div>
        </div>
    );

    /**
     * Values — a LIST, not a single select. Each chip reads the way Excel words it ("Sum of
     * Subtotal") and opens an inline editor rather than a popover: the editor holds two or three
     * <select>s, and Radix's dropdown traps focus in a way that fights native selects.
     */
    const valuesZone = (
        <div>
            <div className="mb-1.5 flex items-center gap-2">
                <p className="m-0 text-[12px] font-bold text-foreground">Values</p>
                <button
                    type="button"
                    onClick={addValue}
                    disabled={!measures.length}
                    className="inline-flex h-6 items-center gap-1 rounded-full border border-border px-2 text-[11px] font-bold text-muted-foreground transition-colors hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
                >
                    <Plus className="size-3" aria-hidden="true" /> Add
                </button>
            </div>
            <div className="flex min-h-[52px] flex-col gap-1.5 rounded-lg border border-border px-3 py-2">
                {values.map((v, i) => (
                    <div key={v.key} className="flex flex-col gap-1.5">
                        <div className="flex w-fit max-w-full items-center gap-1.5">
                            <button
                                type="button"
                                onClick={() => setEditing(editing === i ? null : i)}
                                // Width follows the LABEL, like every chip in Rows and Columns
                                // (user 2026-08-27: "kenapa yang di values … harus fullwidth").
                                // `flex-1` made this one stretch edge to edge, which read as a
                                // dropdown rather than a token and made Values the odd zone out.
                                // The chevron still marks it as configurable — it just sits next
                                // to the text now instead of being flung to the far edge.
                                className="inline-flex h-7 min-w-0 max-w-full items-center gap-1 rounded-full border border-primary/40 bg-accent px-2.5 text-left text-[11.5px] font-semibold text-primary"
                            >
                                <span className="min-w-0 truncate">{valueLabel(v, measures)}</span>
                                <ChevronDown className={cn('size-3 shrink-0 transition-transform', editing === i && 'rotate-180')} aria-hidden="true" />
                            </button>
                            {values.length > 1 && (
                                <button
                                    type="button"
                                    onClick={() => dropValue(i)}
                                    aria-label={`Remove ${valueLabel(v, measures)}`}
                                    className="grid size-5 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-secondary hover:text-foreground"
                                >
                                    <X className="size-3" />
                                </button>
                            )}
                        </div>
                        {editing === i && (
                            <div className="flex flex-wrap items-center gap-1.5 rounded-lg bg-secondary/40 px-2 py-2">
                                <NativeSelect value={v.agg} onChange={(e) => patchValue(i, { agg: e.target.value })} className={SELECT} aria-label="Aggregator">
                                    {Object.entries(AGGS).map(([id, a]) => <option key={id} value={id}>{a.label}</option>)}
                                </NativeSelect>
                                {(AGGS[v.agg] ?? AGGS.sum).args > 0 && (
                                    <>
                                        <span className="text-[12px] text-muted-foreground">of</span>
                                        <NativeSelect value={v.measure} onChange={(e) => patchValue(i, { measure: e.target.value })} className={SELECT} aria-label="Measure">
                                            {measures.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
                                        </NativeSelect>
                                    </>
                                )}
                                {(AGGS[v.agg] ?? AGGS.sum).args > 1 && (
                                    <>
                                        <span className="text-[12px] text-muted-foreground">/</span>
                                        <NativeSelect value={v.measure2} onChange={(e) => patchValue(i, { measure2: e.target.value })} className={SELECT} aria-label="Second measure">
                                            {measures.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
                                        </NativeSelect>
                                    </>
                                )}
                            </div>
                        )}
                    </div>
                ))}
            </div>
        </div>
    );

    // Every group the tree HAS, not just the ones on screen — "Expand all" has to reach levels
    // nobody has opened yet. Present even when the grid came back tooBig, which is the one moment
    // a way back is indispensable: the toggle that caused it is no longer rendered.
    const rowGroups = pivot.rowGroups ?? [];
    const collapseAll = () => setOpenGroups(new Set());
    const drillBtn = 'inline-flex h-6 items-center gap-1 rounded-full border border-border px-2 text-[11px] font-bold text-muted-foreground transition-colors hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-40';

    const tooManySeries = rendDef.chart && chartSeries.length > CHART_MAX;
    const chartIsText = rendDef.chart && values.some((v) => {
        const f = (AGGS[v.agg] ?? AGGS.sum).format;
        return f === 'text' || f === 'auto';
    });

    return (
        // The toolbar (the report's server-side filters — Early/End Date and friends) sits ABOVE
        // the two-column grid, not inside its right column (user 2026-08-27: "bisa ga naik? jadi
        // pengennya rows sama columns sejajar sama field"). Rendered inside the right column it
        // pushed Rows/Columns down by its own height while Fields still started at the top, so the
        // three zones a user reads together never lined up. Full width also gives the date pills
        // room instead of squeezing them into the narrower column.
        <div className="flex flex-col gap-4">
            {toolbar}

            <div className="grid items-start gap-4 lg:grid-cols-[230px_minmax(0,1fr)]">
            {/* Fields — the left card: a vertical drag list with grip handles. */}
            <aside className="rounded-xl border border-border bg-card p-3.5">
                <p className="m-0 mb-2.5 text-[12px] font-bold text-foreground">Fields</p>
                <div className="mb-2 flex items-center gap-1.5 rounded-lg border border-dashed border-border px-2.5 py-2 text-[11.5px] text-muted-foreground/70">
                    <Plus className="size-3.5" aria-hidden="true" /> Drag items, or use Add
                </div>
                <div className="flex flex-col gap-1.5" onDragOver={(e) => e.preventDefault()} onDrop={dropTo('available')}>
                    {available.map((f) => (
                        <span
                            key={f.id}
                            draggable
                            onDragStart={(e) => onDragStart(e, f.id, 'available')}
                            className="flex cursor-grab select-none items-center gap-2 rounded-lg border border-border bg-card px-2.5 py-2 text-[12px] font-semibold text-foreground transition-colors hover:border-primary hover:text-primary active:cursor-grabbing"
                        >
                            <GripVertical className="size-3.5 shrink-0 text-muted-foreground/50" aria-hidden="true" />
                            {f.label}
                        </span>
                    ))}
                    {!available.length && <span className="px-1 text-[11px] text-muted-foreground/60">All fields in use</span>}
                </div>
            </aside>

            <div className="flex min-w-0 flex-col gap-4">
                {/* Excel's 2×2 area grid, but with the TWO AXES on the same line (user
                    2026-08-27: "rows ma columns harus sebelahan"). Excel's own arrangement puts
                    them diagonally opposite — Filters/Columns above, Rows/Values below — which
                    reads fine as a form and badly as a picture of the grid you are building.
                    Rows beside Columns mirrors the table underneath: left axis, then top axis. */}
                <div className="grid gap-x-4 gap-y-3 md:grid-cols-2">
                    {axisZone('Rows', 'rows', rowDims, rowOrder, cycleRowOrder)}
                    {axisZone('Columns', 'cols', colDims, colOrder, setColOrder)}
                    {filtersZone}
                    {valuesZone}
                </div>

                <div>
                    <div className="mb-1.5 flex flex-wrap items-center gap-2">
                        <p className="m-0 mr-auto text-[12px] font-bold text-foreground">Pivot</p>
                        {/* Only with a second row dimension is there anything to open, so with the
                            layouts the reports ship these two do not appear at all. */}
                        {rowGroups.length > 0 && (
                            <>
                                <button
                                    type="button"
                                    onClick={() => setOpenGroups(new Set(rowGroups))}
                                    disabled={openGroups.size >= rowGroups.length}
                                    className={drillBtn}
                                >
                                    Expand all
                                </button>
                                <button type="button" onClick={collapseAll} disabled={openGroups.size === 0} className={drillBtn}>
                                    Collapse all
                                </button>
                            </>
                        )}
                        {/* Twelve renderers is past what a pill group can hold, so this is a
                            select — grouped Table vs Chart, as PivotTable.js lists them. */}
                        <NativeSelect value={renderer} onChange={(e) => setRenderer(e.target.value)} className={SELECT} aria-label="Renderer">
                            <optgroup label="Table">
                                {RENDERERS.filter((r) => r.group === 'Table').map((r) => <option key={r.id} value={r.id}>{r.label}</option>)}
                            </optgroup>
                            <optgroup label="Chart">
                                {RENDERERS.filter((r) => r.group === 'Chart').map((r) => <option key={r.id} value={r.id}>{r.label}</option>)}
                            </optgroup>
                        </NativeSelect>
                    </div>

                    {placeholder ? placeholder : pivot.tooBig ? (
                        <div className="rounded-lg border border-border bg-secondary/40 px-4 py-6 text-center">
                            <p className="m-0 text-[12.5px] text-muted-foreground">
                                {pivot.combos.toLocaleString('en-US')} row × column combinations is too many to render — close a group, remove a dimension, or drop a value field.
                            </p>
                            {/* Without this the grid that vanished takes its own toggles with it,
                                and the only way back is to dismantle the layout. */}
                            {openGroups.size > 0 && (
                                <button type="button" onClick={collapseAll} className={cn(drillBtn, 'mt-3')}>
                                    Collapse all rows
                                </button>
                            )}
                        </div>
                    ) : chartIsText ? (
                        <p className="m-0 rounded-lg border border-border bg-secondary/40 px-4 py-6 text-center text-[12.5px] text-muted-foreground">
                            A value field here produces text, which cannot be charted — switch the renderer to Table, or pick numeric aggregators.
                        </p>
                    ) : tooManySeries ? (
                        <p className="m-0 rounded-lg border border-border bg-secondary/40 px-4 py-6 text-center text-[12.5px] text-muted-foreground">
                            {chartSeries.length} series is more than the {CHART_MAX} distinct colours this palette has — drop a Columns dimension, or use a table renderer.
                        </p>
                    ) : rendDef.chart ? (
                        <div className="rounded-lg border border-border/60 p-3">
                            <ResponsiveContainer width="100%" height={340}>
                                {chart()}
                            </ResponsiveContainer>
                        </div>
                    ) : (
                        <div className="overflow-x-auto rounded-lg border border-[color-mix(in_srgb,var(--color-card-foreground)_14%,var(--color-card))]">
                            <table className="w-max min-w-full border-collapse [&_td]:min-w-[96px]">
                                <thead>
                                    {colLevels.map((level, li) => (
                                        <tr key={li}>
                                            {li === 0 && (
                                                <th className={cn(TH, 'whitespace-nowrap align-bottom')} colSpan={rowDepth} rowSpan={colDepth}>
                                                    {/* ROW axis only. The column axis is spelled out in
                                                        the header cells immediately to the right, so
                                                        naming it here too made the widest, least
                                                        readable string in the grid say nothing new. */}
                                                    {rowDims.map((d) => fieldById[d]?.label).join(' · ') || '—'}
                                                </th>
                                            )}
                                            {level.map((c, ci) => {
                                                const on = activeCell === `${li}:${ci}`;
                                                return (
                                                    <th
                                                        key={`${c.key}-${ci}`}
                                                        colSpan={c.span}
                                                        rowSpan={c.rowSpan}
                                                        // p-0 because the padding moves onto the button, so the whole
                                                        // cell is the hit target. The values match TH's own px-3 py-2.5,
                                                        // which keeps the locked row height.
                                                        className={cn(TH, 'whitespace-nowrap text-right p-0', c.total && 'text-card-foreground')}
                                                        aria-sort={on ? (rowOrder === 'asc' ? 'ascending' : 'descending') : undefined}
                                                    >
                                                        {/* An axis with no dimension renders ONE blank member cell. A
                                                            button there is an unlabelled tab stop, and it would only
                                                            duplicate the Totals column anyway. */}
                                                        {c.label === '' ? <span className="block px-3 py-2.5">&nbsp;</span> : (
                                                            /* A plain <button>, never shadcn's — a variant-less <Button>
                                                               is a violet gradient, and tailwind-merge will not let a
                                                               className override remove it (ui-conventions.md). */
                                                            <button
                                                                type="button"
                                                                onClick={() => sortByColumn(c.sortKey)}
                                                                title={on
                                                                    ? `Rows: ${rowOrder === 'desc' ? 'highest' : 'lowest'} ${c.label} first`
                                                                    : `Sort rows by ${c.label}`}
                                                                className="inline-flex w-full items-center justify-end gap-1 px-3 py-2.5 text-inherit transition-colors hover:text-primary"
                                                            >
                                                                {c.label}
                                                                {/* The neutral chevron stays VISIBLE at opacity-40. It was
                                                                    briefly hover-only to keep a dense head quiet; that
                                                                    breaks the locked ruling, which pins the neutral state
                                                                    at that opacity — and :hover never fires on a tablet
                                                                    before the first tap, so the whole feature was
                                                                    undiscoverable there. */}
                                                                {on
                                                                    ? (rowOrder === 'asc'
                                                                        ? <ArrowUp className="size-3 shrink-0" aria-hidden="true" />
                                                                        : <ArrowDown className="size-3 shrink-0" aria-hidden="true" />)
                                                                    : <ChevronsUpDown className="size-3 shrink-0 opacity-40" aria-hidden="true" />}
                                                            </button>
                                                        )}
                                                    </th>
                                                );
                                            })}
                                        </tr>
                                    ))}
                                </thead>
                                <tbody>
                                    {pivot.displayRows.map((dr, ri) => (
                                        <tr key={ri} className={cn('group transition-colors', SELECTED_HOVER_TR, rowBand(dr, ri))}>
                                            {rowLabelCells[ri].map((label, li) => (
                                                <td
                                                    key={li}
                                                    className={cn(TD, TD_AXIS, SELECTED_HOVER_CELL, 'whitespace-nowrap !text-left font-bold', dr.total && 'font-extrabold')}
                                                    // Excel's outline form: a level is printed only in its own
                                                    // column, so nesting reads as an indent instead of forty repeats.
                                                    style={{ paddingLeft: `${12 + li * 14}px` }}
                                                >
                                                    {/* The whole label is the hit target, not just the chevron. A
                                                        4-px glyph beside clickable-looking text is the dead-zone
                                                        mistake the filter pills already had to be fixed for. */}
                                                    {label !== null && dr.expandable ? (
                                                        <button
                                                            type="button"
                                                            onClick={() => toggleGroup(dr.key)}
                                                            aria-expanded={dr.open}
                                                            title={dr.open ? `Collapse ${label}` : `Expand ${label}`}
                                                            className="inline-flex max-w-full items-center gap-1 text-left text-inherit transition-colors hover:text-primary"
                                                        >
                                                            <ChevronRight
                                                                aria-hidden="true"
                                                                className={cn('size-3 shrink-0 transition-transform', dr.open && 'rotate-90')}
                                                            />
                                                            <span className="truncate">{label}</span>
                                                        </button>
                                                    ) : (label ?? '')}
                                                </td>
                                            ))}
                                            {pivot.grid[ri].map((val, ci) => (
                                                <td
                                                    key={ci}
                                                    className={cn(
                                                        TD, 'relative whitespace-nowrap',
                                                        isTextAt(ri, ci) && '!text-left',
                                                        (dr.total || pivot.displayCols[ci].total) && 'font-bold',
                                                        dr.total && pivot.displayCols[ci].total && 'text-primary',
                                                    )}
                                                    style={cellTint(val, ri, ci)}
                                                >
                                                    {cellBar(val, ri, ci)}
                                                    <span className="relative">{cellContent(val, ri, ci)}</span>
                                                </td>
                                            ))}
                                        </tr>
                                    ))}
                                </tbody>
                            </table>
                        </div>
                    )}
                </div>
            </div>
            </div>
        </div>
    );
}
