#!/usr/bin/env python3
import cgi, sys
sys.path.insert(0, '/opt/ngon/apps')
from managers.auth_manager import AuthManager, generate_login_page_html

_form = cgi.FieldStorage()
_auth = AuthManager('gen_service')
_auth_required, _should_exit, _headers = _auth.require_auth(_form)

if _should_exit:
    print("Content-Type: application/json")
    if _headers:
        print(_headers)
    print("")
    if _auth_required:
        print('{"success": false, "error": "Authentication required"}')
    else:
        print('{"success": true}')
    sys.exit(0)

if _auth_required:
    print("Content-Type: text/html")
    print("Cache-Control: no-cache, no-store, must-revalidate")
    print("Expires: 0")
    print("")
    print(generate_login_page_html("Generator Service History"))
    sys.exit(0)


import cgi
import cgitb
import sys

sys.path.insert(0, '/var/www/html/ngon')
from links import generate_dropdown_html, generate_dropdown_css, generate_dropdown_js

cgitb.enable()
_current_user = ''.join(c for c in (AuthManager.get_current_user() or '')
                        if c.isalnum() or c in '_-.')
# Service records change under the reader (imports, estimate status, QBO export
# stamps), and with no directive a browser applies heuristic caching — WebKit
# especially — so someone can be shown records that have since moved on.
print("Content-Type: text/html")
print("Cache-Control: no-cache, no-store, must-revalidate")
print("Expires: 0")
print("")

print("""<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>NGON Generator Service History</title>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            background: #1a1a1a;
            color: #e0e0e0;
            margin: 0;
            padding: 0;
            line-height: 1.4;
        }
        /* Scrollbars */
        ::-webkit-scrollbar { width: 6px; height: 6px; }
        ::-webkit-scrollbar-track { background: transparent; }
        ::-webkit-scrollbar-thumb { background: #666; border-radius: 3px; }
        ::-webkit-scrollbar-thumb:hover { background: #888; }
        ::-webkit-scrollbar-corner { background: transparent; }
        * { scrollbar-width: thin; scrollbar-color: #666 transparent; }

        """ + generate_dropdown_css() + """

        /* Header - matching status page style */
        .header {
            background-color: rgba(255,255,255,0.05) !important;
            border-radius: 8px !important;
            padding: 30px !important;
            margin: 20px !important;
            border: 1px solid rgba(255,255,255,0.1) !important;
            border-bottom: none !important;
            display: flex !important;
            justify-content: space-between !important;
            align-items: flex-start !important;
        }

        /* Tabs */
        .tabs { display: flex; gap: 0; margin: 0 20px; }
        .tab-btn {
            padding: 12px 28px; background: #2a2a2a; border: 1px solid #3a3a3a;
            border-bottom: none; color: #888; cursor: pointer; font-size: 14px;
            border-radius: 8px 8px 0 0; transition: all 0.2s;
        }
        .tab-btn:hover { color: #e0e0e0; background: #333; }
        .tab-btn.active { background: #1e1e1e; color: #00ff00; border-color: #00ff00; border-bottom: 1px solid #1e1e1e; }
        .tab-content { display: none; margin: 0 20px 20px; padding: 20px; background: #1e1e1e; border: 1px solid #3a3a3a; border-radius: 0 8px 8px 8px; }
        .tab-content.active { display: block; }

        /* Filter bar */
        .filter-bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 15px; }
        .filter-bar label { color: #888; font-size: 12px; margin-right: 2px; }
        .filter-bar select, .filter-bar input {
            background: #2a2a2a; border: 1px solid #3a3a3a; color: #e0e0e0;
            padding: 6px 10px; border-radius: 4px; font-size: 13px;
        }
        .filter-bar select:focus, .filter-bar input:focus { border-color: #00ff00; outline: none; }
        .filter-group { display: flex; align-items: center; gap: 4px; }

        /* Buttons */
        .btn {
            padding: 7px 16px; border: 1px solid #3a3a3a; border-radius: 4px;
            cursor: pointer; font-size: 13px; transition: all 0.2s; background: #2a2a2a; color: #e0e0e0;
        }
        .btn:hover { border-color: #00ff00; color: #00ff00; }
        .btn-primary { background: #00aa00; color: #fff; border-color: #00aa00; }
        .btn-primary:hover { background: #00cc00; }
        .btn-danger { background: #aa0000; color: #fff; border-color: #aa0000; }
        .btn-danger:hover { background: #cc0000; }
        .btn-small { padding: 3px 8px; font-size: 11px; }
        .btn:disabled { opacity: 0.5; cursor: not-allowed; }

        /* Tables */
        table { width: 100%; border-collapse: collapse; font-size: 13px; }
        th { background: #2a2a2a; color: #00ff00; text-align: left; padding: 8px 10px; cursor: pointer; user-select: none; white-space: nowrap; border-bottom: 2px solid #3a3a3a; }
        th:hover { background: #333; }
        th .sort-arrow { margin-left: 4px; font-size: 10px; }
        td { padding: 6px 10px; border-bottom: 1px solid #2a2a2a; }
        tr:hover { background: rgba(255,255,255,0.03); }
        .amount-cell { text-align: right; font-family: monospace; }

        /* Grouped pivot table */
        .pivot-table td.num, .pivot-table th.num { text-align: right; font-family: monospace; white-space: nowrap; }
        .pivot-table td.rowlabel, .pivot-table th.rowlabel { text-align: left; white-space: nowrap; position: sticky; left: 0; background: #1e1e1e; z-index: 1; }
        .pivot-table th.rowlabel { background: #2a2a2a; }
        .pivot-table tr:hover td.rowlabel { background: #262626; }
        .pivot-table .zero { color: #555; }
        .pivot-table tfoot td { border-top: 2px solid #3a3a3a; font-weight: bold; color: #00ff00; background: #222; }
        .pivot-table tfoot td.rowlabel { background: #222; }
        .pivot-table td.total, .pivot-table th.total { border-left: 2px solid #3a3a3a; font-weight: bold; }
        .pivot-table tr.grp-row td { font-weight: 600; }
        .pivot-table tr.subrow td { font-size: 12px; color: #b0b0b0; }
        .pivot-table tr.subrow td.rowlabel { background: #191919; }
        .pivot-table tr.subrow:hover td.rowlabel { background: #202020; }
        .pivot-table td.sublabel { padding-left: 30px; color: #aaa; font-weight: normal; }
        .pivot-table .pivot-arrow { display: inline-block; width: 12px; color: #00ff00; font-size: 10px; }
        .pivot-table .pivot-arrow-spacer { display: inline-block; width: 12px; }

        /* Pagination */
        .pagination { display: flex; align-items: center; gap: 10px; margin-top: 15px; justify-content: space-between; }
        .page-info { color: #888; font-size: 13px; }
        .page-btns { display: flex; gap: 5px; }

        /* Modal */
        .modal-overlay {
            display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
            background: rgba(0,0,0,0.7); z-index: 1000; justify-content: center; align-items: center;
        }
        .modal-overlay.show { display: flex; }
        .modal {
            background: #1e1e1e; border: 1px solid #3a3a3a; border-radius: 8px;
            width: 90%; max-width: 1100px; max-height: 85vh; overflow-y: auto; padding: 25px;
        }
        .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
        .modal-header h2 { color: #00ff00; margin: 0; }
        .modal-close { background: none; border: none; color: #888; font-size: 24px; cursor: pointer; }
        .modal-close:hover { color: #fff; }
        .modal-stats { display: flex; gap: 20px; margin-bottom: 15px; flex-wrap: wrap; }
        .stat-box { background: #2a2a2a; border-radius: 6px; padding: 12px 18px; }
        .stat-label { color: #888; font-size: 11px; text-transform: uppercase; }
        .stat-value { color: #00ff00; font-size: 20px; font-weight: 600; }

        /* Gen modal: lock height so sub-tab switching doesn't reflow */
        #gen-modal .modal { height: 85vh; }

        /* Modal sub-tabs */
        .subtab-bar { display: flex; gap: 4px; margin-bottom: 15px; border-bottom: 1px solid #3a3a3a; }
        .subtab-btn {
            background: transparent; border: none; color: #888;
            padding: 8px 18px; cursor: pointer; font-size: 14px; font-weight: 500;
            border-bottom: 2px solid transparent;
        }
        .subtab-btn:hover { color: #e0e0e0; }
        .subtab-btn.active { color: #00ff00; border-bottom-color: #00ff00; }
        .subtab-count { background: #3a3a3a; color: #888; padding: 1px 8px; border-radius: 10px; font-size: 11px; margin-left: 4px; }
        .subtab-content { display: none; }
        .subtab-content.active { display: block; }
        /* Notes list */
        .notes-list { display: flex; flex-direction: column; gap: 10px; }
        .note-card { background: #2a2a2a; border-radius: 6px; padding: 12px 16px; border-left: 3px solid #444; }
        .note-header { display: flex; gap: 10px; align-items: center; font-size: 12px; color: #888; margin-bottom: 6px; flex-wrap: wrap; }
        .note-status { padding: 2px 10px; border-radius: 10px; font-weight: 600; font-size: 11px; text-transform: uppercase; }
        .note-text { color: #e0e0e0; font-size: 13px; white-space: pre-wrap; }

        /* Import */
        .import-toggle { display: flex; gap: 0; margin-bottom: 15px; }
        .import-toggle .btn { border-radius: 0; }
        .import-toggle .btn:first-child { border-radius: 4px 0 0 4px; }
        .import-toggle .btn:last-child { border-radius: 0 4px 4px 0; }
        .import-toggle .btn.active { background: #00aa00; color: #fff; border-color: #00aa00; }
        textarea.import-area {
            width: 100%; min-height: 160px; background: #2a2a2a; border: 1px solid #3a3a3a;
            color: #e0e0e0; padding: 10px; border-radius: 4px; font-family: monospace; font-size: 12px;
            resize: vertical;
        }
        textarea.import-area:focus { border-color: #00ff00; outline: none; }
        .import-summary { display: flex; gap: 15px; margin: 12px 0; flex-wrap: wrap; }
        .import-summary .badge {
            padding: 4px 12px; border-radius: 12px; font-size: 13px; font-weight: 600;
        }
        .badge-green { background: rgba(0,170,0,0.2); color: #00ff00; }
        .badge-yellow { background: rgba(200,180,0,0.2); color: #ffcc00; }
        .badge-red { background: rgba(200,0,0,0.2); color: #ff4444; }

        .review-row-matched { }
        .review-row-unmatched { background: rgba(255,200,0,0.05); }
        .review-row-error { background: rgba(255,0,0,0.1); color: #ff6666; }

        /* File drop zone */
        .drop-zone {
            border: 2px dashed #3a3a3a; border-radius: 8px; padding: 40px 20px; text-align: center;
            background: #242424; color: #888; cursor: pointer; transition: all 0.15s;
        }
        .drop-zone:hover { border-color: #00aa00; color: #ccc; }
        .drop-zone.dragover { border-color: #00ff00; background: rgba(0,170,0,0.08); color: #00ff00; }
        .drop-zone .drop-icon { font-size: 32px; margin-bottom: 8px; }
        .drop-zone .drop-hint { font-size: 12px; color: #666; margin-top: 8px; }
        .file-info {
            display: none; margin-top: 12px; padding: 10px 14px; background: #2a2a2a;
            border: 1px solid #3a3a3a; border-radius: 4px; font-size: 13px;
        }
        .file-info.error { border-color: rgba(200,0,0,0.4); color: #ff6666; }
        .file-info .file-meta { color: #888; font-size: 11px; margin-top: 4px; }
        .batch-controls {
            display: flex; gap: 12px; margin: 12px 0; align-items: center; flex-wrap: wrap;
            padding: 10px 14px; background: #242424; border-radius: 4px;
        }
        .batch-controls label { color: #888; font-size: 12px; }
        .batch-controls select {
            background: #2a2a2a; border: 1px solid #3a3a3a; color: #e0e0e0;
            padding: 5px 10px; border-radius: 4px; font-size: 13px;
        }

        .inline-select {
            background: #333; border: 1px solid #555; color: #e0e0e0;
            padding: 3px 6px; border-radius: 3px; font-size: 12px;
        }
        .inline-input {
            background: #333; border: 1px solid #555; color: #e0e0e0;
            padding: 3px 6px; border-radius: 3px; font-size: 12px; width: 100px;
        }

        /* Rules table */
        .rules-section { margin-top: 30px; border-top: 1px solid #3a3a3a; padding-top: 20px; }
        .rules-section h3 { color: #00ff00; margin-bottom: 10px; }
        .add-rule-row { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; align-items: center; }
        .add-rule-row input, .add-rule-row select {
            background: #2a2a2a; border: 1px solid #3a3a3a; color: #e0e0e0;
            padding: 5px 8px; border-radius: 4px; font-size: 12px;
        }

        /* Analysis */
        .chart-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
        .chart-panel { background: #2a2a2a; border-radius: 8px; padding: 15px; }
        .chart-panel h3 { color: #00ff00; margin-bottom: 10px; font-size: 14px; }
        .chart-container { position: relative; height: 320px; }
        .analysis-summary { display: flex; gap: 20px; margin-bottom: 20px; flex-wrap: wrap; }

        /* Status messages */
        .status-msg { padding: 10px 15px; border-radius: 4px; margin: 10px 0; font-size: 13px; }
        .status-msg.success { background: rgba(0,170,0,0.15); color: #00ff00; border: 1px solid rgba(0,170,0,0.3); }
        .status-msg.error { background: rgba(200,0,0,0.15); color: #ff4444; border: 1px solid rgba(200,0,0,0.3); }

        @media (max-width: 900px) {
            .chart-grid { grid-template-columns: 1fr; }
            .header { flex-direction: column; gap: 10px; }
            .filter-bar { flex-direction: column; align-items: stretch; }
        }

        /* Edit modal */
        .edit-field { margin-bottom: 10px; }
        .edit-field label { display: block; color: #888; font-size: 12px; margin-bottom: 3px; }
        .edit-field input, .edit-field select {
            width: 100%; background: #2a2a2a; border: 1px solid #3a3a3a; color: #e0e0e0;
            padding: 6px 10px; border-radius: 4px; font-size: 13px;
        }
        .edit-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }

        /* Estimates */
        .est-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
        .est-chip {
            padding: 5px 14px; background: #2a2a2a; border: 1px solid #3a3a3a;
            border-radius: 14px; cursor: pointer; font-size: 12px; color: #888;
        }
        .est-chip:hover { color: #e0e0e0; }
        .est-chip.active { background: #00aa00; color: #fff; border-color: #00aa00; }
        /* Query-builder column chips: drag-to-reorder. The chip itself is
           draggable; a drop on the left half of a target inserts before it,
           right half inserts after. gen_id is locked first and never gets
           drag handlers. */
        .qb-col-chip { cursor: grab; user-select: none; }
        .qb-col-chip:active { cursor: grabbing; }
        .qb-col-chip.qb-drag-src { opacity: 0.4; }
        .qb-col-chip.qb-drag-over-l { box-shadow: -3px 0 0 #4a9eff; }
        .qb-col-chip.qb-drag-over-r { box-shadow:  3px 0 0 #4a9eff; }
        .est-status {
            padding: 2px 10px; border-radius: 10px; font-size: 11px;
            font-weight: 600; text-transform: uppercase;
        }
        .est-status.pending { background: rgba(74,158,255,0.2); color: #4a9eff; }
        .est-status.completed { background: rgba(0,170,0,0.2); color: #00ff00; }
        .est-status.declined { background: rgba(136,136,136,0.25); color: #aaa; }
        .est-status.superseded { background: rgba(255,102,0,0.2); color: #ff6600; }
        .est-stale { color: #ffcc00; font-size: 11px; }
        .recon-ok { color: #00ff00; }
        .recon-bad { color: #ff6600; }
        .est-section-head {
            background: #2a2a2a; color: #00ff00; padding: 6px 10px;
            font-weight: 600; margin-top: 10px; border-radius: 4px 4px 0 0;
        }
        .est-line-incomplete { background: rgba(255,0,0,0.08); }

        /* Query Builder result table -- spreadsheet-style grid */
        #qb-table th, #qb-table td { border: 1px solid #3a3a3a; }
        #qb-table td { padding: 5px 10px; }
    </style>
</head>
<body>

<div class="header">
    <div class="dropdown">
        <h1 class="dropdown-title">NGON Mining - Generator Service History</h1>
        <div class="dropdown-content">""" + generate_dropdown_html(AuthManager.get_user_access()) + """</div>
    </div>
</div>

<div class="tabs">
    <div class="tab-btn active" onclick="switchTab('browse')">Browse / Search</div>
    <div class="tab-btn" onclick="switchTab('import')">Import</div>
    <div class="tab-btn" onclick="switchTab('analysis')">Analysis</div>
    <div class="tab-btn" onclick="switchTab('pmsc')">PM / SC</div>
    <div class="tab-btn" onclick="switchTab('parts')">Parts / Categories</div>
    <div class="tab-btn" onclick="switchTab('qbo')">QBO Export</div>
    <div class="tab-btn" onclick="switchTab('bills')">Bills</div>
    <div class="tab-btn" onclick="switchTab('estimates')">Estimates</div>
    <div class="tab-btn" onclick="switchTab('query')">Query Builder</div>
</div>

<!-- ===== BROWSE TAB ===== -->
<div id="tab-browse" class="tab-content active">
    <div class="filter-bar" id="browse-filters">
        <div class="filter-group">
            <label>Site</label>
            <select id="f-site"><option value="">All Sites</option></select>
        </div>
        <div class="filter-group">
            <label>Gen ID</label>
            <input id="f-gen-id" placeholder="e.g. 22-24-315" style="width:120px">
        </div>
        <div class="filter-group">
            <label>Category</label>
            <select id="f-category"><option value="">All</option></select>
        </div>
        <div class="filter-group">
            <label>Group</label>
            <select id="f-grouped"><option value="">All</option></select>
        </div>
        <div class="filter-group">
            <label>Type</label>
            <select id="f-service-type">
                <option value="">All</option>
                <option value="PM">PM</option>
                <option value="SC">SC</option>
            </select>
        </div>
        <div class="filter-group">
            <label title="Derived from billed mileage, not a Mesa field">Where *</label>
            <select id="f-visit-type">
                <option value="">All</option>
                <option value="shop">Shop</option>
                <option value="field">Field</option>
                <option value="parts_only">Parts only</option>
            </select>
        </div>
        <div class="filter-group">
            <label>From</label>
            <input type="date" id="f-date-from">
        </div>
        <div class="filter-group">
            <label>To</label>
            <input type="date" id="f-date-to">
        </div>
        <div class="filter-group">
            <label>Search</label>
            <input id="f-search" placeholder="description, invoice..." style="width:160px">
        </div>
        <button class="btn btn-primary" onclick="loadRecords(0)">Search</button>
        <button class="btn" onclick="resetFilters()">Reset</button>
        <button class="btn" onclick="exportCSV()">Export CSV</button>
    </div>

    <div id="browse-status"></div>
    <table>
        <thead>
            <tr id="browse-header">
                <th data-col="invoice">Invoice <span class="sort-arrow"></span></th>
                <th data-col="order_date">Date <span class="sort-arrow"></span></th>
                <th data-col="gen_id">Gen ID <span class="sort-arrow"></span></th>
                <th data-col="site">Site <span class="sort-arrow"></span></th>
                <th data-col="wrangled_description">Description <span class="sort-arrow"></span></th>
                <th data-col="service_type">Type <span class="sort-arrow"></span></th>
                <th data-col="visit_type" title="Derived from billed mileage, not a Mesa field">Where * <span class="sort-arrow"></span></th>
                <th data-col="category">Category <span class="sort-arrow"></span></th>
                <th data-col="grouped_category">Group <span class="sort-arrow"></span></th>
                <th data-col="amount" class="amount-cell">Amount <span class="sort-arrow"></span></th>
                <th>Actions</th>
            </tr>
        </thead>
        <tbody id="browse-body"></tbody>
    </table>
    <div class="pagination">
        <div class="page-info" id="page-info"></div>
        <div class="page-btns">
            <button class="btn btn-small" id="btn-prev" onclick="prevPage()">Prev</button>
            <button class="btn btn-small" id="btn-next" onclick="nextPage()">Next</button>
        </div>
    </div>
</div>

<!-- ===== IMPORT TAB ===== -->
<div id="tab-import" class="tab-content">
    <div class="import-toggle">
        <button class="btn active" id="toggle-file" onclick="setImportMode('file')">Drop Excel File</button>
        <button class="btn" id="toggle-paste" onclick="setImportMode('paste')">Paste CSV</button>
        <button class="btn" id="toggle-wrangled" onclick="setImportMode('wrangled')">Wrangled (advanced)</button>
    </div>

    <!-- File drop mode -->
    <div id="import-file">
        <div class="drop-zone" id="drop-zone" onclick="document.getElementById('xlsx-file-input').click()">
            <div class="drop-icon">+</div>
            <div>Drop an Excel file here, or click to browse</div>
            <div class="drop-hint">Expected columns (any order): Invoice, Order Date, Unit Number, Description, $ Amount</div>
        </div>
        <input type="file" id="xlsx-file-input" accept=".xlsx,.xls,.csv" style="display:none">
        <div class="file-info" id="file-info"></div>

        <div class="batch-controls">
            <label>Service type for this batch:</label>
            <select id="file-service-type-override">
                <option value="">Auto (use rules + OEM detection)</option>
                <option value="PM">Force PM (preventive maintenance)</option>
                <option value="SC">Force SC (service call)</option>
            </select>
            <button class="btn btn-primary" onclick="parseRawImport('file')" id="btn-parse-file" disabled>Parse &amp; Preview</button>
        </div>
    </div>

    <!-- Paste CSV mode -->
    <div id="import-paste" style="display:none">
        <p style="color:#888;font-size:13px;margin-bottom:8px">
            Paste CSV rows: <code>invoice, date, unit_number, description, amount</code>
            &nbsp;<span style="color:#666">(service type comes from rules + OEM detection, or set the override below)</span>
        </p>
        <textarea class="import-area" id="raw-csv" placeholder="SV02224,3/31/2026,22-24-310,SPARK PLUG; NGK 5115,148.79"></textarea>

        <div class="batch-controls">
            <label>Service type for this batch:</label>
            <select id="paste-service-type-override">
                <option value="">Auto (use rules + OEM detection)</option>
                <option value="PM">Force PM</option>
                <option value="SC">Force SC</option>
            </select>
            <button class="btn btn-primary" onclick="parseRawImport('paste')">Parse &amp; Preview</button>
        </div>
    </div>

    <!-- Wrangled bulk mode (existing) -->
    <div id="import-wrangled" style="display:none">
        <p style="color:#888;font-size:13px;margin-bottom:8px">
            Paste CSV rows: <code>invoice, date, gen_id, raw_desc, wrangled_desc, amount, service_type, category, grouped_category, site(optional)</code>
        </p>
        <textarea class="import-area" id="wrangled-csv" placeholder="INV-001,2025-01-15,22-24-315,Tote 22-24-315,Tote,450.00,PM,Tote,Oil,Alpha"></textarea>
        <div style="margin-top:10px">
            <button class="btn btn-primary" onclick="bulkWrangledImport()">Import</button>
        </div>
        <div id="wrangled-status"></div>
    </div>

    <!-- Shared parse results (file + paste modes both populate this) -->
    <div id="parse-status"></div>
    <div id="parse-summary" class="import-summary" style="display:none"></div>
    <div id="parse-results" style="display:none">
        <table>
            <thead>
                <tr>
                    <th>#</th><th>Invoice</th><th>Date</th><th>Gen ID</th><th>Site</th>
                    <th>Description</th><th>Type</th><th>Category</th><th>Group</th>
                    <th>Amount</th><th>Save Rule</th>
                </tr>
            </thead>
            <tbody id="parse-body"></tbody>
        </table>
        <div style="margin-top:15px;display:flex;gap:15px;align-items:center;flex-wrap:wrap">
            <button class="btn btn-primary" id="btn-commit" onclick="commitImport()">Commit Import</button>
            <span style="color:#888;font-size:12px">Duplicates (same invoice + date + description + amount) are skipped automatically.</span>
        </div>
    </div>

    <!-- Add Category -->
    <div class="rules-section">
        <h3>Add Category</h3>
        <div class="add-rule-row">
            <input id="new-cat-name" placeholder="New Category" style="width:160px">
            <select id="new-cat-group" style="width:160px"><option value="">Select Group...</option></select>
            <button class="btn btn-primary btn-small" onclick="addCategory()">Add Category</button>
        </div>
        <div id="add-cat-status"></div>
    </div>

    <!-- Category Rules -->
    <div class="rules-section">
        <h3>Category Rules</h3>
        <div class="add-rule-row">
            <input id="new-rule-pattern" placeholder="Pattern" style="width:140px">
            <select id="new-rule-match">
                <option value="contains">contains</option>
                <option value="startswith">startswith</option>
                <option value="exact">exact</option>
            </select>
            <input id="new-rule-category" placeholder="Category" style="width:120px" list="cat-list">
            <input id="new-rule-grouped" placeholder="Grouped Category" style="width:140px" list="grp-list">
            <input id="new-rule-priority" placeholder="Priority" type="number" value="100" style="width:70px">
            <select id="new-rule-service-type" style="width:60px">
                <option value="SC">SC</option>
                <option value="PM">PM</option>
            </select>
            <button class="btn btn-primary btn-small" onclick="addRule()">Add Rule</button>
        </div>
        <div id="rules-status"></div>
        <table>
            <thead>
                <tr>
                    <th>Priority</th><th>Pattern</th><th>Match Type</th>
                    <th>Category</th><th>Grouped Category</th><th>Type</th><th>Actions</th>
                </tr>
            </thead>
            <tbody id="rules-body"></tbody>
        </table>
    </div>
</div>

<!-- ===== ANALYSIS TAB ===== -->
<div id="tab-analysis" class="tab-content">
    <div class="filter-bar">
        <div class="filter-group">
            <label>Site</label>
            <select id="a-site"><option value="">All Sites</option></select>
        </div>
        <div class="filter-group">
            <label>Type</label>
            <select id="a-service-type">
                <option value="">All</option>
                <option value="PM">PM</option>
                <option value="SC">SC</option>
            </select>
        </div>
        <div class="filter-group">
            <label>From</label>
            <input type="date" id="a-date-from">
        </div>
        <div class="filter-group">
            <label>To</label>
            <input type="date" id="a-date-to">
        </div>
        <button class="btn btn-primary" onclick="loadAnalysis()">Apply</button>
    </div>

    <div class="analysis-summary" id="analysis-summary"></div>

    <div class="chart-grid">
        <div class="chart-panel">
            <h3>Cost by Site</h3>
            <div class="chart-container"><canvas id="chart-site"></canvas></div>
        </div>
        <div class="chart-panel">
            <h3>Cost by Category</h3>
            <div class="chart-container"><canvas id="chart-category"></canvas></div>
        </div>
        <div class="chart-panel">
            <h3>Monthly Trend</h3>
            <div class="chart-container"><canvas id="chart-month"></canvas></div>
        </div>
        <div class="chart-panel">
            <h3>Top 20 Generators by Cost</h3>
            <div class="chart-container"><canvas id="chart-top-gens"></canvas></div>
        </div>
    </div>
    <div class="chart-panel" style="margin-top:20px">
        <h3>Monthly Cost by Site</h3>
        <div class="chart-container" style="height:400px"><canvas id="chart-site-month"></canvas></div>
    </div>
</div>

<!-- ===== PM / SC TAB ===== -->
<div id="tab-pmsc" class="tab-content">
    <div class="filter-bar">
        <div class="filter-group">
            <label>Site</label>
            <select id="ps-site"><option value="">All Sites</option></select>
        </div>
        <div class="filter-group">
            <label>From</label>
            <input type="date" id="ps-date-from">
        </div>
        <div class="filter-group">
            <label>To</label>
            <input type="date" id="ps-date-to">
        </div>
        <button class="btn btn-primary" onclick="loadPmSc()">Apply</button>
    </div>

    <h3 style="color:#00ff00;margin:15px 0 10px">Spend by Work Type
        <span style="font-size:12px;color:#888;font-weight:normal">&mdash; shop pulled out of SC; partly derived</span>
    </h3>
    <label style="display:inline-flex;align-items:center;gap:7px;color:#aaa;font-size:12px;margin-bottom:12px;cursor:pointer">
        <input type="checkbox" id="ps-allocate-oil" onchange="loadPmSc()">
        Attribute bulk oil to PM
        <span style="color:#666">&mdash; the 275gal totes were billed with no generator, so they sit in
        Parts-only. Ticking this moves them to PM using the modelled per-gen allocation.</span>
    </label>
    <div style="background:#252525;border:1px solid #3a3a3a;border-left:3px solid #ffaa00;
                border-radius:4px;padding:10px 14px;margin-bottom:15px;font-size:12px;color:#aaa">
        Mesa's export has no <code style="color:#ddd">service_location</code> column, so this split is
        <strong style="color:#ffaa00">inferred</strong>: an invoice that billed <em>mileage</em> was a
        field visit; one that billed <em>transport</em>, or labor with no travel, was shop work; one with
        neither is parts-only. Transport-billed is the only hard shop evidence &mdash; the
        &ldquo;labor, no mileage&rdquo; case also catches a field trip where travel was waived.
        Treat as a strong indicator, not ground truth.
    </div>
    <div id="visit-oil-note"></div>
    <div class="chart-grid" style="margin-bottom:15px">
        <div class="chart-panel">
            <h3>Share of Spend</h3>
            <div class="chart-container" style="height:300px"><canvas id="chart-visit-split"></canvas></div>
        </div>
        <div class="chart-panel">
            <h3>Monthly Spend &mdash; PM / SC / Shop / Parts-only</h3>
            <div class="chart-container" style="height:300px"><canvas id="chart-visit-monthly"></canvas></div>
        </div>
    </div>
    <table style="margin-bottom:12px">
        <thead>
            <tr><th>Work Type</th><th>Invoices</th><th>Gens</th>
                <th class="amount-cell">Total</th><th class="amount-cell">% of Spend</th></tr>
        </thead>
        <tbody id="visit-summary-body"></tbody>
    </table>
    <div style="max-height:320px;overflow-y:auto;margin-bottom:30px">
        <table>
            <thead>
                <tr><th>Site</th>
                    <th class="amount-cell">PM (field)</th>
                    <th class="amount-cell">SC (field)</th>
                    <th class="amount-cell">Shop</th>
                    <th class="amount-cell">Parts-only</th>
                    <th class="amount-cell">Total</th>
                    <th class="amount-cell">Shop Share</th></tr>
            </thead>
            <tbody id="visit-site-body"></tbody>
        </table>
    </div>

    <h3 style="color:#00ff00;margin:15px 0 10px">PM / SC / Shop Visits by Site per Month
        <span style="font-size:12px;color:#888;font-weight:normal">&mdash; shop is its own category, no longer counted as SC</span>
    </h3>
    <div style="margin-bottom:10px">
        <div class="chart-panel">
            <div class="chart-container" style="height:350px"><canvas id="chart-pmsc-monthly"></canvas></div>
        </div>
    </div>
    <div style="max-height:400px;overflow-y:auto;margin-bottom:30px">
        <table>
            <thead>
                <tr><th>Site</th><th>Month</th><th>PM Visits</th><th>SC Visits</th><th>Shop Visits</th>
                    <th class="amount-cell">PM Cost</th><th class="amount-cell">SC Cost</th>
                    <th class="amount-cell">Shop Cost</th><th class="amount-cell">Total Cost</th></tr>
            </thead>
            <tbody id="pmsc-monthly-body"></tbody>
        </table>
    </div>

    <h3 style="color:#00ff00;margin:15px 0 10px">Average PM / SC Cost per Generator by Site
        <span style="font-size:12px;color:#888;font-weight:normal">&mdash; field visits only; shop excluded</span>
    </h3>
    <div class="chart-grid" style="margin-bottom:15px">
        <div class="chart-panel">
            <h3>Avg Cost per Gen by Site</h3>
            <div class="chart-container" style="height:350px"><canvas id="chart-pmsc-avg"></canvas></div>
        </div>
        <div class="chart-panel">
            <h3>Avg Visits per Gen by Site</h3>
            <div class="chart-container" style="height:350px"><canvas id="chart-pmsc-avg-visits"></canvas></div>
        </div>
    </div>
    <table>
        <thead>
            <tr><th>Site</th><th>PM Gens</th><th>PM Visits</th><th class="amount-cell">PM Total</th><th class="amount-cell">PM Avg/Gen</th>
                <th>SC Gens</th><th>SC Visits</th><th class="amount-cell">SC Total</th><th class="amount-cell">SC Avg/Gen</th></tr>
        </thead>
        <tbody id="pmsc-avg-body"></tbody>
    </table>
</div>

<!-- ===== PARTS / CATEGORIES TAB ===== -->
<div id="tab-parts" class="tab-content">
    <div class="filter-bar">
        <div class="filter-group">
            <label>Site</label>
            <select id="pt-site"><option value="">All Sites</option></select>
        </div>
        <div class="filter-group">
            <label>Type</label>
            <select id="pt-service-type">
                <option value="">All</option><option value="PM">PM</option><option value="SC">SC</option>
            </select>
        </div>
        <div class="filter-group">
            <label>Group</label>
            <select id="pt-grouped"><option value="">All Groups</option></select>
        </div>
        <div class="filter-group">
            <label>From</label>
            <input type="date" id="pt-date-from">
        </div>
        <div class="filter-group">
            <label>To</label>
            <input type="date" id="pt-date-to">
        </div>
        <button class="btn btn-primary" onclick="loadParts()">Apply</button>
    </div>

    <div class="analysis-summary" id="parts-summary"></div>

    <div class="chart-grid">
        <div class="chart-panel">
            <h3>Top 15 Categories by Cost</h3>
            <div class="chart-container" style="height:400px"><canvas id="chart-parts-top"></canvas></div>
        </div>
        <div class="chart-panel">
            <h3>Category by Site</h3>
            <div class="chart-container" style="height:400px"><canvas id="chart-parts-site"></canvas></div>
        </div>
    </div>

    <div class="chart-panel" style="margin-top:20px">
        <h3>Top Category Trends by Month</h3>
        <div class="chart-container" style="height:350px"><canvas id="chart-parts-trend"></canvas></div>
    </div>

    <h3 style="color:#00ff00;margin:20px 0 10px">2026 Spend by Group per Month
        <span style="color:#888;font-size:12px;font-weight:normal">— click a group to expand its sub-categories</span></h3>
    <div style="overflow-x:auto">
        <table id="grouped-pivot-table" class="pivot-table">
            <thead><tr id="grouped-pivot-head"></tr></thead>
            <tbody id="grouped-pivot-body"></tbody>
            <tfoot id="grouped-pivot-foot"></tfoot>
        </table>
    </div>

    <h3 style="color:#00ff00;margin:20px 0 10px">Category Detail</h3>
    <div style="max-height:500px;overflow-y:auto">
        <table>
            <thead>
                <tr><th>Category</th><th>Group</th><th>Occurrences</th><th>Gens Affected</th>
                    <th class="amount-cell">Total Cost</th><th class="amount-cell">Avg Cost/Occurrence</th><th>Top Site</th></tr>
            </thead>
            <tbody id="parts-table-body"></tbody>
        </table>
    </div>
</div>

<!-- ===== QBO EXPORT TAB ===== -->
<div id="tab-qbo" class="tab-content">
    <div class="filter-bar">
        <div class="filter-group">
            <label>Mode</label>
            <select id="q-mode" onchange="qboModeChanged()">
                <option value="new">New since last export</option>
                <option value="received">Date range &mdash; received</option>
                <option value="service">Date range &mdash; service date</option>
            </select>
        </div>
        <div class="filter-group" id="q-range-from">
            <label>From</label>
            <input type="date" id="q-date-from">
        </div>
        <div class="filter-group" id="q-range-to">
            <label>To</label>
            <input type="date" id="q-date-to">
        </div>
        <button class="btn btn-primary" onclick="loadQboPreview()">Generate Preview</button>
        <button class="btn" onclick="qboDownload()" id="qbo-dl-btn" disabled>Download CSV</button>
        <button class="btn" onclick="qboMarkExported()" id="qbo-mark-btn" disabled>Mark as Exported</button>
    </div>
    <div style="color:#888;font-size:12px;margin-bottom:10px" id="qbo-mode-help"></div>

    <div id="qbo-status" style="margin-bottom:10px"></div>
    <div id="qbo-summary" class="import-summary" style="display:none"></div>
    <div id="qbo-table-wrap" style="overflow-x:auto;max-height:600px;overflow-y:auto;display:none">
        <table id="qbo-table" style="font-size:12px;white-space:nowrap">
            <thead id="qbo-head"></thead>
            <tbody id="qbo-body"></tbody>
        </table>
    </div>
</div>

<!-- ===== ESTIMATES TAB ===== -->
<div id="tab-estimates" class="tab-content">
    <div class="drop-zone" id="est-drop-zone" onclick="document.getElementById('est-file-input').click()">
        <div class="drop-icon">+</div>
        <div>Drop a Mesa repair-quote PDF here, or click to browse</div>
        <div class="drop-hint">Parsed on the server &mdash; you review every estimate before it is saved.</div>
    </div>
    <input type="file" id="est-file-input" accept=".pdf,application/pdf" style="display:none">
    <div id="est-parse-status"></div>
    <div id="est-preview" style="display:none;margin-top:15px"></div>

    <div style="margin-top:22px;display:flex;align-items:center;gap:12px;flex-wrap:wrap">
        <button class="btn" onclick="estAutoMatch(true)">Preview auto-match</button>
        <button class="btn btn-primary" onclick="estAutoMatch(false)">Run auto-match</button>
        <span style="color:#888;font-size:12px">
            Mesa never puts the quote number on the invoice, so matches are inferred from
            parts overlap, total, and date on the same generator. High-confidence matches are
            marked completed; weaker ones are linked but left pending for you to confirm.
        </span>
    </div>
    <div id="est-match-status" style="margin-top:10px"></div>

    <div class="est-chips" id="est-chips" style="margin-top:22px">
        <div class="est-chip active" data-status="pending" onclick="setEstFilter('pending')">Pending</div>
        <div class="est-chip" data-status="completed" onclick="setEstFilter('completed')">Completed</div>
        <div class="est-chip" data-status="declined" onclick="setEstFilter('declined')">Declined</div>
        <div class="est-chip" data-status="superseded" onclick="setEstFilter('superseded')">Superseded</div>
        <div class="est-chip" data-status="all" onclick="setEstFilter('all')">All</div>
    </div>
    <div class="filter-bar">
        <div class="filter-group">
            <label>Site</label>
            <select id="ef-site"><option value="">All Sites</option></select>
        </div>
        <div class="filter-group">
            <label>Gen ID</label>
            <input id="ef-gen-id" placeholder="e.g. 22-24-228" style="width:120px">
        </div>
        <div class="filter-group">
            <label>From</label>
            <input type="date" id="ef-date-from">
        </div>
        <div class="filter-group">
            <label>To</label>
            <input type="date" id="ef-date-to">
        </div>
        <div class="filter-group">
            <label>Search</label>
            <input id="ef-search" placeholder="quote, advisor, notes..." style="width:160px">
        </div>
        <button class="btn btn-primary" onclick="loadEstimates()">Search</button>
        <button class="btn" onclick="resetEstFilters()">Reset</button>
        <span id="est-summary" style="color:#888;font-size:12px;margin-left:auto"></span>
    </div>
    <div id="est-list-status"></div>
    <table>
        <thead>
            <tr>
                <th>Quote No</th><th>Quote Date</th><th>Gen ID</th><th>Site</th>
                <th>Model</th><th class="amount-cell">Grand Total</th>
                <th>Status</th><th>Matched Invoice</th><th>Age</th><th>Actions</th>
            </tr>
        </thead>
        <tbody id="est-body"></tbody>
    </table>
</div>

<!-- ===== BILLS (AP) TAB ===== -->
<div id="tab-bills" class="tab-content">
    <p style="color:#888;font-size:13px;margin-bottom:14px">
        Paste a QuickBooks bill export to see service spend split by paid vs unpaid.
        Upload the file directly (.xlsx, .xls or .csv) or paste the rows &mdash; either way
        the columns are detected from the header, so both layouts work.
        <strong style="color:#ffaa00">The paid export carries a Bill No. and matches exactly;
        the unpaid export has none</strong>, so those can only be matched on amount, which is
        not always unique.
    </p>
    <div class="filter-bar" style="align-items:flex-start;margin-bottom:8px">
        <div class="filter-group">
            <label title="Detected from the columns: a balance/status column means unpaid, bill-detail columns mean paid">These rows are</label>
            <select id="bills-status">
                <option value="auto">Detect automatically</option>
                <option value="paid">Paid (force)</option>
                <option value="unpaid">Unpaid (force)</option>
            </select>
        </div>
        <div class="filter-group" style="flex:1;min-width:280px">
            <label>Upload a file</label>
            <div class="drop-zone" id="bills-drop-zone" style="padding:14px"
                 onclick="document.getElementById('bills-file-input').click()">
                <div class="drop-icon" style="font-size:20px">+</div>
                <div>Drop an .xlsx, .xls or .csv export here, or click to browse</div>
                <div class="drop-hint">Paid vs unpaid is worked out from the columns &mdash; override it above if needed.</div>
            </div>
            <input type="file" id="bills-file-input" style="display:none"
                   accept=".xlsx,.xlsm,.xls,.csv,.tsv,.txt">
        </div>
    </div>
    <div class="filter-bar" style="align-items:flex-start">
        <div class="filter-group" style="flex:1;min-width:320px">
            <label>&hellip;or paste rows (with header)</label>
            <textarea id="bills-paste" style="width:100%;height:120px;background:#141414;color:#ddd;
                border:1px solid #3a3a3a;border-radius:4px;padding:8px;font-family:monospace;font-size:12px"
                placeholder="Vendor&#9;Source&#9;Bill No.&#9;Bill Date&#9;..."></textarea>
        </div>
        <button class="btn btn-primary" onclick="importBills()">Import pasted rows</button>
        <button class="btn" onclick="rematchBills()" title="Re-run matching over existing bills">Re-match</button>
    </div>
    <div id="bills-import-status"></div>

    <h3 style="color:#00ff00;margin:20px 0 10px">Spend by Work Type &mdash; Paid vs Unpaid</h3>
    <table style="margin-bottom:24px">
        <thead>
            <tr><th>Work Type</th>
                <th>Paid bills</th><th class="amount-cell">Paid $</th>
                <th>Unpaid bills</th><th class="amount-cell">Unpaid $</th>
                <th class="amount-cell">Outstanding</th>
                <th class="amount-cell">Total</th></tr>
        </thead>
        <tbody id="bills-summary-body"></tbody>
    </table>

    <h3 style="color:#00ff00;margin:20px 0 6px">Monthly Spend by Category</h3>
    <p style="color:#888;font-size:12px;margin:0 0 10px">
        Month comes from the matched <strong>invoice</strong>, not the bill &mdash; the unpaid export
        has no bill date, and its due dates ran 57&ndash;128 days past the work.
        Click a row to drill into that month.
    </p>
    <div style="max-height:340px;overflow-y:auto;margin-bottom:24px">
        <table>
            <thead>
                <tr><th>Month</th>
                    <th class="amount-cell">Shop</th><th class="amount-cell">SC (field)</th>
                    <th class="amount-cell">PM (field)</th><th class="amount-cell">Parts-only</th>
                    <th class="amount-cell">Not matched</th>
                    <th class="amount-cell">Paid</th><th class="amount-cell">Unpaid</th>
                    <th class="amount-cell">Total</th></tr>
            </thead>
            <tbody id="bills-monthly-body"></tbody>
        </table>
    </div>

    <h3 style="color:#00ff00;margin:20px 0 10px">Bills</h3>
    <div class="filter-bar">
        <div class="filter-group"><label>Month</label>
            <select id="bf-month" onchange="loadBills()"><option value="">All months</option></select></div>
        <div class="filter-group"><label>Status</label>
            <select id="bf-status" onchange="loadBills()">
                <option value="">All</option><option value="paid">Paid</option><option value="unpaid">Unpaid</option>
            </select></div>
        <div class="filter-group"><label>Match</label>
            <select id="bf-match" onchange="loadBills()">
                <option value="">All</option><option value="high">Matched</option>
                <option value="confirmed">Confirmed by hand</option>
                <option value="ambiguous">Ambiguous</option><option value="none">No match</option>
            </select></div>
        <div class="filter-group"><label>Work Type</label>
            <select id="bf-work" onchange="loadBills()">
                <option value="">All</option><option value="shop">Shop</option>
                <option value="pm_field">PM (field)</option><option value="sc_field">SC (field)</option>
                <option value="parts_only">Parts-only</option>
                <option value="(unmatched)">Not matched</option>
            </select></div>
        <label style="display:inline-flex;align-items:center;gap:6px;color:#aaa;font-size:12px">
            <input type="checkbox" id="bf-variance" onchange="loadBills()"> Only bills that differ from the invoice
        </label>
        <button class="btn btn-small" onclick="resetBillFilters()">Reset</button>
        <span id="bills-count" style="color:#888;font-size:12px;margin-left:auto"></span>
    </div>
    <div id="bills-totals" style="margin-bottom:10px"></div>
    <table>
        <thead>
            <tr><th>Status</th><th>Month</th><th>Bill No.</th><th>Bill Date</th><th>Due</th>
                <th class="amount-cell">Amount</th><th>Invoice</th><th>Gen</th>
                <th>Work Type</th><th>Match</th><th>Difference</th></tr>
        </thead>
        <tbody id="bills-body"></tbody>
    </table>
</div>

<!-- ===== QUERY BUILDER TAB ===== -->
<div id="tab-query" class="tab-content">
    <p style="color:#888;font-size:13px;margin-bottom:14px">
        Build a spreadsheet of generator data &mdash; one row per generator, columns
        pulled from any source. Pick columns, add filters, run, then export to CSV.
    </p>
    <div style="background:#1a1a1a;border:1px solid #2a2a2a;border-radius:6px;padding:10px 12px;margin-bottom:14px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
        <label style="color:#888;font-size:12px;margin-right:4px">Saved queries</label>
        <select id="qb-saved-select" onchange="qbApplySaved(this.value)"
                style="background:#2a2a2a;border:1px solid #3a3a3a;color:#e0e0e0;padding:6px 10px;border-radius:4px;font-size:13px;min-width:240px">
            <option value="">&mdash; pick one to load &mdash;</option>
        </select>
        <button class="btn btn-small" onclick="qbSaveCurrent()" title="Save current columns / filters / sort / dates">Save current&hellip;</button>
        <button class="btn btn-small" onclick="qbDeleteSelected()" id="qb-saved-del" disabled title="Delete the selected saved query">Delete</button>
        <span id="qb-saved-status" style="color:#888;font-size:12px;margin-left:6px"></span>
    </div>
    <div style="display:flex;gap:25px;flex-wrap:wrap;margin-bottom:16px">
        <div style="flex:1;min-width:300px">
            <label style="color:#888;font-size:12px;display:block;margin-bottom:5px">Columns</label>
            <div style="display:flex;gap:8px;margin-bottom:8px">
                <select id="qb-add-col" style="background:#2a2a2a;border:1px solid #3a3a3a;color:#e0e0e0;padding:6px 10px;border-radius:4px;font-size:13px"></select>
                <button class="btn btn-small" onclick="qbAddColumn()">Add column</button>
            </div>
            <div id="qb-columns" class="est-chips"></div>
        </div>
        <div style="min-width:230px">
            <label style="color:#888;font-size:12px;display:block;margin-bottom:5px">Date range <span style="color:#666">(service &amp; estimate columns)</span></label>
            <div style="display:flex;gap:6px">
                <input type="date" id="qb-date-from" style="background:#2a2a2a;border:1px solid #3a3a3a;color:#e0e0e0;padding:6px 10px;border-radius:4px;font-size:13px">
                <input type="date" id="qb-date-to" style="background:#2a2a2a;border:1px solid #3a3a3a;color:#e0e0e0;padding:6px 10px;border-radius:4px;font-size:13px">
            </div>
        </div>
    </div>
    <div style="margin-bottom:14px">
        <label style="color:#888;font-size:12px;display:block;margin-bottom:5px">Filters <span style="color:#666">(all must match)</span></label>
        <div id="qb-filters"></div>
        <button class="btn btn-small" onclick="qbAddFilter()" style="margin-top:6px">Add filter</button>
    </div>
    <div style="display:flex;gap:10px;margin-bottom:14px;align-items:center">
        <button class="btn btn-primary" onclick="qbRun()">Run Query</button>
        <button class="btn" onclick="qbSortStatusOrder()" id="qb-status-sort-btn"
                title="Order the rows the way the status page lists generators">Sort by status page</button>
        <button class="btn" onclick="qbExportCsv()" id="qb-export-btn" disabled>Export CSV</button>
        <span id="qb-status" style="color:#888;font-size:13px"></span>
    </div>
    <div style="overflow-x:auto">
        <table id="qb-table" style="font-size:12px;white-space:nowrap">
            <thead id="qb-head"></thead>
            <tbody id="qb-body"></tbody>
        </table>
    </div>
</div>

<!-- Gen History Modal -->
<div class="modal-overlay" id="gen-modal">
    <div class="modal">
        <div class="modal-header">
            <h2 id="modal-title">Generator History</h2>
            <button class="modal-close" onclick="closeModal()">&times;</button>
        </div>
        <div class="modal-stats" id="modal-stats"></div>

        <div class="subtab-bar">
            <button class="subtab-btn active" data-subtab="overview" onclick="switchModalTab('overview')">Overview</button>
            <button class="subtab-btn" data-subtab="records" onclick="switchModalTab('records')">Records <span id="modal-records-count" class="subtab-count"></span></button>
            <button class="subtab-btn" data-subtab="invoices" onclick="switchModalTab('invoices')">Invoices <span id="modal-invoices-count" class="subtab-count"></span></button>
            <button class="subtab-btn" data-subtab="bills" onclick="switchModalTab('bills')">Bills <span id="modal-bills-count" class="subtab-count"></span></button>
            <button class="subtab-btn" data-subtab="notes" onclick="switchModalTab('notes')">Notes <span id="modal-notes-count" class="subtab-count"></span></button>
            <button class="subtab-btn" data-subtab="estimates" onclick="switchModalTab('estimates')">Estimates <span id="modal-estimates-count" class="subtab-count"></span></button>
        </div>

        <div id="modal-overview" class="subtab-content active">
            <div class="chart-grid">
                <div class="chart-panel"><h3>Cost by Category</h3><div class="chart-container" style="height:260px"><canvas id="chart-modal-category"></canvas></div></div>
                <div class="chart-panel"><h3>Cost by Group</h3><div class="chart-container" style="height:260px"><canvas id="chart-modal-group"></canvas></div></div>
                <div class="chart-panel"><h3>PM vs SC</h3><div class="chart-container" style="height:260px"><canvas id="chart-modal-pmsc"></canvas></div></div>
                <div class="chart-panel"><h3>Monthly Trend (PM/SC)</h3><div class="chart-container" style="height:260px"><canvas id="chart-modal-month"></canvas></div></div>
            </div>
        </div>

        <div id="modal-records" class="subtab-content">
            <div class="filter-bar" id="modal-filters" style="margin-bottom:10px">
                <div class="filter-group">
                    <label>Category</label>
                    <select id="mf-category" onchange="filterGenModal()"><option value="">All</option></select>
                </div>
                <div class="filter-group">
                    <label>Group</label>
                    <select id="mf-grouped" onchange="filterGenModal()"><option value="">All</option></select>
                </div>
                <div class="filter-group">
                    <label>Category</label>
                    <select id="mf-type" onchange="filterGenModal()">
                        <option value="">All</option>
                        <option value="pm_field">PM (field)</option>
                        <option value="sc_field">SC (field)</option>
                        <option value="shop">Shop</option>
                        <option value="parts_only">Parts-only</option>
                        <option value="_PM">Mesa flag: PM</option>
                        <option value="_SC">Mesa flag: SC</option>
                    </select>
                </div>
                <button class="btn btn-small" onclick="document.getElementById('mf-category').value='';document.getElementById('mf-grouped').value='';document.getElementById('mf-type').value='';filterGenModal()">Reset</button>
            </div>
            <table>
                <thead>
                    <tr><th>Date</th><th>Invoice</th><th>Description</th><th>Type</th><th>Category</th><th>Group</th><th class="amount-cell">Amount</th></tr>
                </thead>
                <tbody id="modal-body"></tbody>
                <tfoot>
                    <tr style="border-top:2px solid #3a3a3a;font-weight:600">
                        <td colspan="6" style="text-align:right;color:#888">Filtered Total</td>
                        <td class="amount-cell" id="modal-filtered-total" style="color:#00ff00"></td>
                    </tr>
                </tfoot>
            </table>
        </div>

        <div id="modal-invoices" class="subtab-content">
            <div class="filter-bar" style="margin-bottom:10px">
                <div class="filter-group">
                    <label>Category</label>
                    <select id="mi-work" onchange="filterGenInvoices()">
                        <option value="">All</option>
                        <option value="pm_field">PM (field)</option>
                        <option value="sc_field">SC (field)</option>
                        <option value="shop">Shop</option>
                        <option value="parts_only">Parts-only</option>
                    </select>
                </div>
                <div class="filter-group">
                    <label>Mesa flag</label>
                    <select id="mi-type" onchange="filterGenInvoices()">
                        <option value="">All</option><option value="PM">PM</option><option value="SC">SC</option>
                    </select>
                </div>
                <button class="btn btn-small" onclick="document.getElementById('mi-work').value='';document.getElementById('mi-type').value='';filterGenInvoices()">Reset</button>
                <span id="modal-inv-count" style="color:#888;font-size:12px;margin-left:auto"></span>
            </div>
            <p style="color:#888;font-size:12px;margin:0 0 10px">
                One row per invoice &mdash; the Records tab lists the individual lines.
                &ldquo;Where&rdquo; is derived, not billed by Mesa. Warranty is our estimate of
                no-charge work, valued from what the same parts cost elsewhere.
            </p>
            <table>
                <thead>
                    <tr><th>Date</th><th>Invoice</th><th>Category</th><th>Mesa PM/SC</th>
                        <th>Lines</th><th class="amount-cell">Labor hrs</th>
                        <th class="amount-cell">Billed</th>
                        <th class="amount-cell">Warranty (est)</th>
                        <th>Quote</th></tr>
                </thead>
                <tbody id="modal-invoices-body"></tbody>
                <tfoot>
                    <tr style="border-top:2px solid #3a3a3a;font-weight:600">
                        <td colspan="5" style="text-align:right;color:#888">Total</td>
                        <td class="amount-cell" id="modal-inv-hours" style="color:#aaa"></td>
                        <td class="amount-cell" id="modal-inv-billed" style="color:#00ff00"></td>
                        <td class="amount-cell" id="modal-inv-warranty" style="color:#ffaa00"></td>
                        <td></td>
                    </tr>
                </tfoot>
            </table>
        </div>

        <div id="modal-bills" class="subtab-content">
            <div class="filter-bar" style="margin-bottom:10px">
                <div class="filter-group">
                    <label>Status</label>
                    <select id="mb-status" onchange="filterGenBills()">
                        <option value="">All</option><option value="paid">Paid</option>
                        <option value="unpaid">Unpaid</option>
                    </select>
                </div>
                <div class="filter-group">
                    <label>Category</label>
                    <select id="mb-work" onchange="filterGenBills()">
                        <option value="">All</option>
                        <option value="pm_field">PM (field)</option>
                        <option value="sc_field">SC (field)</option>
                        <option value="shop">Shop</option>
                        <option value="parts_only">Parts-only</option>
                    </select>
                </div>
                <span id="modal-bills-totals" style="color:#888;font-size:12px;margin-left:auto"></span>
            </div>
            <p style="color:#888;font-size:12px;margin:0 0 10px">
                Bills reach a generator through the invoice they were matched to, so this is the
                reconciled slice &mdash; an unmatched bill has no generator and won't appear here.
            </p>
            <table>
                <thead>
                    <tr><th>Status</th><th>Month</th><th>Bill No.</th><th>Due</th>
                        <th class="amount-cell">Amount</th><th class="amount-cell">Balance</th>
                        <th>Invoice</th><th>Category</th><th>Difference</th></tr>
                </thead>
                <tbody id="modal-bills-body"></tbody>
            </table>
        </div>

        <div id="modal-notes" class="subtab-content">
            <div id="modal-notes-list"></div>
        </div>

        <div id="modal-estimates" class="subtab-content">
            <table>
                <thead>
                    <tr><th>Quote No</th><th>Quote Date</th><th class="amount-cell">Grand Total</th>
                        <th>Status</th><th>Linked Invoice</th><th>Age</th></tr>
                </thead>
                <tbody id="modal-estimates-body"></tbody>
            </table>
        </div>
    </div>
</div>

<!-- Invoice Detail Modal -->
<div class="modal-overlay" id="invoice-modal">
    <div class="modal" style="max-width:900px">
        <div class="modal-header">
            <h2 id="invoice-modal-title">Invoice Detail</h2>
            <button class="modal-close" onclick="closeInvoiceModal()">&times;</button>
        </div>
        <div class="modal-stats" id="invoice-modal-stats"></div>
        <table>
            <thead>
                <tr><th>Gen ID</th><th>Site</th><th>Description</th><th>Type</th><th>Category</th><th>Group</th><th class="amount-cell">Amount</th></tr>
            </thead>
            <tbody id="invoice-modal-body"></tbody>
            <tfoot>
                <tr style="border-top:2px solid #3a3a3a;font-weight:600">
                    <td colspan="6" style="text-align:right;color:#888">Total</td>
                    <td class="amount-cell" id="invoice-modal-total" style="color:#00ff00"></td>
                </tr>
            </tfoot>
        </table>
    </div>
</div>

<!-- Edit Record Modal -->
<div class="modal-overlay" id="edit-modal">
    <div class="modal" style="max-width:550px">
        <div class="modal-header">
            <h2>Edit Record</h2>
            <button class="modal-close" onclick="closeEditModal()">&times;</button>
        </div>
        <input type="hidden" id="edit-id">
        <div class="edit-row">
            <div class="edit-field"><label>Invoice</label><input id="edit-invoice"></div>
            <div class="edit-field"><label>Date</label><input type="date" id="edit-date"></div>
        </div>
        <div class="edit-row">
            <div class="edit-field"><label>Gen ID</label><input id="edit-gen-id"></div>
            <div class="edit-field"><label>Site</label><input id="edit-site"></div>
        </div>
        <div class="edit-field"><label>Description</label><input id="edit-desc"></div>
        <div class="edit-row">
            <div class="edit-field"><label>Service Type</label>
                <select id="edit-type"><option value="PM">PM</option><option value="SC">SC</option></select>
            </div>
            <div class="edit-field"><label>Amount</label><input type="number" step="0.01" id="edit-amount"></div>
        </div>
        <div class="edit-row">
            <div class="edit-field"><label>Category</label><input id="edit-category" list="cat-list"></div>
            <div class="edit-field"><label>Grouped Category</label><input id="edit-grouped" list="grp-list"></div>
        </div>
        <div style="margin-top:15px;display:flex;gap:10px">
            <button class="btn btn-primary" onclick="saveEdit()">Save</button>
            <button class="btn" onclick="closeEditModal()">Cancel</button>
        </div>
        <div id="edit-status"></div>
    </div>
</div>

<!-- Estimate Detail Modal -->
<div class="modal-overlay" id="estimate-modal">
    <div class="modal">
        <div class="modal-header">
            <h2 id="est-modal-title">Estimate</h2>
            <button class="modal-close" onclick="closeEstimateModal()">&times;</button>
        </div>
        <div id="est-modal-status"></div>
        <div id="est-modal-body"></div>
    </div>
</div>

<script>
""" + generate_dropdown_js() + """

const API = '/api/gen_service';
const EST_USER = '""" + _current_user + """';

// ===== State =====
let browseSort = 'order_date';
let browseOrder = 'DESC';
let browseOffset = 0;
const browseLimit = 100;
let parsedData = null;  // holds parse results for commit
let charts = {};

// ===== Tabs =====
function switchTab(tab) {
    document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
    document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
    document.getElementById('tab-' + tab).classList.add('active');
    document.querySelectorAll('.tab-btn').forEach(b => {
        if (b.dataset.tab === tab) b.classList.add('active');
    });
    if (typeof TAB_INIT !== 'undefined' && TAB_INIT[tab]) TAB_INIT[tab]();
}
// Each button's tab comes from its own switchTab('...') markup rather than its
// position in a parallel array -- inserting a tab used to silently re-map every
// button after it.
const TAB_INIT = {
    analysis: () => loadAnalysis(), import: () => loadRules(), pmsc: () => loadPmSc(),
    parts: () => loadParts(), qbo: () => initQboTab(), bills: () => initBillsTab(),
    estimates: () => loadEstimates(), query: () => initQueryTab()
};
document.querySelectorAll('.tab-btn').forEach(btn => {
    const m = (btn.getAttribute('onclick') || '').match(/switchTab\('([^']+)'\)/);
    if (!m) return;
    const name = m[1];
    btn.dataset.tab = name;
    btn.onclick = () => {
        document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
        document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
        btn.classList.add('active');
        const pane = document.getElementById('tab-' + name);
        if (pane) pane.classList.add('active');
        if (TAB_INIT[name]) TAB_INIT[name]();
    };
});

// ===== Bills (AP) tab =====
// Paid bills carry a Bill No. and match exactly; unpaid ones carry only an amount,
// so a match there is an inference and is labelled as such.
const BILL_WORK_TITLES = {pm_field: 'PM (field)', sc_field: 'SC (field)', shop: 'Shop',
                          parts_only: 'Parts-only', '(unmatched)': 'Not matched'};

const BILL_CATS = ['shop', 'sc_field', 'pm_field', 'parts_only', '(unmatched)'];

function initBillsTab() {
    loadBillsSummary();
    loadBillsMonthly();
    loadBills();
    if (!window._billsWired) {
        window._billsWired = true;
        const inp = document.getElementById('bills-file-input');
        inp.addEventListener('change', () => {
            if (inp.files.length) uploadBillsFile(inp.files[0]);
            inp.value = '';
        });
        const dz = document.getElementById('bills-drop-zone');
        ['dragenter', 'dragover'].forEach(ev => dz.addEventListener(ev, e => {
            e.preventDefault(); dz.classList.add('dragover');
        }));
        ['dragleave', 'drop'].forEach(ev => dz.addEventListener(ev, e => {
            e.preventDefault(); dz.classList.remove('dragover');
        }));
        dz.addEventListener('drop', e => {
            if (e.dataTransfer.files.length) uploadBillsFile(e.dataTransfer.files[0]);
        });
    }
}

// Spreadsheets go up as multipart and are parsed server-side -- openpyxl for
// .xlsx, xlrd for legacy .xls. Same header detection as the paste box.
async function uploadBillsFile(file) {
    const box = document.getElementById('bills-import-status');
    const status = document.getElementById('bills-status').value;
    box.innerHTML = '<div class="status-msg">Reading ' + escHtml(file.name) +
        (status === 'auto' ? '&hellip;' : ' as <strong>' + status + '</strong> bills&hellip;') + '</div>';
    const fd = new FormData();
    fd.append('file', file);
    fd.append('pay_status', status);
    let d;
    try {
        d = await (await fetch(API + '/bills/import', {method: 'POST', body: fd})).json();
    } catch (err) {
        box.innerHTML = '<div class="status-msg error">Upload failed: ' + escHtml(String(err)) + '</div>';
        return;
    }
    showBillImportResult(d, box, file.name);
}

async function importBills() {
    const box = document.getElementById('bills-import-status');
    const text = document.getElementById('bills-paste').value;
    if (!text.trim()) { box.innerHTML = '<div class="status-msg error">Nothing pasted.</div>'; return; }
    box.innerHTML = '<div class="status-msg">Importing&hellip;</div>';
    const d = await apiFetch('/bills/import', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({csv_data: text,
                              pay_status: document.getElementById('bills-status').value})
    });
    showBillImportResult(d, box);
    document.getElementById('bills-paste').value = '';
}

function showBillImportResult(d, box, filename) {
    if (d.error) { box.innerHTML = '<div class="status-msg error">' + escHtml(d.error) + '</div>'; return; }
    const m = d.matching || {};
    const c = d.row_counts || {};
    // say what it decided and why, so a wrong guess is visible rather than silent
    const split = Object.keys(c).length
        ? ' <span style="color:#888">(' + Object.keys(c).sort().map(k => c[k] + ' ' + k).join(', ') +
          (d.pay_status_source === 'chosen' ? ', as chosen'
             : ' &mdash; ' + escHtml(d.pay_status_source)) + ')</span>'
        : '';
    box.innerHTML = '<div class="status-msg success">' +
        (filename ? escHtml(filename) + ': i' : 'I') + 'mported <strong>' + d.inserted + '</strong> bills' + split +
        (d.skipped_duplicate ? ' (' + d.skipped_duplicate + ' already present)' : '') +
        (d.errors ? ', ' + d.errors + ' unreadable rows' : '') + '. Matched ' +
        ((m.bill_no || 0) + (m.amount || 0)) + ' &mdash; ' + (m.bill_no || 0) + ' by bill no, ' +
        (m.amount || 0) + ' by amount' +
        ((m.ambiguous || 0) ? ', <span style="color:#ffaa00">' + m.ambiguous + ' ambiguous</span>' : '') +
        ((m.unmatched || 0) ? ', ' + m.unmatched + ' with no invoice' : '') + '.</div>';
    loadBillsSummary();
    loadBillsMonthly();
    loadBills();
}

async function rematchBills() {
    const d = await apiFetch('/bills/rematch', {method: 'POST',
        headers: {'Content-Type': 'application/json'}, body: JSON.stringify({all: true})});
    if (d.error) { alert(d.error); return; }
    initBillsTab();
}

async function loadBillsSummary() {
    const d = await apiFetch('/bills/summary');
    const tb = document.getElementById('bills-summary-body');
    if (d.error || !d.by_work_type) { tb.innerHTML = ''; return; }
    const rows = {};
    d.by_work_type.forEach(r => {
        const k = r.work_type;
        if (!rows[k]) rows[k] = {paid: 0, paid_n: 0, unpaid: 0, unpaid_n: 0, bal: 0};
        if (r.pay_status === 'paid') { rows[k].paid += r.amount; rows[k].paid_n += r.bills; }
        else { rows[k].unpaid += r.amount; rows[k].unpaid_n += r.bills; rows[k].bal += r.balance; }
    });
    const order = ['shop', 'sc_field', 'pm_field', 'parts_only', '(unmatched)'];
    const keys = order.filter(k => rows[k]).concat(Object.keys(rows).filter(k => !order.includes(k)));
    if (!keys.length) {
        tb.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#888;padding:20px">No bills imported yet.</td></tr>';
        return;
    }
    const tot = {paid: 0, paid_n: 0, unpaid: 0, unpaid_n: 0, bal: 0};
    tb.innerHTML = keys.map(k => {
        const r = rows[k];
        ['paid', 'paid_n', 'unpaid', 'unpaid_n', 'bal'].forEach(f => tot[f] += r[f]);
        const em = k === 'shop' ? ' style="color:#ff6600;font-weight:600"' : '';
        return '<tr' + em + '><td>' + escHtml(BILL_WORK_TITLES[k] || k) + '</td>' +
            '<td>' + r.paid_n + '</td><td class="amount-cell">' + fmt$(r.paid) + '</td>' +
            '<td>' + r.unpaid_n + '</td><td class="amount-cell">' + fmt$(r.unpaid) + '</td>' +
            '<td class="amount-cell" style="color:#ffaa00">' + fmt$(r.bal) + '</td>' +
            '<td class="amount-cell">' + fmt$(r.paid + r.unpaid) + '</td></tr>';
    }).join('') +
        '<tr style="border-top:2px solid #3a3a3a;font-weight:600"><td>Total</td>' +
        '<td>' + tot.paid_n + '</td><td class="amount-cell">' + fmt$(tot.paid) + '</td>' +
        '<td>' + tot.unpaid_n + '</td><td class="amount-cell">' + fmt$(tot.unpaid) + '</td>' +
        '<td class="amount-cell" style="color:#ffaa00">' + fmt$(tot.bal) + '</td>' +
        '<td class="amount-cell" style="color:#00ff00">' + fmt$(tot.paid + tot.unpaid) + '</td></tr>';
}

async function loadBillsMonthly() {
    const d = await apiFetch('/bills/monthly');
    const tb = document.getElementById('bills-monthly-body');
    if (d.error) { tb.innerHTML = ''; return; }

    const m = {};
    (d.rows || []).forEach(r => {
        const k = r.month || '(no date)';
        if (!m[k]) { m[k] = {paid: 0, unpaid: 0}; BILL_CATS.forEach(c => m[k][c] = 0); }
        if (m[k][r.work_type] !== undefined) m[k][r.work_type] += r.amount;
        m[k][r.pay_status] += r.amount;
    });

    const sel = document.getElementById('bf-month');
    const keep = sel.value;
    sel.innerHTML = '<option value="">All months</option>' +
        (d.months || []).map(x => '<option value="' + x + '">' + x + '</option>').join('');
    sel.value = keep;

    const months = Object.keys(m).sort().reverse();
    if (!months.length) {
        tb.innerHTML = '<tr><td colspan="9" style="text-align:center;color:#888;padding:20px">No bills imported yet.</td></tr>';
        return;
    }
    const tot = {paid: 0, unpaid: 0};
    BILL_CATS.forEach(c => tot[c] = 0);
    const money = v => v ? fmt$(v) : '<span style="color:#444">-</span>';
    tb.innerHTML = months.map(k => {
        const r = m[k];
        Object.keys(tot).forEach(f => tot[f] += r[f]);
        const sum = BILL_CATS.reduce((a, c) => a + r[c], 0);
        return `<tr title="Click a cell for just that category, or the month for all of it">` +
            `<td style="cursor:pointer" onclick="drillMonth('${k}')">` + escHtml(k) + '</td>' +
            BILL_CATS.map(c => '<td class="amount-cell" style="cursor:pointer' +
                (c === 'shop' && r[c] ? ';color:#ff6600' : '') + '"' +
                ` onclick="drillMonth('${k}','` + c + `')">` + money(r[c]) + '</td>').join('') +
            '<td class="amount-cell">' + money(r.paid) + '</td>' +
            '<td class="amount-cell" style="color:#ffaa00">' + money(r.unpaid) + '</td>' +
            '<td class="amount-cell" style="color:#ddd">' + fmt$(sum) + '</td></tr>';
    }).join('') +
        '<tr style="border-top:2px solid #3a3a3a;font-weight:600"><td>All months</td>' +
        BILL_CATS.map(c => '<td class="amount-cell">' + fmt$(tot[c]) + '</td>').join('') +
        '<td class="amount-cell">' + fmt$(tot.paid) + '</td>' +
        '<td class="amount-cell" style="color:#ffaa00">' + fmt$(tot.unpaid) + '</td>' +
        '<td class="amount-cell" style="color:#00ff00">' +
        fmt$(BILL_CATS.reduce((a, c) => a + tot[c], 0)) + '</td></tr>';
}

function drillMonth(month, workType) {
    document.getElementById('bf-month').value = month;
    document.getElementById('bf-work').value = workType || '';
    loadBills();
    document.getElementById('bills-totals').scrollIntoView({behavior: 'smooth', block: 'center'});
}

function resetBillFilters() {
    ['bf-month', 'bf-status', 'bf-match', 'bf-work'].forEach(id => document.getElementById(id).value = '');
    document.getElementById('bf-variance').checked = false;
    loadBills();
}

async function loadBills() {
    const p = new URLSearchParams();
    const st = document.getElementById('bf-status').value;
    const mc = document.getElementById('bf-match').value;
    const wt = document.getElementById('bf-work').value;
    const mo = document.getElementById('bf-month').value;
    if (st) p.set('pay_status', st);
    if (mc) p.set('match_confidence', mc);
    if (wt) p.set('work_type', wt);
    if (mo) p.set('month', mo);
    if (document.getElementById('bf-variance').checked) p.set('has_variance', '1');
    const d = await apiFetch('/bills?' + p.toString());
    const tb = document.getElementById('bills-body');
    if (d.error) { tb.innerHTML = ''; return; }
    document.getElementById('bills-count').textContent = d.count + ' bills';
    // totals for whatever is on screen, so a paid/unpaid filter answers "how much"
    const t = d.totals || {};
    const box = (lbl, o, col) => o ? '<span style="margin-right:22px">' + lbl +
        ' <strong style="color:' + col + '">' + fmt$(o.amount) + '</strong>' +
        ' <span style="color:#666">(' + o.bills + ')</span></span>' : '';
    const outstanding = t.unpaid ? t.unpaid.balance : 0;
    document.getElementById('bills-totals').innerHTML =
        '<div class="status-msg" style="margin:0">' +
        box('Paid', t.paid, '#00aa00') + box('Unpaid', t.unpaid, '#ffaa00') +
        (outstanding ? '<span style="margin-right:22px">Outstanding <strong style="color:#ffaa00">' +
            fmt$(outstanding) + '</strong></span>' : '') +
        '<span>Total <strong style="color:#00ff00">' +
        fmt$((t.paid ? t.paid.amount : 0) + (t.unpaid ? t.unpaid.amount : 0)) + '</strong></span></div>';
    if (!d.bills.length) {
        tb.innerHTML = '<tr><td colspan="11" style="text-align:center;color:#888;padding:20px">No bills match this filter.</td></tr>';
        document.getElementById('bills-totals').innerHTML = '';
        return;
    }
    tb.innerHTML = d.bills.map(b => {
        const mc = b.match_confidence || '';
        const col = mc === 'high' || mc === 'confirmed' ? '#00aa00' : mc === 'ambiguous' ? '#ffaa00' : '#888';
        const label = mc === 'high' ? (b.match_method === 'bill_no' ? 'bill no' : 'by amount')
                    : mc === 'confirmed' ? 'confirmed' : mc === 'ambiguous' ? 'ambiguous' : 'no match';
        const inv = b.matched_invoice
            ? `<a href="#" onclick="showInvoice('${escHtml(b.matched_invoice)}');return false" style="color:#4a9eff">${escHtml(b.matched_invoice)}</a>`
            : (mc === 'ambiguous'
               ? `<button class="btn btn-small" onclick="resolveBill(${b.id})" title="${escHtml(b.match_note || '')}">Pick&hellip;</button>`
               : '<span style="color:#555">-</span>');
        // a bill matched by number can still disagree with the invoice; that gap is
        // the finding, so it gets its own column rather than hiding in a tooltip
        const av = b.amount_variance, dv = b.date_variance_days;
        let diff = '<span style="color:#444">-</span>';
        if (av !== null && av !== undefined && Math.abs(av) > 0.005) {
            diff = '<span style="color:#ff6600" title="' + escHtml(b.match_note || '') + '">' +
                   (av > 0 ? '+' : '') + fmt$(av) + '</span>';
        } else if (dv !== null && dv !== undefined && Math.abs(dv) > 45) {
            diff = '<span style="color:#ffaa00" title="' + escHtml(b.match_note || '') + '">' +
                   (dv > 0 ? '+' : '') + dv + 'd</span>';
        } else if (b.matched_invoice) {
            diff = '<span style="color:#00aa00">match</span>';
        }
        return `<tr>
            <td>${b.pay_status === 'unpaid' ? '<span style="color:#ffaa00">unpaid</span>' : 'paid'}</td>
            <td>${escHtml(b.month || '-')}</td>
            <td>${escHtml(b.bill_no || '-')}</td>
            <td>${escHtml(b.bill_date || '-')}</td>
            <td>${escHtml(b.due_date || '-')}</td>
            <td class="amount-cell">${fmt$(b.amount)}</td>
            <td>${inv}</td>
            <td>${escHtml(b.gen_id || '-')}</td>
            <td>${escHtml(BILL_WORK_TITLES[b.work_type] || '-')}</td>
            <td style="color:${col};font-size:12px" title="${escHtml(b.match_note || '')}">${label}</td>
            <td>${diff}</td></tr>`;
    }).join('');
}

async function resolveBill(id) {
    const bill = (await apiFetch('/bills?limit=500')).bills.find(b => b.id === id);
    const inv = prompt('Which invoice paid this bill? ' + (bill ? bill.match_note : ''), '');
    if (inv === null) return;
    const d = await apiFetch('/bills/' + id + '/match', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({invoice: inv.trim()})
    });
    if (d.error) { alert(d.error); return; }
    initBillsTab();
}

// ===== Utility =====
function fmt$(n) { return '$' + Number(n).toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2}); }
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }

async function apiFetch(url, opts) {
    const resp = await fetch(API + url, opts);
    return resp.json();
}

// ===== Load filter options =====
async function loadFilterOptions() {
    const [data, rulesData] = await Promise.all([apiFetch('/categories'), apiFetch('/rules')]);
    if (data.error) return;

    // Build combined sets from records + rules
    const allCats = new Set(data.categories || []);
    const allGrps = new Set(data.grouped_categories || []);
    window._catToGrp = {};
    (rulesData.rules || []).forEach(r => {
        allCats.add(r.category);
        allGrps.add(r.grouped_category);
        window._catToGrp[r.category] = r.grouped_category;
    });

    // Populate site dropdowns
    const siteSelects = [document.getElementById('f-site'), document.getElementById('a-site'),
                         document.getElementById('ef-site')];
    siteSelects.forEach(sel => {
        const cur = sel.value;
        sel.innerHTML = '<option value="">All Sites</option>';
        (data.sites || []).forEach(s => { const o = document.createElement('option'); o.value = s; o.textContent = s; sel.appendChild(o); });
        sel.value = cur;
    });

    // Populate filter dropdowns
    const catSel = document.getElementById('f-category');
    const curCat = catSel.value;
    catSel.innerHTML = '<option value="">All</option>';
    [...allCats].sort().forEach(c => { const o = document.createElement('option'); o.value = c; o.textContent = c; catSel.appendChild(o); });
    catSel.value = curCat;

    const grpSel = document.getElementById('f-grouped');
    const curGrp = grpSel.value;
    grpSel.innerHTML = '<option value="">All</option>';
    [...allGrps].sort().forEach(g => { const o = document.createElement('option'); o.value = g; o.textContent = g; grpSel.appendChild(o); });
    grpSel.value = curGrp;

    // Populate datalists for combo-box inputs (import, edit, rules)
    let dl1 = document.getElementById('cat-list');
    if (!dl1) { dl1 = document.createElement('datalist'); dl1.id = 'cat-list'; document.body.appendChild(dl1); }
    dl1.innerHTML = [...allCats].sort().map(c => '<option value="'+escHtml(c)+'">').join('');
    let dl2 = document.getElementById('grp-list');
    if (!dl2) { dl2 = document.createElement('datalist'); dl2.id = 'grp-list'; document.body.appendChild(dl2); }
    dl2.innerHTML = [...allGrps].sort().map(g => '<option value="'+escHtml(g)+'">').join('');

    // Populate the Add Category group dropdown on the import tab
    const newCatGrp = document.getElementById('new-cat-group');
    if (newCatGrp) {
        const cur = newCatGrp.value;
        newCatGrp.innerHTML = '<option value="">Select Group...</option>' +
            [...allGrps].sort().map(g => '<option value="'+escHtml(g)+'">'+escHtml(g)+'</option>').join('');
        newCatGrp.value = cur;
    }
}

// ===== Add Category =====
async function addCategory() {
    const nameEl = document.getElementById('new-cat-name');
    const grpEl = document.getElementById('new-cat-group');
    const statusEl = document.getElementById('add-cat-status');
    const name = nameEl.value.trim();
    const grp = grpEl.value;
    if (!name || !grp) {
        statusEl.innerHTML = '<div class="status-msg error">Both category name and group are required.</div>';
        return;
    }
    const resp = await apiFetch('/categories', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({ category: name, grouped_category: grp })
    });
    if (resp.error || !resp.success) {
        statusEl.innerHTML = '<div class="status-msg error">' + escHtml(resp.error || 'Failed') + '</div>';
        return;
    }
    statusEl.innerHTML = '<div class="status-msg success">Added "' + escHtml(name) + '" → ' + escHtml(grp) + '</div>';
    setTimeout(() => statusEl.innerHTML = '', 3000);
    nameEl.value = '';
    grpEl.value = '';
    loadFilterOptions();
}

// ===== Browse Tab =====
// Shop/field/parts-only is DERIVED (see gen_service_api._recompute_visit_types),
// so it renders muted with a "*" to distinguish it from fields Mesa actually bills.
const VISIT_LABELS = {shop: 'Shop', field: 'Field', parts_only: 'Parts'};
function visitLabel(vt) {
    const lbl = VISIT_LABELS[vt];
    if (!lbl) return '';
    return '<span style="color:#888" title="Derived from billed mileage, not a Mesa field">'
           + lbl + ' *</span>';
}

async function loadRecords(offset_val) {
    if (offset_val !== undefined) browseOffset = offset_val;
    const params = new URLSearchParams({
        sort: browseSort, order: browseOrder,
        limit: browseLimit, offset: browseOffset
    });
    const fields = {gen_id: 'f-gen-id', site: 'f-site', category: 'f-category',
                    grouped_category: 'f-grouped', service_type: 'f-service-type',
                    visit_type: 'f-visit-type',
                    date_from: 'f-date-from', date_to: 'f-date-to', q: 'f-search'};
    for (const [k, id] of Object.entries(fields)) {
        const v = document.getElementById(id).value;
        if (v) params.set(k, v);
    }
    // set only by a query-builder cell drill-down; there is no visible control
    if (qbDrillWorkType) params.set('work_type', qbDrillWorkType);

    const data = await apiFetch('/records?' + params.toString());
    if (data.error) {
        document.getElementById('browse-status').innerHTML = '<div class="status-msg error">' + escHtml(data.error) + '</div>';
        return;
    }
    document.getElementById('browse-status').innerHTML = '';

    const tbody = document.getElementById('browse-body');
    tbody.innerHTML = '';
    data.records.forEach(r => {
        const tr = document.createElement('tr');
        tr.innerHTML = `
            <td><a href="#" onclick="showInvoice('${escHtml(r.invoice)}');return false" style="color:#4a9eff">${escHtml(r.invoice)}</a></td>
            <td>${escHtml(r.order_date)}</td>
            <td><a href="#" onclick="showGenHistory('${escHtml(r.gen_id)}');return false" style="color:#4a9eff">${escHtml(r.gen_id)}</a></td>
            <td>${escHtml(r.site)}</td>
            <td>${escHtml(r.wrangled_description)}</td>
            <td>${escHtml(r.service_type)}</td>
            <td>${visitLabel(r.visit_type)}</td>
            <td>${escHtml(r.category)}</td>
            <td>${escHtml(r.grouped_category)}</td>
            <td class="amount-cell">${fmt$(r.amount)}</td>
            <td>
                <button class="btn btn-small" onclick="editRecord(${r.id})">Edit</button>
                <button class="btn btn-small btn-danger" onclick="deleteRecord(${r.id})">Del</button>
            </td>`;
        tbody.appendChild(tr);
    });

    // Pagination
    const total = data.total;
    const from = total > 0 ? browseOffset + 1 : 0;
    const to = Math.min(browseOffset + browseLimit, total);
    document.getElementById('page-info').textContent = `Showing ${from}-${to} of ${total}`;
    document.getElementById('btn-prev').disabled = browseOffset === 0;
    document.getElementById('btn-next').disabled = browseOffset + browseLimit >= total;

    // Update sort arrows
    document.querySelectorAll('#browse-header th[data-col]').forEach(th => {
        const arrow = th.querySelector('.sort-arrow');
        if (th.dataset.col === browseSort) {
            arrow.textContent = browseOrder === 'ASC' ? '\\u25B2' : '\\u25BC';
        } else {
            arrow.textContent = '';
        }
    });
}

function prevPage() { loadRecords(Math.max(0, browseOffset - browseLimit)); }
function nextPage() { loadRecords(browseOffset + browseLimit); }

function resetFilters() {
    qbDrillWorkType = '';
    ['f-gen-id','f-search'].forEach(id => document.getElementById(id).value = '');
    ['f-site','f-category','f-grouped','f-service-type','f-visit-type'].forEach(id => document.getElementById(id).value = '');
    ['f-date-from','f-date-to'].forEach(id => document.getElementById(id).value = '');
    browseSort = 'order_date'; browseOrder = 'DESC';
    loadRecords(0);
}

// Column sort click
document.querySelectorAll('#browse-header th[data-col]').forEach(th => {
    th.addEventListener('click', () => {
        const col = th.dataset.col;
        if (browseSort === col) {
            browseOrder = browseOrder === 'ASC' ? 'DESC' : 'ASC';
        } else {
            browseSort = col;
            browseOrder = col === 'amount' ? 'DESC' : 'ASC';
        }
        loadRecords(0);
    });
});

// Enter key in search
['f-gen-id','f-search'].forEach(id => {
    document.getElementById(id).addEventListener('keydown', e => { if (e.key === 'Enter') loadRecords(0); });
});

function exportCSV() {
    const params = new URLSearchParams();
    const fields = {gen_id: 'f-gen-id', site: 'f-site', category: 'f-category',
                    grouped_category: 'f-grouped', service_type: 'f-service-type',
                    visit_type: 'f-visit-type',
                    date_from: 'f-date-from', date_to: 'f-date-to', q: 'f-search'};
    for (const [k, id] of Object.entries(fields)) {
        const v = document.getElementById(id).value;
        if (v) params.set(k, v);
    }
    window.open(API + '/export?' + params.toString(), '_blank');
}

// ===== Gen History Modal =====
let genHistoryChart = null;
let genModalRecords = [];  // store for filtering
let genModalCharts = {};

const GEN_STATUS_COLORS = {
    active: '#00aa00', 'active-swap': '#ffaa00', swap: '#ffcc00', inspect: '#4a9eff',
    parts: '#ff6600', gas: '#00d4ff', loaner: '#aa44ff', oos: '#ff4444'
};

function switchModalTab(name) {
    document.querySelectorAll('#gen-modal .subtab-btn').forEach(b =>
        b.classList.toggle('active', b.dataset.subtab === name));
    document.querySelectorAll('#gen-modal .subtab-content').forEach(c =>
        c.classList.toggle('active', c.id === 'modal-' + name));
    // Charts can render zero-size if their tab was hidden — resize on activation
    if (name === 'overview') Object.values(genModalCharts).forEach(c => c.resize());
}

async function showGenHistory(genId) {
    document.getElementById('modal-title').textContent = 'Service History: ' + genId;
    switchModalTab('overview');
    document.getElementById('gen-modal').classList.add('show');

    const [serviceData, notesData, estData, oilData] = await Promise.all([
        apiFetch('/gen/' + encodeURIComponent(genId)),
        fetch('/api/gen_history/history/' + encodeURIComponent(genId))
            .then(r => r.json()).catch(() => ({ history: [] })),
        apiFetch('/estimates/gen/' + encodeURIComponent(genId)).catch(() => ({ estimates: [] })),
        apiFetch('/oil_allocation?by=gen&gen_id=' + encodeURIComponent(genId))
            .catch(() => ({ rows: [] }))
    ]);

    if (serviceData.error) return alert(serviceData.error);
    const records = serviceData.records || [];
    genModalRecords = records;

    // ===== Summary cards =====
    const total = records.reduce((s, r) => s + r.amount, 0);
    const dates = records.map(r => r.order_date).sort();
    const pmRecs = records.filter(r => r.service_type === 'PM');
    const scRecs = records.filter(r => r.service_type === 'SC');
    const pmInvoices = new Set(pmRecs.map(r => r.invoice));
    const scInvoices = new Set(scRecs.map(r => r.invoice));
    const allInvoices = new Set(records.map(r => r.invoice));
    const sites = [...new Set(records.map(r => r.site))].filter(Boolean).join(', ');
    document.getElementById('modal-stats').innerHTML =
        '<div class="stat-box"><div class="stat-label">Total Spend</div><div class="stat-value">' + fmt$(total) + '</div></div>' +
        '<div class="stat-box"><div class="stat-label">Records</div><div class="stat-value">' + records.length + '</div></div>' +
        '<div class="stat-box"><div class="stat-label">Invoices</div><div class="stat-value">' + allInvoices.size + '</div></div>' +
        '<div class="stat-box"><div class="stat-label">PM / SC Visits</div><div class="stat-value">' + pmInvoices.size + ' / ' + scInvoices.size + '</div></div>' +
        '<div class="stat-box"><div class="stat-label">Date Range</div><div class="stat-value" style="font-size:14px">' + (dates[0] || 'N/A') + ' to ' + (dates[dates.length - 1] || 'N/A') + '</div></div>' +
        '<div class="stat-box"><div class="stat-label">Site(s)</div><div class="stat-value" style="font-size:14px">' + escHtml(sites || '-') + '</div></div>' +
        genModalDerivedStats(records, oilData);

    // Warranty and bulk-oil are DERIVED, so they sit apart from billed spend and
    // are always labelled. Neither is money invoiced against this generator.
    function genModalDerivedStats(recs, oil) {
        const warr = (recs || []).reduce((a, r) => a + (r.warranty_value_imputed || 0), 0);
        const row = ((oil && oil.rows) || [])[0] || {};
        const oilAmt = row.amount || 0;
        let html = '';
        if (warr) html += '<div class="stat-box"><div class="stat-label">Warranty (est)</div>' +
            '<div class="stat-value" style="color:#ffaa00">' + fmt$(warr) + '</div></div>';
        if (oilAmt) html += '<div class="stat-box"><div class="stat-label" ' +
            'title="Bulk oil totes carried no gen; modelled from PM visits Oct 2024 - Mar 2026">' +
            'Bulk Oil (modelled)</div><div class="stat-value" style="color:#ffaa00">' +
            fmt$(oilAmt) + (row.gallons_est ? ' <span style="font-size:12px;color:#888">' +
            Math.round(row.gallons_est) + ' gal</span>' : '') + '</div></div>';
        return html;
    }

    // ===== Overview charts =====
    const byCategory = {};
    records.forEach(r => { const k = r.category || '(uncategorized)'; byCategory[k] = (byCategory[k] || 0) + r.amount; });
    const catSorted = Object.entries(byCategory).sort((a, b) => b[1] - a[1]);
    drawModalBar('chart-modal-category', catSorted.map(e => e[0]), catSorted.map(e => e[1]), '#00ff00');

    const byGroup = {};
    records.forEach(r => { const k = r.grouped_category || '(none)'; byGroup[k] = (byGroup[k] || 0) + r.amount; });
    const grpSorted = Object.entries(byGroup).sort((a, b) => b[1] - a[1]);
    drawModalBar('chart-modal-group', grpSorted.map(e => e[0]), grpSorted.map(e => e[1]), '#4a9eff');

    const pmTotal = pmRecs.reduce((s, r) => s + r.amount, 0);
    const scTotal = scRecs.reduce((s, r) => s + r.amount, 0);
    drawModalBar('chart-modal-pmsc', ['PM', 'SC'], [pmTotal, scTotal], ['#00ff00', '#ff6600']);

    const monthMap = {};
    records.forEach(r => {
        const m = r.order_month;
        if (!monthMap[m]) monthMap[m] = { pm: 0, sc: 0 };
        if (r.service_type === 'PM') monthMap[m].pm += r.amount;
        else monthMap[m].sc += r.amount;
    });
    const months = Object.keys(monthMap).sort();
    drawModalMonthly('chart-modal-month', months, months.map(m => monthMap[m].pm), months.map(m => monthMap[m].sc));

    // ===== Records sub-tab =====
    document.getElementById('modal-records-count').textContent = records.length;
    const cats = [...new Set(records.map(r => r.category))].sort();
    const grps = [...new Set(records.map(r => r.grouped_category))].sort();
    document.getElementById('mf-category').innerHTML = '<option value="">All</option>' + cats.map(c => '<option value="'+escHtml(c)+'">'+escHtml(c)+'</option>').join('');
    document.getElementById('mf-grouped').innerHTML = '<option value="">All</option>' + grps.map(g => '<option value="'+escHtml(g)+'">'+escHtml(g)+'</option>').join('');
    document.getElementById('mf-type').value = '';
    renderGenModalTable(records);

    // ===== Notes sub-tab =====
    renderGenNotes(notesData.history || []);

    // ===== Invoices sub-tab =====
    renderGenInvoices(records, (estData && estData.estimates) || []);

    // ===== Bills sub-tab =====
    loadGenBills(genId);

    // ===== Estimates sub-tab =====
    renderGenEstimates((estData && estData.estimates) || []);
}

// Category is derived, but an operator can override it per invoice -- the select
// writes straight through and the override survives every later recompute.
const WT_OPTIONS = [['', 'auto'], ['pm_field', 'PM (field)'], ['sc_field', 'SC (field)'],
                    ['shop', 'Shop'], ['parts_only', 'Parts-only']];

// Same rule as _WORK_TYPE_SQL server-side; kept in step so the modal never
// disagrees with the tables.
function resolveWorkType(r) {
    if (r.work_type_override) return r.work_type_override;
    if (r.visit_type === 'shop') return 'shop';
    if (r.visit_type === 'parts_only') return 'parts_only';
    return r.service_type === 'PM' ? 'pm_field' : 'sc_field';
}

function workTypeCell(invoice, workType, overridden) {
    const opts = WT_OPTIONS.map(([v, l]) =>
        '<option value="' + v + '"' + (v === (overridden || '') ? ' selected' : '') + '>' +
        l + '</option>').join('');
    const shown = WT_OPTIONS.find(o => o[0] === workType);
    const style = 'background:#141414;color:' + (overridden ? '#ffaa00' : '#aaa') +
                  ';border:1px solid #3a3a3a;border-radius:3px;font-size:11px;padding:1px 3px';
    const title = overridden ? 'Manually set - overrides the derived category'
                             : 'Derived: ' + (shown ? shown[1] : '-');
    return `<select style="${style}" title="${title}" ` +
        `onchange="setInvoiceWorkType(this, '${invoice}')">` + opts + '</select>' +
        (overridden ? '' : ' <span style="color:#666;font-size:11px">' +
            (shown ? shown[1] : '') + '</span>');
}

async function setInvoiceWorkType(sel, invoice) {
    const d = await apiFetch('/invoice/' + encodeURIComponent(invoice) + '/work_type', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({work_type: sel.value})
    });
    if (d.error) { alert(d.error); return; }
    sel.style.borderColor = '#00aa00';
    setTimeout(() => { sel.style.borderColor = ''; }, 1200);
}

// Rolls the line-level records up to one row per invoice. Everything here is
// derived client-side from the same payload the Records tab uses -- no extra call.
let genModalInvoiceRows = [];

function filterGenInvoices() {
    const wt = document.getElementById('mi-work').value;
    const st = document.getElementById('mi-type').value;
    renderGenInvoiceRows(genModalInvoiceRows.filter(b =>
        (!wt || b.work_type === wt) &&
        (!st || b.types.has(st))));
}

function renderGenInvoices(records, estimates) {
    const quoteFor = {};
    (estimates || []).forEach(e => { if (e.linked_invoice) quoteFor[e.linked_invoice] = e; });

    const byInv = {};
    (records || []).forEach(r => {
        const k = r.invoice || '(none)';
        if (!byInv[k]) byInv[k] = {invoice: k, date: r.order_date, lines: 0, billed: 0,
                                   warranty: 0, hours: 0, where: r.visit_type || '',
                                   overridden: r.work_type_override || '',
                                   work_type: resolveWorkType(r), types: new Set()};
        const b = byInv[k];
        b.lines += 1;
        b.billed += r.amount || 0;
        b.warranty += r.warranty_value_imputed || 0;
        if (/^hour/i.test(r.uom || '')) b.hours += r.quantity || 0;
        if (r.service_type) b.types.add(r.service_type);
        if (r.order_date < b.date) b.date = r.order_date;
    });

    genModalInvoiceRows = Object.values(byInv).sort((a, b) => (b.date || '').localeCompare(a.date || ''));
    document.getElementById('modal-invoices-count').textContent = genModalInvoiceRows.length;
    document.getElementById('mi-work').value = '';
    document.getElementById('mi-type').value = '';
    genModalQuoteFor = quoteFor;
    renderGenInvoiceRows(genModalInvoiceRows);
}

let genModalQuoteFor = {};
let genModalBills = [];

// AP bills for one generator. Reached via matched invoices, so the count is the
// reconciled slice rather than everything Mesa has billed.
async function loadGenBills(genId) {
    const d = await apiFetch('/bills/gen/' + encodeURIComponent(genId)).catch(() => ({bills: []}));
    genModalBills = (d && d.bills) || [];
    document.getElementById('modal-bills-count').textContent = genModalBills.length;
    document.getElementById('mb-status').value = '';
    document.getElementById('mb-work').value = '';
    filterGenBills();
}

function filterGenBills() {
    const st = document.getElementById('mb-status').value;
    const wt = document.getElementById('mb-work').value;
    const rows = genModalBills.filter(b =>
        (!st || b.pay_status === st) && (!wt || b.work_type === wt));
    const tb = document.getElementById('modal-bills-body');
    const tot = {paid: 0, unpaid: 0, bal: 0};
    rows.forEach(b => {
        if (b.pay_status === 'paid') tot.paid += b.amount || 0;
        else { tot.unpaid += b.amount || 0; tot.bal += (b.balance == null ? b.amount : b.balance) || 0; }
    });
    document.getElementById('modal-bills-totals').innerHTML = rows.length
        ? 'Paid <strong style="color:#00aa00">' + fmt$(tot.paid) + '</strong> &middot; ' +
          'Unpaid <strong style="color:#ffaa00">' + fmt$(tot.unpaid) + '</strong> &middot; ' +
          'Still owed <strong style="color:#ffaa00">' + fmt$(tot.bal) + '</strong>'
        : '';
    if (!rows.length) {
        tb.innerHTML = '<tr><td colspan="9" style="text-align:center;color:#888;padding:20px">' +
            (genModalBills.length ? 'No bills match this filter.'
                                  : 'No matched bills for this generator.') + '</td></tr>';
        return;
    }
    tb.innerHTML = rows.map(b => {
        const av = b.amount_variance;
        const diff = (av !== null && av !== undefined && Math.abs(av) > 0.005)
            ? '<span style="color:#ff6600" title="' + escHtml(b.match_note || '') + '">' +
              (av > 0 ? '+' : '') + fmt$(av) + '</span>'
            : '<span style="color:#00aa00">match</span>';
        return `<tr>
            <td>${b.pay_status === 'unpaid' ? '<span style="color:#ffaa00">unpaid</span>' : 'paid'}</td>
            <td>${escHtml(b.month || '-')}</td>
            <td>${escHtml(b.bill_no || '-')}</td>
            <td>${escHtml(b.due_date || '-')}</td>
            <td class="amount-cell">${fmt$(b.amount)}</td>
            <td class="amount-cell">${b.balance == null ? '-' : fmt$(b.balance)}</td>
            <td><a href="#" onclick="showInvoice('${escHtml(b.matched_invoice)}');return false" style="color:#4a9eff">${escHtml(b.matched_invoice)}</a></td>
            <td>${escHtml(BILL_WORK_TITLES[b.work_type] || '-')}</td>
            <td>${diff}</td></tr>`;
    }).join('');
}

function renderGenInvoiceRows(rows) {
    const quoteFor = genModalQuoteFor;
    const tb = document.getElementById('modal-invoices-body');
    document.getElementById('modal-inv-count').textContent =
        rows.length + ' of ' + genModalInvoiceRows.length + ' invoices';
    if (!rows.length) {
        tb.innerHTML = '<tr><td colspan="9" style="text-align:center;color:#888;padding:20px">No invoices match this filter.</td></tr>';
        ['modal-inv-hours','modal-inv-billed','modal-inv-warranty'].forEach(id =>
            document.getElementById(id).textContent = '');
        return;
    }

    tb.innerHTML = rows.map(b => {
        const q = quoteFor[b.invoice];
        const quoteCell = q
            ? `<a href="#" onclick="closeModal();showEstimate(${q.id});return false" style="color:#4a9eff">${escHtml(q.quote_no)}</a>`
            : '<span style="color:#555">-</span>';
        const type = Array.from(b.types).sort().join(' / ') || '-';
        return `<tr>
            <td>${escHtml(b.date || '-')}</td>
            <td><a href="#" onclick="showInvoice('${escHtml(b.invoice)}');return false" style="color:#4a9eff">${escHtml(b.invoice)}</a></td>
            <td>${workTypeCell(b.invoice, b.work_type, b.overridden)}</td>
            <td>${escHtml(type)}</td>
            <td>${b.lines}</td>
            <td class="amount-cell">${b.hours ? b.hours.toFixed(1) : '-'}</td>
            <td class="amount-cell">${fmt$(b.billed)}</td>
            <td class="amount-cell">${b.warranty ? '<span style="color:#ffaa00">' + fmt$(b.warranty) + '</span>' : '<span style="color:#555">-</span>'}</td>
            <td>${quoteCell}</td></tr>`;
    }).join('');

    const sum = (f) => rows.reduce((a, r) => a + f(r), 0);
    document.getElementById('modal-inv-hours').textContent = sum(r => r.hours).toFixed(1);
    document.getElementById('modal-inv-billed').textContent = fmt$(sum(r => r.billed));
    document.getElementById('modal-inv-warranty').textContent = fmt$(sum(r => r.warranty));
}

function renderGenEstimates(estimates) {
    document.getElementById('modal-estimates-count').textContent = estimates.length;
    const tb = document.getElementById('modal-estimates-body');
    if (!estimates.length) {
        tb.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#888;padding:20px">No estimates for this generator.</td></tr>';
        return;
    }
    tb.innerHTML = estimates.map(e =>
        '<tr><td><a href="#" onclick="closeModal();showEstimate(' + e.id + ');return false" style="color:#4a9eff">' + escHtml(e.quote_no) + '</a></td>' +
        '<td>' + escHtml(e.quote_date || '-') + '</td>' +
        '<td class="amount-cell">' + (e.grand_total != null ? fmt$(e.grand_total) : '-') + '</td>' +
        '<td><span class="est-status ' + e.status + '">' + e.status + '</span></td>' +
        '<td>' + escHtml(e.linked_invoice || '-') + '</td>' +
        '<td>' + estAgeLabel(e) + '</td></tr>'
    ).join('');
}

function renderGenNotes(notes) {
    document.getElementById('modal-notes-count').textContent = notes.length;
    const list = document.getElementById('modal-notes-list');
    if (!notes.length) {
        list.className = '';
        list.innerHTML = '<div style="color:#888;text-align:center;padding:30px">No notes for this generator.</div>';
        return;
    }
    list.className = 'notes-list';
    list.innerHTML = notes.map(n => {
        const color = GEN_STATUS_COLORS[n.status] || '#888';
        const loc = [n.site_name, n.group_name].filter(Boolean).map(escHtml).join(' / ');
        return '<div class="note-card" style="border-left-color:' + color + '">' +
            '<div class="note-header">' +
                '<span class="note-status" style="background:' + color + '22;color:' + color + '">' + escHtml(n.status || 'note') + '</span>' +
                '<span>' + escHtml(n.timestamp || '') + '</span>' +
                (loc ? '<span>' + loc + '</span>' : '') +
                '<span style="margin-left:auto">by ' + escHtml(n.user || '?') + '</span>' +
            '</div>' +
            '<div class="note-text">' + escHtml(n.note_text || '') + '</div>' +
        '</div>';
    }).join('');
}

function drawModalBar(canvasId, labels, values, color) {
    if (genModalCharts[canvasId]) genModalCharts[canvasId].destroy();
    genModalCharts[canvasId] = new Chart(document.getElementById(canvasId), {
        type: 'bar',
        data: { labels: labels, datasets: [{ data: values, backgroundColor: color, borderWidth: 0 }] },
        options: {
            responsive: true, maintainAspectRatio: false,
            plugins: { legend: { display: false } },
            scales: {
                x: { ticks: { color: '#888' }, grid: { color: '#2a2a2a' } },
                y: { beginAtZero: true, ticks: { color: '#888', callback: v => '$' + v.toLocaleString() }, grid: { color: '#2a2a2a' } }
            }
        }
    });
}

function drawModalMonthly(canvasId, months, pmData, scData) {
    if (genModalCharts[canvasId]) genModalCharts[canvasId].destroy();
    genModalCharts[canvasId] = new Chart(document.getElementById(canvasId), {
        type: 'bar',
        data: {
            labels: months,
            datasets: [
                { label: 'PM', data: pmData, backgroundColor: '#00ff00', stack: 's' },
                { label: 'SC', data: scData, backgroundColor: '#ff6600', stack: 's' }
            ]
        },
        options: {
            responsive: true, maintainAspectRatio: false,
            plugins: { legend: { labels: { color: '#888' } } },
            scales: {
                x: { stacked: true, ticks: { color: '#888' }, grid: { color: '#2a2a2a' } },
                y: { stacked: true, beginAtZero: true, ticks: { color: '#888', callback: v => '$' + v.toLocaleString() }, grid: { color: '#2a2a2a' } }
            }
        }
    });
}

function renderGenModalTable(records) {
    const tbody = document.getElementById('modal-body');
    tbody.innerHTML = '';
    let total = 0;
    records.forEach(r => {
        total += r.amount;
        const tr = document.createElement('tr');
        tr.innerHTML = `<td>${escHtml(r.order_date)}</td>
            <td><a href="#" onclick="showInvoice('${escHtml(r.invoice)}');return false" style="color:#4a9eff">${escHtml(r.invoice)}</a></td>
            <td>${escHtml(r.wrangled_description)}</td><td>${escHtml(r.service_type)}</td>
            <td>${escHtml(r.category)}</td><td>${escHtml(r.grouped_category)}</td>
            <td class="amount-cell">${fmt$(r.amount)}</td>`;
        tbody.appendChild(tr);
    });
    document.getElementById('modal-filtered-total').textContent = fmt$(total);
}

function filterGenModal() {
    const cat = document.getElementById('mf-category').value;
    const grp = document.getElementById('mf-grouped').value;
    const typ = document.getElementById('mf-type').value;
    // a leading underscore means Mesa's raw PM/SC flag; anything else is our
    // four-way category, so both are reachable from one control
    const matchesType = r => !typ ||
        (typ.startsWith('_') ? r.service_type === typ.slice(1) : resolveWorkType(r) === typ);
    const filtered = genModalRecords.filter(r =>
        (!cat || r.category === cat) && (!grp || r.grouped_category === grp) && matchesType(r)
    );
    renderGenModalTable(filtered);
}

function closeModal() { document.getElementById('gen-modal').classList.remove('show'); }

// ===== Invoice Detail Modal =====
async function showInvoice(invoiceNum) {
    const data = await apiFetch('/records?limit=500&q=' + encodeURIComponent(invoiceNum));
    if (data.error) return alert(data.error);

    // Filter to exact invoice match
    const records = data.records.filter(r => r.invoice === invoiceNum);
    if (records.length === 0) return alert('No records found for invoice ' + invoiceNum);

    document.getElementById('invoice-modal-title').textContent = 'Invoice: ' + invoiceNum;

    const total = records.reduce((s, r) => s + r.amount, 0);
    const date = records[0].order_date;
    const types = [...new Set(records.map(r => r.service_type))].join('/');
    document.getElementById('invoice-modal-stats').innerHTML = `
        <div class="stat-box"><div class="stat-label">Date</div><div class="stat-value" style="font-size:14px">${escHtml(date)}</div></div>
        <div class="stat-box"><div class="stat-label">Line Items</div><div class="stat-value">${records.length}</div></div>
        <div class="stat-box"><div class="stat-label">Total</div><div class="stat-value">${fmt$(total)}</div></div>
        <div class="stat-box"><div class="stat-label">Type</div><div class="stat-value" style="font-size:14px">${escHtml(types)}</div></div>`;

    const tbody = document.getElementById('invoice-modal-body');
    tbody.innerHTML = '';
    records.forEach(r => {
        const tr = document.createElement('tr');
        tr.innerHTML = `<td><a href="#" onclick="closeInvoiceModal();showGenHistory('${escHtml(r.gen_id)}');return false" style="color:#4a9eff">${escHtml(r.gen_id)}</a></td>
            <td>${escHtml(r.site)}</td>
            <td>${escHtml(r.wrangled_description)}</td><td>${escHtml(r.service_type)}</td>
            <td>${escHtml(r.category)}</td><td>${escHtml(r.grouped_category)}</td>
            <td class="amount-cell">${fmt$(r.amount)}</td>`;
        tbody.appendChild(tr);
    });
    document.getElementById('invoice-modal-total').textContent = fmt$(total);
    document.getElementById('invoice-modal').classList.add('show');
}
function closeInvoiceModal() { document.getElementById('invoice-modal').classList.remove('show'); }

// ===== Edit Record =====
async function editRecord(id) {
    const data = await apiFetch('/records?limit=1&offset=0&q=&gen_id=&site=&category=&grouped_category=&service_type=&date_from=&date_to=');
    // Fetch all to find record - simpler: re-fetch with specific filter not available, so get from current page
    const row = document.getElementById('browse-body').querySelector('tr button[onclick="editRecord(' + id + ')"]');
    // Better: fetch by searching records (no single-record GET, so use records endpoint)
    const resp = await fetch(API + '/records?limit=1&offset=0', {method: 'GET'});
    // Actually let's just find it in the DOM or refetch. Let's do a simple approach:
    const all = await apiFetch('/records?limit=10000&offset=0');
    const rec = all.records.find(r => r.id === id);
    if (!rec) return alert('Record not found');

    document.getElementById('edit-id').value = rec.id;
    document.getElementById('edit-invoice').value = rec.invoice;
    document.getElementById('edit-date').value = rec.order_date;
    document.getElementById('edit-gen-id').value = rec.gen_id;
    document.getElementById('edit-site').value = rec.site;
    document.getElementById('edit-desc').value = rec.wrangled_description;
    document.getElementById('edit-type').value = rec.service_type;
    document.getElementById('edit-amount').value = rec.amount;
    document.getElementById('edit-category').value = rec.category;
    document.getElementById('edit-grouped').value = rec.grouped_category;
    document.getElementById('edit-status').innerHTML = '';
    document.getElementById('edit-modal').classList.add('show');
}

async function saveEdit() {
    const id = document.getElementById('edit-id').value;
    const body = {
        invoice: document.getElementById('edit-invoice').value,
        order_date: document.getElementById('edit-date').value,
        gen_id: document.getElementById('edit-gen-id').value,
        site: document.getElementById('edit-site').value,
        wrangled_description: document.getElementById('edit-desc').value,
        service_type: document.getElementById('edit-type').value,
        amount: parseFloat(document.getElementById('edit-amount').value),
        category: document.getElementById('edit-category').value,
        grouped_category: document.getElementById('edit-grouped').value
    };
    const data = await apiFetch('/records/' + id, {
        method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
    });
    if (data.success) {
        closeEditModal();
        loadRecords();
    } else {
        document.getElementById('edit-status').innerHTML = '<div class="status-msg error">' + escHtml(data.error) + '</div>';
    }
}
function closeEditModal() { document.getElementById('edit-modal').classList.remove('show'); }

async function deleteRecord(id) {
    if (!confirm('Delete this record?')) return;
    const data = await apiFetch('/records/' + id, {method: 'DELETE'});
    if (data.success) loadRecords();
    else alert(data.error);
}

// ===== Import Tab =====
let droppedCsv = null;  // CSV string built from a dropped xlsx file

function setImportMode(mode) {
    document.getElementById('import-file').style.display = mode === 'file' ? 'block' : 'none';
    document.getElementById('import-paste').style.display = mode === 'paste' ? 'block' : 'none';
    document.getElementById('import-wrangled').style.display = mode === 'wrangled' ? 'block' : 'none';
    document.getElementById('toggle-file').classList.toggle('active', mode === 'file');
    document.getElementById('toggle-paste').classList.toggle('active', mode === 'paste');
    document.getElementById('toggle-wrangled').classList.toggle('active', mode === 'wrangled');
}

// --- xlsx file handling ---
function csvEscape(v) {
    const s = (v === null || v === undefined) ? '' : String(v);
    return /[",\\n\\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}

function findHeaderRow(rows) {
    // Look for a row in the first 20 that contains Invoice + Date + Amount-ish headers
    for (let i = 0; i < Math.min(rows.length, 20); i++) {
        const cells = (rows[i] || []).map(c => String(c == null ? '' : c).trim().toLowerCase());
        const hasInvoice = cells.some(c => c === 'invoice' || c.startsWith('invoice'));
        const hasDate = cells.some(c => c.includes('date'));
        const hasAmount = cells.some(c => c.includes('amount') || c === '$ amount');
        if (hasInvoice && hasDate && hasAmount) return i;
    }
    return -1;
}

function findCol(header, predicates) {
    for (const p of predicates) {
        const idx = header.findIndex(p);
        if (idx >= 0) return idx;
    }
    return -1;
}

async function parseDroppedFile(file) {
    const fileInfo = document.getElementById('file-info');
    fileInfo.style.display = 'block';
    fileInfo.classList.remove('error');
    fileInfo.innerHTML = `<div>Reading <b>${escHtml(file.name)}</b>...</div>`;

    try {
        const buf = await file.arrayBuffer();
        const wb = XLSX.read(buf, {type: 'array'});
        const ws = wb.Sheets[wb.SheetNames[0]];
        // raw:true returns dates as Excel serials (which our backend's parse_date handles)
        const rows = XLSX.utils.sheet_to_json(ws, {header: 1, raw: true, defval: ''});

        const headerIdx = findHeaderRow(rows);
        if (headerIdx < 0) {
            throw new Error('Could not locate header row. Expected columns including Invoice, Date, Amount.');
        }
        const header = rows[headerIdx].map(c => String(c == null ? '' : c).trim().toLowerCase());

        const colInvoice = findCol(header, [c => c === 'invoice', c => c.startsWith('invoice')]);
        const colDate    = findCol(header, [c => c.includes('order date'), c => c.includes('date')]);
        const colUnit    = findCol(header, [c => c.includes('unit'), c => c === 'gen_id', c => c === 'gen id']);
        const colDesc    = findCol(header, [c => c === 'description', c => c.includes('description')]);
        const colAmount  = findCol(header, [c => c === '$ amount', c => c.includes('amount'), c => c === '$']);

        const missing = [];
        if (colInvoice < 0) missing.push('Invoice');
        if (colDate < 0)    missing.push('Order Date');
        if (colUnit < 0)    missing.push('Unit Number');
        if (colDesc < 0)    missing.push('Description');
        if (colAmount < 0)  missing.push('$ Amount');
        if (missing.length) {
            throw new Error('Missing column(s): ' + missing.join(', ') + '. Found: ' + header.filter(Boolean).join(', '));
        }

        const csvLines = [];
        for (let i = headerIdx + 1; i < rows.length; i++) {
            const row = rows[i] || [];
            // Detect invoice trailer rows (Total / Applied filters: ...) and stop reading
            const firstNonBlank = row.find(c => c != null && String(c).trim() !== '');
            if (firstNonBlank != null) {
                const first = String(firstNonBlank).trim().toLowerCase();
                if (first === 'total' || first.startsWith('applied filter')) break;
            }
            const cells = [
                row[colInvoice], row[colDate], row[colUnit], row[colDesc], row[colAmount]
            ].map(c => (c == null ? '' : c));
            // Skip fully blank rows and rows missing both invoice and amount (likely subtotals/footers)
            const allBlank = cells.every(c => String(c).trim() === '');
            if (allBlank) continue;
            const noInvoiceOrAmount = String(cells[0]).trim() === '' && String(cells[4]).trim() === '';
            if (noInvoiceOrAmount) continue;
            csvLines.push(cells.map(csvEscape).join(','));
        }

        if (csvLines.length === 0) throw new Error('No data rows found below the header.');

        droppedCsv = csvLines.join('\\n');
        fileInfo.innerHTML = `<div><b>${escHtml(file.name)}</b> - ${csvLines.length} data rows ready to parse</div>
            <div class="file-meta">Columns mapped: Invoice=${header[colInvoice]}, Date=${header[colDate]}, Unit=${header[colUnit]}, Description=${header[colDesc]}, Amount=${header[colAmount]}</div>`;
        document.getElementById('btn-parse-file').disabled = false;
    } catch (err) {
        droppedCsv = null;
        fileInfo.classList.add('error');
        fileInfo.innerHTML = `<div><b>${escHtml(file.name)}</b></div><div class="file-meta">${escHtml(err.message)}</div>`;
        document.getElementById('btn-parse-file').disabled = true;
    }
}

// Drop-zone wiring (runs once; safe even before tab is opened)
(function initDropZone() {
    const dz = document.getElementById('drop-zone');
    const input = document.getElementById('xlsx-file-input');
    if (!dz || !input) return;
    dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('dragover'); });
    dz.addEventListener('dragleave', () => dz.classList.remove('dragover'));
    dz.addEventListener('drop', e => {
        e.preventDefault();
        dz.classList.remove('dragover');
        const f = e.dataTransfer.files && e.dataTransfer.files[0];
        if (f) parseDroppedFile(f);
    });
    input.addEventListener('change', e => {
        const f = e.target.files && e.target.files[0];
        if (f) parseDroppedFile(f);
    });
})();

async function parseRawImport(mode) {
    let csvData, override;
    if (mode === 'file') {
        if (!droppedCsv) return;
        csvData = droppedCsv;
        override = document.getElementById('file-service-type-override').value;
    } else {
        csvData = document.getElementById('raw-csv').value;
        if (!csvData.trim()) return;
        override = document.getElementById('paste-service-type-override').value;
    }

    document.getElementById('parse-status').innerHTML = '<div class="status-msg">Parsing...</div>';
    const data = await apiFetch('/import/parse', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({csv_data: csvData, service_type_override: override || ''})
    });

    if (data.error) {
        document.getElementById('parse-status').innerHTML = '<div class="status-msg error">' + escHtml(data.error) + '</div>';
        return;
    }
    document.getElementById('parse-status').innerHTML = '';
    parsedData = data;

    // Summary badges
    const sumDiv = document.getElementById('parse-summary');
    sumDiv.style.display = 'flex';
    sumDiv.innerHTML = `
        <span class="badge badge-green">Matched: ${data.summary.matched}</span>
        <span class="badge badge-yellow">Unmatched: ${data.summary.unmatched}</span>
        <span class="badge badge-red">Errors: ${data.summary.errors}</span>`;

    // Build review table
    const tbody = document.getElementById('parse-body');
    tbody.innerHTML = '';

    // Ensure datalists are current
    await loadFilterOptions();
    const catToGrp = window._catToGrp || {};

    // Matched rows
    data.matched.forEach((r, i) => {
        const tr = document.createElement('tr');
        tr.className = 'review-row-matched';
        tr.dataset.index = i;
        tr.dataset.type = 'matched';
        tr.innerHTML = `<td>${r.line}</td><td>${escHtml(r.invoice)}</td><td>${escHtml(r.order_date)}</td>
            <td>${escHtml(r.gen_id)}</td><td>${escHtml(r.site)}</td>
            <td>${escHtml(r.wrangled_description)}</td><td>${escHtml(r.service_type)}</td>
            <td>${escHtml(r.category)}</td><td>${escHtml(r.grouped_category)}</td>
            <td class="amount-cell">${fmt$(r.amount)}</td><td></td>`;
        tbody.appendChild(tr);
    });

    // Unmatched rows
    data.unmatched.forEach((r, i) => {
        const tr = document.createElement('tr');
        tr.className = 'review-row-unmatched';
        tr.dataset.index = i;
        tr.dataset.type = 'unmatched';
        tr.innerHTML = `<td>${r.line}</td><td>${escHtml(r.invoice)}</td><td>${escHtml(r.order_date)}</td>
            <td>${escHtml(r.gen_id)}</td><td>${escHtml(r.site)}</td>
            <td>${escHtml(r.wrangled_description)}</td><td>${escHtml(r.service_type)}</td>
            <td><input class="inline-input um-cat" data-i="${i}" placeholder="Category" list="cat-list"></td>
            <td><input class="inline-input um-grp" data-i="${i}" placeholder="Grouped" list="grp-list"></td>
            <td class="amount-cell">${fmt$(r.amount)}</td>
            <td><label style="font-size:11px"><input type="checkbox" class="um-save-rule" data-i="${i}"> Rule</label></td>`;
        tbody.appendChild(tr);
    });

    // Error rows
    data.errors.forEach(e => {
        const tr = document.createElement('tr');
        tr.className = 'review-row-error';
        tr.innerHTML = `<td>${e.line}</td><td colspan="10">Error: ${escHtml(e.error)} | Data: ${escHtml(JSON.stringify(e.data))}</td>`;
        tbody.appendChild(tr);
    });

    // Auto-fill grouped category when category is selected from known rules
    document.querySelectorAll('.um-cat').forEach(input => {
        input.addEventListener('change', function() {
            const grpInput = document.querySelector('.um-grp[data-i="'+this.dataset.i+'"]');
            if (grpInput && !grpInput.value && catToGrp[this.value]) {
                grpInput.value = catToGrp[this.value];
            }
        });
    });

    document.getElementById('parse-results').style.display = 'block';
}

async function commitImport() {
    if (!parsedData) return;
    const records = [...parsedData.matched];
    const newRules = [];

    // Collect unmatched with user-assigned categories
    parsedData.unmatched.forEach((r, i) => {
        const cat = document.querySelector('.um-cat[data-i="'+i+'"]');
        const grp = document.querySelector('.um-grp[data-i="'+i+'"]');
        const saveRule = document.querySelector('.um-save-rule[data-i="'+i+'"]');
        if (cat && grp && cat.value && grp.value) {
            r.category = cat.value;
            r.grouped_category = grp.value;
            records.push(r);

            if (saveRule && saveRule.checked) {
                // Use wrangled description as pattern
                const pattern = r.wrangled_description.toLowerCase().split(' ')[0];
                if (pattern) {
                    newRules.push({pattern: pattern, match_type: 'contains', category: cat.value, grouped_category: grp.value, priority: 100});
                }
            }
        }
    });

    if (records.length === 0) return alert('No records to import (assign categories to unmatched rows first)');

    document.getElementById('btn-commit').disabled = true;
    const data = await apiFetch('/import/commit', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({records, new_rules: newRules, notes: 'Web import'})
    });

    document.getElementById('btn-commit').disabled = false;
    if (data.success) {
        let msg = 'Inserted ' + data.records_inserted + ' new records (batch #' + data.batch_id + ')';
        if (data.records_skipped_duplicate) msg += ', skipped ' + data.records_skipped_duplicate + ' duplicates already in DB';
        if (data.rules_added) msg += ', ' + data.rules_added + ' new rules added';
        document.getElementById('parse-status').innerHTML = '<div class="status-msg success">' + msg + '</div>';
        document.getElementById('parse-results').style.display = 'none';
        document.getElementById('parse-summary').style.display = 'none';
        document.getElementById('raw-csv').value = '';
        droppedCsv = null;
        document.getElementById('file-info').style.display = 'none';
        document.getElementById('xlsx-file-input').value = '';
        document.getElementById('btn-parse-file').disabled = true;
        parsedData = null;
        loadFilterOptions();
        loadRules();
    } else {
        document.getElementById('parse-status').innerHTML = '<div class="status-msg error">' + escHtml(data.error) + '</div>';
    }
}

async function bulkWrangledImport() {
    const csvData = document.getElementById('wrangled-csv').value;
    if (!csvData.trim()) return;

    document.getElementById('wrangled-status').innerHTML = '<div class="status-msg">Importing...</div>';
    const data = await apiFetch('/import/bulk_wrangled', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({csv_data: csvData, notes: 'Bulk wrangled web import'})
    });

    if (data.success) {
        let msg = 'Imported ' + data.records_inserted + ' records (batch #' + data.batch_id + ')';
        if (data.error_count > 0) msg += ', ' + data.error_count + ' errors';
        document.getElementById('wrangled-status').innerHTML = '<div class="status-msg success">' + msg + '</div>';
        if (data.error_count > 0) {
            const errList = data.errors.slice(0, 10).map(e => 'Line ' + e.line + ': ' + e.error).join('<br>');
            document.getElementById('wrangled-status').innerHTML += '<div class="status-msg error" style="margin-top:5px">' + errList + '</div>';
        }
        document.getElementById('wrangled-csv').value = '';
        loadFilterOptions();
    } else {
        document.getElementById('wrangled-status').innerHTML = '<div class="status-msg error">' + escHtml(data.error) + '</div>';
    }
}

// ===== Rules =====
async function loadRules() {
    const data = await apiFetch('/rules');
    if (data.error) return;
    const tbody = document.getElementById('rules-body');
    tbody.innerHTML = '';
    data.rules.forEach(r => {
        const tr = document.createElement('tr');
        tr.innerHTML = `<td>${r.priority}</td><td>${escHtml(r.pattern)}</td><td>${escHtml(r.match_type)}</td>
            <td>${escHtml(r.category)}</td><td>${escHtml(r.grouped_category)}</td>
            <td>${escHtml(r.service_type || 'SC')}</td>
            <td>
                <button class="btn btn-small btn-danger" onclick="deleteRule(${r.id})">Delete</button>
            </td>`;
        tbody.appendChild(tr);
    });
}

async function addRule() {
    const body = {
        pattern: document.getElementById('new-rule-pattern').value,
        match_type: document.getElementById('new-rule-match').value,
        category: document.getElementById('new-rule-category').value,
        grouped_category: document.getElementById('new-rule-grouped').value,
        priority: parseInt(document.getElementById('new-rule-priority').value) || 100,
        service_type: document.getElementById('new-rule-service-type').value
    };
    if (!body.pattern || !body.category || !body.grouped_category) return alert('Fill in pattern, category, and grouped category');

    const data = await apiFetch('/rules', {
        method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
    });
    if (data.success) {
        document.getElementById('new-rule-pattern').value = '';
        document.getElementById('new-rule-category').value = '';
        document.getElementById('new-rule-grouped').value = '';
        document.getElementById('new-rule-priority').value = '100';
        document.getElementById('new-rule-service-type').value = 'SC';
        document.getElementById('rules-status').innerHTML = '<div class="status-msg success">Rule added</div>';
        setTimeout(() => document.getElementById('rules-status').innerHTML = '', 3000);
        loadRules();
    } else {
        document.getElementById('rules-status').innerHTML = '<div class="status-msg error">' + escHtml(data.error) + '</div>';
    }
}

async function deleteRule(id) {
    if (!confirm('Delete this rule?')) return;
    const data = await apiFetch('/rules/' + id, {method: 'DELETE'});
    if (data.success) loadRules();
    else alert(data.error);
}

// ===== Analysis Tab =====
const chartColors = ['#00aa00','#4a9eff','#ff6600','#ff4444','#ffcc00','#aa44ff','#00cccc','#ff69b4',
                     '#88cc00','#ff8800','#6644ff','#44ddaa','#cc4488','#aaaaaa','#dddd00','#8888ff',
                     '#00ff88','#ff4488','#44aaff','#ffaa44'];

function getAnalysisParams() {
    const params = new URLSearchParams();
    const site = document.getElementById('a-site').value;
    const stype = document.getElementById('a-service-type').value;
    const df = document.getElementById('a-date-from').value;
    const dt = document.getElementById('a-date-to').value;
    if (site) params.set('site', site);
    if (stype) params.set('service_type', stype);
    if (df) params.set('date_from', df);
    if (dt) params.set('date_to', dt);
    return params.toString();
}

async function loadAnalysis() {
    const qs = getAnalysisParams();

    // Fetch all analysis data in parallel
    const [summary, bySite, byCat, byMonth, topGens, bySiteMonth] = await Promise.all([
        apiFetch('/analysis/summary?' + qs),
        apiFetch('/analysis/by_site?' + qs),
        apiFetch('/analysis/by_category?' + qs),
        apiFetch('/analysis/by_month?' + qs),
        apiFetch('/analysis/top_gens?' + qs),
        apiFetch('/analysis/cost_by_site_month?' + qs)
    ]);

    // Summary cards
    document.getElementById('analysis-summary').innerHTML = `
        <div class="stat-box"><div class="stat-label">Total Spend</div><div class="stat-value">${fmt$(summary.total_spend || 0)}</div></div>
        <div class="stat-box"><div class="stat-label">Records</div><div class="stat-value">${(summary.record_count || 0).toLocaleString()}</div></div>
        <div class="stat-box"><div class="stat-label">Date Range</div><div class="stat-value" style="font-size:14px">${summary.earliest_date || 'N/A'} to ${summary.latest_date || 'N/A'}</div></div>`;

    // Destroy old charts
    Object.values(charts).forEach(c => c.destroy());
    charts = {};

    // Cost by Site - horizontal bar
    if (bySite.data && bySite.data.length) {
        charts.site = new Chart(document.getElementById('chart-site'), {
            type: 'bar',
            data: {
                labels: bySite.data.map(d => d.site),
                datasets: [{label: 'Total Cost', data: bySite.data.map(d => d.total_cost),
                    backgroundColor: chartColors.slice(0, bySite.data.length), borderWidth: 0}]
            },
            options: {
                indexAxis: 'y', responsive: true, maintainAspectRatio: false,
                plugins: {legend: {display: false}},
                scales: {x: {ticks: {color: '#888', callback: v => '$'+v.toLocaleString()}, grid: {color: '#333'}},
                         y: {ticks: {color: '#e0e0e0'}, grid: {display: false}}}
            }
        });
    }

    // Cost by Category - doughnut
    if (byCat.data && byCat.data.length) {
        charts.category = new Chart(document.getElementById('chart-category'), {
            type: 'doughnut',
            data: {
                labels: byCat.data.map(d => d.grouped_category),
                datasets: [{data: byCat.data.map(d => d.total_cost),
                    backgroundColor: chartColors.slice(0, byCat.data.length), borderWidth: 0}]
            },
            options: {
                responsive: true, maintainAspectRatio: false,
                plugins: {legend: {position: 'right', labels: {color: '#e0e0e0', font: {size: 12}}}}
            }
        });
    }

    // Monthly Trend - line
    if (byMonth.data && byMonth.data.length) {
        charts.month = new Chart(document.getElementById('chart-month'), {
            type: 'line',
            data: {
                labels: byMonth.data.map(d => d.order_month),
                datasets: [{label: 'Monthly Cost', data: byMonth.data.map(d => d.total_cost),
                    borderColor: '#00aa00', backgroundColor: 'rgba(0,170,0,0.1)', fill: true,
                    tension: 0.3, pointRadius: 3, pointBackgroundColor: '#00ff00'}]
            },
            options: {
                responsive: true, maintainAspectRatio: false,
                plugins: {legend: {display: false}},
                scales: {x: {ticks: {color: '#888', maxRotation: 45}, grid: {color: '#333'}},
                         y: {ticks: {color: '#888', callback: v => '$'+v.toLocaleString()}, grid: {color: '#333'}}}
            }
        });
    }

    // Top Gens - horizontal bar
    if (topGens.data && topGens.data.length) {
        const topGensContainer = document.getElementById('chart-top-gens').parentElement;
        topGensContainer.style.height = Math.max(320, topGens.data.length * 28) + 'px';
        const topGensCanvas = document.getElementById('chart-top-gens');
        charts.topGens = new Chart(topGensCanvas, {
            type: 'bar',
            data: {
                labels: topGens.data.map(d => d.gen_id),
                datasets: [{label: 'Total Cost', data: topGens.data.map(d => d.total_cost),
                    backgroundColor: 'rgba(74,158,255,0.6)', borderColor: '#4a9eff', borderWidth: 1}]
            },
            options: {
                indexAxis: 'y', responsive: true, maintainAspectRatio: false,
                plugins: {legend: {display: false}},
                scales: {x: {ticks: {color: '#888', callback: v => '$'+v.toLocaleString()}, grid: {color: '#333'}},
                         y: {ticks: {color: '#4a9eff', font: {size: 11, weight: '600'}, autoSkip: false}, grid: {display: false}}}
            }
        });
        // Native click listener on canvas — fires regardless of Chart.js event filtering.
        // Hit-tests the y-axis label area (left of the plot area).
        const topGensHit = (ev) => {
            const rect = topGensCanvas.getBoundingClientRect();
            const x = ev.clientX - rect.left;
            const y = ev.clientY - rect.top;
            const ys = charts.topGens.scales.y;
            if (x < ys.left || x > ys.right || y < ys.top || y > ys.bottom) return null;
            const idx = Math.floor((y - ys.top) / (ys.height / charts.topGens.data.labels.length));
            return (idx >= 0 && idx < charts.topGens.data.labels.length) ? idx : null;
        };
        topGensCanvas.onclick = (ev) => {
            const idx = topGensHit(ev);
            if (idx !== null) showGenHistory(charts.topGens.data.labels[idx]);
        };
        topGensCanvas.onmousemove = (ev) => {
            topGensCanvas.style.cursor = topGensHit(ev) !== null ? 'pointer' : 'default';
        };
    }

    // Monthly Cost by Site - multi-line
    if (bySiteMonth.data && bySiteMonth.data.length) {
        // Pivot: collect all months and all sites
        const allMonths = [...new Set(bySiteMonth.data.map(d => d.order_month))].sort();
        const siteData = {};
        bySiteMonth.data.forEach(d => {
            if (!siteData[d.site]) siteData[d.site] = {};
            siteData[d.site][d.order_month] = d.total_cost;
        });
        const siteNames = Object.keys(siteData).sort();
        const datasets = siteNames.map((site, i) => ({
            label: site,
            data: allMonths.map(m => siteData[site][m] || 0),
            borderColor: chartColors[i % chartColors.length],
            backgroundColor: 'transparent',
            tension: 0.3,
            pointRadius: 2,
            borderWidth: 2
        }));
        charts.siteMonth = new Chart(document.getElementById('chart-site-month'), {
            type: 'line',
            data: { labels: allMonths, datasets },
            options: {
                responsive: true, maintainAspectRatio: false,
                plugins: {legend: {position: 'top', labels: {color: '#e0e0e0', font: {size: 11}, boxWidth: 12, padding: 10}}},
                scales: {
                    x: {ticks: {color: '#888', maxRotation: 45}, grid: {color: '#333'}},
                    y: {ticks: {color: '#888', callback: v => '$'+v.toLocaleString()}, grid: {color: '#333'}}
                },
                interaction: {mode: 'nearest', intersect: true}
            }
        });
    }
}

// ===== PM/SC Tab =====
let pmscCharts = {};

function getPmScParams() {
    const params = new URLSearchParams();
    const site = document.getElementById('ps-site').value;
    const df = document.getElementById('ps-date-from').value;
    const dt = document.getElementById('ps-date-to').value;
    if (site) params.set('site', site);
    if (df) params.set('date_from', df);
    if (dt) params.set('date_to', dt);
    return params.toString();
}

// Renders the derived shop/field/parts-only split. Fed by
// /analysis/visit_type_summary, which flags itself `derived: true` -- everything
// here is labelled so it never reads as a Mesa-billed field.
const VISIT_ORDER = ['field', 'shop', 'parts_only'];
const VISIT_TITLES = {field: 'Field', shop: 'Shop', parts_only: 'Parts only'};
const VISIT_COLORS = {field: '#4a9eff', shop: '#ff6600', parts_only: '#888888'};

// The 4-way operator axis: shop repairs are pulled OUT of SC so SC means field
// service. Mutually exclusive, sums to total.
const WORK_ORDER = ['pm_field', 'sc_field', 'shop', 'parts_only'];
const WORK_TITLES = {pm_field: 'PM (field)', sc_field: 'SC (field)',
                     shop: 'Shop', parts_only: 'Parts only'};
const WORK_COLORS = {pm_field: '#00aa00', sc_field: '#4a9eff',
                     shop: '#ff6600', parts_only: '#888888'};

// Jump to Browse showing the lines behind one work type. Shop and parts-only are
// a single visit_type; the field buckets also need the PM/SC filter set.
function browseWorkType(wt) {
    const visit = (wt === 'shop' || wt === 'parts_only') ? wt : 'field';
    const type = wt === 'pm_field' ? 'PM' : wt === 'sc_field' ? 'SC' : '';
    switchTab('browse');
    document.getElementById('f-visit-type').value = visit;
    document.getElementById('f-service-type').value = type;
    // carry over the site/date filters the PM/SC tab was showing
    document.getElementById('f-site').value = document.getElementById('ps-site').value || '';
    document.getElementById('f-date-from').value = document.getElementById('ps-date-from').value || '';
    document.getElementById('f-date-to').value = document.getElementById('ps-date-to').value || '';
    loadRecords(0);
    document.getElementById('browse-status').innerHTML =
        '<div class="status-msg">Showing <strong>' + WORK_TITLES[wt] + '</strong> line items. ' +
        'Clear with Reset.</div>';
}

function renderVisitSplit(v) {
    const work = v.work_totals || [];
    const grand = work.reduce((a, r) => a + (r.total_cost || 0), 0);

    const note = document.getElementById('visit-oil-note');
    if (note) note.innerHTML = v.oil_allocated
        ? '<div class="status-msg" style="margin-bottom:10px">Bulk oil attributed: <strong>' +
          fmt$(v.oil_moved) + '</strong> moved from Parts-only into PM (field), modelled from PM ' +
          'visits Oct 2024 &ndash; Mar 2026. Totals are unchanged.</div>'
        : '';

    const tb = document.getElementById('visit-summary-body');
    tb.innerHTML = '';
    WORK_ORDER.forEach(wt => {
        const t = work.find(r => r.work_type === wt);
        if (!t) return;
        const share = grand ? (t.total_cost / grand * 100) : 0;
        const tr = document.createElement('tr');
        tr.style.cursor = 'pointer';
        tr.title = 'Show these line items in Browse';
        tr.onclick = () => browseWorkType(wt);
        tr.innerHTML = '<td><span style="color:' + WORK_COLORS[wt] + '">\u25CF</span> ' +
            WORK_TITLES[wt] + ' <span style="color:#555;font-size:11px">&rsaquo; lines</span></td>' +
            '<td>' + t.visit_count + '</td><td>' + t.gen_count + '</td>' +
            '<td class="amount-cell">' + fmt$(t.total_cost) + '</td>' +
            '<td class="amount-cell">' + share.toFixed(1) + '%</td>';
        tb.appendChild(tr);
    });

    // --- per-site table on the same 4-way axis as the charts ---
    // work_by_site already reflects the oil toggle, so the table can't drift
    // out of step with the charts above it.
    const bySite = {};
    (v.work_by_site || []).forEach(r => {
        const st = r.site || '(none)';
        if (!bySite[st]) bySite[st] = {pm_field: 0, sc_field: 0, shop: 0, parts_only: 0};
        if (bySite[st][r.work_type] !== undefined) bySite[st][r.work_type] += r.total_cost || 0;
    });
    const siteRows = Object.entries(bySite).map(([site, d]) => {
        const tot = WORK_ORDER.reduce((a, k) => a + d[k], 0);
        return {site: site, d: d, total: tot, share: tot ? d.shop / tot : 0};
    }).sort((a, b) => b.total - a.total);

    const stb = document.getElementById('visit-site-body');
    stb.innerHTML = '';
    siteRows.forEach(r => {
        const tr = document.createElement('tr');
        tr.innerHTML = '<td>' + escHtml(r.site) + '</td>' +
            WORK_ORDER.map(k => '<td class="amount-cell">' + fmt$(r.d[k]) + '</td>').join('') +
            '<td class="amount-cell" style="color:#ddd">' + fmt$(r.total) + '</td>' +
            '<td class="amount-cell">' + (r.share * 100).toFixed(1) + '%</td>';
        stb.appendChild(tr);
    });

    // --- doughnut: share of spend by work type ---
    const present = WORK_ORDER.filter(wt => work.some(r => r.work_type === wt));
    pmscCharts.visitSplit = new Chart(document.getElementById('chart-visit-split'), {
        type: 'doughnut',
        data: {
            labels: present.map(wt => WORK_TITLES[wt]),
            datasets: [{
                data: present.map(wt => (work.find(r => r.work_type === wt) || {}).total_cost || 0),
                backgroundColor: present.map(wt => WORK_COLORS[wt]),
                borderColor: '#1e1e1e', borderWidth: 2
            }]
        },
        options: {
            responsive: true, maintainAspectRatio: false,
            plugins: {legend: {position: 'bottom', labels: {color: '#888'}}}
        }
    });

    // --- stacked bars: monthly spend on the 4-way axis ---
    const months = Array.from(new Set((v.work_by_month || []).map(r => r.order_month))).sort();
    const mIdx = {};
    (v.work_by_month || []).forEach(r => { mIdx[r.order_month + '|' + r.work_type] = r.total_cost || 0; });
    pmscCharts.visitMonthly = new Chart(document.getElementById('chart-visit-monthly'), {
        type: 'bar',
        data: {
            labels: months,
            datasets: WORK_ORDER.map(wt => ({
                label: WORK_TITLES[wt],
                data: months.map(m => mIdx[m + '|' + wt] || 0),
                backgroundColor: WORK_COLORS[wt],
                borderWidth: 0
            }))
        },
        options: {
            responsive: true, maintainAspectRatio: false,
            interaction: {mode: 'index', intersect: false},
            plugins: {
                legend: {position: 'bottom', labels: {color: '#888'}},
                tooltip: {callbacks: {
                    label: (c) => c.dataset.label + ': ' + fmt$(c.parsed.y),
                    footer: (items) => 'Total: ' + fmt$(items.reduce((a, i) => a + i.parsed.y, 0))
                }}
            },
            scales: {
                x: {stacked: true, ticks: {color: '#888'}, grid: {color: '#2a2a2a'}},
                y: {stacked: true, beginAtZero: true, grid: {color: '#2a2a2a'},
                    ticks: {color: '#888', callback: (v) => '$' + (v / 1000) + 'k'}}
            }
        }
    });
}

async function loadPmSc() {
    const qs = getPmScParams();

    // Populate site dropdown from categories endpoint
    const catData = await apiFetch('/categories');
    const psSite = document.getElementById('ps-site');
    const curVal = psSite.value;
    psSite.innerHTML = '<option value="">All Sites</option>';
    (catData.sites || []).forEach(s => { const o = document.createElement('option'); o.value = s; o.textContent = s; psSite.appendChild(o); });
    psSite.value = curVal;

    const [monthly, avg, visits] = await Promise.all([
        apiFetch('/analysis/pm_sc_by_site_month?' + qs),
        apiFetch('/analysis/pm_sc_avg_by_site?' + qs),
        apiFetch('/analysis/visit_type_summary?' + qs +
                 (document.getElementById('ps-allocate-oil').checked ? '&allocate_oil=1' : ''))
    ]);

    // Destroy old charts
    Object.values(pmscCharts).forEach(c => c.destroy());
    pmscCharts = {};

    renderVisitSplit(visits);

    // --- Monthly table ---
    // Pivot: group by site+month, merge PM/SC rows
    // Shop is its own series now -- it used to be folded into SC, which read up to
    // 2x high at shop-heavy sites (Ellyson and Shared were ~50% shop).
    const monthlyMap = {};
    (monthly.data || []).forEach(r => {
        const key = r.site + '|' + r.order_month;
        if (!monthlyMap[key]) monthlyMap[key] = {site: r.site, month: r.order_month,
            pm_visits: 0, sc_visits: 0, shop_visits: 0, pm_cost: 0, sc_cost: 0, shop_cost: 0};
        const m = monthlyMap[key];
        if (r.work_type === 'pm_field') { m.pm_visits = r.visit_count; m.pm_cost = r.total_cost; }
        else if (r.work_type === 'sc_field') { m.sc_visits = r.visit_count; m.sc_cost = r.total_cost; }
        else if (r.work_type === 'shop') { m.shop_visits = r.visit_count; m.shop_cost = r.total_cost; }
    });
    const monthlyRows = Object.values(monthlyMap).sort((a, b) => a.site.localeCompare(b.site) || a.month.localeCompare(b.month));

    const mtbody = document.getElementById('pmsc-monthly-body');
    mtbody.innerHTML = '';
    monthlyRows.forEach(r => {
        const tr = document.createElement('tr');
        tr.innerHTML = '<td>' + escHtml(r.site) + '</td><td>' + escHtml(r.month) + '</td>' +
            '<td>' + r.pm_visits + '</td><td>' + r.sc_visits + '</td><td>' + r.shop_visits + '</td>' +
            '<td class="amount-cell">' + fmt$(r.pm_cost) + '</td>' +
            '<td class="amount-cell">' + fmt$(r.sc_cost) + '</td>' +
            '<td class="amount-cell" style="color:#ff6600">' + fmt$(r.shop_cost) + '</td>' +
            '<td class="amount-cell">' + fmt$(r.pm_cost + r.sc_cost + r.shop_cost) + '</td>';
        mtbody.appendChild(tr);
    });

    // --- Monthly stacked bar chart (aggregate across sites by month) ---
    const monthTotals = {};
    monthlyRows.forEach(r => {
        if (!monthTotals[r.month]) monthTotals[r.month] = {pm: 0, sc: 0, shop: 0};
        monthTotals[r.month].pm += r.pm_visits;
        monthTotals[r.month].sc += r.sc_visits;
        monthTotals[r.month].shop += r.shop_visits;
    });
    const allMonths = Object.keys(monthTotals).sort();
    pmscCharts.monthly = new Chart(document.getElementById('chart-pmsc-monthly'), {
        type: 'bar',
        data: {
            labels: allMonths,
            datasets: [
                {label: 'PM Visits', data: allMonths.map(m => monthTotals[m].pm), backgroundColor: 'rgba(0,170,0,0.7)'},
                {label: 'SC Visits', data: allMonths.map(m => monthTotals[m].sc), backgroundColor: 'rgba(74,158,255,0.7)'},
                {label: 'Shop Visits', data: allMonths.map(m => monthTotals[m].shop || 0), backgroundColor: 'rgba(255,102,0,0.75)'}
            ]
        },
        options: {
            responsive: true, maintainAspectRatio: false,
            plugins: {legend: {labels: {color: '#e0e0e0'}}},
            scales: {
                x: {stacked: true, ticks: {color: '#888', maxRotation: 45}, grid: {color: '#333'}},
                y: {stacked: true, ticks: {color: '#888'}, grid: {color: '#333'}, title: {display: true, text: 'Visits', color: '#888'}}
            }
        }
    });

    // --- Avg per gen table ---
    const avgMap = {};
    (avg.data || []).forEach(r => {
        if (!avgMap[r.site]) avgMap[r.site] = {site: r.site, pm_gens: 0, pm_visits: 0, pm_total: 0,
                                               sc_gens: 0, sc_visits: 0, sc_total: 0};
        const a = avgMap[r.site];
        if (r.work_type === 'pm_field') { a.pm_gens = r.gen_count; a.pm_visits = r.visit_count; a.pm_total = r.total_cost; }
        else if (r.work_type === 'sc_field') { a.sc_gens = r.gen_count; a.sc_visits = r.visit_count; a.sc_total = r.total_cost; }
    });
    const avgRows = Object.values(avgMap).sort((a, b) => a.site.localeCompare(b.site));

    const atbody = document.getElementById('pmsc-avg-body');
    atbody.innerHTML = '';
    avgRows.forEach(r => {
        const pmAvg = r.pm_gens > 0 ? r.pm_total / r.pm_gens : 0;
        const scAvg = r.sc_gens > 0 ? r.sc_total / r.sc_gens : 0;
        const tr = document.createElement('tr');
        tr.innerHTML = '<td>' + escHtml(r.site) + '</td>' +
            '<td>' + r.pm_gens + '</td><td>' + r.pm_visits + '</td><td class="amount-cell">' + fmt$(r.pm_total) + '</td><td class="amount-cell">' + fmt$(pmAvg) + '</td>' +
            '<td>' + r.sc_gens + '</td><td>' + r.sc_visits + '</td><td class="amount-cell">' + fmt$(r.sc_total) + '</td><td class="amount-cell">' + fmt$(scAvg) + '</td>';
        atbody.appendChild(tr);
    });

    // --- Avg cost per gen chart (grouped bar) ---
    const sites = avgRows.map(r => r.site);
    pmscCharts.avg = new Chart(document.getElementById('chart-pmsc-avg'), {
        type: 'bar',
        data: {
            labels: sites,
            datasets: [
                {label: 'PM Avg/Gen', data: avgRows.map(r => r.pm_gens > 0 ? r.pm_total / r.pm_gens : 0), backgroundColor: 'rgba(0,170,0,0.7)'},
                {label: 'SC Avg/Gen', data: avgRows.map(r => r.sc_gens > 0 ? r.sc_total / r.sc_gens : 0), backgroundColor: 'rgba(74,158,255,0.7)'}
            ]
        },
        options: {
            responsive: true, maintainAspectRatio: false,
            plugins: {legend: {labels: {color: '#e0e0e0'}}},
            scales: {
                x: {ticks: {color: '#888'}, grid: {color: '#333'}},
                y: {ticks: {color: '#888', callback: v => '$'+v.toLocaleString()}, grid: {color: '#333'}}
            }
        }
    });

    // --- Avg visits per gen chart ---
    pmscCharts.avgVisits = new Chart(document.getElementById('chart-pmsc-avg-visits'), {
        type: 'bar',
        data: {
            labels: sites,
            datasets: [
                {label: 'PM Visits/Gen', data: avgRows.map(r => r.pm_gens > 0 ? (r.pm_visits / r.pm_gens).toFixed(1) : 0), backgroundColor: 'rgba(0,170,0,0.7)'},
                {label: 'SC Visits/Gen', data: avgRows.map(r => r.sc_gens > 0 ? (r.sc_visits / r.sc_gens).toFixed(1) : 0), backgroundColor: 'rgba(74,158,255,0.7)'}
            ]
        },
        options: {
            responsive: true, maintainAspectRatio: false,
            plugins: {legend: {labels: {color: '#e0e0e0'}}},
            scales: {
                x: {ticks: {color: '#888'}, grid: {color: '#333'}},
                y: {ticks: {color: '#888'}, grid: {color: '#333'}, title: {display: true, text: 'Visits per Gen', color: '#888'}}
            }
        }
    });
}

// ===== Parts / Categories Tab =====
let partsCharts = {};

function getPartsParams() {
    const params = new URLSearchParams();
    const site = document.getElementById('pt-site').value;
    const stype = document.getElementById('pt-service-type').value;
    const grp = document.getElementById('pt-grouped').value;
    const df = document.getElementById('pt-date-from').value;
    const dt = document.getElementById('pt-date-to').value;
    if (site) params.set('site', site);
    if (stype) params.set('service_type', stype);
    if (df) params.set('date_from', df);
    if (dt) params.set('date_to', dt);
    return [params.toString(), grp];
}

async function loadParts() {
    const [qs, grpFilter] = getPartsParams();

    // Populate dropdowns
    const catData = await apiFetch('/categories');
    const ptSite = document.getElementById('pt-site');
    const curSite = ptSite.value;
    ptSite.innerHTML = '<option value="">All Sites</option>';
    (catData.sites || []).forEach(s => { const o = document.createElement('option'); o.value = s; o.textContent = s; ptSite.appendChild(o); });
    ptSite.value = curSite;

    const ptGrp = document.getElementById('pt-grouped');
    const curGrp = ptGrp.value;
    ptGrp.innerHTML = '<option value="">All Groups</option>';
    (catData.grouped_categories || []).forEach(g => { const o = document.createElement('option'); o.value = g; o.textContent = g; ptGrp.appendChild(o); });
    ptGrp.value = curGrp;

    const [catSummary, catByMonth, catBySite, grpByMonth] = await Promise.all([
        apiFetch('/analysis/category_summary?' + qs),
        apiFetch('/analysis/category_by_month?' + qs),
        apiFetch('/analysis/category_by_site?' + qs),
        apiFetch('/analysis/grouped_by_month?' + qs)
    ]);

    // Apply grouped_category filter client-side
    let summaryData = catSummary.data || [];
    let monthData = catByMonth.data || [];
    let siteData = catBySite.data || [];
    if (grpFilter) {
        const cats = new Set(summaryData.filter(d => d.grouped_category === grpFilter).map(d => d.category));
        summaryData = summaryData.filter(d => d.grouped_category === grpFilter);
        monthData = monthData.filter(d => cats.has(d.category));
        siteData = siteData.filter(d => cats.has(d.category));
    }

    // Summary boxes
    const totalSpend = summaryData.reduce((s, d) => s + d.total_cost, 0);
    const topCat = summaryData.length > 0 ? summaryData[0].category : 'N/A';
    document.getElementById('parts-summary').innerHTML = `
        <div class="stat-box"><div class="stat-label">Total Spend</div><div class="stat-value">${fmt$(totalSpend)}</div></div>
        <div class="stat-box"><div class="stat-label">Categories</div><div class="stat-value">${summaryData.length}</div></div>
        <div class="stat-box"><div class="stat-label">Most Expensive</div><div class="stat-value" style="font-size:14px">${escHtml(topCat)}</div></div>`;

    // Destroy old charts
    Object.values(partsCharts).forEach(c => c.destroy());
    partsCharts = {};

    // --- Top 15 categories bar chart ---
    const top15 = summaryData.slice(0, 15);
    if (top15.length) {
        partsCharts.top = new Chart(document.getElementById('chart-parts-top'), {
            type: 'bar',
            data: {
                labels: top15.map(d => d.category),
                datasets: [{label: 'Total Cost', data: top15.map(d => d.total_cost),
                    backgroundColor: chartColors.slice(0, top15.length), borderWidth: 0}]
            },
            options: {
                indexAxis: 'y', responsive: true, maintainAspectRatio: false,
                plugins: {legend: {display: false}},
                scales: {x: {ticks: {color: '#888', callback: v => '$'+v.toLocaleString()}, grid: {color: '#333'}},
                         y: {ticks: {color: '#e0e0e0', font: {size: 11}, autoSkip: false}, grid: {display: false}}}
            }
        });
    }

    // --- Category by Site chart (top 8 categories, stacked by site) ---
    const top8cats = summaryData.slice(0, 8).map(d => d.category);
    const sitesByCat = {};
    siteData.forEach(d => {
        if (!top8cats.includes(d.category)) return;
        if (!sitesByCat[d.site]) sitesByCat[d.site] = {};
        sitesByCat[d.site][d.category] = d.total_cost;
    });
    const allSites = Object.keys(sitesByCat).sort();
    if (top8cats.length && allSites.length) {
        partsCharts.site = new Chart(document.getElementById('chart-parts-site'), {
            type: 'bar',
            data: {
                labels: allSites,
                datasets: top8cats.map((cat, i) => ({
                    label: cat,
                    data: allSites.map(s => (sitesByCat[s] && sitesByCat[s][cat]) || 0),
                    backgroundColor: chartColors[i % chartColors.length]
                }))
            },
            options: {
                responsive: true, maintainAspectRatio: false,
                plugins: {legend: {position: 'top', labels: {color: '#e0e0e0', font: {size: 10}, boxWidth: 10, padding: 8}}},
                scales: {
                    x: {stacked: true, ticks: {color: '#888', maxRotation: 45}, grid: {color: '#333'}},
                    y: {stacked: true, ticks: {color: '#888', callback: v => '$'+v.toLocaleString()}, grid: {color: '#333'}}
                }
            }
        });
    }

    // --- Top category trend by month (top 6) ---
    const top6cats = summaryData.slice(0, 6).map(d => d.category);
    const allMonths = [...new Set(monthData.map(d => d.order_month))].sort();
    const catMonthData = {};
    monthData.forEach(d => {
        if (!top6cats.includes(d.category)) return;
        if (!catMonthData[d.category]) catMonthData[d.category] = {};
        catMonthData[d.category][d.order_month] = d.total_cost;
    });
    if (top6cats.length && allMonths.length) {
        partsCharts.trend = new Chart(document.getElementById('chart-parts-trend'), {
            type: 'line',
            data: {
                labels: allMonths,
                datasets: top6cats.map((cat, i) => ({
                    label: cat,
                    data: allMonths.map(m => (catMonthData[cat] && catMonthData[cat][m]) || 0),
                    borderColor: chartColors[i % chartColors.length],
                    backgroundColor: 'transparent',
                    tension: 0.3, pointRadius: 2, borderWidth: 2
                }))
            },
            options: {
                responsive: true, maintainAspectRatio: false,
                plugins: {legend: {position: 'top', labels: {color: '#e0e0e0', font: {size: 11}, boxWidth: 12, padding: 10}}},
                scales: {
                    x: {ticks: {color: '#888', maxRotation: 45}, grid: {color: '#333'}},
                    y: {ticks: {color: '#888', callback: v => '$'+v.toLocaleString()}, grid: {color: '#333'}}
                },
                interaction: {mode: 'nearest', intersect: true}
            }
        });
    }

    // --- Detail table ---
    const tbody = document.getElementById('parts-table-body');
    tbody.innerHTML = '';
    summaryData.forEach(d => {
        const tr = document.createElement('tr');
        tr.innerHTML = '<td>' + escHtml(d.category) + '</td><td>' + escHtml(d.grouped_category) + '</td>' +
            '<td>' + d.occurrences + '</td><td>' + d.gens_affected + '</td>' +
            '<td class="amount-cell">' + fmt$(d.total_cost) + '</td>' +
            '<td class="amount-cell">' + fmt$(d.avg_cost) + '</td>' +
            '<td>' + escHtml(d.top_site) + '</td>';
        tbody.appendChild(tr);
    });

    // --- 2026 grouped-category pivot (groups on rows, months across the top) ---
    // Sub-category (next level down) drill-down uses the category-by-month data + a
    // category -> group map built from the summary rows.
    let grpMonthData = grpByMonth.data || [];
    if (grpFilter) grpMonthData = grpMonthData.filter(d => d.grouped_category === grpFilter);
    const catToGrp = {};
    summaryData.forEach(d => { catToGrp[d.category] = d.grouped_category; });
    renderGroupedPivot(grpMonthData, monthData, catToGrp);
}

// Whole-dollar formatter for the wide pivot (12 month columns).
function fmt$0(n) { return '$' + Math.round(Number(n)).toLocaleString('en-US'); }

// Toggle a group's sub-category rows (the next level down).
function togglePivotGroup(gi) {
    const rows = document.querySelectorAll('.subrow-' + gi);
    const arrow = document.getElementById('pivot-arrow-' + gi);
    if (!rows.length) return;
    const show = rows[0].style.display === 'none';
    rows.forEach(r => r.style.display = show ? 'table-row' : 'none');
    if (arrow) arrow.textContent = show ? '▾' : '▸';  // ▾ / ▸
}

// Build the 2026 "spend by group per month" pivot table with expandable sub-categories.
// rows = grouped_category by month; catRows = category by month; catToGrp = category -> group.
function renderGroupedPivot(rows, catRows, catToGrp) {
    const head = document.getElementById('grouped-pivot-head');
    const body = document.getElementById('grouped-pivot-body');
    const foot = document.getElementById('grouped-pivot-foot');
    head.innerHTML = ''; body.innerHTML = ''; foot.innerHTML = '';

    // Keep only 2026 months.
    rows = (rows || []).filter(d => (d.order_month || '').startsWith('2026'));
    catRows = (catRows || []).filter(d => (d.order_month || '').startsWith('2026'));
    const months = [...new Set(rows.map(d => d.order_month))].sort();
    if (!months.length) {
        head.innerHTML = '<th class="rowlabel">Group</th>';
        body.innerHTML = '<tr><td class="rowlabel" style="color:#888">No 2026 data for this filter.</td></tr>';
        return;
    }

    // grouped_category -> { month -> $ }
    const byGrp = {};
    rows.forEach(d => {
        const g = d.grouped_category || '(none)';
        if (!byGrp[g]) byGrp[g] = {};
        byGrp[g][d.order_month] = (byGrp[g][d.order_month] || 0) + d.total_cost;
    });

    // group -> category -> { month -> $ } (the sub-category / next-level-down breakdown)
    const byGrpCat = {};
    catRows.forEach(d => {
        const cat = d.category || '(uncategorized)';
        const g = catToGrp[cat] || '(none)';
        if (!byGrpCat[g]) byGrpCat[g] = {};
        if (!byGrpCat[g][cat]) byGrpCat[g][cat] = {};
        byGrpCat[g][cat][d.order_month] = (byGrpCat[g][cat][d.order_month] || 0) + d.total_cost;
    });

    // Sort groups by total spend descending.
    const monthTotal = (map) => months.reduce((s, m) => s + (map[m] || 0), 0);
    const groups = Object.keys(byGrp).sort((a, b) => monthTotal(byGrp[b]) - monthTotal(byGrp[a]));

    // Header: Group | <months...> | Total
    const monthLabel = m => { const [y, mo] = m.split('-'); return ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][(+mo) - 1] + " '" + y.slice(2); };
    head.innerHTML = '<th class="rowlabel">Group</th>' +
        months.map(m => '<th class="num">' + monthLabel(m) + '</th>').join('') +
        '<th class="num total">Total</th>';

    // Build a row of month cells + a total cell for a given {month->$} map.
    const monthCells = (map) => {
        let c = '';
        months.forEach(m => {
            const v = map[m] || 0;
            c += '<td class="num' + (v ? '' : ' zero') + '">' + (v ? fmt$0(v) : '-') + '</td>';
        });
        c += '<td class="num total">' + fmt$0(monthTotal(map)) + '</td>';
        return c;
    };

    // Body rows.
    const colTotals = {}; months.forEach(m => colTotals[m] = 0);
    let grand = 0;
    groups.forEach((g, gi) => {
        months.forEach(m => colTotals[m] += (byGrp[g][m] || 0));
        grand += monthTotal(byGrp[g]);

        // Sub-categories in this group (sorted by total desc). Only show a toggle if
        // there's a genuine breakdown (more than one sub-category, or one that differs).
        const subCats = Object.keys(byGrpCat[g] || {})
            .sort((a, b) => monthTotal(byGrpCat[g][b]) - monthTotal(byGrpCat[g][a]));
        const expandable = subCats.length > 1 || (subCats.length === 1 && subCats[0] !== g);

        const gtr = document.createElement('tr');
        gtr.className = 'grp-row';
        const arrow = expandable
            ? '<span class="pivot-arrow" id="pivot-arrow-' + gi + '">▸</span> '
            : '<span class="pivot-arrow-spacer"></span>';
        if (expandable) { gtr.style.cursor = 'pointer'; gtr.onclick = () => togglePivotGroup(gi); }
        gtr.innerHTML = '<td class="rowlabel">' + arrow + escHtml(g) + '</td>' + monthCells(byGrp[g]);
        body.appendChild(gtr);

        if (expandable) {
            subCats.forEach(cat => {
                const str = document.createElement('tr');
                str.className = 'subrow subrow-' + gi;
                str.style.display = 'none';
                str.innerHTML = '<td class="rowlabel sublabel">' + escHtml(cat) + '</td>' + monthCells(byGrpCat[g][cat]);
                body.appendChild(str);
            });
        }
    });

    // Footer totals row.
    foot.innerHTML = '<td class="rowlabel">Total</td>' +
        months.map(m => '<td class="num">' + fmt$0(colTotals[m]) + '</td>').join('') +
        '<td class="num total">' + fmt$0(grand) + '</td>';
}

// ===== QBO Export Tab =====
let qboInited = false;
let qboLastQuery = null;   // the selection the on-screen preview was built from

const QBO_MODE_HELP = {
    new: `Everything not yet exported, ignoring dates. Mesa back-bills work weeks `
       + `to months later, so a service-date window silently skips those invoices; `
       + `this mode has no window to fall outside of. Download, import to `
       + `QuickBooks, then press Mark as Exported so the same lines are never `
       + `offered twice.`,
    received: `Service records that LANDED in our database in this window, `
       + `whatever date the work was done. Careful: historical bulk imports all `
       + `arrived on a handful of dates, so an early-2026 window sweeps up years `
       + `of already-booked history. Check the backdated warning before importing.`,
    service: `Records whose WORK was done in this window. This is the original `
       + `behaviour and the one that lost 13 invoices off the 2026-09-03 `
       + `statement - an invoice Mesa bills late arrives with a service date `
       + `inside a period you already closed, so no later export ever emits it. `
       + `Use it to re-cut a specific period, not for routine close.`,
};

function qboMode() { return document.getElementById('q-mode').value; }

function qboModeChanged() {
    const usesDates = qboMode() !== 'new';
    document.getElementById('q-range-from').style.display = usesDates ? '' : 'none';
    document.getElementById('q-range-to').style.display = usesDates ? '' : 'none';
    document.getElementById('qbo-mode-help').textContent = QBO_MODE_HELP[qboMode()] || '';
    document.getElementById('qbo-dl-btn').disabled = true;
    document.getElementById('qbo-mark-btn').disabled = true;
    qboLastQuery = null;
}

function qboParams() {
    const params = new URLSearchParams();
    const mode = qboMode();
    if (mode === 'new') {
        params.set('unexported_only', '1');
    } else {
        const dfrom = document.getElementById('q-date-from').value;
        const dto = document.getElementById('q-date-to').value;
        if (dfrom) params.set('date_from', dfrom);
        if (dto) params.set('date_to', dto);
        params.set('basis', mode);
    }
    return params;
}

function initQboTab() {
    if (qboInited) return;
    qboInited = true;
    // Default to previous calendar month (only used by the date-range modes)
    const now = new Date();
    const firstOfThisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
    const lastOfPrevMonth = new Date(firstOfThisMonth.getTime() - 86400000);
    const firstOfPrevMonth = new Date(lastOfPrevMonth.getFullYear(), lastOfPrevMonth.getMonth(), 1);
    const iso = d => d.toISOString().slice(0, 10);
    document.getElementById('q-date-from').value = iso(firstOfPrevMonth);
    document.getElementById('q-date-to').value = iso(lastOfPrevMonth);
    qboModeChanged();
}

async function loadQboPreview() {
    const status = document.getElementById('qbo-status');
    const summary = document.getElementById('qbo-summary');
    const wrap = document.getElementById('qbo-table-wrap');
    const dlBtn = document.getElementById('qbo-dl-btn');

    const markBtn = document.getElementById('qbo-mark-btn');
    status.innerHTML = '<span style="color:#888">Loading...</span>';
    summary.style.display = 'none';
    wrap.style.display = 'none';
    dlBtn.disabled = true;
    markBtn.disabled = true;
    qboLastQuery = null;

    const params = qboParams();

    let data;
    try {
        data = await apiFetch('/qbo_preview?' + params.toString());
    } catch (e) {
        status.innerHTML = '<span style="color:#ff4444">Error: ' + escHtml(String(e)) + '</span>';
        return;
    }
    if (data.error) {
        status.innerHTML = '<span style="color:#ff4444">Error: ' + escHtml(data.error) + '</span>';
        return;
    }

    status.innerHTML = '';
    if (!data.rows.length) {
        status.innerHTML = (qboMode() === 'new')
            ? '<span style="color:#888">Nothing new &mdash; every service record has already been exported.</span>'
            : '<span style="color:#888">No service records in that date range.</span>';
        return;
    }

    summary.style.display = 'block';
    let summaryHtml =
        '<strong>' + data.count + '</strong> line items across <strong>' +
        data.invoice_count + '</strong> invoice(s), total <strong style="color:#00ff00">' +
        fmt$(data.total_amount) + '</strong>';
    // Backdated = work done before the window opened, i.e. Mesa billed it late.
    // These post to an earlier (possibly closed) period once imported, so say so
    // rather than letting them ride along unnoticed.
    const bd = data.backdated;
    if (bd && bd.count) {
        summaryHtml +=
            '<div style="margin-top:8px;color:#ffaa00">&#9888; <strong>' + bd.count +
            '</strong> line(s) across <strong>' + bd.invoice_count +
            '</strong> invoice(s), ' + fmt$(bd.total_amount) +
            ', are for work done BEFORE this window &mdash; Mesa back-billed them. ' +
            'They will post to earlier periods in QuickBooks. Oldest: ' +
            escHtml(bd.invoices.slice(0, 5).map(i => i.invoice + ' (' + i.order_date + ')').join(', ')) +
            (bd.invoice_count > 5 ? ', &hellip;' : '') + '</div>';
    }
    summary.innerHTML = summaryHtml;
    qboLastQuery = params.toString();

    // Build header
    const head = document.getElementById('qbo-head');
    head.innerHTML = '<tr>' + data.headers.map(h =>
        '<th style="position:sticky;top:0;background:#1e1e1e;z-index:1">' +
        escHtml(h.replace('\\n', ' / ')) + '</th>').join('') + '</tr>';

    // Build body
    const body = document.getElementById('qbo-body');
    body.innerHTML = data.rows.map(r =>
        '<tr>' + r.map((cell, i) => {
            // right-align Unit Price (col 12)
            const cls = (i === 12) ? ' class="amount-cell"' : '';
            return '<td' + cls + '>' + escHtml(cell == null ? '' : String(cell)) + '</td>';
        }).join('') + '</tr>'
    ).join('');

    wrap.style.display = 'block';
    dlBtn.disabled = false;
    markBtn.disabled = false;
}

function qboDownload() {
    window.open(API + '/qbo_export?' + qboParams().toString(), '_blank');
}

// Stamps exactly what the on-screen preview covers, so the next "New since
// last export" run cannot re-offer the same lines. Deliberately a separate
// button from Download: the stamp should land after QuickBooks has actually
// accepted the import, not when the file is generated.
async function qboMarkExported() {
    if (!qboLastQuery) return;
    const p = new URLSearchParams(qboLastQuery);
    const body = {
        date_from: p.get('date_from') || '',
        date_to: p.get('date_to') || '',
        basis: p.get('basis') || 'service',
        unexported_only: true,
    };
    if (!confirm(`Mark these lines as exported to QuickBooks?\n\n`
               + `Only do this once the import has been accepted in QuickBooks. `
               + `They will no longer appear under "New since last export".`)) return;
    const status = document.getElementById('qbo-status');
    try {
        const res = await fetch(API + '/qbo_mark_exported', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(body),
        });
        const data = await res.json();
        if (data.error) throw new Error(data.error);
        status.innerHTML = '<span style="color:#00ff00">Marked ' + data.stamped +
            ' line(s) as exported. ' + data.remaining_unexported +
            ' line(s) still unexported.</span>';
        document.getElementById('qbo-mark-btn').disabled = true;
    } catch (e) {
        status.innerHTML = '<span style="color:#ff4444">Mark failed: ' + escHtml(String(e)) + '</span>';
    }
}

// ===== Estimates Tab =====
let estFilter = 'pending';
let estStaging = null;        // current parse preview awaiting commit
let estCurrentDetail = null;  // estimate currently open in the detail modal

function estAgeLabel(e) {
    if (!e.quote_date) return '-';
    const days = Math.floor((Date.now() - new Date(e.quote_date + 'T00:00:00').getTime()) / 86400000);
    if (isNaN(days)) return '-';
    if (e.status === 'pending' && days > 30)
        return '<span class="est-stale">' + days + 'd &middot; validity lapsed</span>';
    return days + 'd';
}

function setEstFilter(status) {
    estFilter = status;
    document.querySelectorAll('#est-chips .est-chip').forEach(c =>
        c.classList.toggle('active', c.dataset.status === status));
    loadEstimates();
}

function resetEstFilters() {
    ['ef-gen-id', 'ef-date-from', 'ef-date-to', 'ef-search'].forEach(id =>
        document.getElementById(id).value = '');
    document.getElementById('ef-site').value = '';
    loadEstimates();
}

// An auto-match is inferred, never billed -- it always renders with its score so
// nobody reads a linked invoice as something Mesa asserted.
function estMatchCell(e) {
    if (!e.linked_invoice) return '<span style="color:#555">-</span>';
    const inv = `<a href="#" onclick="showInvoice('${escHtml(e.linked_invoice)}');return false" style="color:#4a9eff">${escHtml(e.linked_invoice)}</a>`;
    if (!e.match_confidence) return inv;   // operator linked it by hand
    const col = e.match_confidence === 'high' ? '#00aa00' : '#ffaa00';
    const lbl = e.match_confidence === 'high' ? 'auto' : 'review';
    return inv + ' <span style="color:' + col + ';font-size:11px" title="' +
           escHtml(e.match_basis || '') + '">' + lbl +
           (e.match_score != null ? ' ' + e.match_score.toFixed(2) : '') + '</span>';
}

async function estAutoMatch(dryRun) {
    const box = document.getElementById('est-match-status');
    box.innerHTML = '<div class="status-msg">Scoring estimates against invoices&hellip;</div>';
    const d = await apiFetch('/estimates/automatch', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({dry_run: !!dryRun})
    });
    if (d.error) { box.innerHTML = '<div class="status-msg error">' + escHtml(d.error) + '</div>'; return; }
    let html = '<div class="status-msg' + (dryRun ? '' : ' success') + '">' +
        (dryRun ? 'Preview only &mdash; nothing saved. ' : 'Applied. ') +
        '<strong>' + d.matched_high + '</strong> high-confidence (marked completed), ' +
        '<strong>' + d.matched_review + '</strong> needing review (left pending).</div>';
    if (d.matches.length) {
        html += '<table style="margin-top:10px"><thead><tr><th>Quote</th><th>Invoice</th>' +
                '<th class="amount-cell">Invoice $</th><th>Confidence</th><th>Why</th>' +
                (dryRun ? '<th>Accept</th>' : '') + '</tr></thead><tbody>';
        d.matches.sort((a, b) => b.score - a.score).forEach(m => {
            const col = m.confidence === 'high' ? '#00aa00' : '#ffaa00';
            // Accept applies just this one row and marks it operator-confirmed, so a
            // later full run won't second-guess it.
            const accept = dryRun
                ? '<td><button class="btn btn-small btn-primary" id="acc-' + m.estimate_id + '" ' +
                  'onclick="estConfirmMatch(' + m.estimate_id + ', this)" ' +
                  'data-invoice="' + escHtml(m.invoice) + '" data-score="' + m.score + '" ' +
                  'data-basis="' + escHtml(m.basis) + '">Accept</button></td>'
                : '';
            html += '<tr><td>' + escHtml(m.quote_no) + '</td><td>' + escHtml(m.invoice) + '</td>' +
                    '<td class="amount-cell">' + fmt$(m.invoice_amount) + '</td>' +
                    '<td style="color:' + col + '">' + m.confidence + ' ' + m.score.toFixed(2) + '</td>' +
                    '<td style="color:#888;font-size:12px">' + escHtml(m.basis) + '</td>' +
                    accept + '</tr>';
        });
        html += '</tbody></table>';
    }
    box.innerHTML = html;
    if (!dryRun) loadEstimates();
}

// Accept straight from the list (the 'review' rows automatch linked but left pending).
async function estAcceptRow(id) {
    const rows = (window._estRows || []).filter(r => r.id === id);
    const e = rows[0];
    if (!e || !e.linked_invoice) { alert('No linked invoice to accept.'); return; }
    if (!confirm('Mark ' + e.quote_no + ' completed against invoice ' + e.linked_invoice + '?')) return;
    const d = await apiFetch('/estimates/' + id + '/match', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({invoice: e.linked_invoice, score: e.match_score,
                              basis: e.match_basis, user: EST_USER})
    });
    if (d.error) { alert(d.error); return; }
    loadEstimates();
}

async function estConfirmMatch(id, btn) {
    const inv = btn.dataset.invoice;
    btn.disabled = true; btn.textContent = 'Saving...';
    const d = await apiFetch('/estimates/' + id + '/match', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({invoice: inv, score: parseFloat(btn.dataset.score),
                              basis: btn.dataset.basis, user: EST_USER})
    });
    if (d.error) { btn.disabled = false; btn.textContent = 'Accept'; alert(d.error); return; }
    btn.outerHTML = '<span style="color:#00aa00">accepted</span>';
    loadEstimates();
}

async function estUnmatch(id) {
    if (!confirm('Undo this auto-match? The estimate goes back to pending.')) return;
    const d = await apiFetch('/estimates/unmatch/' + id, {method: 'POST'});
    if (d.error) { alert(d.error); return; }
    loadEstimates();
}

async function loadEstimates() {
    const params = new URLSearchParams();
    if (estFilter && estFilter !== 'all') params.set('status', estFilter);
    const map = {site: 'ef-site', gen_id: 'ef-gen-id', date_from: 'ef-date-from',
                 date_to: 'ef-date-to', q: 'ef-search'};
    for (const [k, id] of Object.entries(map)) {
        const v = document.getElementById(id).value;
        if (v) params.set(k, v);
    }
    const data = await apiFetch('/estimates?' + params.toString());
    const tbody = document.getElementById('est-body');
    if (data.error) {
        document.getElementById('est-list-status').innerHTML =
            '<div class="status-msg error">' + escHtml(data.error) + '</div>';
        return;
    }
    document.getElementById('est-list-status').innerHTML = '';
    tbody.innerHTML = '';
    if (!data.estimates.length) {
        tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;color:#888;padding:25px">No estimates match this filter.</td></tr>';
    }
    window._estRows = data.estimates;
    data.estimates.forEach(e => {
        const genCell = e.gen_id
            ? '<a href="#" onclick="showGenHistory(\\'' + escHtml(e.gen_id) + '\\');return false" style="color:#4a9eff">' + escHtml(e.gen_id) + '</a>'
            : '-';
        const tr = document.createElement('tr');
        tr.innerHTML =
            '<td><a href="#" onclick="showEstimate(' + e.id + ');return false" style="color:#4a9eff">' + escHtml(e.quote_no) + '</a></td>' +
            '<td>' + escHtml(e.quote_date || '-') + '</td>' +
            '<td>' + genCell + '</td>' +
            '<td>' + escHtml(e.site || '-') + '</td>' +
            '<td>' + escHtml(e.model || '-') + '</td>' +
            '<td class="amount-cell">' + (e.grand_total != null ? fmt$(e.grand_total) : '-') + '</td>' +
            '<td><span class="est-status ' + e.status + '">' + e.status + '</span></td>' +
            '<td>' + estMatchCell(e) + '</td>' +
            '<td>' + estAgeLabel(e) + '</td>' +
            '<td><button class="btn btn-small" onclick="showEstimate(' + e.id + ')">Open</button>' +
              (e.match_confidence === 'review' && e.linked_invoice
                 ? ' <button class="btn btn-small btn-primary" onclick="estAcceptRow(' + e.id + ')" title="Confirm this invoice paid for the quote">Accept</button>' : '') +
              (e.match_confidence ? ' <button class="btn btn-small btn-danger" onclick="estUnmatch(' + e.id + ')" title="Undo the match">Unlink</button>' : '') +
            '</td>';
        tbody.appendChild(tr);
    });
    const s = data.summary || {};
    document.getElementById('est-summary').textContent =
        (s.count || 0) + ' estimate(s) \\u00b7 ' + fmt$(s.total_value || 0) + ' total';
}

function initEstimateUpload() {
    const dz = document.getElementById('est-drop-zone');
    const inp = document.getElementById('est-file-input');
    inp.addEventListener('change', () => { if (inp.files[0]) uploadEstimatePdf(inp.files[0]); });
    ['dragover', 'dragenter'].forEach(ev => dz.addEventListener(ev, e => {
        e.preventDefault(); dz.classList.add('dragover');
    }));
    ['dragleave', 'drop'].forEach(ev => dz.addEventListener(ev, e => {
        e.preventDefault(); dz.classList.remove('dragover');
    }));
    dz.addEventListener('drop', e => {
        if (e.dataTransfer.files[0]) uploadEstimatePdf(e.dataTransfer.files[0]);
    });
}

async function uploadEstimatePdf(file) {
    const st = document.getElementById('est-parse-status');
    if (!file.name.toLowerCase().endsWith('.pdf')) {
        st.innerHTML = '<div class="status-msg error">Please choose a PDF file.</div>';
        return;
    }
    st.innerHTML = '<div class="status-msg">Parsing ' + escHtml(file.name) + ' &hellip;</div>';
    let data;
    try {
        const fd = new FormData();
        fd.append('file', file);
        const resp = await fetch(API + '/estimates/parse', {method: 'POST', body: fd});
        data = await resp.json();
    } catch (err) {
        st.innerHTML = '<div class="status-msg error">Upload failed: ' + escHtml(String(err)) + '</div>';
        return;
    }
    if (data.error) {
        st.innerHTML = '<div class="status-msg error">' + escHtml(data.error) + '</div>';
        return;
    }
    st.innerHTML = '';
    estStaging = data;
    renderEstimatePreview(data);
    document.getElementById('est-preview').scrollIntoView({behavior: 'smooth', block: 'nearest'});
}

function renderEstLineTables(lines, recon) {
    let html = '';
    [['parts', 'Parts'], ['labor', 'Labor'], ['other', 'Other']].forEach(pair => {
        const sec = pair[0], label = pair[1];
        const secLines = lines.filter(l => l.section === sec);
        if (!secLines.length) return;
        const r = (recon && recon[sec]) || {};
        let reconNote = 'lines ' + fmt$(r.calculated || 0);
        if (r.stated != null) {
            reconNote += ' / quote ' + fmt$(r.stated) +
                ' <span class="' + (r.ok ? 'recon-ok' : 'recon-bad') + '">' +
                (r.ok ? '&#10003;' : '&#10007;') + '</span>';
        }
        html += '<div class="est-section-head">' + label +
            '<span style="float:right;font-weight:400;font-size:12px">' + reconNote + '</span></div>';
        html += '<table style="font-size:12px"><thead><tr><th>Item</th><th>Description</th>' +
            '<th class="amount-cell">Qty</th><th>UOM</th><th class="amount-cell">Unit</th>' +
            '<th class="amount-cell">Total</th></tr></thead><tbody>';
        secLines.forEach(l => {
            const cls = (l.line_total == null) ? ' class="est-line-incomplete"' : '';
            html += '<tr' + cls + '><td>' + escHtml(l.item_no || '') + '</td>' +
                '<td>' + escHtml(l.description || '') + '</td>' +
                '<td class="amount-cell">' + (l.qty != null ? l.qty : '?') + '</td>' +
                '<td>' + escHtml(l.uom || '') + '</td>' +
                '<td class="amount-cell">' + (l.unit_price != null ? fmt$(l.unit_price) : '?') + '</td>' +
                '<td class="amount-cell">' + (l.line_total != null ? fmt$(l.line_total)
                    : '<span style="color:#ff4444">missing</span>') + '</td></tr>';
        });
        html += '</tbody></table>';
    });
    return html;
}

function renderEstTotals(d) {
    const cell = (lbl, v) => lbl + ' <b>' + (v != null ? fmt$(v) : '&mdash;') + '</b>';
    return '<div style="margin-top:12px;text-align:right;font-size:13px;color:#aaa">' +
        cell('Product', d.product_subtotal) + ' &nbsp;&nbsp; ' +
        cell('Labor', d.labor_subtotal) + ' &nbsp;&nbsp; ' +
        cell('Other', d.other_subtotal) + ' &nbsp;&nbsp; ' +
        '<span style="color:#00ff00">Grand Total <b>' +
        (d.grand_total != null ? fmt$(d.grand_total) : '&mdash;') + '</b></span></div>';
}

function renderEstimatePreview(d) {
    const box = document.getElementById('est-preview');
    box.style.display = 'block';
    let banners = '';
    if (d.already_imported) {
        banners += '<div class="status-msg error">Quote <b>' + escHtml(d.quote_no) +
            '</b> is already imported (estimate #' + d.already_imported.id + ', ' +
            escHtml(d.already_imported.status) + '). Re-committing will be rejected.</div>';
    }
    const priorOther = (d.prior_revisions || []).filter(r => r.quote_no !== d.quote_no);
    if (priorOther.length) {
        const pend = priorOther.filter(r => r.status === 'pending').map(r => r.quote_no);
        banners += '<div class="status-msg" style="background:rgba(255,200,0,0.12);color:#ffcc00">' +
            'Prior revision(s) on file: ' +
            priorOther.map(r => escHtml(r.quote_no) + ' (' + escHtml(r.status) + ')').join(', ') + '.' +
            (pend.length ? ' ' + escHtml(pend.join(', ')) + ' will be auto-superseded on commit.' : '') +
            '</div>';
    }
    const recon = d.reconciliation || {};
    if (d.parse_ok) {
        banners += '<div class="status-msg success">Parsed cleanly &mdash; every line item reconciles to the stated totals.</div>';
    } else {
        const bad = ['parts', 'labor', 'other', 'grand']
            .filter(k => recon[k] && !recon[k].ok)
            .map(k => k + ': lines ' + fmt$(recon[k].calculated || 0) + ' vs quote ' +
                (recon[k].stated != null ? fmt$(recon[k].stated) : 'n/a'))
            .join('; ');
        banners += '<div class="status-msg" style="background:rgba(255,200,0,0.12);color:#ffcc00">' +
            'Review needed &mdash; ' + escHtml(bad) + '. This is often a Mesa quote error rather ' +
            'than a parse fault; check it against the PDF, then commit if it looks right.</div>';
    }
    if ((d.warnings || []).length) {
        banners += '<div class="status-msg" style="background:rgba(255,200,0,0.12);color:#ffcc00">' +
            d.warnings.map(escHtml).join('<br>') + '</div>';
    }
    const hdr = '<div class="edit-row" style="grid-template-columns:repeat(3,1fr)">' +
        '<div class="edit-field"><label>Quote No</label><input value="' + escHtml(d.quote_no) + '" disabled></div>' +
        '<div class="edit-field"><label>Quote Date</label><input type="date" id="est-pv-date" value="' + escHtml(d.quote_date || '') + '"></div>' +
        '<div class="edit-field"><label>Revision</label><input value="' + d.revision + '" disabled></div>' +
        '<div class="edit-field"><label>Gen ID</label><input id="est-pv-gen" value="' + escHtml(d.gen_id || '') + '"></div>' +
        '<div class="edit-field"><label>Site</label><input id="est-pv-site" value="' + escHtml(d.site || '') + '"></div>' +
        '<div class="edit-field"><label>Model</label><input value="' + escHtml(d.model || '') + '" disabled></div>' +
        '</div>';
    const meta = '<div style="color:#888;font-size:12px;margin:4px 0 8px">Advisor: ' +
        escHtml(d.service_advisor || '-') + ' &nbsp;|&nbsp; Location: ' +
        escHtml(d.service_location || '-') + ' &nbsp;|&nbsp; Engine hours: ' +
        (d.engine_hours != null ? d.engine_hours.toLocaleString() : '-') + ' &nbsp;|&nbsp; ' +
        (d.lines || []).length + ' line items</div>';
    const notes = '<div class="edit-field" style="margin-top:8px"><label>Notes (optional)</label>' +
        '<input id="est-pv-notes" placeholder="Anything worth recording about this quote"></div>';
    const actions = '<div style="margin-top:14px;display:flex;gap:10px">' +
        '<button class="btn btn-primary" onclick="commitEstimate()"' +
        (d.already_imported ? ' disabled' : '') + '>Commit Estimate</button>' +
        '<button class="btn" onclick="cancelEstimatePreview()">Cancel</button></div>';
    box.innerHTML = '<h3 style="color:#00ff00;margin-bottom:10px">Review Estimate &mdash; ' +
        escHtml(d.quote_no) + '</h3>' + banners + hdr + meta +
        renderEstLineTables(d.lines, recon) + renderEstTotals(d) + notes + actions;
}

function cancelEstimatePreview() {
    estStaging = null;
    const box = document.getElementById('est-preview');
    box.style.display = 'none';
    box.innerHTML = '';
    document.getElementById('est-file-input').value = '';
}

async function commitEstimate() {
    if (!estStaging) return;
    const d = estStaging;
    const dateVal = document.getElementById('est-pv-date').value || d.quote_date;
    const est = {
        quote_no: d.quote_no, quote_base: d.quote_base, revision: d.revision,
        quote_date: dateVal,
        quote_month: dateVal ? dateVal.slice(0, 7) : d.quote_month,
        gen_id: document.getElementById('est-pv-gen').value.trim(),
        site: document.getElementById('est-pv-site').value.trim(),
        model: d.model, engine_hours: d.engine_hours,
        service_advisor: d.service_advisor, service_location: d.service_location,
        customer_no: d.customer_no,
        product_subtotal: d.product_subtotal, labor_subtotal: d.labor_subtotal,
        other_subtotal: d.other_subtotal, grand_total: d.grand_total,
        parse_ok: d.parse_ok,
        notes: document.getElementById('est-pv-notes').value
    };
    const resp = await apiFetch('/estimates/commit', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({estimate: est, lines: d.lines, staging_id: d.staging_id})
    });
    const st = document.getElementById('est-parse-status');
    if (resp.error) {
        st.innerHTML = '<div class="status-msg error">' + escHtml(resp.error) + '</div>';
        return;
    }
    let msg = 'Estimate ' + escHtml(d.quote_no) + ' saved.';
    if (resp.superseded && resp.superseded.length)
        msg += ' Superseded prior revision(s): ' + resp.superseded.map(escHtml).join(', ') + '.';
    st.innerHTML = '<div class="status-msg success">' + msg + '</div>';
    cancelEstimatePreview();
    setEstFilter('pending');
}

function computeEstRecon(d) {
    const sums = {parts: 0, labor: 0, other: 0};
    (d.lines || []).forEach(l => {
        if (sums[l.section] != null) sums[l.section] += (l.line_total || 0);
    });
    const mk = (calc, stated) => ({
        calculated: Math.round(calc * 100) / 100, stated: stated,
        ok: stated != null && Math.abs(calc - stated) <= 0.5
    });
    return {
        parts: mk(sums.parts, d.product_subtotal),
        labor: mk(sums.labor, d.labor_subtotal),
        other: mk(sums.other, d.other_subtotal)
    };
}

async function showEstimate(id) {
    const d = await apiFetch('/estimates/' + id);
    if (d.error) { alert(d.error); return; }
    estCurrentDetail = d;
    document.getElementById('est-modal-title').textContent =
        'Estimate ' + d.quote_no + (d.revision ? ' (rev ' + d.revision + ')' : '');
    document.getElementById('est-modal-status').innerHTML = '';
    document.getElementById('est-modal-body').innerHTML = renderEstimateDetail(d);
    document.getElementById('estimate-modal').classList.add('show');
}

function renderEstimateDetail(d) {
    const recon = computeEstRecon(d);
    const stat = (l, v) => '<div class="stat-box"><div class="stat-label">' + l +
        '</div><div class="stat-value" style="font-size:15px">' + v + '</div></div>';
    const stats = '<div class="modal-stats">' +
        stat('Grand Total', d.grand_total != null ? fmt$(d.grand_total) : '&mdash;') +
        stat('Status', '<span class="est-status ' + d.status + '">' + d.status + '</span>') +
        stat('Quote Date', escHtml(d.quote_date || '-')) +
        stat('Gen / Site', escHtml((d.gen_id || '-') + ' / ' + (d.site || '-'))) +
        stat('Advisor', escHtml(d.service_advisor || '-')) +
        stat('Engine Hrs', d.engine_hours != null ? d.engine_hours.toLocaleString() : '-') +
        '</div>';

    let actions = '<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;' +
        'margin:12px 0;padding:12px;background:#242424;border-radius:6px">';
    if (d.status === 'pending') {
        actions += '<input id="est-link-invoice" class="inline-input" style="width:150px" ' +
            'placeholder="Invoice # (optional)" value="' + escHtml(d.linked_invoice || '') + '">' +
            '<button class="btn btn-primary" onclick="setEstimateStatus(' + d.id + ', \\'completed\\')">Mark Completed</button>' +
            '<button class="btn" onclick="setEstimateStatus(' + d.id + ', \\'declined\\')">Decline</button>';
    } else {
        actions += '<span style="color:#888;font-size:12px">' +
            (d.status === 'superseded' ? 'Superseded' : 'Resolved') +
            (d.resolved_at ? ' ' + escHtml(d.resolved_at) : '') +
            (d.resolved_by ? ' by ' + escHtml(d.resolved_by) : '') +
            (d.linked_invoice ? ' &middot; invoice ' + escHtml(d.linked_invoice) : '') + '</span>' +
            '<button class="btn" onclick="setEstimateStatus(' + d.id + ', \\'pending\\')">Reopen</button>';
    }
    if (d.pdf_path) {
        actions += '<a class="btn" href="' + API + '/estimates/' + d.id +
            '/pdf" target="_blank" style="text-decoration:none">View PDF</a>';
    }
    actions += '<button class="btn btn-danger" style="margin-left:auto" onclick="deleteEstimate(' +
        d.id + ')">Delete</button></div>';

    const edit = '<div class="edit-row" style="grid-template-columns:repeat(3,1fr)">' +
        '<div class="edit-field"><label>Gen ID</label><input id="est-ed-gen" value="' + escHtml(d.gen_id || '') + '"></div>' +
        '<div class="edit-field"><label>Site</label><input id="est-ed-site" value="' + escHtml(d.site || '') + '"></div>' +
        '<div class="edit-field"><label>Notes</label><input id="est-ed-notes" value="' + escHtml(d.notes || '') + '"></div>' +
        '</div><div style="margin:6px 0 14px"><button class="btn btn-small" onclick="saveEstimateFields(' +
        d.id + ')">Save details</button></div>';

    return stats + actions + edit + renderEstLineTables(d.lines || [], recon) + renderEstTotals(d);
}

async function setEstimateStatus(id, status) {
    const body = {status: status, user: EST_USER};
    if (status === 'completed') {
        const inv = document.getElementById('est-link-invoice');
        if (inv) body.linked_invoice = inv.value.trim();
    }
    const resp = await apiFetch('/estimates/' + id, {
        method: 'PUT', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(body)
    });
    if (resp.error) { alert(resp.error); return; }
    await showEstimate(id);
    loadEstimates();
}

async function saveEstimateFields(id) {
    const body = {
        gen_id: document.getElementById('est-ed-gen').value.trim(),
        site: document.getElementById('est-ed-site').value.trim(),
        notes: document.getElementById('est-ed-notes').value
    };
    const resp = await apiFetch('/estimates/' + id, {
        method: 'PUT', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(body)
    });
    if (resp.error) {
        document.getElementById('est-modal-status').innerHTML =
            '<div class="status-msg error">' + escHtml(resp.error) + '</div>';
        return;
    }
    await showEstimate(id);
    document.getElementById('est-modal-status').innerHTML =
        '<div class="status-msg success">Saved.</div>';
    loadEstimates();
}

async function deleteEstimate(id) {
    if (!confirm('Delete this estimate, its line items, and its archived PDF? This cannot be undone.')) return;
    const resp = await apiFetch('/estimates/' + id, {method: 'DELETE'});
    if (resp.error) { alert(resp.error); return; }
    closeEstimateModal();
    loadEstimates();
}

function closeEstimateModal() {
    document.getElementById('estimate-modal').classList.remove('show');
    estCurrentDetail = null;
}

// ===== Query Builder Tab =====
let qbFields = null;                 // column catalog from /query/fields
let qbColumns = ['gen_id'];          // selected column keys (gen_id always first)
let qbFilterSeq = 0;
let qbLastResult = null;             // last result, for CSV export
let qbSort = 'gen_id', qbSortDir = 'asc';

const QB_OPS_TEXT = [['=', 'equals'], ['!=', 'not equals'], ['contains', 'contains'],
    ['not_contains', 'does not contain'], ['empty', 'is empty'], ['not_empty', 'is not empty']];
const QB_OPS_NUM = [['=', '='], ['!=', 'not ='], ['>', 'greater than'], ['<', 'less than'],
    ['>=', 'at least'], ['<=', 'at most'], ['empty', 'is empty'], ['not_empty', 'is not empty']];

function qbField(key) {
    for (const g of (qbFields || []))
        for (const f of g.fields)
            if (f.key === key) return f;
    return null;
}

async function initQueryTab() {
    if (qbFields) return;            // catalog loads once
    const data = await apiFetch('/query/fields');
    if (data.error) { document.getElementById('qb-status').textContent = data.error; return; }
    qbFields = data.groups;
    const sel = document.getElementById('qb-add-col');
    sel.innerHTML = '<option value="">Choose a column&hellip;</option>';
    qbFields.forEach(g => {
        const og = document.createElement('optgroup');
        og.label = g.source;
        g.fields.forEach(f => {
            const o = document.createElement('option');
            o.value = f.key;
            o.textContent = f.label;
            og.appendChild(o);
        });
        sel.appendChild(og);
    });
    qbColumns = ['gen_id', 'site', 'status', 'service_total', 'est_pending_count'];
    renderQbColumns();
    qbLoadSavedList();               // populate Saved-queries dropdown
    qbRun();                         // show something straight away
}

// --- Saved queries -----------------------------------------------------
// Operator-named presets stored globally in gen_service.db. The dropdown
// is populated from /saved_queries; picking one applies its columns,
// filters, sort, and date range then re-runs the query.
let qbSavedQueries = [];   // [{id, name, body:{columns,filters,sort,sort_dir,date_from,date_to}, created_by}]

async function qbLoadSavedList() {
    const sel = document.getElementById('qb-saved-select');
    try {
        const data = await apiFetch('/saved_queries');
        qbSavedQueries = data.queries || [];
    } catch (e) {
        qbSavedQueries = [];
        document.getElementById('qb-saved-status').textContent = 'load failed: ' + e;
        return;
    }
    sel.innerHTML = '<option value="">&mdash; pick one to load &mdash;</option>' +
        qbSavedQueries.map(q => '<option value="' + q.id + '">' + escHtml(q.name) +
            (q.created_by ? ' (' + escHtml(q.created_by) + ')' : '') + '</option>').join('');
    document.getElementById('qb-saved-status').textContent =
        qbSavedQueries.length ? qbSavedQueries.length + ' saved' : '';
    document.getElementById('qb-saved-del').disabled = true;
}

function qbApplySaved(idStr) {
    document.getElementById('qb-saved-del').disabled = !idStr;
    if (!idStr) return;
    const q = qbSavedQueries.find(x => String(x.id) === String(idStr));
    if (!q) return;
    const b = q.body || {};
    // Restore columns (drop any unknown keys, keep gen_id first)
    const valid = new Set();
    (qbFields || []).forEach(g => g.fields.forEach(f => valid.add(f.key)));
    qbColumns = ['gen_id'].concat((b.columns || []).filter(k => k !== 'gen_id' && valid.has(k)));
    renderQbColumns();

    // Restore filters by tearing down and rebuilding the rows
    const filtersDiv = document.getElementById('qb-filters');
    filtersDiv.innerHTML = '';
    (b.filters || []).forEach(flt => {
        qbAddFilter();
        const last = filtersDiv.lastElementChild;
        if (!last) return;
        last.querySelector('.qbf-field').value = flt.field || '';
        qbFilterFieldChanged(last.id);
        last.querySelector('.qbf-op').value = flt.op || '';
        last.querySelector('.qbf-val').value = flt.value || '';
    });

    // Restore sort + dates
    qbSort = b.sort || 'gen_id';
    qbSortDir = b.sort_dir || 'asc';
    document.getElementById('qb-date-from').value = b.date_from || '';
    document.getElementById('qb-date-to').value   = b.date_to   || '';

    qbRun();
}

async function qbSaveCurrent() {
    const name = (prompt('Name this query:') || '').trim();
    if (!name) return;
    // Mirror the body assembled by qbRun, minus the running state
    const filters = [];
    document.querySelectorAll('#qb-filters .add-rule-row').forEach(row => {
        const field = row.querySelector('.qbf-field').value;
        const op    = row.querySelector('.qbf-op').value;
        const value = row.querySelector('.qbf-val').value;
        if (field && op) filters.push({field: field, op: op, value: value});
    });
    const body = {
        name: name,
        body: {
            columns:   qbColumns,
            filters:   filters,
            sort:      qbSort,
            sort_dir:  qbSortDir,
            date_from: document.getElementById('qb-date-from').value || null,
            date_to:   document.getElementById('qb-date-to').value   || null,
        },
        created_by: (typeof EST_USER === 'string' && EST_USER) ? EST_USER : '',
    };
    const status = document.getElementById('qb-saved-status');
    status.textContent = 'Saving…';
    const r = await apiFetch('/saved_queries', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(body),
    });
    if (r && r.error) {
        status.textContent = '';
        alert(r.error);
        return;
    }
    await qbLoadSavedList();
    // Reselect the just-saved entry so it's clear what was just created
    if (r && r.id) {
        document.getElementById('qb-saved-select').value = String(r.id);
        document.getElementById('qb-saved-del').disabled = false;
    }
    status.textContent = 'Saved.';
    setTimeout(() => { if (status.textContent === 'Saved.') status.textContent = qbSavedQueries.length + ' saved'; }, 1500);
}

async function qbDeleteSelected() {
    const sel = document.getElementById('qb-saved-select');
    const id = sel.value;
    if (!id) return;
    const q = qbSavedQueries.find(x => String(x.id) === String(id));
    if (!confirm('Delete saved query "' + (q ? q.name : id) + '"?')) return;
    const r = await apiFetch('/saved_queries/' + id, {method: 'DELETE'});
    if (r && r.error) { alert(r.error); return; }
    sel.value = '';
    await qbLoadSavedList();
}

function renderQbColumns() {
    document.getElementById('qb-columns').innerHTML = qbColumns.map(k => {
        const f = qbField(k);
        const label = escHtml(f ? f.label : k);
        if (k === 'gen_id')
            return '<span class="est-chip active" style="cursor:default">' + label + '</span>';
        return '<span class="est-chip active" style="cursor:default">' + label +
            ' <span style="cursor:pointer" onclick="qbRemoveColumn(\\'' + k + '\\')">&times;</span></span>';
    }).join('');
}

function qbAddColumn() {
    const sel = document.getElementById('qb-add-col');
    const k = sel.value;
    if (k && !qbColumns.includes(k)) { qbColumns.push(k); renderQbColumns(); }
    sel.value = '';
}

function qbRemoveColumn(k) {
    qbColumns = qbColumns.filter(c => c !== k);
    if (qbSort === k) { qbSort = 'gen_id'; qbSortDir = 'asc'; }
    renderQbColumns();
}

// Drag-to-reorder for the query-builder column chips. Wraps renderQbColumns
// so the original chip HTML stays untouched — we just augment every non-first
// chip with HTML5 drag handlers after each re-render. Drop on the left half
// of a target chip inserts the source before it, right half inserts after.
// On drop we rebuild the table client-side from qbLastResult; no /query/run
// round-trip because row data hasn't changed, only column order.
(function () {
    const origRender = window.renderQbColumns;
    window.renderQbColumns = function () {
        origRender.apply(this, arguments);
        const chips = document.querySelectorAll("#qb-columns .est-chip.active");
        chips.forEach((chip, i) => {
            if (i === 0) return; // first chip is gen_id, locked
            const key = qbColumns[i];
            if (!key) return;
            chip.classList.add("qb-col-chip");
            chip.draggable = true;
            chip.title = "Drag to reorder";
            chip.addEventListener("dragstart", e => {
                e.dataTransfer.setData("text/plain", key);
                e.dataTransfer.effectAllowed = "move";
                chip.classList.add("qb-drag-src");
            });
            chip.addEventListener("dragend", () => {
                chip.classList.remove("qb-drag-src");
                document.querySelectorAll(".qb-drag-over-l, .qb-drag-over-r")
                    .forEach(el => el.classList.remove("qb-drag-over-l", "qb-drag-over-r"));
            });
            chip.addEventListener("dragover", e => {
                e.preventDefault();
                e.dataTransfer.dropEffect = "move";
                const rect = chip.getBoundingClientRect();
                const mid = rect.left + rect.width / 2;
                chip.classList.toggle("qb-drag-over-l", e.clientX <  mid);
                chip.classList.toggle("qb-drag-over-r", e.clientX >= mid);
            });
            chip.addEventListener("dragleave", () => {
                chip.classList.remove("qb-drag-over-l", "qb-drag-over-r");
            });
            chip.addEventListener("drop", e => {
                e.preventDefault();
                const srcKey = e.dataTransfer.getData("text/plain");
                chip.classList.remove("qb-drag-over-l", "qb-drag-over-r");
                if (!srcKey || srcKey === key) return;
                if (srcKey === "gen_id" || key === "gen_id") return;
                const rect = chip.getBoundingClientRect();
                const after = e.clientX >= rect.left + rect.width / 2;
                qbColumns = qbColumns.filter(k => k !== srcKey);
                let idx = qbColumns.indexOf(key);
                if (idx < 0) return;
                if (after) idx += 1;
                qbColumns.splice(idx, 0, srcKey);
                renderQbColumns();
                if (typeof qbLastResult !== "undefined" && qbLastResult) {
                    const byKey = {};
                    qbLastResult.columns.forEach(c => { byKey[c.key] = c; });
                    qbLastResult.columns = qbColumns.map(k => byKey[k]).filter(Boolean);
                    renderQbTable(qbLastResult);
                }
            });
        });
    };
})();

function qbAddFilter() {
    if (!qbFields) return;
    const id = 'qbf-' + (qbFilterSeq++);
    let fieldOpts = '<option value="">Choose a field&hellip;</option>';
    qbFields.forEach(g => {
        fieldOpts += '<optgroup label="' + escHtml(g.source) + '">';
        g.fields.forEach(f => {
            fieldOpts += '<option value="' + f.key + '">' + escHtml(f.label) + '</option>';
        });
        fieldOpts += '</optgroup>';
    });
    const div = document.createElement('div');
    div.className = 'add-rule-row';
    div.id = id;
    div.innerHTML =
        '<select class="qbf-field" onchange="qbFilterFieldChanged(\\'' + id + '\\')">' + fieldOpts + '</select>' +
        '<select class="qbf-op"></select>' +
        '<input class="qbf-val" placeholder="value" style="width:150px">' +
        '<button class="btn btn-small btn-danger" onclick="document.getElementById(\\'' + id + '\\').remove()">&times;</button>';
    document.getElementById('qb-filters').appendChild(div);
}

function qbFilterFieldChanged(rowId) {
    const row = document.getElementById(rowId);
    const f = qbField(row.querySelector('.qbf-field').value);
    const ops = (f && f.type === 'text') ? QB_OPS_TEXT : (f ? QB_OPS_NUM : QB_OPS_TEXT);
    row.querySelector('.qbf-op').innerHTML =
        ops.map(o => '<option value="' + o[0] + '">' + escHtml(o[1]) + '</option>').join('');
}

async function qbRun() {
    if (!qbFields) return;
    const filters = [];
    document.querySelectorAll('#qb-filters .add-rule-row').forEach(row => {
        const field = row.querySelector('.qbf-field').value;
        const op = row.querySelector('.qbf-op').value;
        const value = row.querySelector('.qbf-val').value;
        if (field && op) filters.push({field: field, op: op, value: value});
    });
    const body = {
        columns: qbColumns,
        filters: filters,
        date_from: document.getElementById('qb-date-from').value || null,
        date_to: document.getElementById('qb-date-to').value || null,
        sort: qbSort, sort_dir: qbSortDir
    };
    document.getElementById('qb-status').textContent = 'Running\\u2026';
    const data = await apiFetch('/query/run', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(body)
    });
    if (data.error) {
        document.getElementById('qb-status').textContent = '';
        alert(data.error);
        return;
    }
    qbLastResult = data;
    renderQbTable(data);
    document.getElementById('qb-status').textContent = data.total + ' generator(s)';
    document.getElementById('qb-export-btn').disabled = data.rows.length === 0;
}

function qbFmtCell(v, type) {
    if (v === null || v === undefined || v === '')
        return '<span style="color:#555">&mdash;</span>';
    if (type === 'money') return fmt$(v);
    if (type === 'number') return Number(v).toLocaleString();
    return escHtml(String(v));
}

function qbAlign(type) {
    // Numbers/money align right; text and dates align left. Headers match.
    return (type === 'money' || type === 'number') ? 'right' : 'left';
}

function renderQbTable(data) {
    document.getElementById('qb-head').innerHTML = '<tr>' + data.columns.map(c => {
        const arrow = (c.key === qbSort) ? (qbSortDir === 'asc' ? ' \\u25B2' : ' \\u25BC') : '';
        return '<th style="text-align:' + qbAlign(c.type) + '" onclick="qbSortBy(\\'' +
            c.key + '\\')">' + escHtml(c.label) + arrow + '</th>';
    }).join('') + '</tr>';
    const body = document.getElementById('qb-body');
    if (!data.rows.length) {
        body.innerHTML = '<tr><td colspan="' + data.columns.length +
            '" style="text-align:center;color:#888;padding:25px">No generators match these filters.</td></tr>';
        return;
    }
    body.innerHTML = data.rows.map(r => '<tr>' + data.columns.map(c => {
        const align = qbAlign(c.type);
        const mono = (c.type === 'money' || c.type === 'number') ? 'font-family:monospace;' : '';
        if (c.key === 'gen_id') {
            return `<td style="text-align:${align}"><a href="#" onclick="showGenHistory('${escHtml(r[c.key])}');return false" style="color:#4a9eff">${escHtml(r[c.key])}</a></td>`;
        }
        // a money cell with charges behind it opens them; everything else is inert
        if (QB_DRILL[c.key] && r[c.key]) {
            return `<td style="${mono}text-align:${align};cursor:pointer;text-decoration:underline dotted #555" title="Show the charges behind this" onclick="qbDrill('${escHtml(r.gen_id)}','${c.key}','${escHtml(c.label)}')">` +
                   qbFmtCell(r[c.key], c.type) + '</td>';
        }
        return '<td style="' + mono + 'text-align:' + align + '">' +
            qbFmtCell(r[c.key], c.type) + '</td>';
    }).join('') + '</tr>').join('');
}

// Which service lines sit behind a given query column. Only columns that map to
// real service_records rows are clickable -- the modelled oil and the AP columns
// are not line items, so they stay inert rather than opening an empty list.
const QB_DRILL = {
    service_total: {}, service_count: {},
    service_pm_total: {work_type: 'pm_field'},
    service_sc_total: {work_type: 'sc_field'},
    service_shop_total: {work_type: 'shop'},
    service_parts_total: {work_type: 'parts_only'},
    service_field_total: {visit_type: 'field'},
    service_pm_raw_total: {service_type: 'PM'},
    service_sc_raw_total: {service_type: 'SC'},
    service_shop_pm_total: {work_type: 'shop', service_type: 'PM'},
    service_shop_sc_total: {work_type: 'shop', service_type: 'SC'},
    service_field_pm_total: {work_type: 'pm_field'},
    service_field_sc_total: {work_type: 'sc_field'},
    service_parts_pm_total: {work_type: 'parts_only', service_type: 'PM'},
    service_parts_sc_total: {work_type: 'parts_only', service_type: 'SC'}
};

// work_type has no visible control on the Browse bar, so a drill-down parks it
// here and loadRecords() adds it to the request.
let qbDrillWorkType = '';

function qbDrill(genId, key, label) {
    const f = QB_DRILL[key];
    if (!f) return;
    switchTab('browse');
    resetFilters();
    document.getElementById('f-gen-id').value = genId;
    document.getElementById('f-visit-type').value = f.visit_type || '';
    document.getElementById('f-service-type').value = f.service_type || '';
    qbDrillWorkType = f.work_type || '';
    loadRecords(0);
    document.getElementById('browse-status').innerHTML =
        '<div class="status-msg">Charges behind <strong>' + escHtml(label) + '</strong> for ' +
        '<strong>' + escHtml(genId) + '</strong>. Clear with Reset.</div>';
}

// The status page renders sites straight from master_config key order (TX
// John..Walker, then ND Dan..WW) and generators in their configured group order,
// which is the sequence operators read the fleet in. Alphabetical-by-site or
// numeric-by-gen scrambles it, so this restores the familiar running order.
function qbSortStatusOrder() {
    qbSort = 'status_order';
    qbSortDir = 'asc';
    qbRun();
    const btn = document.getElementById('qb-status-sort-btn');
    btn.classList.add('btn-primary');
    setTimeout(() => btn.classList.remove('btn-primary'), 900);
}

function qbSortBy(key) {
    if (qbSort === key) qbSortDir = (qbSortDir === 'asc' ? 'desc' : 'asc');
    else { qbSort = key; qbSortDir = 'asc'; }
    qbRun();
}

function qbExportCsv() {
    if (!qbLastResult) return;
    const cols = qbLastResult.columns;
    const esc = v => {
        if (v === null || v === undefined) return '';
        const s = String(v);
        return /[",\\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
    };
    let csv = cols.map(c => esc(c.label)).join(',') + '\\n';
    qbLastResult.rows.forEach(r => {
        csv += cols.map(c => esc(r[c.key])).join(',') + '\\n';
    });
    const blob = new Blob([csv], {type: 'text/csv'});
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = 'gen_query_' + new Date().toISOString().slice(0, 10) + '.csv';
    a.click();
    URL.revokeObjectURL(a.href);
}

// ===== Init =====
loadFilterOptions();
loadRecords(0);
initEstimateUpload();

// Close modals on overlay click
document.getElementById('gen-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(); });
document.getElementById('edit-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEditModal(); });
document.getElementById('invoice-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeInvoiceModal(); });
document.getElementById('estimate-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEstimateModal(); });
// Close modals on Escape
document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeModal(); closeEditModal(); closeInvoiceModal(); closeEstimateModal(); } });
</script>
</body>
</html>""")
