import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
    ArrowLeftRight, Calendar, Check, ChevronDown, Eye, EyeOff, History, Inbox,
    Link2, Maximize2, MessageSquare, Minimize2, RotateCcw, Search, Settings, X,
} from 'lucide-react';
import AppLayout from '@/Layouts/AppLayout';
import { AddFilterMenu, FilterPill } from '@/Components/ui/filter-pill';
import { CheckBox } from '@/Components/Proto/UI/CheckBox';
import { Button } from '@/Components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/Components/ui/dialog';
import { Textarea } from '@/Components/ui/textarea';
import { LinkedRecordsDialog } from '@/Components/DocumentLinks/LinkedRecordsDialog';
import { router, useHttp } from '@inertiajs/react';
import { ListFooter, StatsDot } from '@/Components/Table/ListFooter';
import { Crumb, CrumbCurrent, CrumbSep } from '@/Components/Table';
import { useToast } from '@/Components/Toast';
import { useClientSort, SortButton } from '@/lib/ClientSort';
import { SELECTED_BG, SELECTED_HOVER_CELL, SELECTED_HOVER_TR } from '@/lib/rowTint';
import { DecisionBar } from '@/Components/MenuSampleOrders/DecisionBar';

const DATE_RANGE_OPTIONS = [
    { value: 'all', label: 'All time' },
    { value: '7', label: 'Last 7 days' },
    { value: '30', label: 'Last 30 days' },
    { value: '90', label: 'Last 90 days' },
    { value: '365', label: 'Last year' },
];

// Backend money strings are "Rp 1,234,567.89" (comma thousands, dot decimal)
// and line totals are plain "1234567.89" — dot is ALWAYS the decimal separator.
function parseIdr(text) {
    if (text === null || text === undefined || text === '') return 0;
    const str = String(text).replace(/[^0-9.,-]/g, '');
    if (!str) return 0;
    const n = Number.parseFloat(str.replace(/,/g, ''));
    return Number.isNaN(n) ? 0 : n;
}

function lineTotalIdr(q) {
    if (!q.lineItems || q.lineItems.length === 0) {
        return parseIdr(q.totals?.idr ?? '');
    }
    return q.lineItems.reduce((sum, li) => sum + parseIdr(li.totalIdr), 0);
}

function lineTotalUsd(q) {
    if (!q.lineItems || q.lineItems.length === 0) {
        return parseIdr(q.totals?.usd ?? '');
    }
    return q.lineItems.reduce((sum, li) => sum + parseIdr(li.totalUsd), 0);
}

function formatIdr(n) {
    if (!n) return 'Rp 0';
    return 'Rp ' + new Intl.NumberFormat('id-ID', { maximumFractionDigits: 0 }).format(n);
}

function formatUsd(n) {
    if (!n) return '$0';
    return '$' + new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(n);
}

function formatCompact(n) {
    if (!n) return 'Rp 0';
    const fmt = (v) => v.toLocaleString('id-ID', { maximumFractionDigits: 1 });
    if (n >= 1_000_000_000_000) return `Rp ${fmt(n / 1_000_000_000_000)} T`;
    if (n >= 1_000_000_000) return `Rp ${fmt(n / 1_000_000_000)} B`;
    if (n >= 1_000_000) return `Rp ${fmt(n / 1_000_000)} M`;
    if (n >= 1_000) return `Rp ${fmt(n / 1_000)} K`;
    return `Rp ${fmt(n)}`;
}

function isOrder(q) {
    // Prefer the real IsOrder flag; fall back to PO presence for older payloads.
    if (typeof q.isOrder === 'boolean') return q.isOrder;
    return Boolean(q.poNo && q.poNo !== '—' && q.poNo !== '-');
}

// Muted "NA" placeholder (same convention as LWR Approval PM — no dashes).
// An em dash, not the letters "NA": a dash reads as "no value" at a glance, where "NA" reads as
// content. At /60 it was also the faintest text on the page (user 2026-08-24 asked for contrast).
const NA = () => <span className="font-normal text-muted-foreground">—</span>;
// Normalizes backend placeholder strings ("—"/"-"/empty) to the NA mark.
const orNA = (v) => (!v || v === '—' || v === '-') ? <NA /> : v;

// Truncated text that reveals its full content on click (toggles wrapping); the
// native title still shows the full value on hover. Click again to collapse.
function ExpandableText({ children, title, className = '' }) {
    const [open, setOpen] = useState(false);
    const toggle = (e) => { e.stopPropagation(); setOpen((o) => !o); };
    return (
        <span
            className={`block cursor-pointer ${open ? 'whitespace-normal break-words' : 'truncate'} ${className}`}
            title={title}
            role="button"
            tabIndex={0}
            onClick={toggle}
            onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(e); } }}
        >
            {children}
        </span>
    );
}

// Instant hover popover. Uses position:fixed measured from the trigger rect so it
// escapes the table's overflow-x-auto clipping (native `title` is slow & gets
// clipped). Flips below the trigger when there's no room above, and clamps the
// left edge inside the viewport. `content` is rich JSX (a panel of records).
function Tip({ content, children, className = '', width = 300 }) {
    const [pos, setPos] = useState(null);
    const ref = useRef(null);
    const show = () => {
        const r = ref.current?.getBoundingClientRect();
        if (!r) return;
        let left = r.left;
        const max = window.innerWidth - width - 8;
        if (left > max) left = max;
        if (left < 8) left = 8;
        const below = r.top < 220;
        setPos({ left, top: below ? r.bottom + 6 : r.top - 6, below });
    };
    return (
        <span ref={ref} className={className} onMouseEnter={show} onMouseLeave={() => setPos(null)}>
            {children}
            {/* Portaled to <body>: a sticky/frozen cell is its own stacking context, so an
                inline fixed popover gets painted over by the NEXT sticky column no matter
                its z-index. The portal also stops trigger styles (uppercase chip) leaking in. */}
            {pos && createPortal(
                <span
                    className={`pointer-events-none fixed z-[100] block rounded-lg border border-border bg-card p-2.5 text-left text-[11px] font-normal normal-case leading-snug tracking-normal text-foreground shadow-modal ${pos.below ? '' : '-translate-y-full'}`}
                    style={{ left: pos.left, top: pos.top, width, maxWidth: '85vw' }}>
                    {content}
                </span>,
                document.body,
            )}
        </span>
    );
}

// Shared layout for a hover popover: a small title + a stacked list of records.
function RecordPanel({ title, rows, empty = 'No records' }) {
    return (
        <span className="flex flex-col gap-1">
            <span className="border-b border-border/60 pb-1 text-[10px] font-bold uppercase tracking-wide text-muted-foreground">{title}</span>
            {rows.length
                ? rows.map((r, i) => <span key={i} className="block leading-snug">{r}</span>)
                : <span className="text-muted-foreground">{empty}</span>}
        </span>
    );
}

function daysAgo(dateStr) {
    if (!dateStr || dateStr === '—' || dateStr === '-') return null;
    const t = Date.parse(dateStr);
    if (Number.isNaN(t)) return null;
    return (Date.now() - t) / (1000 * 60 * 60 * 24);
}

const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
// "2026-06-12" → "12 Jun" (compact). Returns null for empty/placeholder.
function shortDate(d) {
    if (!d || d === '—' || d === '-') return null;
    const t = Date.parse(d);
    if (Number.isNaN(t)) return String(d).slice(0, 10);
    const dt = new Date(t);
    return `${dt.getDate()} ${MONTHS_SHORT[dt.getMonth()]} '${String(dt.getFullYear()).slice(-2)}`;
}
// Compact relative age for the Meta cell ("today", "5d", "3w", "Apr '26").
function relAge(dateStr) {
    const days = daysAgo(dateStr);
    if (days === null) return null;
    if (days < 1) return 'today';
    if (days < 7) return `${Math.floor(days)}d`;
    if (days < 31) return `${Math.floor(days / 7)}w`;
    return shortDate(dateStr);
}

// ── Meta hover record builders ──
// Each returns the ACTUAL records only. When the real log is empty they return an
// empty array and the cell renders nothing — a PM signs off on prices here, so an
// invented approver/remark/date is worse than a blank cell. (Legacy behaves the same:
// getlastquotationpm.php / getlastquotationsm.php render nothing when there is no data.)
function remarkRecords(q) {
    return [
        ...distinctLineVals(q, 'remarks').map((t) => ({ tag: 'Q', text: t })),
        ...distinctLineVals(q, 'remarkInternal').map((t) => ({ tag: 'I', text: t })),
    ];
}
function historyRecords(q) {
    return (q.history?.entries ?? [])
        .filter((e) => e.Status || e.Comment)
        .map((e) => ({
            status: e.Status || '—',
            date: shortDate(e.Tanggal),
            user: e.User,
            comment: e.Comment && e.Comment !== '—' ? e.Comment : '',
        }));
}

function OrderBadge({ order }) {
    return order
        ? (<span className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md bg-success/10 px-2.5 py-1 text-[11.5px] font-bold text-success-text">
            <span aria-hidden="true" className="size-1.5 rounded-full bg-current" />Order
          </span>)
        : (<span className="inline-flex items-center gap-1.5 whitespace-nowrap py-1 text-[11.5px] font-medium text-muted-foreground">
            <span aria-hidden="true" className="size-1.5 rounded-full bg-muted-foreground/50" />No Order
          </span>);
}

const STATUS_TONES = {
    approved: 'bg-success/10 text-success-text',
    revised: 'bg-warning-bg text-warning-text',
    rejected: 'bg-danger/10 text-danger-text',
    pending: 'bg-warning/10 text-warning-text',
};

function StatusBadge({ status }) {
    const tone = STATUS_TONES[(status || '').toLowerCase()] || 'bg-primary/10 text-primary';
    return (
        <span className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-md px-2.5 py-1 text-[11.5px] font-bold capitalize ${tone}`}>
            <span aria-hidden="true" className="size-1.5 rounded-full bg-current" />{status || 'Draft'}
        </span>
    );
}

// Distinct line-item values for a quotation, in order. `keyOrFn` is a field name
// or a mapper applied to each line item.
function distinctLineVals(q, keyOrFn) {
    const fn = typeof keyOrFn === 'function' ? keyOrFn : (li) => li[keyOrFn];
    return Array.from(new Set((q.lineItems ?? []).map(fn).filter(Boolean)));
}

// Compact cell for a multi-valued field: first value + "+N" (full list on hover).
function MultiCell({ values }) {
    if (!values.length) return <NA />;
    const [first, ...rest] = values;
    return (
        <span className="inline-flex max-w-[180px] items-center gap-1.5 align-bottom">
            <span className="min-w-0 truncate">{first}</span>
            {rest.length > 0 && (
                <span className="shrink-0 rounded bg-muted px-1.5 py-px text-[10px] font-bold text-muted-foreground" title={values.join(', ')}>+{rest.length}</span>
            )}
        </span>
    );
}

// Distinct "original / print" name pairs from line items: original on top, the
// print name (when it differs) muted below. Multiple distinct pairs → first + "+N".
function DualCell({ q, origKey, printKey }) {
    const seen = new Set();
    const pairs = [];
    (q.lineItems ?? []).forEach((li) => {
        const orig = li[origKey] || '';
        const print = li[printKey] || '';
        if (!orig && !print) return;
        const k = `${orig}||${print}`;
        if (seen.has(k)) return;
        seen.add(k);
        pairs.push({ orig, print });
    });
    if (!pairs.length) return <NA />;
    const [first, ...rest] = pairs;
    const full = pairs.map((p) => (p.orig && p.print && p.orig !== p.print) ? `${p.orig} / ${p.print}` : (p.orig || p.print)).join(', ');
    const sub = first.orig && first.print && first.print !== first.orig ? first.print : '';
    return (
        <span className="inline-flex max-w-[180px] flex-col align-bottom" title={full}>
            <span className="flex items-center gap-1.5">
                <span className="min-w-0 truncate">{first.orig || first.print}</span>
                {rest.length > 0 && <span className="shrink-0 rounded bg-muted px-1.5 py-px text-[10px] font-bold text-muted-foreground">+{rest.length}</span>}
            </span>
            {sub && <span className="truncate text-[11.5px] text-muted-foreground">{sub}</span>}
        </span>
    );
}

// Truncating cell for long free text (full value on hover).
function TextCell({ value }) {
    if (!value || value === '—') return <NA />;
    return <span className="block max-w-[200px] truncate" title={value}>{value}</span>;
}

// "Last Quotation PM/SM" cell — latest PM + latest SM displayed inline (prototype style).
// Can also fetch cross-quotation product history on expand.
function PmSmCell({ q, barangId, companyId }) {
    const [open, setOpen] = useState(false);
    const [loaded, setLoaded] = useState(false);
    const [loading, setLoading] = useState(false);
    const [extraPm, setExtraPm] = useState([]);
    const [extraSm, setExtraSm] = useState([]);
    const http = useHttp({});

    const toRec = (e) => ({
        date: shortDate(e.Tanggal || e.date) || '—',
        user: e.User || e.user || '',
        note: (e.Comment && e.Comment !== '—') ? e.Comment : (e.comment && e.comment !== '—') ? e.comment : '',
    });

    const historyEntries = q?.history?.entries ?? [];
    let pmList = historyEntries.filter((x) => /pm/i.test(x.Status || '')).map(toRec);
    let smList = historyEntries.filter((x) => /sm/i.test(x.Status || '')).map(toRec);

    if (extraPm.length) pmList = [...pmList, ...extraPm.map(toRec)];
    if (extraSm.length) smList = [...smList, ...extraSm.map(toRec)];

    const all = [
        ...pmList.map((e) => ({ ...e, stage: 'PM' })),
        ...smList.map((e) => ({ ...e, stage: 'SM' })),
    ];

    const preview = [
        pmList[0] && { ...pmList[0], stage: 'PM' },
        smList[0] && { ...smList[0], stage: 'SM' },
    ].filter(Boolean);

    const moreCount = all.length - preview.length;
    const canFetch = Boolean(barangId && companyId);

    const fetchHistory = useCallback(() => {
        if (canFetch && !loaded && !loading) {
            setLoading(true);
            let done = 0;
            const finish = () => { if (++done === 2) { setLoading(false); setLoaded(true); } };
            const args = { barang: barangId, companyId, limit: 5 };
            http.get(route('quotations.products.last-pm-comments', args), {
                onSuccess: (data) => setExtraPm(data.comments ?? []),
                onFinish: finish,
            });
            http.get(route('quotations.products.last-sm-comments', args), {
                onSuccess: (data) => setExtraSm(data.comments ?? []),
                onFinish: finish,
            });
        }
    }, [barangId, companyId, canFetch, loaded, loading, http]);

    useEffect(() => {
        if (canFetch && !loaded) {
            fetchHistory();
        }
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [canFetch]);

    const loadMore = () => {
        setOpen(!open);
    };

    const tip = (e) => `${e.stage} ${e.date}${e.user ? ` · ${e.user}` : ''}${e.note ? ` · ${e.note}` : ''}`;
    const body = (e) => (
        <>
            <span className="font-bold text-primary">{e.stage}</span> {e.date}{e.note ? ` · ${e.note}` : ''}
        </>
    );

    if (open) {
        return (
            <div className="flex w-full min-w-0 flex-col gap-0.5 leading-tight text-[11px]">
                {all.map((e, i) => (
                    <span key={i} className="whitespace-normal break-words text-muted-foreground" title={tip(e)}>{body(e)}</span>
                ))}
                {loading && <span className="text-[11px] italic text-muted-foreground/50">Loading…</span>}
                <button type="button" onClick={() => setOpen(false)}
                    className="mt-0.5 inline-flex w-fit items-center gap-0.5 text-[10.5px] font-medium text-muted-foreground transition-colors hover:text-foreground">
                    Show less<ChevronDown className="size-3 rotate-180" />
                </button>
            </div>
        );
    }

    if (preview.length === 0) {
        return (
            <div className="flex w-full min-w-0 flex-col gap-0.5 leading-tight text-[11px]">
                {loading ? (
                    <span className="text-[11px] italic text-muted-foreground/50">Loading…</span>
                ) : (
                    <span className="text-[11px] italic text-muted-foreground/50">—</span>
                )}
            </div>
        );
    }

    return (
        <div className="flex max-w-[260px] min-w-0 flex-col gap-0.5 leading-tight text-[11px]">
            {preview[0] && <span className="truncate text-muted-foreground" title={tip(preview[0])}>{body(preview[0])}</span>}
            <div className="flex min-w-0 items-center gap-1.5">
                {preview[1] && <span className="min-w-0 max-w-full truncate text-muted-foreground" title={tip(preview[1])}>{body(preview[1])}</span>}
                {!preview[1] && loading && <span className="text-[10px] italic text-muted-foreground/50 ml-1">Loading…</span>}
                {moreCount > 0 && !loading && (
                    <button type="button" onClick={loadMore} title={`${moreCount} more PM/SM notes`}
                        className="inline-flex shrink-0 items-center gap-0.5 text-[10.5px] font-medium text-muted-foreground transition-colors hover:text-foreground">
                        +{moreCount}<ChevronDown className="size-3" />
                    </button>
                )}
            </div>
        </div>
    );
}

// Compact history cell. `cols` = 1 (stacked) or 2 (side-by-side, for the History
// column). Shows a preview; the rest expand INLINE in the column via "view more"
// (no row dropdown).
function HistoryCell({ entries, cols = 1 }) {
    const [showAll, setShowAll] = useState(false);
    if (!entries || entries.length === 0) return <NA />;
    // Collapsed preview stays ONE entry tall (single-line comment) so every row
    // keeps the same height; "view more" expands to the full wrapped log.
    const preview = cols === 2 ? 2 : 1;
    const shown = showAll ? entries : entries.slice(0, preview);
    return (
        <div className={cols === 2 ? 'w-[300px]' : 'w-[200px]'}>
            <div className={cols === 2 ? 'grid grid-cols-2 gap-x-4 gap-y-1.5' : 'flex flex-col gap-1.5'}>
                {shown.map((e, i) => (
                    <div key={i} className="leading-snug">
                        <span className="tabular-nums text-[10px] text-muted-foreground">{e.Tanggal}</span>
                        {e.Status && e.Status !== '—' && <span className="ml-1 text-[10px] font-semibold text-primary/80">{e.Status}</span>}
                        {e.Comment && e.Comment !== '—' && (
                            <span className={`block text-[11px] text-foreground ${showAll ? 'whitespace-normal break-words' : 'truncate'}`} title={e.Comment}>{e.Comment}</span>
                        )}
                    </div>
                ))}
            </div>
            {entries.length > preview && (
                <button type="button" onClick={(ev) => { ev.stopPropagation(); setShowAll((s) => !s); }}
                    className="mt-1.5 text-[10px] font-semibold text-primary hover:underline">
                    {showAll ? 'view less' : `view more (+${entries.length - preview})`}
                </button>
            )}
        </div>
    );
}

// Single-select pill (PillSelect look, but one value) — used for the date range.
function RangePill({ label, value, options, onChange }) {
    const [open, setOpen] = useState(false);
    const ref = useRef(null);
    useEffect(() => {
        const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
        if (open) document.addEventListener('mousedown', h);
        return () => document.removeEventListener('mousedown', h);
    }, [open]);
    const current = options.find((o) => o.value === value);
    const hasVal = value !== options[0].value;
    return (
        <div ref={ref} className="relative">
            <button type="button" onClick={() => setOpen((o) => !o)}
                className={`inline-flex h-8 items-center gap-1.5 whitespace-nowrap rounded-full border px-3 text-[12.5px] font-semibold shadow-sm transition-colors hover:border-primary hover:text-primary ${hasVal ? 'border-border-soft-strong bg-accent text-primary' : open ? 'border-primary bg-card text-primary' : 'border-border/50 bg-card text-muted-foreground'}`}>
                <Calendar aria-hidden="true" className="size-[13px] shrink-0 opacity-80" />
                <span>{hasVal ? `${label}: ${current?.label}` : `${label}: All`}</span>
                {hasVal ? (
                    <span role="button" aria-label="Clear" onClick={(e) => { e.stopPropagation(); onChange(options[0].value); }}
                        className="inline-flex size-4 items-center justify-center rounded-full bg-primary/18 text-[0.75rem] leading-none text-primary hover:bg-danger hover:text-white">×</span>
                ) : (
                    <ChevronDown aria-hidden="true" strokeWidth={2.5} className={`size-2.5 transition-transform ${open ? 'rotate-180' : ''}`} />
                )}
            </button>
            {open && (
                <div className="absolute left-0 top-full z-50 mt-1.5 w-[180px] overflow-hidden rounded-xl border border-border bg-surface py-1.5 shadow-modal">
                    {options.map((o) => (
                        <button key={o.value} type="button" onClick={() => { onChange(o.value); setOpen(false); }}
                            className={`flex w-full items-center justify-between px-3.5 py-2 text-left text-[0.8rem] transition-colors hover:bg-surface-tint ${o.value === value ? 'font-bold text-primary' : 'font-medium text-foreground'}`}>
                            {o.label}
                            {o.value === value && <Check className="size-3.5" strokeWidth={3} />}
                        </button>
                    ))}
                </div>
            )}
        </div>
    );
}

const emptyFilters = () => ({
    q: '', isOrder: false, dateRange: 'all',
    company: [], principal: [], sales: [], industry: [], product: [], application: [], status: [],
});
const inSel = (arr, v) => !arr.length || arr.includes(v);

// Reorderable columns (checkbox + Actions stay pinned, outside this set). Every
// column is always visible — no hiding; order is customizable via header drag.
// `groupId` drives spacing + emphasis; `align` → right-align; `top` → multi-line cell.
// High-density SINGLE-LINE design: 8 merged columns (no 20-column horizontal
// chaos). Each cell packs related fields; rows stay one line, ~44px tall.
const COLUMN_DEFS = [
    { id: 'entity', label: 'Company', required: true, w: 'w-56' },
    { id: 'product', label: 'Product', required: true, w: 'w-[185px]' },
    { id: 'specs', label: 'Packing', w: 'max-w-[130px]' },
    { id: 'qtyValue', label: 'Qty', align: true, w: '' },
    { id: 'price', label: 'Unit Price', align: true, w: '' },
    { id: 'value', label: 'Value', align: true, w: '' },
    { id: 'po', label: 'PO', w: '' },
    { id: 'deliveryDate', label: 'Delivery Date', w: '' },
    { id: 'pmsm', label: 'Latest from PM & SM', w: 'max-w-[250px]' },
    { id: 'meta', label: 'Activity', w: '' },
];
const GROUP_EMPHASIS = {};
// Frozen (sticky-left) columns — checkbox + Company + Product stay put on horizontal
// scroll. Widths are FIXED via `table-fixed` + the <colgroup> below (COL_W), so the freeze
// offsets are EXACT regardless of cell content or tree-indent: checkbox 48px + Company 300px
// → Product sticks at exactly 348px (no overlap, no transparent gap). Opaque bg (bg-card /
// rowBg) so scrolling cells never show through. Only valid in the default column order
// (reorder Company/Product off the left and the freeze comes off).
const FROZEN = {
    entity: 'sticky left-12 z-20',
    product: 'sticky left-[348px] z-20',
};
// Pinned columns must stay first (in this order) — their sticky-left offsets above only
// work in this position, so reordering can never move them or slot a column before them.
const PINNED = ['entity', 'product'];
// Fixed per-column pixel widths for the table-fixed layout. The frozen offsets above depend
// on CHECKBOX_W + entity width = product offset (48 + 300 = 348) — keep them in sync.
const CHECKBOX_W = 48;
const ACTIONS_W = 116;
const COL_W_DEFAULT = 130;
const COL_W = { entity: 300, product: 220, specs: 165, price: 190, qtyValue: 92, value: 120, po: 120, deliveryDate: 110, pmsm: 285, meta: 128 };
// Sortable columns — id → RAW record (quotation) value for useClientSort. Only
// record-level scalars sort: Product/Packing/Qty/Price/Value hold one value PER
// LINE ITEM (an array at the record level) and pmsm/meta are history-derived,
// so none of those has a single row value to order by.
const SORT_GETTERS = {
    entity: (q) => q.company,
    po: (q) => (q.order?.['Customer PO No'] ?? q.poNo),
    deliveryDate: (q) => q.deliveryDate,
    // Product sorts by the quotation's alphabetically FIRST product (user 2026-08-24). A
    // quotation can carry several line items and useClientSort orders RECORDS, so there is no
    // single product per sort unit — the rows of one quotation always travel together. Taking
    // the minimum is the rule that makes the column read predictably: a quotation lands where its
    // earliest product name would.
    product: (q) => {
        const names = (q.lineItems ?? []).map((li) => li.productName).filter(Boolean);
        return names.length ? names.slice().sort()[0] : '';
    },
};
// FULL per-item rows: each line item is its own independent row (no rowSpan). Helpers
// to enumerate a quotation's line items and a collision-free per-item key (prefixed so
// a detail id never clashes with a quotation id in the shared selection/status maps).
// What the toolbar's search box looks through. Product names are included (user
// 2026-08-24) — they live on the LINE ITEMS, so a quotation matches when ANY of its
// products does, and both the suggestion dropdown and the row filter read this one
// function so they can never disagree about what counts as a match.
const searchHaystack = (q) => [
    q.id,
    q.company ?? '',
    q.poNo ?? '',
    ...(q.lineItems ?? []).flatMap((li) => [li.productName, li.productOriginal]),
].filter(Boolean).join(' ').toLowerCase();

const itemsOf = (q) => (q.lineItems && q.lineItems.length) ? q.lineItems : [{}];
const liKey = (q, li, idx) => 'd' + (li.id || `${q.id}_${idx}`);
// A quotation's line keys that the viewing PM may act on (canAct from the backend).
const actionableKeys = (q) => itemsOf(q)
    .map((li, idx) => ({ li, idx }))
    .filter(({ li }) => li.canAct)
    .map(({ li, idx }) => liKey(q, li, idx));
// ── Grouping (BnT Approval PM rules, same as LWR Approval PM): the Rows list
// holds every header-level dim; only the CHECKED pills nest the table.
// Default: Company first and the only grouped one.
const GROUP_DIMS = ['company', 'principal', 'sales', 'industry', 'application', 'creator'];
const GROUP_META = {
    company: { label: 'Company', val: (q) => q.company || 'No Company' },
    // Principal lives on the LINE ITEMS — a quotation can carry several, so a
    // mixed quotation groups under the joined combination ("ARKEMA + Angel Yeast").
    principal: {
        label: 'Principal',
        val: (q) => {
            const names = Array.from(new Set((q.lineItems ?? []).map((li) => li.principalName).filter(Boolean))).sort();
            return names.length ? names.join(' + ') : 'No Principal';
        },
    },
    sales: { label: 'Sales', val: (q) => q.sales || 'No Sales' },
    industry: { label: 'Industry', val: (q) => q.industry || 'No Industry' },
    application: { label: 'Application', val: (q) => q.division || 'No Application' },
    creator: { label: 'Creator', val: (q) => q.creator || 'System' },
};
// Merged columns can't cleanly auto-hide per grouped dim (one cell holds many
// fields), so nothing auto-hides here.
const DIM_COL = {};
const buildTree = (rows, dims) => {
    const [dim, ...rest] = dims;
    const map = new Map();
    rows.forEach((q) => { const k = GROUP_META[dim].val(q); if (!map.has(k)) map.set(k, []); map.get(k).push(q); });
    return [...map.entries()]
        .map(([key, rs]) => ({ dim, key, label: key, rows: rs, children: rest.length ? buildTree(rs, rest) : null }))
        .sort((a, b) => a.label.localeCompare(b.label));
};
// Tree node → stable expansion key (dim:value), shared by toggle + collapse-all.
const nodePath = (parentPath, node) => `${parentPath}¦${node.dim}:${node.key}`;

// Native HTML5 drag reorder (same as the BnT/LWR panels — react-sortablejs
// crashes under React 19, so we roll our own).
function SortableList({ ids, onReorder, className, renderItem }) {
    const [dragIdx, setDragIdx] = useState(null);
    const move = (from, to) => {
        if (from === null || to < 0 || to >= ids.length || from === to) return;
        const n = [...ids]; const [m] = n.splice(from, 1); n.splice(to, 0, m); onReorder(n);
    };
    return (
        <div className={className}>
            {ids.map((id, i) => (
                <div
                    key={id} draggable
                    onDragStart={() => setDragIdx(i)}
                    onDragEnter={() => { if (dragIdx !== null && dragIdx !== i) { move(dragIdx, i); setDragIdx(i); } }}
                    onDragOver={(e) => e.preventDefault()}
                    onDragEnd={() => setDragIdx(null)}
                    className={`cursor-grab active:cursor-grabbing ${dragIdx === i ? 'opacity-40' : ''}`}
                >
                    {renderItem(id)}
                </div>
            ))}
        </div>
    );
}

// "Rows" pill in the Configuration panel — grip + label + group checkbox +
// remove (BnT PivotPill contract: every dim listed, only CHECKED ones group).
function GroupPill({ label, grouped, onToggleGroup, onRemove }) {
    return (
        <div className="flex h-9 items-center gap-2 rounded-lg border border-primary/20 bg-primary/10 px-2.5 text-[12px] font-bold text-primary shadow-sm">
            <span className="grid place-items-center opacity-60 transition-opacity hover:opacity-100" aria-hidden="true">
                <svg width="10" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><circle cx="9" cy="5" r="1" /><circle cx="9" cy="12" r="1" /><circle cx="9" cy="19" r="1" /><circle cx="15" cy="5" r="1" /><circle cx="15" cy="12" r="1" /><circle cx="15" cy="19" r="1" /></svg>
            </span>
            <span className="flex-1 truncate">{label}</span>
            <span className="flex items-center" title={grouped ? 'Grouped — uncheck to ungroup' : 'Check to group by this field'}>
                <CheckBox size="sm" checked={!!grouped} onChange={onToggleGroup} ariaLabel={`Group by ${label}`} />
            </span>
            <button type="button" onClick={onRemove} title="Remove from list" aria-label={`Remove ${label}`}
                className="grid size-5 place-items-center rounded-md opacity-60 transition-all hover:bg-danger/15 hover:text-danger-text hover:opacity-100">
                <X className="size-3" strokeWidth={3} />
            </button>
        </div>
    );
}

// Slim default — decision essentials only (same approach as LWR Approval PM);
// everything else stays available via Customize Columns / header drag.
const DEFAULT_VISIBLE = new Set(['entity', 'product', 'specs', 'price', 'qtyValue', 'value', 'po', 'deliveryDate', 'pmsm', 'meta']);
const COLUMN_STORAGE_KEY = 'quotationApprovalPmColumns_v7';
const defaultColumnState = () => COLUMN_DEFS.map((d) => ({ id: d.id, visible: DEFAULT_VISIBLE.has(d.id) }));
function loadColumnState() {
    try {
        const raw = localStorage.getItem(COLUMN_STORAGE_KEY);
        if (!raw) return defaultColumnState();
        const parsed = JSON.parse(raw).filter((c) => COLUMN_DEFS.some((d) => d.id === c.id));
        const ids = new Set(parsed.map((c) => c.id));
        COLUMN_DEFS.forEach((d) => { if (!ids.has(d.id)) parsed.push({ id: d.id, visible: DEFAULT_VISIBLE.has(d.id) }); });
        return parsed;
    } catch {
        return defaultColumnState();
    }
}

// No `scoped` prop any more: the queue is strictly scoped to the viewer's head-div
// principals server-side (2026-08-07), so a principal-less user gets an empty queue
// rather than a read-only god-view that needed a flag to disable its own buttons.
export default function ApprovalPmIndex({ quotations = [] }) {
    const { show: showToast } = useToast();
    const [filters, setFiltersRaw] = useState(() => {
        // Deep-link dari dialog 🔗 modul lain: /…/approval-pm?q={nomor dokumen}.
        const q = new URLSearchParams(window.location.search).get('q') || '';
        return { ...emptyFilters(), q };
    });
    const [extras, setExtras] = useState([]);

    // Grouping + panel (BnT Approval PM rules, same as LWR Approval PM).
    const [groupBy, setGroupBy] = useState(['company', 'principal', 'sales', 'industry', 'application', 'creator']);
    // Default: flat list (no group captions / ungrouped). User can enable grouping via Configuration.
    const [grouped, setGrouped] = useState(() => new Set());
    const toggleGrouped = (id) => setGrouped((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
    const [collapsedPaths, setCollapsedPaths] = useState(() => new Set());
    const [showConfig, setShowConfig] = useState(false);

    const [pageSize, setPageSize] = useState(10);
    const [page, setPage] = useState(1);
    const [selectedIds, setSelectedIds] = useState(new Set());

    // Approve/Revise submit state (wired to quotations.approval-pm.act).
    const [commentDialog, setCommentDialog] = useState({ open: false, action: 'approve', ids: [] });
    const [commentDraft, setCommentDraft] = useState('');
    const [submitting, setSubmitting] = useState(false);
    const [formError, setFormError] = useState('');
    // Linked-data viewer (opened by the 🔗 icon and the ⋯ → Linked Data item).
    const [linkedQ, setLinkedQ] = useState(null);

    // Column ORDER only (persisted). Every column is always visible — no hiding
    // (per requirement); order is customizable via header drag.
    const [columnState, setColumnState] = useState(loadColumnState);
    const [dragColId, setDragColId] = useState(null);
    // Per-column widths (resizable). Seeded from COL_W, overridable by drag.
    const [colW, setColW] = useState(() => ({ ...COL_W }));
    const [resizingId, setResizingId] = useState(null);
    const resizeRef = useRef(null);
    const startResize = (e, id) => {
        e.preventDefault();
        e.stopPropagation();
        resizeRef.current = { id, startX: e.clientX, startW: colW[id] ?? COL_W_DEFAULT };
        setResizingId(id);
        const onMove = (ev) => {
            const r = resizeRef.current;
            if (!r) return;
            const next = Math.max(64, r.startW + (ev.clientX - r.startX));
            setColW((w) => ({ ...w, [r.id]: next }));
        };
        const onUp = () => {
            resizeRef.current = null;
            setResizingId(null);
            document.removeEventListener('mousemove', onMove);
            document.removeEventListener('mouseup', onUp);
        };
        document.addEventListener('mousemove', onMove);
        document.addEventListener('mouseup', onUp);
    };
    const colDefById = useMemo(() => new Map(COLUMN_DEFS.map((d) => [d.id, d])), []);
    const visibleCols = useMemo(
        () => columnState
            .filter((c) => { const d = colDefById.get(c.id); return d && (d.required || c.visible !== false); })
            .map((c) => colDefById.get(c.id)),
        [columnState, colDefById],
    );
    const toggleColumnVisible = (id) => {
        setColumnState((prev) => {
            const next = prev.map((c) => (c.id === id ? { ...c, visible: c.visible === false } : c));
            try { localStorage.setItem(COLUMN_STORAGE_KEY, JSON.stringify(next)); } catch { /* ignore */ }
            return next;
        });
    };
    // Keep pinned (frozen) columns first, in canonical order — guarantees column 1/2
    // are never empty and their sticky offsets stay valid.
    const normalizePins = (arr) => {
        const pins = PINNED.map((id) => arr.find((c) => c.id === id)).filter(Boolean);
        const rest = arr.filter((c) => !PINNED.includes(c.id));
        return [...pins, ...rest];
    };
    const reorderCols = (fromId, toId) => {
        if (!fromId || fromId === toId) return;
        if (PINNED.includes(fromId)) return; // pinned columns can't be moved
        setColumnState((prev) => {
            const fi = prev.findIndex((c) => c.id === fromId);
            const ti = prev.findIndex((c) => c.id === toId);
            if (fi < 0 || ti < 0) return prev;
            const next = [...prev];
            const [moved] = next.splice(fi, 1);
            next.splice(ti, 0, moved);
            const ordered = normalizePins(next);
            try { localStorage.setItem(COLUMN_STORAGE_KEY, JSON.stringify(ordered)); } catch { /* ignore */ }
            return ordered;
        });
    };

    const set = (key, val) => { setFiltersRaw((f) => ({ ...f, [key]: val })); setPage(1); };

    const source = quotations || [];

    // Search autocomplete (BnT pattern) — typing surfaces matching quotations;
    // picking one fills the box with its QT No (unique → narrows to that row).
    const [searchOpen, setSearchOpen] = useState(false);
    const searchRef = useRef(null);
    useEffect(() => {
        if (!searchOpen) return;
        const h = (e) => { if (searchRef.current && !searchRef.current.contains(e.target)) setSearchOpen(false); };
        document.addEventListener('mousedown', h);
        return () => document.removeEventListener('mousedown', h);
    }, [searchOpen]);
    const sq = filters.q.trim().toLowerCase();
    const searchMatches = useMemo(() => {
        if (!sq) return [];
        return source
            .filter((q) => searchHaystack(q).includes(sq))
            .slice(0, 8);
    }, [source, sq]);

    // Backend queue status (no client optimistic state — acted lines drop out on reload).
    const statusOf = (q) => q.status?.toLowerCase() || 'pending';
    const uniq = (arr) => Array.from(new Set(arr.filter(Boolean))).sort();

    const companyOptions = useMemo(() => uniq(source.map((q) => q.company)), [source]);
    const salesOptions = useMemo(() => uniq(source.map((q) => q.sales)), [source]);
    const industryOptions = useMemo(() => uniq(source.map((q) => q.industry)), [source]);
    const applicationOptions = useMemo(() => uniq(source.map((q) => q.division)), [source]);
    const principalOptions = useMemo(() => uniq(source.flatMap((q) => (q.lineItems ?? []).map((li) => li.principalName))), [source]);
    const productOptions = useMemo(() => uniq(source.flatMap((q) => (q.lineItems ?? []).map((li) => li.productName))), [source]);
    const statusOptions = useMemo(() => uniq(source.map(statusOf)), [source]);

    const rows = useMemo(() => {
        const qq = filters.q.trim().toLowerCase();
        return source.filter((q) => {
            if (qq) {
                if (!searchHaystack(q).includes(qq)) return false;
            }
            if (filters.isOrder && !isOrder(q)) return false;
            if (!inSel(filters.company, q.company)) return false;
            if (!inSel(filters.sales, q.sales)) return false;
            if (!inSel(filters.industry, q.industry)) return false;
            if (!inSel(filters.application, q.division)) return false;
            if (!inSel(filters.status, statusOf(q))) return false;
            if (filters.principal.length && !(q.lineItems ?? []).some((li) => filters.principal.includes(li.principalName))) return false;
            if (filters.product.length && !(q.lineItems ?? []).some((li) => filters.product.includes(li.productName))) return false;
            if (filters.dateRange !== 'all') {
                const limit = Number(filters.dateRange);
                const ago = daysAgo(q.tanggal);
                if (ago === null || ago > limit) return false;
            }
            return true;
        });
    }, [source, filters]);

    // Only the CHECKED Rows pills nest the table, in pill order (BnT rule).
    const effGroup = useMemo(() => groupBy.filter((d) => grouped.has(d)), [groupBy, grouped]);

    // Header sort (ClientSort house pattern) — applied to the filtered QUOTATIONS
    // before the grouping order + pagination slice, so it holds within groups and
    // across pages (each quotation's line-item rows stay together).
    const { sorted: clientSorted, sortKey, sortDir, toggleSort } = useClientSort(rows, SORT_GETTERS);

    // Order by the grouping dims first so groups stay contiguous across pages;
    // within a group, an active header sort wins (Array.sort is stable).
    const sortedRows = useMemo(() => {
        if (!effGroup.length) return clientSorted;
        return [...clientSorted].sort((a, b) => {
            for (const d of effGroup) {
                const c = GROUP_META[d].val(a).localeCompare(GROUP_META[d].val(b));
                if (c) return c;
            }
            return sortKey ? 0 : Number(b.id) - Number(a.id);
        });
    }, [clientSorted, effGroup, sortKey]);

    const totalPages = Math.max(1, Math.ceil(sortedRows.length / pageSize));
    const currentPage = Math.min(page, totalPages);
    const startIdx = (currentPage - 1) * pageSize;
    const pageRows = sortedRows.slice(startIdx, startIdx + pageSize);

    // Grouping tree over the current page (paths are dim:value, so the
    // collapsed state survives paging and filtering).
    const tree = useMemo(() => (effGroup.length ? buildTree(pageRows, effGroup) : []), [pageRows, effGroup]);
    const allGroupPaths = useMemo(() => {
        const acc = [];
        const walk = (nodes, parent) => nodes.forEach((n) => { const p = nodePath(parent, n); acc.push(p); if (n.children) walk(n.children, p); });
        walk(tree, '');
        return acc;
    }, [tree]);
    const toggleCollapse = (path) => setCollapsedPaths((prev) => { const n = new Set(prev); n.has(path) ? n.delete(path) : n.add(path); return n; });
    const expandAll = () => setCollapsedPaths(new Set());
    const collapseAll = () => setCollapsedPaths(new Set(allGroupPaths));

    // Selection + bulk operate on LINE ITEMS now (each row is one item).
    const pageItemIds = useMemo(() => pageRows.flatMap((q) => actionableKeys(q)), [pageRows]);
    const allOnPageSelected = pageItemIds.length > 0 && pageItemIds.every((id) => selectedIds.has(id));
    const toggleOne = (id) => {
        const next = new Set(selectedIds);
        if (next.has(id)) next.delete(id);
        else next.add(id);
        setSelectedIds(next);
    };
    const toggleAll = () => {
        const next = new Set(selectedIds);
        if (allOnPageSelected) pageItemIds.forEach((id) => next.delete(id));
        else pageItemIds.forEach((id) => next.add(id));
        setSelectedIds(next);
    };
    const toggleGroup = (ids) => {
        const all = ids.every((id) => selectedIds.has(id));
        setSelectedIds((prev) => { const n = new Set(prev); ids.forEach((id) => (all ? n.delete(id) : n.add(id))); return n; });
    };
    const groupState = (ids) => {
        const c = ids.reduce((n, id) => n + (selectedIds.has(id) ? 1 : 0), 0);
        return { checked: c > 0 && c === ids.length, indeterminate: c > 0 && c < ids.length };
    };
    const openComment = (action, ids) => {
        if (!ids || ids.length === 0) return;
        setCommentDraft('');
        setFormError('');
        setCommentDialog({ open: true, action, ids });
    };
    // 🔗 click: always open the chooser pop-up first so the user can preview WHERE
    // each link leads before navigating (no surprise jump straight to another page).
    const openLinks = (q) => setLinkedQ(q);
    // Strip the 'd' prefix from a line key → integer quotationdetails id.
    const toDetailId = (key) => Number(String(key).replace(/^d/, ''));
    const submitComment = () => {
        const text = commentDraft.trim();
        if (!text || submitting) return;
        const { action, ids } = commentDialog;
        const details = ids.map(toDetailId).filter((n) => Number.isInteger(n) && n > 0);
        if (details.length === 0) return;
        setSubmitting(true);
        setFormError('');
        router.post(
            route('quotations.approval-pm.act', { action }),
            { details, comment: text },
            {
                preserveScroll: true,
                onSuccess: () => {
                    setSelectedIds(new Set());
                    setCommentDialog((d) => ({ ...d, open: false }));
                    setCommentDraft('');
                },
                onError: (errors) => {
                    // Inline names WHICH line failed; the toast says THAT it failed — the
                    // dialog can be scrolled past on a long queue. Both are required by
                    // .claude/rules/notifications.md, and the 422 text there is locked.
                    setFormError(errors.details || errors.comment || 'Processing failed — reload the queue.');
                    showToast('Please check the form and try again.', 'error');
                },
                onFinish: () => setSubmitting(false),
            },
        );
    };

    const stats = useMemo(() => {
        const total = rows.length;
        const orders = rows.filter(isOrder).length;
        return {
            total,
            orders,
            noOrder: total - orders,
            value: rows.reduce((s, q) => s + lineTotalIdr(q), 0),
        };
    }, [rows]);

    const activeFilterCount =
        (filters.q.trim() ? 1 : 0) +
        (filters.isOrder ? 1 : 0) +
        (filters.dateRange !== 'all' ? 1 : 0) +
        ['company', 'principal', 'sales', 'industry', 'product', 'application', 'status']
            .reduce((n, k) => n + (filters[k].length ? 1 : 0), 0);

    const resetFilters = () => {
        setFiltersRaw(emptyFilters());
        setExtras([]);
        setPage(1);
        // React state alone isn't enough: the `q` seed above reads straight from the
        // address bar on mount, so a hard reload after Reset would resurrect the very
        // filter just cleared. approvalPm() reads no query params server-side, so
        // scrubbing it is pure URL hygiene — no visit/reload triggered.
        const u = new URL(window.location.href);
        if (u.searchParams.has('q')) {
            u.searchParams.delete('q');
            window.history.replaceState({}, '', u);
        }
    };

    // Core pills are always shown; the rest sit behind "+ Add filter" until added
    // (or auto-shown when they carry a value) — same contract as BudgetFilterBar.
    const coreFields = [
        { key: 'company', label: 'Company', opts: companyOptions },
        { key: 'principal', label: 'Principal', opts: principalOptions },
        { key: 'product', label: 'Product', opts: productOptions },
        { key: 'sales', label: 'Sales', opts: salesOptions },
    ];
    const extraFields = [
        { key: 'industry', label: 'Industry', opts: industryOptions },
        { key: 'application', label: 'Application', opts: applicationOptions },
        { key: 'status', label: 'Status', opts: statusOptions },
    ];
    const visibleExtras = extraFields.filter((f) => extras.includes(f.key) || filters[f.key]?.length);
    const hiddenExtras = extraFields.filter((f) => !visibleExtras.some((v) => v.key === f.key));
    const removeExtra = (key) => {
        setExtras((e) => e.filter((x) => x !== key));
        set(key, []);
    };
    const inactiveDims = GROUP_DIMS.filter((d) => !groupBy.includes(d));

    // Columns whose dim is currently grouped are redundant — auto-hidden.
    const displayCols = useMemo(
        () => visibleCols.filter((c) => !effGroup.some((d) => DIM_COL[d] === c.id)),
        [visibleCols, effGroup],
    );

    // first-of-list + "+N" helper for multi-line-item fields.
    const firstPlus = (vals) => (vals.length ? vals[0] + (vals.length > 1 ? ` +${vals.length - 1}` : '') : null);

    // Per-item columns get a specific line item `li` (sub-row); per-quotation columns
    // ignore it and read from `q`. Defaults to the first line item when not passed.
    const renderCell = (q, colId, li = (q.lineItems ?? [])[0] || {}) => {
        const o = q.order ?? {};
        switch (colId) {
            // 1. PRODUCT · PRINCIPAL · APPLICATION — Cath's column 2. Product print
            //    (original) on top; principal print below; application last. Division
            //    moved to column 1.
            case 'product': {
                const print = li.productName;
                const orig = li.productOriginal;
                const showOrig = orig && orig !== li.productName;
                const principalShown = !effGroup.includes('principal');
                const pTxt = li.principalName;
                const pOrig = li.principalOriginal;
                const showPOrig = pOrig && pOrig !== (li.principalName || pTxt);
                const application = li.application;
                return (
                    <div className="flex w-full min-w-0 flex-col gap-0.5 leading-tight">
                        <ExpandableText className="font-semibold text-foreground" title={`${print || ''}${showOrig ? ` (${orig})` : ''}`}>
                            {print || <NA />}
                            {showOrig && <span className="font-normal text-muted-foreground"> ({orig})</span>}
                        </ExpandableText>
                        {principalShown && (pTxt || application) && (
                            <ExpandableText className="text-[11px] text-muted-foreground"
                                title={[pTxt, application, showPOrig && `(orig: ${pOrig})`].filter(Boolean).join(' · ')}>
                                {pTxt || ''}
                                {application && <span className="text-muted-foreground/80">{pTxt ? ' · ' : ''}{application}</span>}
                            </ExpandableText>
                        )}
                    </div>
                );
            }
            // 2. COMPANY · #QT · DIVISION · INDUSTRY — Cath's column 1, TWO lines spread
            //    left↔right so it doesn't feel packed: company (+ sales) top-left, #QT
            //    top-right; division · industry bottom-left, Terms chip bottom-right.
            case 'entity': {
                const companyShown = !effGroup.includes('company');
                const salesShown = !effGroup.includes('sales');
                const company = q.company || 'NA';
                const head = companyShown ? company : (q.sales || company);
                const sales = salesShown && q.sales ? q.sales : '';
                const ctx = [q.division, q.industry].filter(Boolean).join(' · ');
                const tcEntries = Object.entries(q.terms ?? {}).filter(([, v]) => v && v !== '—');
                return (
                    <div className="flex w-full min-w-0 flex-col gap-0.5 leading-tight">
                        <div className="flex items-baseline justify-between gap-2 pr-1">
                            <ExpandableText className="min-w-0" title={sales ? `${head} · ${sales}` : head}>
                                <span className="font-semibold text-foreground">{head}</span>
                                {companyShown && sales && <span className="ml-1.5 text-[10px] font-normal text-muted-foreground">{sales}</span>}
                            </ExpandableText>
                            <span className="shrink-0 text-[11px] font-bold text-primary">#{q.id}</span>
                        </div>
                        {(ctx || tcEntries.length > 0) && (
                            <div className="flex items-center justify-between gap-2 pr-1 text-[11px]">
                                <ExpandableText className="min-w-0 text-muted-foreground" title={ctx}>{ctx || <NA />}</ExpandableText>
                                {tcEntries.length > 0 && (
                                    <Tip width={320} className="inline-flex shrink-0 cursor-help items-center gap-1 rounded border border-border/70 bg-muted/60 px-1 py-px text-[9px] font-bold uppercase tracking-wide leading-none text-muted-foreground hover:border-border hover:text-foreground"
                                        content={
                                            <RecordPanel title="Terms & Conditions" rows={tcEntries.map(([k, v]) => (
                                                <span><span className="font-semibold text-foreground">{k}</span>: <span className="text-muted-foreground">{v}</span></span>
                                            ))} />
                                        }>
                                        Terms
                                    </Tip>
                                )}
                            </div>
                        )}
                    </div>
                );
            }
            // 3. PACKING — qty+satuan · container. Weight comes from QuantityPacking;
            //    only the container type is taken from PackingName (the part after "/"),
            //    so the weight isn't shown twice. Full packing name stays in the hover
            //    tooltip. e.g. "20kg · bag". (Original-vs-print lives in the Product column.)
            case 'specs': {
                // PACKING — "packs × pack-size", e.g. "1x20" (Cath). Packs = OrderQuantity ÷
                // QuantityPacking (order 20, pack of 20 → 1; order 1 → 0.05). Container
                // stacked below. Non-orders (no order qty) show just the pack spec.
                const packing = li.packing;               // PackingName, e.g. "20 kg/bag"
                const packSize = parseFloat(li.qty);      // QuantityPacking (pack size)
                const orderQty = parseFloat(li.orderQty); // OrderQuantity
                if (!packing && !packSize) return <NA />;
                const slash = packing ? packing.indexOf('/') : -1;
                const weight = packing ? (slash >= 0 ? packing.slice(0, slash) : packing).trim() : '';
                const container = slash >= 0 ? packing.slice(slash + 1).trim() : '';
                const sizeTxt = weight || (packSize ? String(packSize) : '');
                const packs = (packSize > 0 && orderQty > 0) ? parseFloat((orderQty / packSize).toFixed(2)) : null;
                const top = packs != null ? `${packs}x${sizeTxt}` : sizeTxt;
                const full = [packs != null && `${packs} pack`, packing].filter(Boolean).join(' · ');
                return (
                    <div className="flex w-full min-w-0 flex-col gap-0.5 leading-tight" title={full}>
                        <span className="truncate text-foreground">{top || <NA />}</span>
                        {container && <span className="truncate text-[11px] text-muted-foreground">{container}</span>}
                    </div>
                );
            }
            // 4. UNIT PRICE — 2 lines: USD (bold) on top; bottom line = rate · IDR, with the
            //    IDR on the RIGHT so (right-aligned) it sits directly under the USD, and the
            //    rate to its left (user: swap IDR & rate left↔right, IDR under USD).
            case 'price': {
                const usd = parseIdr(li.unitUsd), idr = parseIdr(li.unitIdr);
                if (!usd && !idr) return <NA />;
                const rate = parseIdr(li.usdRate);
                const usdTxt = usd ? `$${usd.toLocaleString('en-US', { maximumFractionDigits: 2 })}` : '';
                const idrTxt = idr ? formatIdr(idr) : '';
                const rateVal = (usd && rate) ? rate.toLocaleString('id-ID') : '';
                return (
                    <div className="flex flex-col items-end gap-0.5 leading-tight tabular-nums">
                        {usdTxt && <span className="whitespace-nowrap font-semibold text-foreground" title={usdTxt}>{usdTxt}</span>}
                        {(idrTxt || rateVal) && (
                            <span className="inline-flex items-center gap-1.5 whitespace-nowrap text-[11px] text-muted-foreground">
                                {rateVal && <span className="inline-flex items-center gap-0.5 text-muted-foreground" title={`Conversion rate ${rateVal}`}><ArrowLeftRight className="size-3 shrink-0" aria-hidden="true" />{rateVal}</span>}
                                {rateVal && idrTxt && <span aria-hidden="true" className="h-3 w-px shrink-0 bg-border/60" />}
                                {idrTxt}
                            </span>
                        )}
                    </div>
                );
            }
            // 5. QTY — order quantity. Is-order shown subtly: the number is bold (orders)
            //    vs muted (non-orders, qty 0), with an "Is Order" tooltip on hover — no
            //    dot/checkbox (user found the dot ugly; prefers a hover hint).
            case 'qtyValue': {
                // filter(Boolean) does NOT drop a zero quantity: orderQty arrives as the STRING
                // "0", which is truthy, so the cell printed a bare 0 where the mockup asks for a
                // dash. Test the NUMBER, and only then keep the unit beside it.
                const qtyNum = Number(String(li.orderQty ?? '').replace(/[^\d.-]/g, ''));
                const qty = qtyNum ? [li.orderQty, li.satuanOrderQty].filter(Boolean).join(' ') : '';
                const order = isOrder(q);
                return (
                    <span className={`tabular-nums ${order ? 'cursor-help font-semibold text-foreground' : 'text-muted-foreground'}`}
                        title={order ? 'Is Order' : 'Not an order'}>
                        {qty || <NA />}
                    </span>
                );
            }
            // 5b. VALUE — THIS line item's value (USD on top, IDR below). Each row is one
            //     item now, so the value is per-item (li subtotal), not the quotation total.
            case 'value': {
                const valIdr = parseIdr(li.totalIdr);
                const valUsd = parseIdr(li.totalUsd);
                if (li.totalIdr == null && li.totalUsd == null) return <NA />;
                if (!valUsd && !valIdr) return <span className="block text-right"><NA /></span>;
                return (
                    <div className="flex flex-col items-end gap-0.5 leading-tight tabular-nums">
                        {valUsd ? <span className="whitespace-nowrap font-semibold text-foreground" title={formatUsd(valUsd)}>{formatUsd(valUsd)}</span> : null}
                        {valIdr ? <span className="whitespace-nowrap text-[11px] text-muted-foreground" title={formatIdr(valIdr)}>{formatIdr(valIdr)}</span> : null}
                    </div>
                );
            }
            // 6. TIMELINE — dates on line 1 (QT · ETA); PO number on line 2 only when
            //    the quotation is an actual Order (pending quotations have no real PO).
            case 'po': {
                const poDate = shortDate(q.poDate);        // Customer PO date (not Quotation date)
                const poNo = o['Customer PO No'] ?? q.poNo;
                const hasPo = poNo && poNo !== '—' && poNo !== '-';
                if (!hasPo && !poDate) return <span className="whitespace-nowrap text-[11px] italic text-muted-foreground/50">No PO</span>;
                return (
                    <div className="flex flex-col gap-0.5 leading-tight text-[11px] text-muted-foreground">
                        {hasPo && <span className="whitespace-nowrap font-semibold text-foreground">PO#{poNo}</span>}
                        {poDate && <span className="whitespace-nowrap tabular-nums text-muted-foreground/80">{poDate}</span>}
                    </div>
                );
            }
            case 'deliveryDate': {
                const eta = shortDate(q.deliveryDate);
                return <span className="block whitespace-nowrap text-[11px] tabular-nums text-muted-foreground">{eta || <span className="italic text-muted-foreground/50">—</span>}</span>;
            }
            // 7. LATEST FROM PM & SM — latest PM + latest SM by default; "+N more" expands
            //    the full PM/SM thread inline.
            case 'pmsm':
                return <PmSmCell q={q} barangId={li.barangId} companyId={q.companyId} />;
            // 8. ACTIVITY — icons instead of cryptic "R/H/L" codes (SPV: "R2 H1 L1
            //    maksudnya apa?"): note = remarks, clock = history, link = linked.
            //    Hovering an icon opens a panel listing the ACTUAL records.
            case 'meta': {
                const remarks = remarkRecords(q);
                const history = historyRecords(q);
                const codeCls = 'inline-flex cursor-help items-center gap-0.5 hover:text-foreground';
                return (
                    <span className="flex items-center gap-2.5 whitespace-nowrap text-[11px] tabular-nums text-muted-foreground/80">
                        <Tip className={codeCls} width={300} content={
                            <RecordPanel title={`Remarks (${remarks.length})`} rows={remarks.map((rec) => (
                                <span><span className="mr-1.5 rounded bg-muted px-1 py-px text-[9px] font-bold text-muted-foreground">{rec.tag}</span><span className="text-foreground">{rec.text}</span></span>
                            ))} />
                        }><MessageSquare className="size-3.5" aria-label="Remarks" />{remarks.length}</Tip>
                        <Tip className={codeCls} width={320} content={
                            <RecordPanel title={`History (${history.length})`} rows={history.map((h) => (
                                <span className="text-muted-foreground"><span className="font-semibold text-foreground">{h.status}</span>{h.date ? ` · ${h.date}` : ''}{h.user ? ` · ${h.user}` : ''}{h.comment ? ` — “${h.comment}”` : ''}</span>
                            ))} />
                        }><History className="size-3.5" aria-label="History" />{history.length}</Tip>
                    </span>
                );
            }
            default: return null;
        }
    };

    // ── Row renderers (BnT/LWR Approval PM tree pattern) ──
    const renderLeaf = (q, depth) => {
        // Real quotationlink count from the backend (approvalPmData()) — q.links.length,
        // precomputed server-side so every line item doesn't recompute it.
        const linkCount = q.linkCount || 0;
        // FULL: one independent row per line item. Every column renders for THAT item
        // (value = item subtotal), and each row is selected / approved / revised on its own.
        return itemsOf(q).map((li, idx) => {
            const id = liKey(q, li, idx);
            // Only actionable lines (still Request + the viewing PM heads the principal)
            // can be selected/approved/revised; others render read-only + muted.
            const canAct = !!li.canAct;
            const isSel = selectedIds.has(id);
            // Every state gets a SOLID/opaque background (and opaque hover) — the sticky frozen
            // columns must never be translucent or scrolling columns bleed through on slide.
            // Selected = light-violet accent so the picked rows read clearly.
            //
            // ⚠️ NOT `bg-accent`: in dark mode `--accent` resolves to
            // `rgb(200 156 255 / 0.16)` — 16% ALPHA — so a selected row went see-through and the
            // frozen columns showed the scrolling ones underneath. Same trap the design doc
            // records for `bg-secondary/40` on even rows. `--color-primary` is opaque in both
            // themes, so mixing it into the card colour keeps the tint AND the opacity.
            const rowBg = isSel ? SELECTED_BG : 'bg-card';
            const rowHoverTr = isSel ? SELECTED_HOVER_TR : 'hover:bg-muted';
            const rowHoverCell = isSel ? SELECTED_HOVER_CELL : 'group-hover:bg-muted';
            return (
              <tr key={id} className={`group ${rowBg} transition-colors ${rowHoverTr} ${canAct ? '' : 'text-muted-foreground'}`}>
                <td className={`sticky left-0 z-20 ${rowBg} transition-colors py-2.5 pl-6 pr-2 align-top ${rowHoverCell}`}>
                   {canAct
                       ? <CheckBox checked={selectedIds.has(id)} onChange={() => toggleOne(id)} ariaLabel="Select line" />
                       : <span className="inline-block size-4" aria-hidden="true" />}
                </td>
                {displayCols.map((col, i) => (
                  <td key={col.id} className={[
                     'py-2.5 pl-4 pr-4 align-top',
                     col.align ? 'text-right tabular-nums' : '',
                     GROUP_EMPHASIS[col.groupId] || '',
                     FROZEN[col.id] ? `${FROZEN[col.id]} ${rowBg} transition-colors ${rowHoverCell}` : '',
                  ].filter(Boolean).join(' ')}
                    style={i === 0 && depth > 0 ? { paddingLeft: 16 + depth * 16 } : undefined}>
                     {renderCell(q, col.id, li)}
                  </td>
                ))}
                {/* ACTIONS — per line item: 🔗 Linked (hover) · ✓ Approve · ↺ Revise. */}
                <td className="px-4 py-2.5 align-top">
                  <div className="flex items-center justify-end gap-1">
                      <Tip width={260} className="inline-flex" content={
                          <RecordPanel title={`Linked (${linkCount})`}
                              rows={(q.links ?? []).map((l) => `${l.typeName} No.${l.linkedId}`)} />
                      }>
                          <button type="button" onClick={() => openLinks(q)} aria-label={`Linked records (${linkCount})`}
                              className="relative grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-primary">
                              <Link2 className="size-5" strokeWidth={1.5} />
                              {linkCount > 0 && <span className="pointer-events-none absolute -top-1 right-0 grid h-3.5 min-w-3.5 place-items-center rounded-full bg-muted px-0.5 text-[8px] font-bold leading-none text-muted-foreground tabular-nums ring-1 ring-card">{linkCount}</span>}
                          </button>
                      </Tip>
                      {canAct ? (
                          <>
                              <button type="button" onClick={() => openComment('approve', [id])} title="Approve" aria-label="Approve"
                                  className="grid size-6 place-items-center rounded-md border border-success/30 bg-success/5 text-success-text transition-colors hover:bg-success/15"><Check className="size-3.5" strokeWidth={3} /></button>
                              <button type="button" onClick={() => openComment('revise', [id])} title="Revise" aria-label="Revise"
                                  className="grid size-6 place-items-center rounded-md border border-border bg-transparent text-muted-foreground transition-colors hover:border-warning hover:bg-warning-bg hover:text-warning-text"><RotateCcw className="size-3.5" strokeWidth={2.5} /></button>
                          </>
                      ) : (
                          <span className="grid size-6 place-items-center text-muted-foreground/40" title="Bukan line Anda / sudah diproses">—</span>
                      )}
                  </div>
                </td>
              </tr>
            );
        });
    };

    const renderNodes = (nodes, depth, parentPath) => nodes.map((node) => {
        const path = nodePath(parentPath, node);
        const isCollapsed = collapsedPaths.has(path);
        const ids = node.rows.flatMap((r) => actionableKeys(r));
        const sel = groupState(ids);
        const orderCount = node.rows.filter(isOrder).length;
        const groupValue = node.rows.reduce((s, r) => s + lineTotalIdr(r), 0);
        const isTop = depth === 0;
        return (
            <Fragment key={path}>
                <tr className={`transition-colors ${isTop ? 'bg-muted/40' : 'bg-muted/20'} hover:bg-muted/60`}>
                    <td className={`py-3 pl-6 pr-2 align-middle ${isTop && !isCollapsed ? 'shadow-[inset_3px_0_0_var(--color-primary)]' : ''}`}>
                        {/* No checkbox when the group holds nothing actionable — e.g. a company
                            whose quotations are all already approved (they stay listed, read-only).
                            A rendered-but-dead checkbox would claim a selection it cannot make. */}
                        {ids.length > 0 && (
                            <CheckBox {...sel} onChange={() => toggleGroup(ids)} ariaLabel={`Select all in ${node.label}`} />
                        )}
                    </td>
                    <td colSpan={displayCols.length + 1} className="py-3 pl-4 pr-4 align-middle">
                        <div className="flex w-full cursor-pointer select-none items-center gap-2.5" style={{ paddingLeft: depth * 16 }} onClick={() => toggleCollapse(path)}>
                            <span className={`grid place-items-center text-primary transition-transform ${isCollapsed ? '-rotate-90' : ''}`}>
                                <ChevronDown className={isTop ? 'size-4' : 'size-3.5'} strokeWidth={2.5} />
                            </span>
                            {/* name left; one quiet stats string flush right */}
                            <span className="text-[12.5px] font-bold normal-case tracking-tight text-foreground">{node.label}</span>
                            <span className="ml-auto inline-flex items-center gap-1.5 whitespace-nowrap pr-2 text-[11.5px] font-medium normal-case tabular-nums text-muted-foreground">
                                {node.rows.length} quotation{node.rows.length !== 1 ? 's' : ''} · {orderCount} order{orderCount !== 1 ? 's' : ''}
                                <span aria-hidden="true" className="text-muted-foreground/40">·</span>
                                <span className="font-bold text-foreground" title={formatIdr(groupValue)}>{formatCompact(groupValue)}</span>
                            </span>
                        </div>
                    </td>
                </tr>
                {!isCollapsed && (node.children ? renderNodes(node.children, depth + 1, path) : node.rows.map((r) => renderLeaf(r, depth + 1)))}
            </Fragment>
        );
    });

    return (
      <section className="flex min-w-0 flex-col gap-[18px]" id="lastQuotationPmView">
        <header className="flex items-start justify-between gap-4">
          <div>
            <p className="m-0 mb-1.5 flex items-center gap-2 text-xs font-medium text-muted-foreground">
              <Crumb href={route('quotations.index')}>Quotations</Crumb>
              <CrumbSep />
              <CrumbCurrent>Approval PM</CrumbCurrent>
            </p>
            <h1 className="m-0 text-2xl font-bold leading-[1.15] tracking-tight text-card-foreground">Approval PM</h1>
          </div>

        </header>

        <div className="flex flex-col lg:flex-row lg:items-start gap-5">
        {/* Main list */}
        <article className="min-w-0 flex-1 overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
          {/* Filter bar — follows the Budget & Target Approval PM pattern */}
          <div className="flex flex-wrap items-center gap-2.5 border-b border-border/50 bg-card px-6 py-5">
            <div ref={searchRef} className="relative min-w-[200px] max-w-[320px] flex-1">
              <label className="inline-flex h-8 w-full items-center gap-2 rounded-full border border-transparent bg-muted/60 px-3.5 text-muted-foreground transition-colors hover:bg-muted focus-within:border-primary/40 focus-within:bg-card">
                <Search aria-hidden="true" className="size-3.5 shrink-0" />
                <input type="search" placeholder="Search QT No, PO, Company, or Product" autoComplete="off" value={filters.q}
                  onChange={(e) => { set('q', e.target.value); setSearchOpen(true); }}
                  onFocus={() => setSearchOpen(true)}
                  className="min-w-0 flex-1 bg-transparent text-[12.5px] font-medium text-foreground outline-none placeholder:text-muted-foreground" />
              </label>
              {searchOpen && searchMatches.length > 0 && (
                <div className="absolute left-0 top-full z-50 mt-1.5 max-h-[280px] w-full overflow-y-auto rounded-xl border border-border bg-surface py-1.5 shadow-modal">
                  {searchMatches.map((m) => (
                    <button key={m.id} type="button" onMouseDown={(e) => { e.preventDefault(); set('q', String(m.id)); setSearchOpen(false); }}
                      className="flex w-full flex-col items-start px-3.5 py-2 text-left transition-colors hover:bg-surface-tint">
                      <span className="text-[12.5px] font-semibold leading-tight text-foreground">#{m.id} · {m.company}</span>
                      <span className="text-[11px] tabular-nums text-muted-foreground">{m.tanggal || ''}{isOrder(m) ? ` · PO ${m.poNo}` : ''}</span>
                    </button>
                  ))}
                </div>
              )}
            </div>

            <span aria-hidden="true" className="h-5 w-px shrink-0 bg-border/70" />

            <label htmlFor="lqpmIsOrder"
              className={`relative inline-flex h-8 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-full border px-3 text-[12.5px] font-semibold shadow-sm transition-colors hover:border-primary hover:text-primary ${filters.isOrder ? 'border-border-soft-strong bg-accent text-primary' : 'border-border/50 bg-card text-muted-foreground'}`}>
              <input type="checkbox" id="lqpmIsOrder" checked={filters.isOrder} onChange={(e) => set('isOrder', e.target.checked)} className="pointer-events-none absolute opacity-0" />
              <span aria-hidden="true" className={`size-1.5 shrink-0 rounded-full transition-colors ${filters.isOrder ? 'bg-primary' : 'bg-muted-foreground/40'}`} />
              <span>Is Order</span>
            </label>

            <RangePill label="Period" value={filters.dateRange} options={DATE_RANGE_OPTIONS} onChange={(v) => set('dateRange', v)} />

            {coreFields.map((f) => (
              <FilterPill key={f.key} label={f.label} value={filters[f.key]} options={f.opts} onChange={(v) => set(f.key, v)} />
            ))}
            {visibleExtras.map((f) => (
              <FilterPill key={f.key} label={f.label} value={filters[f.key]} options={f.opts} onChange={(v) => set(f.key, v)} onRemove={() => removeExtra(f.key)} />
            ))}

            <AddFilterMenu fields={hiddenExtras} onAdd={(k) => setExtras((e) => [...e, k])} />

            {activeFilterCount > 0 && (
              <button type="button" onClick={resetFilters} className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full px-2.5 text-[12.5px] font-semibold text-muted-foreground transition-colors hover:bg-danger/10 hover:text-danger-text">
                Reset filters
              </button>
            )}

            <div className="ml-0 inline-flex items-center gap-0.5 sm:ml-auto">
              <button type="button" onClick={expandAll} title="Expand all" aria-label="Expand all"
                className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
                <Maximize2 className="size-3.5" strokeWidth={2.5} />
              </button>
              <button type="button" onClick={collapseAll} title="Collapse all" aria-label="Collapse all"
                className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
                <Minimize2 className="size-3.5" strokeWidth={2.5} />
              </button>
              <button type="button" onClick={() => setShowConfig((v) => !v)} title="Configuration" aria-label="Configuration" aria-pressed={showConfig}
                className={`grid size-7 place-items-center rounded-md transition-colors ${showConfig ? 'bg-accent text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground'}`}>
                <Settings className="size-3.5" strokeWidth={2.5} />
              </button>
            </div>
          </div>

          <div className="overflow-x-auto">
            <table
              style={{ minWidth: CHECKBOX_W + ACTIONS_W + displayCols.reduce((s, c) => s + (colW[c.id] ?? COL_W_DEFAULT), 0) }}
              className="w-full table-fixed border-separate border-spacing-0 [&_tbody_td]:whitespace-nowrap [&_tbody_td]:align-top [&_tbody_td]:text-[13px] [&_tbody_td]:text-card-foreground [&_tbody_td]:border-b [&_tbody_td]:border-border/60 [&_thead_th]:cursor-default [&_thead_th]:select-none [&_thead_th]:whitespace-nowrap [&_thead_th]:bg-[color-mix(in_srgb,var(--color-muted)_40%,var(--color-card))] [&_thead_th]:text-left [&_thead_th]:text-[11px] [&_thead_th]:font-semibold [&_thead_th]:uppercase [&_thead_th]:tracking-wide [&_thead_th]:text-muted-foreground">
              <colgroup>
                <col style={{ width: CHECKBOX_W }} />
                {displayCols.map((col) => <col key={col.id} style={{ width: colW[col.id] ?? COL_W_DEFAULT }} />)}
                <col />
              </colgroup>
              <thead>
                <tr>
                  <th className="sticky left-0 z-20 w-12 bg-[color-mix(in_srgb,var(--color-muted)_40%,var(--color-card))]! py-3 pl-6 pr-2">
                     <CheckBox checked={allOnPageSelected} onChange={toggleAll} />
                  </th>
                  {displayCols.map((col) => {
                    const pinned = PINNED.includes(col.id);
                    return (
                    <th key={col.id}
                       draggable={!pinned}
                       onDragStart={(e) => { if (pinned || resizeRef.current) { e.preventDefault(); return; } setDragColId(col.id); }}
                       onDragOver={(e) => e.preventDefault()}
                       onDrop={() => { if (!pinned) reorderCols(dragColId, col.id); setDragColId(null); }}
                       onDragEnd={() => setDragColId(null)}
                       title={pinned ? 'Pinned · drag right edge to resize' : 'Drag to reorder · drag right edge to resize'}
                       className={`group/col relative py-3 pl-4 pr-4 ${pinned ? 'cursor-default' : 'cursor-grab active:cursor-grabbing'} ${col.w || ''} ${FROZEN[col.id] ? `${FROZEN[col.id]} bg-[color-mix(in_srgb,var(--color-muted)_40%,var(--color-card))]!` : ''} ${dragColId === col.id ? 'opacity-40' : ''}`}>
                       {SORT_GETTERS[col.id]
                          ? <SortButton id={col.id} label={col.label} sortKey={sortKey} sortDir={sortDir} onToggle={toggleSort} />
                          : col.align ? <span className="block text-right">{col.label}</span> : col.label}
                       {/* Resize grip — invisible at rest (no faint gridline); a violet bar appears on hover/drag. */}
                       <span onMouseDown={(e) => startResize(e, col.id)} onClick={(e) => e.stopPropagation()} title="Drag to resize column"
                          className="group/resize absolute -right-1.5 top-0 z-10 flex h-full w-3 cursor-col-resize select-none items-center justify-center" aria-hidden="true">
                          <span className={`w-0.5 rounded-full transition-all ${resizingId === col.id ? 'h-2/3 bg-primary' : 'h-1/3 bg-transparent group-hover/col:h-2/3 group-hover/col:bg-muted-foreground/30 group-hover/resize:h-2/3 group-hover/resize:bg-primary'}`} />
                       </span>
                    </th>
                    );
                  })}
                  <th className="px-4 py-3 !text-right!">Actions</th>
                </tr>
              </thead>
              <tbody>
                {pageRows.length === 0 ? (
                  <tr>
                    <td colSpan={displayCols.length + 2} className="px-4 py-16 text-center">
                      <Inbox aria-hidden="true" className="mx-auto mb-2 size-8 text-muted-foreground/40" />
                      <p className="m-0 text-[13px] text-muted-foreground">No quotations match the current filters.</p>
                      {activeFilterCount > 0 && (
                        <button type="button" onClick={resetFilters} className="mt-1.5 text-xs font-bold text-primary hover:underline">Reset filters</button>
                      )}
                    </td>
                  </tr>
                ) : (
                  effGroup.length ? renderNodes(tree, 0, '') : pageRows.map((q) => renderLeaf(q, 0))
                )}
              </tbody>
            </table>
          </div>

          <ListFooter page={currentPage} totalPages={totalPages} onPage={setPage}
             pageSize={pageSize} onPageSize={(n) => { setPageSize(n); setPage(1); }}
             pageSizeOptions={[5, 10, 15, 20]} total={rows.length} itemLabel="quotations"
             stats={<>
                <StatsDot />
                <span><b className="font-semibold text-foreground">{stats.total}</b> quotes</span>
                <StatsDot />
                <span><b className="font-semibold text-foreground">{stats.orders}</b> orders</span>
                <StatsDot />
                <span><b className="font-semibold text-foreground">{stats.noOrder}</b> no order</span>
                <StatsDot />
                <span className="font-semibold text-foreground" title={formatIdr(stats.value)}>{formatCompact(stats.value)}</span>
             </>} />
        </article>

        {/* Configuration panel — BnT Approval PM pattern (same as LWR Approval PM) */}
        {showConfig && (
          <div className="lg:sticky lg:top-6 flex h-[70vh] lg:h-[calc(100vh-3rem)] w-full lg:w-[340px] shrink-0 flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
            <div className="flex shrink-0 items-start justify-between border-b border-border/60 px-5 py-5">
              <div>
                <h3 className="m-0 text-[14.5px] font-extrabold tracking-tight text-foreground">Configuration</h3>
                <p className="m-0 mt-1 text-[12.5px] font-medium text-muted-foreground">Group the queue and arrange columns.</p>
              </div>
              <button type="button" onClick={() => setShowConfig(false)} aria-label="Close configuration"
                className="-mr-1.5 -mt-1 rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
                <X className="size-[18px]" strokeWidth={2.5} />
              </button>
            </div>

            <div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto p-5">
              {/* APPLIED FILTERS — mirrors the toolbar pills, BnT-style chips */}
              <div className="flex flex-col gap-3">
                <div className="flex items-center justify-between">
                  <div className="flex items-center gap-2 text-[11px] font-extrabold uppercase tracking-widest text-muted-foreground">
                    Applied filters <span className="text-[10px] font-semibold opacity-70">({activeFilterCount})</span>
                  </div>
                  {activeFilterCount > 0 && (
                    <button type="button" onClick={resetFilters} className="text-[11px] font-bold text-primary hover:underline">Clear all</button>
                  )}
                </div>
                <div className="flex flex-wrap gap-2">
                  {filters.q.trim() && (
                    <div className="inline-flex h-[26px] items-center gap-1.5 rounded border border-border/80 bg-surface px-2.5 text-[11.5px] font-semibold text-foreground shadow-sm">
                      Search: {filters.q.trim()}
                      <button type="button" onClick={() => set('q', '')} className="ml-0.5 text-muted-foreground hover:text-danger-text">×</button>
                    </div>
                  )}
                  {filters.isOrder && (
                    <div className="inline-flex h-[26px] items-center gap-1.5 rounded border border-border/80 bg-surface px-2.5 text-[11.5px] font-semibold text-foreground shadow-sm">
                      Is Order: Yes
                      <button type="button" onClick={() => set('isOrder', false)} className="ml-0.5 text-muted-foreground hover:text-danger-text">×</button>
                    </div>
                  )}
                  {filters.dateRange !== 'all' ? (
                    <div className="inline-flex h-[26px] items-center gap-1.5 rounded border border-border/80 bg-surface px-2.5 text-[11.5px] font-semibold text-foreground shadow-sm">
                      Period: {DATE_RANGE_OPTIONS.find((o) => o.value === filters.dateRange)?.label}
                      <button type="button" onClick={() => set('dateRange', 'all')} className="ml-0.5 text-muted-foreground hover:text-danger-text">×</button>
                    </div>
                  ) : (
                    <div className="inline-flex h-[26px] items-center gap-1.5 rounded border border-border/80 bg-muted/40 px-2.5 text-[11.5px] font-semibold text-muted-foreground">Period: All</div>
                  )}
                  {[...coreFields, ...extraFields].map((f) => {
                    const vals = filters[f.key] || [];
                    if (!vals.length) {
                      return coreFields.some((c) => c.key === f.key) ? (
                        <div key={`${f.key}-all`} className="inline-flex h-[26px] items-center gap-1.5 rounded border border-border/80 bg-muted/40 px-2.5 text-[11.5px] font-semibold text-muted-foreground">{f.label}: All</div>
                      ) : null;
                    }
                    return vals.map((v) => (
                      <div key={`${f.key}-${v}`} className="inline-flex h-[26px] items-center gap-1.5 rounded border border-border/80 bg-surface px-2.5 text-[11.5px] font-semibold text-foreground shadow-sm">
                        {f.label}: {v}
                        <button type="button" onClick={() => set(f.key, vals.filter((x) => x !== v))} className="ml-0.5 text-muted-foreground hover:text-danger-text">×</button>
                      </div>
                    ));
                  })}
                </div>
              </div>

              {/* ROWS — BnT rule: all dims listed; the checkbox makes one a group */}
              <div className="flex flex-col gap-3">
                <div className="text-[11px] font-extrabold uppercase tracking-widest text-muted-foreground">Rows</div>
                <div className="flex flex-col gap-2">
                  <SortableList ids={groupBy} onReorder={setGroupBy} className="flex flex-col gap-2" renderItem={(id) => (
                    <GroupPill
                      label={GROUP_META[id]?.label || id}
                      grouped={grouped.has(id)}
                      onToggleGroup={() => toggleGrouped(id)}
                      onRemove={() => { setGroupBy(groupBy.filter((x) => x !== id)); setGrouped((prev) => { const n = new Set(prev); n.delete(id); return n; }); }}
                    />
                  )} />
                  {inactiveDims.length > 0 && (
                    <select
                      className="h-[34px] w-full cursor-pointer appearance-none rounded-lg border border-dashed border-input bg-muted/30 px-3 text-center text-[12.5px] font-semibold text-muted-foreground transition-colors hover:bg-muted focus:outline-none"
                      value="" onChange={(e) => { if (e.target.value) { setGroupBy([...groupBy, e.target.value]); e.target.value = ''; } }}>
                      <option value="" disabled>+ Add field</option>
                      {inactiveDims.map((d) => <option key={d} value={d}>{GROUP_META[d].label}</option>)}
                    </select>
                  )}
                  <p className="m-0 px-1 text-[11px] leading-snug text-muted-foreground">Check a field to group the table by it (pill order = nesting); drag to reorder, × to remove.</p>
                </div>
              </div>

              {/* COLUMNS — order only: every column always stays visible */}
              <div className="flex flex-col gap-3">
                <div className="flex items-center justify-between">
                  <div className="text-[11px] font-extrabold uppercase tracking-widest text-muted-foreground">Columns</div>
                  <button type="button" onClick={() => { try { localStorage.removeItem(COLUMN_STORAGE_KEY); } catch { /* ignore */ } setColumnState(defaultColumnState()); }} className="text-[11px] font-bold text-primary hover:underline">Reset</button>
                </div>
                <SortableList
                  ids={columnState.map((c) => c.id)}
                  onReorder={(ids) => {
                    const byId = new Map(columnState.map((c) => [c.id, c]));
                    const next = normalizePins(ids.map((id) => byId.get(id)).filter(Boolean));
                    try { localStorage.setItem(COLUMN_STORAGE_KEY, JSON.stringify(next)); } catch { /* ignore */ }
                    setColumnState(next);
                  }}
                  className="flex flex-col gap-2"
                  renderItem={(id) => {
                    const def = colDefById.get(id);
                    const isVisible = columnState.find((c) => c.id === id)?.visible !== false;
                    const required = def?.required;
                    return (
                      <div className={`flex h-9 items-center gap-2 rounded-lg border border-border/80 bg-muted/30 px-2.5 text-[12px] font-bold shadow-sm transition-opacity ${isVisible ? 'text-foreground' : 'text-muted-foreground opacity-60'}`}>
                        <span className="grid place-items-center opacity-60 transition-opacity hover:opacity-100" aria-hidden="true">
                          <svg width="10" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><circle cx="9" cy="5" r="1" /><circle cx="9" cy="12" r="1" /><circle cx="9" cy="19" r="1" /><circle cx="15" cy="5" r="1" /><circle cx="15" cy="12" r="1" /><circle cx="15" cy="19" r="1" /></svg>
                        </span>
                        <span className="flex-1 truncate">{def?.label || id}</span>
                        <button
                          type="button"
                          disabled={required}
                          onPointerDown={(e) => e.stopPropagation()}
                          onClick={() => toggleColumnVisible(id)}
                          title={required ? 'Required column' : isVisible ? 'Hide column' : 'Show column'}
                          className="inline-grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-primary disabled:opacity-30"
                        >
                          {isVisible ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
                        </button>
                      </div>
                    );
                  }}
                />
                <p className="m-0 px-1 text-[11px] leading-snug text-muted-foreground">Drag to reorder · klik ikon mata buat hide/show kolom. Lebar kolom bisa di-drag dari tepi kanan header.</p>
              </div>
            </div>

            <div className="mt-auto flex shrink-0 items-center gap-3 border-t border-border/60 p-5">
              <button type="button" onClick={() => { resetFilters(); setGroupBy(['principal', 'company', 'sales', 'industry', 'application', 'creator']); setGrouped(new Set()); setCollapsedPaths(new Set()); }}
                className="h-9 flex-1 rounded-lg border border-input bg-card text-[13px] font-bold text-foreground transition-colors hover:border-primary hover:text-primary">Reset</button>
              <button type="button" onClick={() => setShowConfig(false)}
                className="h-9 flex-1 rounded-lg border border-transparent bg-linear-to-br from-violet-500 to-primary text-[13px] font-bold text-white shadow-sm transition-[filter] hover:brightness-105">Apply</button>
            </div>
          </div>
        )}
        </div>

        <Dialog open={commentDialog.open} onOpenChange={(o) => setCommentDialog((d) => ({ ...d, open: o }))}>
          <DialogContent className="sm:max-w-[440px]">
            <DialogHeader>
              <DialogTitle className="flex items-center gap-2.5">
                <span className={`grid size-8 shrink-0 place-items-center rounded-full ${commentDialog.action === 'approve' ? 'bg-success/12 text-success-text' : 'bg-warning-bg text-warning-text'}`}>
                  {commentDialog.action === 'approve'
                    ? <Check className="size-4" strokeWidth={3} />
                    : <RotateCcw className="size-4" strokeWidth={2.5} />}
                </span>
                <span>
                  {commentDialog.action === 'approve' ? 'Approve' : 'Revise'}{' '}
                  {commentDialog.ids.length === 1 ? '1 line' : `${commentDialog.ids.length} lines`}?
                </span>
              </DialogTitle>
              <DialogDescription>
                {commentDialog.ids.length === 1 ? 'This line' : `These ${commentDialog.ids.length} lines`} will be{' '}
                <b className={commentDialog.action === 'approve' ? 'text-success-text' : 'text-warning-text'}>
                  {commentDialog.action === 'approve' ? 'Approved' : 'sent back for revision'}
                </b>{' '}and recorded in the quotation history.
              </DialogDescription>
              {commentDialog.action === 'revise' && (
                <p className="mt-1 text-[11px] text-warning-text">Quotation dikembalikan ke pembuatnya; line yang Anda centang ditandai revisi.</p>
              )}
            </DialogHeader>
            <div>
              <label htmlFor="approvalComment" className="mb-1.5 block text-xs font-semibold text-foreground">
                Comment <span className="text-danger-text">*</span>
              </label>
              <Textarea
                id="approvalComment"
                autoFocus
                value={commentDraft}
                onChange={(e) => setCommentDraft(e.target.value)}
                placeholder={commentDialog.action === 'approve' ? 'Reason / approval note…' : 'Apa yang perlu direvisi…'}
                className="min-h-[96px] resize-none"
              />
              <p className="mt-1.5 text-[11px] text-muted-foreground">Required — you can’t continue while it’s empty.</p>
              {formError && <p className="mt-1.5 text-[11px] text-danger-text">{formError}</p>}
            </div>
            <DialogFooter>
              <Button
                size="sm"
                variant="default"
                className={commentDialog.action === 'approve' ? '' : 'border border-warning bg-warning-bg text-warning-text hover:bg-warning-bg/80'}
                disabled={!commentDraft.trim() || submitting}
                onClick={submitComment}
              >
                {submitting ? 'Memproses…' : `Yes, ${commentDialog.action === 'approve' ? 'Approve' : 'Revise'}`}
              </Button>
              <Button variant="outline" size="sm" onClick={() => setCommentDialog((d) => ({ ...d, open: false }))}>Cancel</Button>
            </DialogFooter>
          </DialogContent>
        </Dialog>

        {/* LINKED DATA — shared dialog (Components/DocumentLinks/LinkedRecordsDialog).
            approvalPmData() now ships the real link rows (type/typeName/linkedId/url)
            per queue item, and quotations.links serves the lazy, principal-scoped
            detail lines on open. See Task 6/8 (#62). */}
        <LinkedRecordsDialog
            open={!!linkedQ}
            onClose={() => setLinkedQ(null)}
            docId={linkedQ?.id}
            docLabel={linkedQ ? `quotation #${linkedQ.id}` : ''}
            links={linkedQ?.links ?? []}
            detailsRoute="quotations.links"
        />

        {/* Floating decision pill. `.claude/rules/ui-conventions.md`: "Long detail/approval
            pages (action row against a record) → centred floating pill (DecisionBar)", and the
            reason it gives is this page exactly — "approval pages are long ... without pinning,
            the approver has to scroll to the bottom every time they decide". Here it is worse
            than on a detail page: you tick rows going DOWN a long queue, so buttons in the page
            header mean scrolling back UP to act on them.
            Rendered even with nothing selected, disabled — the checkbox column needs a visible
            verb (user 2026-08-24). DecisionBar carries its own bottom spacer.
            ⚠️ TWO actions only: routes/web.php locks this stage to `approve` and `revise`. */}
        <DecisionBar>
          {selectedIds.size > 0 ? (
            <span className="inline-flex items-center gap-2 text-[12.5px] font-medium text-foreground">
              <span className="grid size-5 place-items-center rounded-full bg-primary text-[11px] font-bold tabular-nums text-primary-foreground">{selectedIds.size}</span>
              selected
              <button type="button" onClick={() => setSelectedIds(new Set())}
                className="text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground">Clear</button>
            </span>
          ) : (
            <span className="text-[12.5px] font-medium text-muted-foreground">Pilih baris untuk di-approve</span>
          )}
          <div className="flex items-center gap-2.5">
            <Button size="sm" disabled={selectedIds.size === 0} onClick={() => openComment('approve', [...selectedIds])}
              className="h-9 gap-1.5 px-4 text-xs font-bold">
              <Check className="size-3.5" strokeWidth={3} />Approve Selected
            </Button>
            <Button variant="outline" size="sm" disabled={selectedIds.size === 0} onClick={() => openComment('revise', [...selectedIds])}
              className="h-9 gap-1.5 border-warning/40 px-4 text-xs font-bold text-warning-text hover:border-warning hover:bg-warning-bg hover:text-warning-text">
              <RotateCcw className="size-3.5" strokeWidth={2.5} />Revise Selected
            </Button>
          </div>
        </DecisionBar>
      </section>
    );
}

ApprovalPmIndex.layout = [AppLayout]
