"""
app.py — QR Carrier PDF generator: single-item and CSV-driven multi-item workflows.

Serves the upload UI at /qr/add/ (Apache ProxyPass).

Environment variables:
  QR_WATCH_DIR   Root directory that contains job subfolders
                 (default: /var/www/html/qr)
  QR_BASE_URL    Public URL that maps to QR_WATCH_DIR
                 (default: http://fileshare.icastinc.com/qr)
  PORT           Port for the built-in dev server (default: 5001)
"""

import base64
import csv
import html
import io
import json
import os
import re
import shutil
import subprocess
import sys
import secrets
import stat
import tempfile
import threading
import time
import uuid
import sqlite3
import zipfile
from datetime import date as _date_type, datetime as _datetime_type, timedelta as _timedelta_type, timezone as _dt_timezone
from pathlib import Path
from urllib.parse import quote, unquote

from flask import Flask, abort, jsonify, render_template_string, request, send_file
from fpdf import FPDF
from openpyxl import load_workbook
from werkzeug.utils import secure_filename

import msal
import qrcode as _qrcode_mod
from PIL import Image as _pil_image
import requests as _http_requests
from functools import wraps
from collections import OrderedDict as _OrderedDict_type
from contextlib import contextmanager as _contextmanager_type
from flask import redirect, session, url_for

# Allow importing qr_merge from the parent /var/www/html/qr/ directory
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

# Prefer merge_item (DVI-176 spec); fall back to build_carrier_pdf until it lands.
try:
    from qr_merge import merge_item as _qr_merge_item  # noqa: E402

    def _merge_item(carrier_path: Path, shop_path: Path, output_folder: Path,
                    submittal_path: Path | None = None) -> Path:
        generated = Path(_qr_merge_item(
            carrier_sheet_path=str(carrier_path),
            shop_drawing_path=str(shop_path),
            output_folder=str(output_folder),
            submittal_path=str(submittal_path) if submittal_path else None,
            base_url=_get_qr_base_url(),
        ))
        # Overwrite the carrier sheet with the merged output; remove generated file.
        if generated.resolve() != carrier_path.resolve():
            carrier_path.write_bytes(generated.read_bytes())
            generated.unlink()
        return carrier_path

except ImportError:
    # merge_item not yet in qr_merge.py — use build_carrier_pdf directly.
    from qr_merge import build_carrier_pdf as _build_carrier_pdf  # noqa: E402

    def _merge_item(carrier_path: Path, shop_path: Path, output_folder: Path,
                    submittal_path: Path | None = None) -> Path:
        _base_url = _get_qr_base_url()
        folder_url = f"{_base_url.rstrip('/')}/{quote(output_folder.name)}"
        shop_url = f"{folder_url}/{quote(shop_path.name)}"
        submittal_url = f"{folder_url}/{quote(submittal_path.name)}" if submittal_path else ""
        carrier_bytes = carrier_path.read_bytes()  # read before overwriting
        result_bytes = _build_carrier_pdf(carrier_bytes, submittal_url, shop_url)
        carrier_path.write_bytes(result_bytes)  # overwrite in place
        return carrier_path

# Also keep build_carrier_pdf available for the legacy single-item route.
try:
    from qr_merge import build_carrier_pdf  # noqa: E402
except ImportError:
    build_carrier_pdf = None  # type: ignore[assignment]

# Notifications core module (DVI-333) — shared Graph send helper + dispatcher.
import notifications  # noqa: E402
# Bot observability platform (DVI-824) — records runs/steps/logs/artifacts.
import bot_diagnostics as _diag_mod

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 500 * 1024 * 1024  # 500 MB (zip files with PDFs)

# ---------------------------------------------------------------------------
# Azure AD / Microsoft 365 SSO configuration
# ---------------------------------------------------------------------------
app.config["SECRET_KEY"] = os.environ["FLASK_SECRET_KEY"]

_AZ_CLIENT_ID  = os.environ["AZURE_CLIENT_ID"]
_AZ_SECRET     = os.environ["AZURE_CLIENT_SECRET"]
_AZ_TENANT     = os.environ["AZURE_TENANT_ID"]

# Per-tenant group mappings for multi-tenant SSO.
# Each tenant lists a base_group (required for any access) and optional
# role groups.  A role key present here means "check this group ID for
# that role"; absence means the role is not available to that tenant.
# base_group=None means any user from that tenant is allowed basic access.
_AZ_TENANT_CONFIG = {
    _AZ_TENANT: {
        "name": "iCast",
        "base_group": None,
        "groups": {
            "wor_admin":         "fed82ffb-eb77-41d4-9f50-59c80f1a942f",
            "togen_admin":       "f0e1aca6-fe4f-4f79-9a8b-3ae4bf644db7",
            "takeoff_workflow":  "13a096d3-08d9-40e2-a6ba-affd2f181249",
            "prod_run":          "75cb48a6-8d0d-48e5-a90e-75d5f65453b7",
            "togen_mts":         "5d5e2955-9b1b-4f16-8e8e-94426158c639",
            "togen_scada":       "8da8d55b-f4f7-4801-84e0-ea509eb5b381",
            "togen_vision":      "ea998389-b27d-45e4-b23e-966bb47cb772",
        },
    },
    "5040da8d-ce20-4443-a761-20600db23e5e": {
        "name": "Voyage Technology",
        "base_group": "f5a4b007-b23a-4db0-9fc7-2e45a08d45b8",
        "groups": {
            "togen_admin": "f5a4b007-b23a-4db0-9fc7-2e45a08d45b8",
            "wor_admin":   "f5a4b007-b23a-4db0-9fc7-2e45a08d45b8",
        },
    },
}

_AZ_AUTHORITY  = "https://login.microsoftonline.com/organizations"
_AZ_AUTHORITY_HOME = f"https://login.microsoftonline.com/{_AZ_TENANT}"
_AZ_SCOPES     = ["User.Read", "GroupMember.Read.All"]
_AZ_REDIRECT   = "https://togen.icastinc.com/auth/callback"

WATCH_DIR  = Path(os.environ.get("QR_WATCH_DIR",    "/var/www/html/qr"))
SHARED_DIR = Path(os.environ.get("QR_SHARED_DIR",  "/var/www/html/shared"))
WOR_DIR    = Path(os.environ.get("WOR_DIR",         "/var/www/html/wor"))
WOR_MAINTENANCE_DIR      = WOR_DIR / "maintenance"
WOR_MAINT_INPROGRESS_DIR = WOR_MAINTENANCE_DIR / "in_progress"
WOR_MAINT_COMPLETED_DIR  = WOR_MAINTENANCE_DIR / "completed"
WOR_MAINT_CANCELLED_DIR  = WOR_MAINTENANCE_DIR / "cancelled"
WOR_MOBILEMACHINE_DIR      = WOR_DIR / "mobilemachine"
WOR_MM_INPROGRESS_DIR      = WOR_MOBILEMACHINE_DIR / "in_progress"
WOR_MM_COMPLETED_DIR       = WOR_MOBILEMACHINE_DIR / "completed"
WOR_MM_CANCELLED_DIR       = WOR_MOBILEMACHINE_DIR / "cancelled"
WOR_SAFETY_DIR             = WOR_DIR / "safety"
WOR_SAFETY_INPROGRESS_DIR  = WOR_SAFETY_DIR / "in_progress"
WOR_SAFETY_COMPLETED_DIR   = WOR_SAFETY_DIR / "completed"
WOR_SAFETY_CANCELLED_DIR   = WOR_SAFETY_DIR / "cancelled"
WOR_PREVENTIVE_DIR           = WOR_DIR / "preventive"
WOR_PREVENTIVE_INPROGRESS_DIR = WOR_PREVENTIVE_DIR / "in_progress"
WOR_PREVENTIVE_COMPLETED_DIR  = WOR_PREVENTIVE_DIR / "completed"
WOR_PREVENTIVE_CANCELLED_DIR  = WOR_PREVENTIVE_DIR / "cancelled"
WOR_RECURRING_PATH         = WOR_DIR / "recurring.json"
# Device manuals (DVI-452) live under the WOR tree so they are reachable through
# the Document Viewer at the shared URL prefix (e.g. docs.icastinc.com/wor/manuals/<file>).
WOR_MANUALS_DIR            = WOR_DIR / "manuals"
# Equipment/Asset/System -> manual association index (kept beside app state, not web-exposed).
WOR_MANUALS_INDEX_FILE     = Path(__file__).resolve().parent / "pm_manuals_index.json"
# Searchable-manual text index (DVI-491): stored-filename -> OCR text + per-page
# offsets. Separate from the association index above, which stays equipment->file.
WOR_MANUALS_TEXT_INDEX_FILE = Path(__file__).resolve().parent / "pm_manuals_text_index.json"
# Pre-rendered Grid-view thumbnails (DVI-585): small JPEGs cached beside the app
# state (not under the web-exposed manuals tree). Keyed by stored filename so the
# grid loads a tiny image instead of downloading + rendering each full PDF.
WOR_MANUALS_THUMBS_DIR     = Path(__file__).resolve().parent / "pm_manuals_thumbs"
MANUAL_THUMB_MAX_PX        = 480  # longest-edge bound for cached thumbnails
ALLOWED_MANUAL_EXTS        = {".pdf", ".doc", ".docx", ".png", ".jpg", ".jpeg"}
# Legacy aliases kept for backward compat during migration
WOR_INPROGRESS_DIR = WOR_MAINT_INPROGRESS_DIR
WOR_COMPLETED_DIR  = WOR_MAINT_COMPLETED_DIR
SUPPORT_DIR = Path(os.environ.get("SUPPORT_DIR", "/var/www/html/support_requests"))
FEATURE_DIR = Path(os.environ.get("FEATURE_DIR", "/var/www/html/feature_requests"))
PROJECTBUILDER_DIR = Path(os.environ.get("PROJECTBUILDER_DIR", "/var/www/html/projectbuilder"))
STOCK_PRODUCTS_DIR = Path(os.environ.get("STOCK_PRODUCTS_DIR", "/var/www/html/stockproducts"))
STOCK_DRAWINGS_DIR = STOCK_PRODUCTS_DIR / "drawings"
MTS_LABELS_DIR = Path(os.environ.get("MTS_LABELS_DIR", "/var/www/html/mtslabels"))
# OCR Tool (DVI-490): job-scoped folders hold the uploaded PDF, the searchable
# output, and a status.json. Jobs older than OCR_JOB_TTL_DAYS are pruned.
OCR_DIR = Path(os.environ.get("OCR_DIR", "/var/www/html/ocr"))
OCR_JOB_TTL_DAYS = 7
BASE_URL   = os.environ.get("QR_BASE_URL", "http://fileshare.icastinc.com/qr")

AZURE_MAIL_FROM = os.environ.get("AZURE_MAIL_FROM", "")

TOOL_VISIBILITY_FILE = Path(__file__).resolve().parent / "tool_visibility.json"
TOOL_ORDER_FILE = Path(__file__).resolve().parent / "tool_order.json"
# Toolbar/top-bar functions managed from Togen Admin -> Tools -> Toolbars Enabled
# (DVI-616). Each has an Admins + Users toggle: "users" => visible to everyone,
# "admins" => visible to Togen Admins (iCast + Voyage Technology groups) only.
TOOLBAR_VISIBILITY_FILE = Path(__file__).resolve().parent / "toolbar_visibility.json"
PUBLIC_VERSION_FILE = Path(__file__).resolve().parent / "public_version.txt"
# About text shown in the Help-menu About popup; editable from Togen Admin (DVI-412).
ABOUT_CONTENT_FILE = Path(__file__).resolve().parent / "about_content.md"
# Header title + tagline shown atop every page; editable from Togen Admin (DVI-555).
SITE_TEXT_SETTINGS_FILE = Path(__file__).resolve().parent / "site_text_settings.json"
# Per-tool display name/description overrides, editable from Togen Admin (DVI-588).
# Shape: {tool_id: {"name": str, "description": str}}. Blank values fall back to
# the built-in client defaults, so the file only stores genuine overrides.
TOOL_OVERRIDES_FILE = Path(__file__).resolve().parent / "tool_overrides.json"
# Desired-state file for the rclone bisync service stop/start toggle (DVI-412).
# The actual host service control is wired by the Linux Automation Engineer subtask.
SYNC_SERVICE_STATE_FILE = Path(__file__).resolve().parent / "sync_service_state.json"
NEWS_FILE = Path(__file__).resolve().parent / "news_posts.json"
SUBSCRIBERS_FILE = Path(__file__).resolve().parent / "news_subscribers.json"
SHARED_URL_PREFIX_FILE = Path(__file__).resolve().parent / "shared_url_prefix.json"
USER_SETTINGS_DIR = Path(__file__).resolve().parent / "user_settings"
# File Browser folder-watcher snapshots (DVI-471). Keyed by watcher id; written
# only by the background watcher loop so it never races user-settings writes.
FB_WATCHER_STATE_FILE = Path(__file__).resolve().parent / "fb_watcher_state.json"
SCADA_CONFIG_FILE = Path(__file__).resolve().parent / "scada_config.json"
SCADA_REPORT_SCHEDULES_FILE = Path(__file__).resolve().parent / "scada_report_schedules.json"
SCADA_ENFORCED_RECIPIENTS_FILE = Path(__file__).resolve().parent / "scada_enforced_recipients.json"
SCADA_EXTERNAL_RECIPIENTS_FILE = Path(__file__).resolve().parent / "scada_external_recipients.json"
# Produced XLSX reports persisted by scada_report_scheduler.py. Must match that
# script's SCADA_REPORTS_DIR (defaults to <app dir>/reports) so the File Browser
# SCADA tab lists the same files the scheduler writes.
SCADA_REPORTS_DIR = Path(
    os.environ.get("SCADA_REPORTS_DIR", str(Path(__file__).resolve().parent / "reports")))
# Vision tool (DVI-399) state files.
VISION_COLLECTORS_FILE = Path(__file__).resolve().parent / "vision_collectors.json"
VISION_SETTINGS_FILE = Path(__file__).resolve().parent / "vision_settings.json"
VISION_AGENTS_FILE = Path(__file__).resolve().parent / "vision_agents.json"
# DVI-682: friendly display-name overrides keyed by machine_id. Lets admins
# present a human name instead of the raw hostname on the Collector tiles.
VISION_AGENT_NAMES_FILE = Path(__file__).resolve().parent / "vision_agent_names.json"
# SQLite DB for Vision auth & check-in history (DVI-408).
VISION_DB_PATH = Path(__file__).resolve().parent / "vision.db"
# A collector is considered online if it checked in within this many seconds.
VISION_STALE_SECONDS = 180
# Phase 3 (DVI-427): per-machine capture policy persisted server-side and pushed
# to agents via the existing check-in response.
VISION_POLICY_FILE = Path(__file__).resolve().parent / "vision_policy.json"
# Captured footage store (D3). The Windows Engineer's capture+send task rsyncs
# frames here under a separate backlogged task; Togen only reads/serves them.
# Path is configurable (never hardcode the rsync host) and defaults next to the
# app. Layout: <root>/<machine_id>/<frame_id>.jpg (+ <frame_id>.thumb.jpg).
VISION_FOOTAGE_DIR = Path(
    os.environ.get("VISION_FOOTAGE_DIR")
    or (Path(__file__).resolve().parent / "vision_footage")
)
# Footage backend selector (DVI-743). All footage access is routed through a
# FootageStore so the local-disk mirror can be swapped for an on-demand remote
# backend without touching the serving endpoints or the auth/quarantine
# contract. "local" (default) serves the rsync mirror; "remote" (DVI-748 Phase 2)
# streams on demand from the storage server over SFTP, no full local copy.
VISION_FOOTAGE_BACKEND = (os.environ.get("VISION_FOOTAGE_BACKEND") or "local").strip().lower()
# Remote backend (DVI-748): SFTP source + bounded on-disk LRU hot-cache. Host /
# port / user reuse the rsync-server config below; the SSH key here is the
# read-only footage-pull identity (Phase-3 transport task deploys it on the app
# host). Cache caps keep the hot-set bounded — no full mirror (D3=ii).
VISION_FOOTAGE_SSH_KEY = (
    os.environ.get("VISION_FOOTAGE_SSH_KEY")
    or str(Path.home() / ".ssh" / "id_togen_rsync_pull")
)
VISION_FOOTAGE_REMOTE_BASE = (os.environ.get("VISION_FOOTAGE_REMOTE_BASE") or "footage").strip()
VISION_FOOTAGE_CACHE_DIR = Path(
    os.environ.get("VISION_FOOTAGE_CACHE_DIR")
    or (Path(__file__).resolve().parent / "vision_footage_cache")
)
VISION_FOOTAGE_CACHE_MAX_BYTES = int(
    os.environ.get("VISION_FOOTAGE_CACHE_MAX_BYTES") or str(512 * 1024 * 1024)
)
VISION_FOOTAGE_CACHE_TTL = int(os.environ.get("VISION_FOOTAGE_CACHE_TTL") or "1800")
# Pooled SSH connections + a short listing-cache TTL avoid a per-request SFTP
# handshake / directory re-scan.
VISION_FOOTAGE_SSH_POOL_SIZE = int(os.environ.get("VISION_FOOTAGE_SSH_POOL_SIZE") or "4")
VISION_FOOTAGE_LISTING_TTL = int(os.environ.get("VISION_FOOTAGE_LISTING_TTL") or "10")
# Frozen agent distribution (DVI-436). The Windows Engineer's packaging task drops
# the bundled agent ZIP (frozen exe + installer) here; Togen only serves it for the
# Enroll tab's "download installer" action. Configurable; defaults next to the app.
VISION_AGENT_DIST_DIR = Path(
    os.environ.get("VISION_AGENT_DIST_DIR")
    or (Path(__file__).resolve().parent / "agent_dist")
)
VISION_AGENT_ZIP_NAME = os.environ.get("VISION_AGENT_ZIP_NAME") or "togen-vision-agent.zip"

# Rsync footage server — connection details used by the server to write approved
# agents' SSH public keys into aiv@<host>:~/.ssh/authorized_keys (DVI-726).
# VISION_RSYNC_MGMT_KEY must be a private key pre-authorized on the rsync server.
_VISION_RSYNC_HOST = os.environ.get("VISION_RSYNC_HOST", "165.188.108.55")
_VISION_RSYNC_PORT = int(os.environ.get("VISION_RSYNC_PORT", "4423"))
_VISION_RSYNC_USER = os.environ.get("VISION_RSYNC_USER", "aiv")
_VISION_RSYNC_MGMT_KEY = (os.environ.get("VISION_RSYNC_MGMT_KEY", "") or "").strip()

# Print system (DVI-667 / DVI-657 Print System P1). Mirrors the Vision agent
# machinery: a Windows Print Agent enrolls, polls /print/checkin, reports its
# discovered printers, and drains a FIFO command queue.
PRINT_DB_PATH = Path(__file__).resolve().parent / "print.db"
# History capture (DVI-789). Stores cross-tool audit events in one lightweight DB.
HISTORY_DB_PATH = Path(__file__).resolve().parent / "history.db"
HISTORY_SETTINGS_FILE = Path(__file__).resolve().parent / "history_settings.json"
# A print agent is considered online if it checked in within this many seconds.
PRINT_STALE_SECONDS = 60
# Frozen print-agent distribution ZIP (built by build_print_agent.ps1 on Windows).
PRINT_AGENT_DIST_DIR = Path(
    os.environ.get("PRINT_AGENT_DIST_DIR")
    or (Path(__file__).resolve().parent / "agent_dist")
)
PRINT_AGENT_ZIP_NAME = os.environ.get("PRINT_AGENT_ZIP_NAME") or "togen-print-agent.zip"

# Branding framework (DVI-357). Corporate asset files are dropped in by the
# Graphic Designer (DVI-356); their absence falls back to the Togen defaults.
BRANDING_SETTINGS_FILE = Path(__file__).resolve().parent / "branding_settings.json"
# Printing defaults (DVI-548). Printer options are supplied by Print agents
# (not yet wired); only the chosen default printer names persist here.
PRINTING_SETTINGS_FILE = Path(__file__).resolve().parent / "printing_settings.json"
WINSTON_SETTINGS_FILE      = Path(__file__).resolve().parent / "winston_settings.json"
WINSTON_SCHEDULES_FILE     = Path(__file__).resolve().parent / "winston_schedules.json"
WINSTON_RUN_RECORDS_FILE   = Path(__file__).resolve().parent / "winston_run_records.json"
CORPORATE_LOGO_FILE = Path(__file__).resolve().parent / "corporate_logo.svg"
CORPORATE_BG_LIGHT_FILE = Path(__file__).resolve().parent / "corporate_bg_light.svg"
CORPORATE_BG_DARK_FILE = Path(__file__).resolve().parent / "corporate_bg_dark.svg"
DEFAULT_BRANDING_SETTINGS = {"logo_mode": "togen", "bg_mode": "togen_default", "bg_opacity_light": 100, "bg_opacity_dark": 100, "tile_columns": 3, "tile_side_padding": 0}
BRANDING_LOGO_MODES = ("togen", "corporate")
BRANDING_BG_MODES = ("togen_default", "flat", "corporate")
# Per-user custom background (DVI-518). A user may upload their own image to use
# as their Togen interface background instead of the company default/corporate
# options. Stored per-user under CUSTOM_BG_DIR keyed by the same safe-email slug
# used for settings files; the chosen extension is mirrored in user settings as
# "custom_bg_ext" so the client knows the asset exists. "custom" is a per-user
# mode only — it is never a company default, so it is excluded from the admin
# BRANDING_BG_MODES set above.
CUSTOM_BG_DIR = Path(__file__).resolve().parent / "custom_backgrounds"
CUSTOM_BG_EXT_MIMES = {
    "png": "image/png",
    "jpg": "image/jpeg",
    "jpeg": "image/jpeg",
    "webp": "image/webp",
    "gif": "image/gif",
    "svg": "image/svg+xml",
}
CUSTOM_BG_MAX_BYTES = 8 * 1024 * 1024  # 8 MB
USER_BG_MODES = BRANDING_BG_MODES + ("custom",)
# Home tile grid layout (DVI-512). "tile_columns" is the number of columns the
# home grid is laid out in (D1 — columns, not per-tile pixels), an int in
# [3, 9] or the string "unlimited" (auto-pack as many as fit). "tile_view"
# (per-user only) selects the grid or compact list presentation (D4).
TILE_COLUMNS_MIN = 3
TILE_COLUMNS_MAX = 9
TILE_VIEW_MODES = ("grid", "list")


def _coerce_tile_columns(value):
    """Return "unlimited" or an int in [3, 9] for a valid input, else None."""
    if value == "unlimited":
        return "unlimited"
    try:
        n = int(value)
    except (TypeError, ValueError):
        return None
    if TILE_COLUMNS_MIN <= n <= TILE_COLUMNS_MAX:
        return n
    return None
# Home tile grid side padding (DVI-515). "tile_side_padding" is a percentage of
# the available width applied as equal left/right padding on the home grid so
# tiles get breathing room on the sides. Admin sets the default; users may
# override (blank/null = inherit). Clamped to [0, 40] so content never vanishes.
TILE_SIDE_PADDING_MIN = 0
TILE_SIDE_PADDING_MAX = 40


def _coerce_tile_side_padding(value):
    """Return an int in [0, 40] for a valid percentage input, else None."""
    try:
        n = int(value)
    except (TypeError, ValueError):
        return None
    if TILE_SIDE_PADDING_MIN <= n <= TILE_SIDE_PADDING_MAX:
        return n
    return None
# Tile/pane transparency (DVI-364). Only takes effect over the corporate
# background; clamped to a 40% floor so panes never become unreadable.
BRANDING_OPACITY_MIN = 40
BRANDING_OPACITY_MAX = 100


def _coerce_bg_opacity(value):
    """Return an int in [40, 100] for a valid opacity input, else None."""
    try:
        n = int(value)
    except (TypeError, ValueError):
        return None
    if BRANDING_OPACITY_MIN <= n <= BRANDING_OPACITY_MAX:
        return n
    return None

SHARED_URL_PREFIX_OPTIONS = [
    {"value": "https://docs.icastinc.com", "label": "docs.icastinc.com/shared (HTTPS)"},
    {"value": "https://fileshare.icastinc.com", "label": "fileshare.icastinc.com/shared (HTTPS)"},
    {"value": "http://fileshare.icastinc.com", "label": "fileshare.icastinc.com/shared (HTTP)"},
]
DEFAULT_SHARED_URL_PREFIX = "https://docs.icastinc.com"

_ALL_TOOL_IDS = [
    "project-builder", "takeoff-bridge", "prod-run-merge",
    "work-order", "wor-admin", "sync-admin", "file-browser",
    "simple-qr", "single-carrier-sheet", "mts-label", "scada-view",
    "vision", "equipment-manuals", "calendar", "ocr-tool", "email",
    "auditor", "repair-parts-manager", "bots",
]

# Tools that default to hidden in the admin Tools-Enabled list. They remain
# always visible to Togen Admins but stay off for everyone else until an admin
# enables them (DVI-569). Access is still enforced separately by role gating.
# repair-parts-manager (DVI-596) defaults off so it stays hidden for Work Order
# Admins until an admin enables it; Togen Admins always see it.
# bots (DVI-803) defaults off so it stays hidden for non-admins until an admin
# enables it; Togen Admins always see it.
_TOOL_DEFAULT_DISABLED = {"auditor", "repair-parts-manager", "bots"}

# Toolbar/top-bar functions (DVI-616). Listed in the Toolbars Enabled admin pane.
_TOOLBAR_IDS = ["search", "assistant", "calendar"]
_TOOLBAR_LABELS = {"search": "Search", "assistant": "Assistant", "calendar": "Calendar"}

# Work Order types managed as subitems of the "work-order" tool (DVI-349).
_WOR_TYPE_IDS = ["maintenance", "mobilemachine", "safety", "preventive", "ithelpdesk"]
WOR_TYPE_VISIBILITY_FILE = Path(__file__).resolve().parent / "wor_type_visibility.json"
WOR_TYPE_ORDER_FILE = Path(__file__).resolve().parent / "wor_type_order.json"
WOR_PRIMARY_FILE = Path(__file__).resolve().parent / "wor_primary.json"
WOR_POLICY_FILE = Path(__file__).resolve().parent / "wor_policy.json"
# Repair Parts Manager (DVI-596). Editable Part Number -> Part Name catalog used
# to autofill the Part Name when a previously-seen Part Number is entered. The
# parts themselves stay on each Work Order sidecar (Option A: WO is the source of
# truth); this file is only the known-parts lookup.
RPM_KNOWN_PARTS_FILE = Path(__file__).resolve().parent / "rpm_known_parts.json"
# RPM tool policy/settings (DVI-607). Currently a single policy toggle.
RPM_SETTINGS_FILE = Path(__file__).resolve().parent / "rpm_settings.json"
# RPM approval permission + auto-approval policy (DVI-614). Who may approve parts
# and which work-order creators get their parts auto-approved on submission.
RPM_APPROVAL_FILE = Path(__file__).resolve().parent / "rpm_approval.json"
# RPM live carrier-tracking config (DVI-612). Provider + API key + poll cadence
# for the background poller that populates tracking_status/delivered_at on parts.
# Kept separate from rpm_settings.json because it holds a credential (string), not
# the bool-only policy toggles. Disabled-until-configured: no key => poller no-ops.
RPM_TRACKING_FILE = Path(__file__).resolve().parent / "rpm_tracking.json"
# Work Order types that record/track repair parts (RPM tabs + WO Admin Parts icon).
RPM_PART_WOR_TYPES = ("mobilemachine", "preventive")


def _load_toolbar_visibility():
    """Return {toolbar_id: {"admins": bool, "users": bool}} for top-bar functions.

    Defaults to both enabled so existing behavior (Search/Assistant/Calendar shown
    to everyone) is preserved until an admin changes it (DVI-616).
    """
    defaults = {tid: {"admins": True, "users": True} for tid in _TOOLBAR_IDS}
    if TOOLBAR_VISIBILITY_FILE.is_file():
        try:
            saved = json.loads(TOOLBAR_VISIBILITY_FILE.read_text())
            for tid in _TOOLBAR_IDS:
                row = saved.get(tid)
                if isinstance(row, dict):
                    defaults[tid] = {
                        "admins": bool(row.get("admins", True)),
                        "users": bool(row.get("users", True)),
                    }
        except (json.JSONDecodeError, OSError):
            pass
    return defaults


def _toolbar_enabled_for(toolbar_id, is_admin, vis=None):
    """Whether a top-bar function is visible/usable for the current user.

    "users" enabled => visible to everyone; otherwise visible only to Togen
    Admins when "admins" is enabled.
    """
    vis = vis or _load_toolbar_visibility()
    row = vis.get(toolbar_id, {"admins": True, "users": True})
    if row.get("users"):
        return True
    return bool(is_admin and row.get("admins"))


def _load_tool_visibility():
    """Return dict of {tool_id: bool} for all manageable tools.

    Tools in ``_TOOL_DEFAULT_DISABLED`` start hidden so they stay invisible to
    non-admins until an admin explicitly enables them (DVI-569: Auditor).
    """
    defaults = {tid: tid not in _TOOL_DEFAULT_DISABLED for tid in _ALL_TOOL_IDS}
    if TOOL_VISIBILITY_FILE.is_file():
        try:
            saved = json.loads(TOOL_VISIBILITY_FILE.read_text())
            for tid in _ALL_TOOL_IDS:
                if tid in saved:
                    defaults[tid] = bool(saved[tid])
        except (json.JSONDecodeError, OSError):
            pass
    return defaults


def _load_tool_order():
    """Return deduplicated list of tool IDs in display order."""
    if TOOL_ORDER_FILE.is_file():
        try:
            saved = json.loads(TOOL_ORDER_FILE.read_text())
            if isinstance(saved, list):
                seen, deduped = set(), []
                for tid in saved:
                    if tid not in seen:
                        seen.add(tid)
                        deduped.append(tid)
                for tid in _ALL_TOOL_IDS:
                    if tid not in seen:
                        seen.add(tid)
                        deduped.append(tid)
                return deduped
        except (json.JSONDecodeError, OSError):
            pass
    return list(_ALL_TOOL_IDS)


def _load_tool_overrides():
    """Return {tool_id: {"name": str, "description": str}} admin overrides for a
    tool's display name/description (DVI-588).

    Only known tool IDs are kept and blank values are dropped, so the client can
    safely fall back to its built-in defaults for anything not present here.
    """
    out = {}
    if TOOL_OVERRIDES_FILE.is_file():
        try:
            saved = json.loads(TOOL_OVERRIDES_FILE.read_text())
            if isinstance(saved, dict):
                for tid, ov in saved.items():
                    if tid not in _ALL_TOOL_IDS or not isinstance(ov, dict):
                        continue
                    entry = {}
                    name = str(ov.get("name", "")).strip()
                    desc = str(ov.get("description", "")).strip()
                    if name:
                        entry["name"] = name
                    if desc:
                        entry["description"] = desc
                    if entry:
                        out[tid] = entry
        except (json.JSONDecodeError, OSError):
            pass
    return out


def _load_wor_type_visibility():
    """Return dict of {wor_type: bool} for each Work Order type."""
    defaults = {tid: True for tid in _WOR_TYPE_IDS}
    if WOR_TYPE_VISIBILITY_FILE.is_file():
        try:
            saved = json.loads(WOR_TYPE_VISIBILITY_FILE.read_text())
            for tid in _WOR_TYPE_IDS:
                if tid in saved:
                    defaults[tid] = bool(saved[tid])
        except (json.JSONDecodeError, OSError):
            pass
    return defaults


def _load_wor_type_order():
    """Return deduplicated list of Work Order type IDs in display order."""
    if WOR_TYPE_ORDER_FILE.is_file():
        try:
            saved = json.loads(WOR_TYPE_ORDER_FILE.read_text())
            if isinstance(saved, list):
                seen, deduped = set(), []
                for tid in saved:
                    if tid in _WOR_TYPE_IDS and tid not in seen:
                        seen.add(tid)
                        deduped.append(tid)
                for tid in _WOR_TYPE_IDS:
                    if tid not in seen:
                        seen.add(tid)
                        deduped.append(tid)
                return deduped
        except (json.JSONDecodeError, OSError):
            pass
    return list(_WOR_TYPE_IDS)


def _user_can_access_pm(user: dict) -> bool:
    """Preventative Maintenance is role-restricted (DVI-462).

    Only Work Order Admins and Togen Admins may see the PM tile or submit a PM
    work order. The ``togen_admin`` role already maps to both the iCast and the
    Voyage Technology Togen-Admin Azure groups (see ``_AZ_TENANT_CONFIG``), so
    those groups are covered by this single check.
    """
    user = user or {}
    return bool(user.get("wor_admin") or user.get("togen_admin"))


def _wor_type_enabled_for(wor_type: str, user: dict) -> bool:
    """Whether *wor_type* is usable by *user*. Togen Admins bypass the gate."""
    # Preventative Maintenance is restricted to specific roles regardless of the
    # admin visibility toggle (DVI-462).
    if wor_type == "preventive":
        return _user_can_access_pm(user)
    if user.get("togen_admin"):
        return True
    return _load_wor_type_visibility().get(wor_type, True)


def _user_settings_path(email: str) -> Path:
    """Return the JSON settings file path for a given user email."""
    safe = re.sub(r'[^a-zA-Z0-9@._-]', '_', email.lower())
    return USER_SETTINGS_DIR / f"{safe}.json"


def _load_user_settings(email: str) -> dict:
    """Return per-user settings dict (tool_visibility, tool_order, theme)."""
    fp = _user_settings_path(email)
    if fp.is_file():
        try:
            return json.loads(fp.read_text())
        except (json.JSONDecodeError, OSError):
            pass
    return {}


def _save_user_settings(email: str, settings: dict):
    """Persist per-user settings to disk."""
    USER_SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
    fp = _user_settings_path(email)
    fp.write_text(json.dumps(settings, indent=2))
    fp.chmod(stat.S_IRUSR | stat.S_IWUSR)


def _custom_bg_path(email: str, ext: str) -> Path:
    """Return the on-disk path for a user's custom background with the given ext."""
    safe = re.sub(r'[^a-zA-Z0-9@._-]', '_', email.lower())
    return CUSTOM_BG_DIR / f"{safe}.{ext}"


def _find_custom_bg(email: str):
    """Return the user's existing custom-background file (any ext), or None."""
    safe = re.sub(r'[^a-zA-Z0-9@._-]', '_', email.lower())
    for ext in CUSTOM_BG_EXT_MIMES:
        fp = CUSTOM_BG_DIR / f"{safe}.{ext}"
        if fp.is_file():
            return fp
    return None


# ---------------------------------------------------------------------------
# Notification preference grid (DVI-335)
#
# The UI renders a friendly 3x5 grid (work-order type x lifecycle column). The
# core module (notifications.py) resolves recipients by reading
# settings["wo_notifications"][wor_type][<event-constant>], so we translate the
# friendly columns into the core's event constants on save and back on load.
# "status_change" covers both the in-progress and cancelled transitions;
# "completed" has its own column. All entries default off.
# ---------------------------------------------------------------------------

_NOTIFY_GRID_TYPES = ("maintenance", "mobilemachine", "safety", "preventive")
_NOTIFY_GRID_COLUMNS = ("new", "status_change", "completed", "due_soon", "overdue")
_NOTIFY_COLUMN_EVENTS = {
    "new":           (notifications.EVENT_WO_CREATED,),
    "status_change": (notifications.EVENT_WO_STATUS_IN_PROGRESS,
                      notifications.EVENT_WO_STATUS_CANCELLED),
    "completed":     (notifications.EVENT_WO_STATUS_COMPLETED,),
    "due_soon":      (notifications.EVENT_WO_RECURRING_DUE_SOON,),
    "overdue":       (notifications.EVENT_WO_RECURRING_OVERDUE,),
}


def _notification_grid_from_settings(settings: dict) -> dict:
    """Derive the friendly 3x5 grid (all-off default) from stored prefs."""
    stored = settings.get("wo_notifications") or {}
    grid = {}
    for wt in _NOTIFY_GRID_TYPES:
        type_prefs = stored.get(wt) or {}
        grid[wt] = {
            col: any(bool(type_prefs.get(ev)) for ev in _NOTIFY_COLUMN_EVENTS[col])
            for col in _NOTIFY_GRID_COLUMNS
        }
    return grid


def _notification_grid_to_storage(grid: dict) -> dict:
    """Translate the friendly grid into the core module's event-keyed shape.

    Only enabled events are stored; all-off types are omitted entirely so the
    absence of an entry reads as "off" in the core resolver.
    """
    out = {}
    for wt in _NOTIFY_GRID_TYPES:
        col_vals = grid.get(wt) or {}
        events = {}
        for col in _NOTIFY_GRID_COLUMNS:
            if col_vals.get(col):
                for ev in _NOTIFY_COLUMN_EVENTS[col]:
                    events[ev] = True
        if events:
            out[wt] = events
    return out


def _user_settings_with_grid(email: str) -> dict:
    """Load per-user settings plus the derived friendly notifications grid."""
    settings = dict(_load_user_settings(email))
    settings["notifications"] = _notification_grid_from_settings(settings)
    return settings


# ---------------------------------------------------------------------------
# Primary recipients (DVI-405)
#
# Company-level (not per-user) lists of email addresses, keyed by WO type. A
# Primary recipient receives EVERY notification for that WO type, with all
# User Settings grid checkboxes enforced on and locked. notifications.py reads
# the same wor_primary.json file as an independent recipient source.
# ---------------------------------------------------------------------------


def _load_wor_primary() -> dict:
    """Return Primary email lists keyed by WO type, normalised to all types."""
    data = {}
    if WOR_PRIMARY_FILE.is_file():
        try:
            raw = json.loads(WOR_PRIMARY_FILE.read_text())
            if isinstance(raw, dict):
                data = raw
        except (json.JSONDecodeError, OSError):
            pass
    out = {}
    for wt in _NOTIFY_GRID_TYPES:
        emails = data.get(wt)
        out[wt] = [e for e in emails if isinstance(e, str)] if isinstance(emails, list) else []
    return out


def _save_wor_primary(data: dict) -> None:
    """Persist Primary lists, de-duplicated and lowercased per WO type."""
    clean = {}
    for wt in _NOTIFY_GRID_TYPES:
        seen, ordered = set(), []
        for e in (data.get(wt) or []):
            if not isinstance(e, str):
                continue
            norm = e.strip().lower()
            if norm and norm not in seen:
                seen.add(norm)
                ordered.append(norm)
        clean[wt] = ordered
    WOR_PRIMARY_FILE.write_text(json.dumps(clean, indent=2))


def _wor_primary_enforced_for(email: str) -> list:
    """WO types for which this email is a Primary recipient (grid enforced on)."""
    if not email:
        return []
    norm = email.strip().lower()
    primary = _load_wor_primary()
    return [wt for wt in _NOTIFY_GRID_TYPES if norm in primary.get(wt, [])]


# ---------------------------------------------------------------------------
# Policy-enforced notifications (DVI-597)
#
# Company-level (not per-user) WO type x event grid. When a cell is enabled, the
# REQUESTING USER of every work order of that type is notified for that event as
# a matter of policy, regardless of their per-WO opt-in. notifications.py reads
# the same wor_policy.json file as an independent recipient source. Stored in the
# core's event-keyed shape (same as the per-user grid) so the resolver can read
# it directly without translation.
# ---------------------------------------------------------------------------


def _load_wor_policy_grid() -> dict:
    """Return the friendly Policy grid (all-off default) from wor_policy.json."""
    stored = {}
    if WOR_POLICY_FILE.is_file():
        try:
            raw = json.loads(WOR_POLICY_FILE.read_text())
            if isinstance(raw, dict):
                stored = raw
        except (json.JSONDecodeError, OSError):
            pass
    return _notification_grid_from_settings({"wo_notifications": stored})


def _save_wor_policy_grid(grid: dict) -> None:
    """Persist the friendly Policy grid in the core's event-keyed shape."""
    WOR_POLICY_FILE.write_text(
        json.dumps(_notification_grid_to_storage(grid), indent=2))


def _load_public_version():
    """Return the public version string."""
    if PUBLIC_VERSION_FILE.is_file():
        return PUBLIC_VERSION_FILE.read_text().strip()
    return "Unknown"


# Default About prose (Markdown) — mirrors the original hard-coded popup text.
DEFAULT_ABOUT_MARKDOWN = (
    "Togen is iCast's integrated tools platform for bridging material quantity "
    "estimation to production. From take-off data to QR-coded labels, work orders, "
    "SCADA monitoring, document sync and preview, and daily production runs — "
    "Togen pulls it forward.\n\n"
    "Why “togen”: Togen is the past participle of the Old English verb "
    "“tēon,” which means to draw or pull.\n\n"
    "The term ‘togen’ in Old English literature is often associated with the "
    "concept of ‘fate’ or ‘destiny’, reflecting the beliefs of the "
    "Anglo-Saxon culture regarding the inevitability of certain events.\n\n"
    "Or its short for TOol GENerator. Built as a platform to build and access tools, "
    "chain them into workflows, and impact operations."
)


def _load_about_content():
    """Return the editable About popup text (Markdown)."""
    if ABOUT_CONTENT_FILE.is_file():
        try:
            text = ABOUT_CONTENT_FILE.read_text()
            if text.strip():
                return text
        except OSError:
            pass
    return DEFAULT_ABOUT_MARKDOWN


def _save_about_content(text):
    ABOUT_CONTENT_FILE.write_text(text)


# Site title + tagline shown in the header on every page; editable from Togen
# Admin → General (DVI-555). Falls back to the original hard-coded values.
DEFAULT_SITE_TITLE = "togen"
DEFAULT_SITE_TAGLINE = "Pull it Forward"


def _load_site_text():
    """Return the editable header title/tagline, falling back to defaults."""
    title, tagline = DEFAULT_SITE_TITLE, DEFAULT_SITE_TAGLINE
    if SITE_TEXT_SETTINGS_FILE.is_file():
        try:
            saved = json.loads(SITE_TEXT_SETTINGS_FILE.read_text())
            if isinstance(saved, dict):
                if isinstance(saved.get("title"), str) and saved["title"].strip():
                    title = saved["title"].strip()
                if isinstance(saved.get("tagline"), str) and saved["tagline"].strip():
                    tagline = saved["tagline"].strip()
        except (json.JSONDecodeError, OSError):
            pass
    return {"title": title, "tagline": tagline}


def _save_site_text(title, tagline):
    SITE_TEXT_SETTINGS_FILE.write_text(
        json.dumps({"title": title, "tagline": tagline}, indent=2)
    )


_BISYNC_UNIT = "rclone-bisync.service"


def _load_sync_service_running():
    """Return the real run-state of the rclone bisync service.

    Queries systemctl first; falls back to the persisted desired-state file when
    systemctl is unavailable (dev environment, insufficient permissions, etc.).
    """
    try:
        result = subprocess.run(
            ["systemctl", "is-active", _BISYNC_UNIT],
            capture_output=True, text=True, timeout=5,
        )
        # "active" → rc 0; "inactive"/"failed"/unknown → non-zero
        return result.returncode == 0
    except Exception:
        pass
    # Fallback: persisted desired state
    if SYNC_SERVICE_STATE_FILE.is_file():
        try:
            data = json.loads(SYNC_SERVICE_STATE_FILE.read_text())
            if isinstance(data, dict) and "running" in data:
                return bool(data["running"])
        except (json.JSONDecodeError, OSError):
            pass
    return True


def _save_sync_service_running(running):
    SYNC_SERVICE_STATE_FILE.write_text(json.dumps({"running": bool(running)}, indent=2))


def _load_news_posts():
    """Return list of news posts, newest first."""
    if NEWS_FILE.is_file():
        try:
            posts = json.loads(NEWS_FILE.read_text())
            if isinstance(posts, list):
                posts.sort(key=lambda p: p.get("date", ""), reverse=True)
                return posts
        except (json.JSONDecodeError, OSError):
            pass
    return []


def _save_news_posts(posts):
    NEWS_FILE.write_text(json.dumps(posts, indent=2))


def _load_subscribers():
    """Return list of subscriber email addresses."""
    if SUBSCRIBERS_FILE.is_file():
        try:
            subs = json.loads(SUBSCRIBERS_FILE.read_text())
            if isinstance(subs, list):
                return subs
        except (json.JSONDecodeError, OSError):
            pass
    return []


def _save_subscribers(subs):
    SUBSCRIBERS_FILE.write_text(json.dumps(subs, indent=2))


def _load_shared_url_prefix():
    """Return the configured shared URL prefix string."""
    if SHARED_URL_PREFIX_FILE.is_file():
        try:
            data = json.loads(SHARED_URL_PREFIX_FILE.read_text())
            if isinstance(data, dict) and data.get("prefix"):
                return data["prefix"]
        except (json.JSONDecodeError, OSError):
            pass
    return DEFAULT_SHARED_URL_PREFIX


def _save_shared_url_prefix(prefix):
    SHARED_URL_PREFIX_FILE.write_text(json.dumps({"prefix": prefix}, indent=2))


# --- Device manuals (DVI-452) -------------------------------------------------

def _normalize_equipment(name: str) -> str:
    """Case/whitespace-insensitive key for matching Equipment/Asset/System names."""
    return " ".join((name or "").strip().lower().split())


def _manual_viewer_url(stored_filename: str) -> str:
    """Document Viewer URL for a stored manual file."""
    prefix = _load_shared_url_prefix()
    return f"{prefix}/wor/manuals/{stored_filename}"


def _load_manuals_index() -> dict:
    """Return the {normalized-equipment: manual-entry} association map."""
    if WOR_MANUALS_INDEX_FILE.is_file():
        try:
            data = json.loads(WOR_MANUALS_INDEX_FILE.read_text())
            if isinstance(data, dict):
                return data
        except (json.JSONDecodeError, OSError):
            pass
    return {}


def _save_manuals_index(index: dict) -> None:
    WOR_MANUALS_INDEX_FILE.write_text(json.dumps(index, indent=2))


def _manuals_index_set(equipment: str, stored_filename: str, original_name: str, user: dict) -> None:
    """Associate a manual with an equipment name so future WORs can reuse it."""
    key = _normalize_equipment(equipment)
    if not key:
        return
    index = _load_manuals_index()
    index[key] = {
        "filename": stored_filename,
        "original_name": original_name,
        "equipment": (equipment or "").strip(),
        "url": _manual_viewer_url(stored_filename),
        "updated_at": _datetime_type.now().strftime("%Y-%m-%dT%H:%M:%S"),
        "updated_by": (user or {}).get("name", ""),
    }
    _save_manuals_index(index)


def _list_all_manuals() -> list:
    """Return every manual in the association index whose file still exists.

    Covers both standalone uploads (Equipment Manuals tool) and manuals attached
    to Preventative Maintenance work orders, since both paths write the same index.
    """
    items = []
    for entry in _load_manuals_index().values():
        fn = entry.get("filename")
        if not fn or not (WOR_MANUALS_DIR / fn).is_file():
            continue
        items.append({
            "filename": fn,
            "original_name": entry.get("original_name", fn),
            "equipment": entry.get("equipment", ""),
            "url": _manual_viewer_url(fn),
            "updated_at": entry.get("updated_at", ""),
            "updated_by": entry.get("updated_by", ""),
        })
    items.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
    return items


def _manuals_index_lookup(equipment: str) -> dict | None:
    """Return the manual entry previously associated with an equipment name, if any."""
    key = _normalize_equipment(equipment)
    if not key:
        return None
    entry = _load_manuals_index().get(key)
    if not entry or not entry.get("filename"):
        return None
    # Only suggest manuals whose file still exists on disk.
    if not (WOR_MANUALS_DIR / entry["filename"]).is_file():
        return None
    # Refresh URL in case the shared prefix changed since it was stored.
    entry = dict(entry)
    entry["url"] = _manual_viewer_url(entry["filename"])
    return entry


def _collect_pm_eas_options() -> list[str]:
    """Distinct Equipment/Asset/System names known to the system (DVI-590).

    Gathers EAS values from every Preventative Maintenance work order sidecar
    (any status) plus the device-manual association index, so the WOR form can
    offer them as type-ahead suggestions. Deduplicated case-insensitively,
    preserving the first-seen display spelling, and sorted alphabetically.
    """
    seen: dict[str, str] = {}

    def _add(raw: str) -> None:
        name = (raw or "").strip()
        if not name:
            return
        key = _normalize_equipment(name)
        if key and key not in seen:
            seen[key] = name

    for directory in _wor_type_dirs("preventive"):
        if not directory.is_dir():
            continue
        for sidecar in directory.glob("*.json"):
            try:
                data = json.loads(sidecar.read_text())
            except (json.JSONDecodeError, OSError):
                continue
            if isinstance(data, dict):
                _add(data.get("equipment", ""))

    for entry in _load_manuals_index().values():
        if isinstance(entry, dict):
            _add(entry.get("equipment", ""))

    return sorted(seen.values(), key=lambda s: s.lower())


def _pm_process_manual(equipment: str, user: dict):
    """Resolve the manual for a PM submission from the request.

    Returns (manual_dict | None, error_message | None). A newly uploaded file
    (request.files['manual']) takes precedence and is saved + indexed; otherwise
    an existing manual referenced via the 'manual_existing' form field is reused
    (and re-associated with the current equipment). No manual -> (None, None).
    """
    f = request.files.get("manual")
    if f and f.filename:
        ext = os.path.splitext(f.filename)[1].lower()
        if ext not in ALLOWED_MANUAL_EXTS:
            allowed = ", ".join(sorted(ALLOWED_MANUAL_EXTS))
            return None, f"Unsupported manual file type. Allowed: {allowed}"
        WOR_MANUALS_DIR.mkdir(parents=True, exist_ok=True)
        base = secure_filename(f.filename) or ("manual" + ext)
        stored = f"{_datetime_type.now().strftime('%Y%m%d_%H%M%S')}_{base}"
        f.save(str(WOR_MANUALS_DIR / stored))
        _manuals_index_set(equipment, stored, f.filename, user)
        return {"filename": stored, "original_name": f.filename,
                "url": _manual_viewer_url(stored)}, None

    existing = request.form.get("manual_existing", "").strip()
    if existing:
        if "/" in existing or "\\" in existing or ".." in existing:
            return None, "Invalid manual reference"
        if (WOR_MANUALS_DIR / existing).is_file():
            original = request.form.get("manual_existing_name", "").strip() or existing
            _manuals_index_set(equipment, existing, original, user)
            return {"filename": existing, "original_name": original,
                    "url": _manual_viewer_url(existing)}, None
    return None, None


# --- Searchable manuals: OCR text index + background OCR (DVI-491) ------------

def _load_manuals_text_index() -> dict:
    """Return the {stored-filename: {text, pages, ...}} OCR text index."""
    if WOR_MANUALS_TEXT_INDEX_FILE.is_file():
        try:
            data = json.loads(WOR_MANUALS_TEXT_INDEX_FILE.read_text())
            if isinstance(data, dict):
                return data
        except (json.JSONDecodeError, OSError):
            pass
    return {}


def _save_manuals_text_index(index: dict) -> None:
    WOR_MANUALS_TEXT_INDEX_FILE.write_text(json.dumps(index, indent=2))


def _manuals_page_offsets(text: str) -> list:
    """Per-page [start, end) character offsets from the OCRmyPDF sidecar text.

    OCRmyPDF separates pages with a form feed (\\f); each page's slice lets the
    search route map a hit's character offset back to a 1-based page number.
    """
    pages = []
    pos = 0
    for i, part in enumerate(text.split("\f")):
        start = pos
        end = pos + len(part)
        pages.append({"number": i + 1, "start": start, "end": end})
        pos = end + 1  # account for the \f delimiter we split on
    return pages


def _manuals_text_index_set(stored_filename: str, text: str, original_name: str) -> None:
    index = _load_manuals_text_index()
    index[stored_filename] = {
        "filename": stored_filename,
        "original_name": original_name,
        "text": text,
        "pages": _manuals_page_offsets(text),
        "indexed_at": _datetime_type.now().strftime("%Y-%m-%dT%H:%M:%S"),
    }
    _save_manuals_text_index(index)


def _run_manual_ocr(stored_filename: str, original_name: str) -> None:
    """Background pass: replace a stored PDF manual with its searchable version
    and record the recognized text in the manuals text index.

    Only PDFs are processed (the only thing OCRmyPDF can turn into a searchable
    PDF in place without changing the stored filename the association index keys
    on). Non-PDF manuals are left untouched and unindexed. Engine-unavailable and
    OCR errors are swallowed so an upload never fails on a best-effort index pass.
    """
    try:
        src = WOR_MANUALS_DIR / stored_filename
        if not src.is_file() or src.suffix.lower() != ".pdf":
            return
        try:
            from ocr_engine import ocr_pdf, OcrEngineUnavailable, SKIP_TEXT
        except ImportError:
            return
        tmp_out = src.with_suffix(".ocrtmp.pdf")
        sidecar = tmp_out.with_suffix(".txt")
        try:
            result = ocr_pdf(src, tmp_out, mode=SKIP_TEXT)
        except OcrEngineUnavailable:
            return
        except Exception:  # noqa: BLE001 — OCR is best-effort; never break upload
            tmp_out.unlink(missing_ok=True)
            sidecar.unlink(missing_ok=True)
            return
        # Swap the searchable PDF in for the original so in-document Ctrl-F works.
        os.replace(str(tmp_out), str(src))
        sidecar.unlink(missing_ok=True)
        _manuals_text_index_set(stored_filename, result.text, original_name)
    except Exception:  # noqa: BLE001 — daemon thread must not crash the app
        return


def _enqueue_manual_ocr(stored_filename: str, original_name: str) -> None:
    """Start a daemon thread to OCR + index a freshly uploaded manual (D3)."""
    threading.Thread(
        target=_run_manual_ocr,
        args=(stored_filename, original_name),
        daemon=True,
    ).start()


def _backfill_manuals_ocr() -> None:
    """One-time idempotent pass: OCR + index any existing PDF manual not yet in
    the text index. Runs in a daemon thread at startup so it never blocks boot."""
    try:
        indexed = set(_load_manuals_text_index().keys())
        # Cover every PDF on disk, not just association-index entries, so manuals
        # attached to work orders are searchable too.
        if not WOR_MANUALS_DIR.is_dir():
            return
        names = {}
        for entry in _load_manuals_index().values():
            fn = entry.get("filename")
            if fn:
                names[fn] = entry.get("original_name", fn)
        for path in WOR_MANUALS_DIR.glob("*.pdf"):
            fn = path.name
            if fn in indexed:
                continue
            _run_manual_ocr(fn, names.get(fn, fn))
    except Exception:  # noqa: BLE001 — backfill is best-effort
        return


# ---------------------------------------------------------------------------
# Equipment Manuals: Grid-view thumbnail cache (DVI-585)
# ---------------------------------------------------------------------------
# The Grid view used to download each full manual and render page 1 client-side
# with pdf.js, which is slow on the first load. We instead pre-render a small
# JPEG per manual once, server-side, and cache it on disk. The grid then loads a
# tiny <img>; the client-side path remains as a graceful fallback when the
# renderers (PyMuPDF / Pillow) are unavailable.
_MANUAL_THUMB_IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "bmp"}


def _manual_thumb_path(stored_filename: str) -> Path:
    return WOR_MANUALS_THUMBS_DIR / (stored_filename + ".jpg")


def _generate_manual_thumb(src: Path, ext: str, thumb: Path) -> Path | None:
    """Render a small JPEG thumbnail for one manual. Returns the path or None.

    Writes atomically (temp + replace) so a concurrent reader never sees a
    partial file. Best-effort: any renderer/IO error yields None so the grid
    falls back to client-side rendering.
    """
    try:
        WOR_MANUALS_THUMBS_DIR.mkdir(parents=True, exist_ok=True)
        if ext == "pdf":
            import fitz  # PyMuPDF — already a dependency of the Claude OCR backend
            import io
            doc = fitz.open(str(src))
            try:
                if doc.page_count < 1:
                    return None
                page = doc.load_page(0)
                zoom = MANUAL_THUMB_MAX_PX / max(page.rect.width, page.rect.height, 1)
                pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
                img = _pil_image.open(io.BytesIO(pix.tobytes("png"))).convert("RGB")
            finally:
                doc.close()
        elif ext in _MANUAL_THUMB_IMAGE_EXTS:
            img = _pil_image.open(str(src)).convert("RGB")
        else:
            return None  # doc/docx and anything else: no server thumbnail
        img.thumbnail((MANUAL_THUMB_MAX_PX, MANUAL_THUMB_MAX_PX))
        tmp = thumb.with_suffix(".tmp.jpg")
        img.save(str(tmp), "JPEG", quality=82)
        os.replace(str(tmp), str(thumb))
        return thumb
    except Exception:  # noqa: BLE001 — thumbnailing is best-effort
        return None


def _ensure_manual_thumb(stored_filename: str) -> Path | None:
    """Return a cached thumbnail path for a manual, regenerating on staleness.

    The cache is keyed by stored filename and invalidated by source mtime, so a
    manual replaced in place (e.g. by the OCR pass) re-renders on next request.
    """
    src = WOR_MANUALS_DIR / stored_filename
    if not src.is_file():
        return None
    thumb = _manual_thumb_path(stored_filename)
    try:
        if thumb.is_file() and thumb.stat().st_mtime >= src.stat().st_mtime:
            return thumb
    except OSError:
        pass
    ext = src.suffix.lower().lstrip(".")
    return _generate_manual_thumb(src, ext, thumb)


def _backfill_manual_thumbs() -> None:
    """Pre-cache Grid-view thumbnails for every manual on disk (DVI-585).

    Runs in a daemon thread at startup so the first Grid-view load is served
    from cache instead of rendering each manual on demand. Best-effort."""
    try:
        if not WOR_MANUALS_DIR.is_dir():
            return
        for path in WOR_MANUALS_DIR.iterdir():
            if not path.is_file():
                continue
            ext = path.suffix.lower().lstrip(".")
            if ext == "pdf" or ext in _MANUAL_THUMB_IMAGE_EXTS:
                _ensure_manual_thumb(path.name)
    except Exception:  # noqa: BLE001 — backfill is best-effort
        return


def _load_branding_settings():
    """Return branding settings, falling back to Togen defaults."""
    settings = dict(DEFAULT_BRANDING_SETTINGS)
    if BRANDING_SETTINGS_FILE.is_file():
        try:
            saved = json.loads(BRANDING_SETTINGS_FILE.read_text())
            if isinstance(saved, dict):
                if saved.get("logo_mode") in BRANDING_LOGO_MODES:
                    settings["logo_mode"] = saved["logo_mode"]
                if saved.get("bg_mode") in BRANDING_BG_MODES:
                    settings["bg_mode"] = saved["bg_mode"]
                # Migration (DVI-366): seed each new theme-specific key from a
                # legacy single bg_opacity when the new key is absent. One-time
                # read only — we never write the old key back.
                legacy = _coerce_bg_opacity(saved.get("bg_opacity"))
                for key in ("bg_opacity_light", "bg_opacity_dark"):
                    opacity = _coerce_bg_opacity(saved.get(key))
                    if opacity is None and key not in saved and legacy is not None:
                        opacity = legacy
                    if opacity is not None:
                        settings[key] = opacity
                tile_columns = _coerce_tile_columns(saved.get("tile_columns"))
                if tile_columns is not None:
                    settings["tile_columns"] = tile_columns
                tile_side_padding = _coerce_tile_side_padding(saved.get("tile_side_padding"))
                if tile_side_padding is not None:
                    settings["tile_side_padding"] = tile_side_padding
        except (json.JSONDecodeError, OSError):
            pass
    return settings


def _save_branding_settings(settings):
    BRANDING_SETTINGS_FILE.write_text(json.dumps(settings, indent=2))


def _get_qr_base_url():
    """Return the base URL for QR-linked files: shared URL prefix + /shared path."""
    prefix = _load_shared_url_prefix()
    return f"{prefix}/shared"


_SAFE_JOB_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9 _\-\.]{0,79}$")


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

WOR_COUNTER_PATH = WOR_DIR / "wor_counter.json"


def _detect_wor_type(file_path: Path) -> str:
    """Detect wor_type from the resolved file path."""
    s = str(file_path.resolve())
    if str(WOR_MOBILEMACHINE_DIR.resolve()) in s:
        return "mobilemachine"
    if str(WOR_SAFETY_DIR.resolve()) in s:
        return "safety"
    if str(WOR_PREVENTIVE_DIR.resolve()) in s:
        return "preventive"
    return "maintenance"


def _wor_dest_dirs(wor_type: str) -> dict[str, Path]:
    """Return status->directory mapping for a WOR type."""
    if wor_type == "mobilemachine":
        return {
            "new": WOR_MOBILEMACHINE_DIR,
            "in_progress": WOR_MM_INPROGRESS_DIR,
            "completed": WOR_MM_COMPLETED_DIR,
            "cancelled": WOR_MM_CANCELLED_DIR,
        }
    if wor_type == "safety":
        return {
            "new": WOR_SAFETY_DIR,
            "in_progress": WOR_SAFETY_INPROGRESS_DIR,
            "completed": WOR_SAFETY_COMPLETED_DIR,
            "cancelled": WOR_SAFETY_CANCELLED_DIR,
        }
    if wor_type == "preventive":
        return {
            "new": WOR_PREVENTIVE_DIR,
            "in_progress": WOR_PREVENTIVE_INPROGRESS_DIR,
            "completed": WOR_PREVENTIVE_COMPLETED_DIR,
            "cancelled": WOR_PREVENTIVE_CANCELLED_DIR,
        }
    return {
        "new": WOR_MAINTENANCE_DIR,
        "in_progress": WOR_MAINT_INPROGRESS_DIR,
        "completed": WOR_MAINT_COMPLETED_DIR,
        "cancelled": WOR_MAINT_CANCELLED_DIR,
    }


# Lifecycle status -> notification event. "new" is not a notifiable transition.
_WOR_STATUS_EVENTS = {
    "in_progress": notifications.EVENT_WO_STATUS_IN_PROGRESS,
    "completed":   notifications.EVENT_WO_STATUS_COMPLETED,
    "cancelled":   notifications.EVENT_WO_STATUS_CANCELLED,
}


def _wor_event_context(filename: str, wor_type: str, sidecar_data: dict) -> dict:
    """Build the notify() context for a work-order event from its sidecar.

    Carries recipient-resolution inputs (per-WO opt-in ``_notify``, the
    submitter email, and ``wor_type``) plus the template fields the branded
    email renders. ``wor_id`` is the filename — stable across status moves so
    the idempotency log keys correctly.
    """
    sd = sidecar_data or {}
    return {
        "wor_id": filename,
        "wor_number": sd.get("_wor_number"),
        "wor_type": wor_type,
        "notify": sd.get("_notify"),
        "submitter_email": sd.get("email"),
        "email": sd.get("email"),
        "requestor_name": sd.get("requestor_name"),
        "location": sd.get("location"),
        "building": sd.get("building"),
        "urgency": sd.get("urgency"),
        "desired_completion_date": sd.get("desired_completion_date"),
        "description": sd.get("description"),
    }


def _next_work_order_number() -> int:
    """Atomically increment and return the next work order number."""
    WOR_DIR.mkdir(parents=True, exist_ok=True)
    counter = 0
    if WOR_COUNTER_PATH.exists():
        try:
            counter = json.loads(WOR_COUNTER_PATH.read_text()).get("next_number", 0)
        except (json.JSONDecodeError, OSError):
            counter = 0
    counter = max(counter, 0) + 1
    WOR_COUNTER_PATH.write_text(json.dumps({"next_number": counter}))
    return counter


def _load_recurring_registry() -> dict:
    """Load the recurring work order registry."""
    if WOR_RECURRING_PATH.exists():
        try:
            return json.loads(WOR_RECURRING_PATH.read_text())
        except (json.JSONDecodeError, OSError):
            pass
    return {}


def _save_recurring_registry(registry: dict):
    """Save the recurring work order registry."""
    WOR_DIR.mkdir(parents=True, exist_ok=True)
    WOR_RECURRING_PATH.write_text(json.dumps(registry, indent=2))


def _calc_next_run(start_date: str, frequency_days: int, after_date: str | None = None) -> str | None:
    """Calculate the next run date from start_date + frequency, on or after after_date."""
    from datetime import timedelta
    try:
        start = _datetime_type.strptime(start_date, "%Y-%m-%d").date()
    except (ValueError, TypeError):
        return None
    ref = _datetime_type.now().date() if not after_date else _datetime_type.strptime(after_date, "%Y-%m-%d").date()
    delta = timedelta(days=frequency_days)
    nxt = start
    while nxt <= ref:
        nxt += delta
    return nxt.strftime("%Y-%m-%d")


def _recurring_runs_in_window(start_date: str, frequency_days: int,
                              end_date: str, win_start, win_end) -> list:
    """Enumerate recurring run dates (date objects) falling within [win_start, win_end].

    ``start_date``/``end_date`` are ``YYYY-MM-DD`` strings (end optional);
    ``win_start``/``win_end`` are ``date`` objects. Used by the Calendar tool to
    plot a marker on every due date of a recurring schedule (DVI-630).
    """
    from datetime import timedelta
    runs: list = []
    try:
        cur = _datetime_type.strptime(start_date, "%Y-%m-%d").date()
    except (ValueError, TypeError):
        return runs
    if not frequency_days or frequency_days <= 0:
        return runs
    end = None
    if end_date:
        try:
            end = _datetime_type.strptime(end_date, "%Y-%m-%d").date()
        except (ValueError, TypeError):
            end = None
    delta = timedelta(days=frequency_days)
    # Fast-forward to the first run on or after the window start.
    if cur < win_start:
        skip = (win_start - cur).days // frequency_days
        cur = cur + delta * skip
        while cur < win_start:
            cur += delta
    while cur <= win_end:
        if end and cur > end:
            break
        runs.append(cur)
        cur += delta
    return runs


WOR_ADMIN_EMAIL = "workorderadmins@icastinc.com"


def _send_wor_admin_email(subject: str, body_text: str):
    """Send a notification email to WOR admins via Microsoft Graph API. Fire-and-forget."""
    if not AZURE_MAIL_FROM:
        app.logger.warning("Cannot send WOR admin email: AZURE_MAIL_FROM not set")
        return
    try:
        app_client = msal.ConfidentialClientApplication(
            _AZ_CLIENT_ID, authority=_AZ_AUTHORITY_HOME, client_credential=_AZ_SECRET)
        token_result = app_client.acquire_token_for_client(
            scopes=["https://graph.microsoft.com/.default"])
        if "access_token" not in token_result:
            app.logger.error("WOR admin email: failed to acquire token")
            return
        graph_body = {
            "message": {
                "subject": subject,
                "body": {"contentType": "Text", "content": body_text},
                "toRecipients": [
                    {"emailAddress": {"address": WOR_ADMIN_EMAIL}},
                ],
            },
            "saveToSentItems": "false",
        }
        resp = _http_requests.post(
            f"https://graph.microsoft.com/v1.0/users/{AZURE_MAIL_FROM}/sendMail",
            headers={
                "Authorization": f"Bearer {token_result['access_token']}",
                "Content-Type": "application/json",
            },
            json=graph_body,
            timeout=30,
        )
        if resp.status_code not in (200, 202):
            app.logger.error("WOR admin email failed: %s", resp.text[:300])
    except Exception:
        app.logger.exception("WOR admin email error")


def _send_wor_admin_email_bg(subject: str, body_text: str):
    """Send admin email in a background thread to avoid blocking requests."""
    t = threading.Thread(target=_send_wor_admin_email, args=(subject, body_text), daemon=True)
    t.start()


def _resolve_wor_sidecar(filename: str, wor_type: str | None = None) -> Path | None:
    """Find the JSON sidecar for a WOR PDF across all directories."""
    json_name = Path(filename).with_suffix(".json").name
    if not json_name or ".." in json_name or "/" in json_name:
        return None
    for d in _wor_type_dirs(wor_type):
        candidate = (d / json_name).resolve()
        if not str(candidate).startswith(str(d.resolve())):
            return None
        if candidate.is_file():
            return candidate
    return None


def _wor_number_label(filename: str, data: dict | None) -> str:
    """Return a "WO #00042" label from sidecar data, falling back to the filename."""
    num = (data or {}).get("_wor_number")
    if isinstance(num, int):
        return f"WO #{num:05d}"
    m = re.search(r"_(\d{4,6})_", filename or "")
    if m:
        return f"WO #{int(m.group(1)):05d}"
    return "WO"


def _wor_abbrev(text: str, limit: int = 40) -> str:
    """Collapse whitespace and truncate request text, appending an ellipsis."""
    text = " ".join((text or "").split())
    if len(text) <= limit:
        return text
    return text[:limit].rstrip() + "..."


def _wor_display_title(filename: str, wor_type: str, data: dict | None) -> str:
    """Build a human-friendly WOR list title from sidecar data (DVI-393, DVI-394).

    Format: "WO #NNNNN - <description> - <requestor>" where description is the
    machine + asset number for mobile-machine WORs, or the abbreviated request
    text otherwise. The WOR type is rendered separately as a list tag, so it is
    intentionally not repeated in the title. Falls back to the filename when no
    sidecar data is available.
    """
    if not data:
        return filename
    wo = _wor_number_label(filename, data)
    if wor_type == "mobilemachine":
        requestor = (data.get("_notify") or {}).get("requestor_name") or ""
        desc = " ".join(s for s in (data.get("machine"), data.get("asset_number")) if s)
    else:
        requestor = (data.get("requestor_name")
                     or (data.get("_notify") or {}).get("requestor_name") or "")
        desc = _wor_abbrev(data.get("description") or "")
    segments = [wo] + ([desc] if desc else []) + ([requestor] if requestor else [])
    return " - ".join(segments)


def _extract_wor_data_from_pdf(pdf_path: Path) -> dict:
    """Best-effort extraction of form data from a WOR PDF (fallback when no sidecar)."""
    from pypdf import PdfReader
    reader = PdfReader(str(pdf_path))
    text = reader.pages[0].extract_text() if reader.pages else ""
    lines = text.split("\n")
    data: dict = {}

    for i, line in enumerate(lines):
        s = line.strip()

        if s.startswith("Requestor name"):
            if i + 1 < len(lines):
                val = lines[i + 1].strip()
                email_match = re.search(r'[\w.\-+]+@[\w.\-]+\.\w+', val)
                if email_match:
                    data["email"] = email_match.group(0)
                    val = val[:email_match.start()].strip()
                phone_match = re.search(r'(\d[\d\-\(\)\s\.]{6,}\d)', val)
                if phone_match:
                    data["phone"] = phone_match.group(1).strip()
                    data["requestor_name"] = val[:phone_match.start()].strip()
                else:
                    data["requestor_name"] = val

        elif s.startswith("Date of request"):
            if i + 1 < len(lines):
                val = lines[i + 1].strip()
                date_match = re.match(r'(\d{4}-\d{2}-\d{2})', val)
                if date_match:
                    data["date_of_request"] = date_match.group(1)
                    data["location"] = val[date_match.end():].strip()
                else:
                    data["date_of_request"] = val

        elif s.startswith("Building"):
            if i + 1 < len(lines):
                data["building"] = lines[i + 1].strip()

        elif s == "Request description":
            desc = []
            for j in range(i + 1, len(lines)):
                if lines[j].strip().startswith("Type of issue"):
                    break
                desc.append(lines[j])
            data["description"] = "\n".join(desc).strip()

        elif s.startswith("Type of issue"):
            types = []
            all_types = [
                "Automotive", "Grounds", "Plumbing", "Custodial", "Cranes",
                "Concrete Forms", "Telehandler/Forklift", "Machine",
                "Electrical", "Tool Repair", "Safety", "Other",
            ]
            for j in range(i + 1, min(i + 8, len(lines))):
                cl = lines[j]
                if cl.strip().startswith("Best times") or cl.strip().startswith("Urgency"):
                    break
                for t in all_types:
                    if "✔" in cl and t in cl:
                        idx_check = cl.find("✔")
                        idx_type = cl.find(t)
                        if idx_check < idx_type and (idx_type - idx_check) < 15:
                            types.append(t)
            # Simpler fallback: just look for ✔ directly before each type name
            if not types:
                block = "\n".join(lines[i + 1: i + 8])
                for t in all_types:
                    if re.search(r"[✔4]\s*" + re.escape(t), block):
                        types.append(t)
            data["issue_type"] = types

        elif s == "Best times for service":
            if i + 1 < len(lines):
                data["best_times"] = lines[i + 1].strip()

        elif s.startswith("Desired completion date"):
            if i + 1 < len(lines):
                data["desired_completion_date"] = lines[i + 1].strip()

        elif s.startswith("Urgency"):
            for j in range(i + 1, min(i + 5, len(lines))):
                if "✔" in lines[j] or "4" == lines[j].strip()[:1]:
                    urg = lines[j].replace("✔", "").strip()
                    if urg in ("Low", "Medium", "High"):
                        data["urgency"] = urg
                    break

    return data


def _safe_job_folder(job_id: str) -> Path:
    """Return resolved Path for job_id; abort(400) if unsafe."""
    if not _SAFE_JOB_RE.match(job_id) or ".." in job_id:
        abort(400)
    folder = (WATCH_DIR / job_id).resolve()
    if not str(folder).startswith(str(WATCH_DIR.resolve())):
        abort(400)
    return folder


def _find_generated(job_folder: Path) -> Path | None:
    if not job_folder.is_dir():
        return None
    for f in job_folder.iterdir():
        if f.is_file() and "generated" in f.name.lower() and f.suffix.lower() == ".pdf":
            return f
    return None


def _state_path(folder: Path) -> Path:
    return folder / "state.json"


def _load_state(folder: Path) -> dict:
    p = _state_path(folder)
    return json.loads(p.read_text()) if p.exists() else {}


def _save_state(folder: Path, state: dict) -> None:
    _state_path(folder).write_text(json.dumps(state, indent=2))


def _match_files_to_items(
    folder: Path,
    items: list[str],
    item_refs: list[str],
    item_cols_a: list[str],
) -> list[dict]:
    """Scan *folder* recursively for PDFs and match them to items by identifier presence + suffix.

    Identifiers: col A (item_cols_a[i]), col H (item_refs[i]), col C (items[i]).
    All non-empty identifiers must appear in the filename stem (case-insensitive).
    Suffix determines file type:
      stem ends with 'shop'      → shop drawing
      stem ends with 'submittal' → submittal document
      stem ends with 'carrierqr' → QR carrier sheet

    Submittal fallback: if no submittal matches all identifiers (common when the submittal is
    a shared document not named per item), retry with only col A as the required identifier.

    Paths stored are relative to *folder* so they work regardless of subdirectory depth.
    Returns a list[dict] parallel to *items*; each dict maps 'shop'/'submittal'/'carrier'
    to the relative path string from *folder* (key absent if no match found).
    """
    # rglob so files inside subdirectories (e.g. zip-extracted sub-folder) are found
    pdf_files = [f for f in folder.rglob("*.pdf")]

    auto_files: list[dict] = []
    for i, item_name in enumerate(items):
        col_a = item_cols_a[i] if i < len(item_cols_a) else ""
        col_h = item_refs[i] if i < len(item_refs) else ""
        col_c = item_name

        # All non-empty identifiers must appear in the stem (case-insensitive)
        identifiers = [s.strip().lower() for s in [col_a, col_h, col_c] if s.strip()]
        col_a_only  = [col_a.strip().lower()] if col_a.strip() else []

        matched: dict = {}
        for pdf in pdf_files:
            stem_lower = pdf.stem.lower()
            rel = str(pdf.relative_to(folder))
            if not all(ident in stem_lower for ident in identifiers):
                continue
            if stem_lower.endswith("shop"):
                matched.setdefault("shop", rel)
            elif stem_lower.endswith("submittal"):
                matched.setdefault("submittal", rel)
            elif stem_lower.endswith("carrierqr"):
                matched.setdefault("carrier", rel)

        # Submittal fallback: shared submittal files often omit item-specific identifiers.
        # Only apply when a shop drawing was also matched — no shop means no submittal.
        if "submittal" not in matched and "shop" in matched and col_a_only:
            for pdf in pdf_files:
                stem_lower = pdf.stem.lower()
                if stem_lower.endswith("submittal") and all(ident in stem_lower for ident in col_a_only):
                    matched.setdefault("submittal", str(pdf.relative_to(folder)))
                    break

        auto_files.append(matched)

    return auto_files


# ---------------------------------------------------------------------------
# Legacy single-item background merge (unchanged from original)
# ---------------------------------------------------------------------------

def _run_merge_single(job_folder: Path) -> None:
    """Classify uploaded PDFs by filename and run the QR merge (legacy route)."""
    if build_carrier_pdf is None:
        return

    carrier = submittal = shop = None
    for f in sorted(job_folder.iterdir()):
        if not f.is_file() or f.suffix.lower() != ".pdf":
            continue
        name = f.name.lower()
        if "generated" in name:
            continue
        if "carrier" in name and carrier is None:
            carrier = f
        elif "submittal" in name and submittal is None:
            submittal = f
        elif "shop" in name and shop is None:
            shop = f

    if not (carrier and submittal and shop):
        return

    folder_name = job_folder.name
    _qr_base = _get_qr_base_url()
    folder_url = f"{_qr_base.rstrip('/')}/{quote(folder_name)}"
    submittal_url = f"{folder_url}/{quote(submittal.name)}"
    shop_url = f"{folder_url}/{quote(shop.name)}"

    blank_bytes = carrier.read_bytes()
    result_bytes = build_carrier_pdf(blank_bytes, submittal_url, shop_url)

    output_path = job_folder / f"{folder_name} CarrierQR generated.pdf"
    output_path.write_bytes(result_bytes)


# ---------------------------------------------------------------------------
# HTML (multi-item CSV workflow at top; legacy single-item below)
# ---------------------------------------------------------------------------

_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Togen — Pull it Forward</title>
<meta name="description" content="Togen — bridging material quantity estimation to production.">
<meta property="og:title" content="Togen — Pull it Forward">
<meta property="og:description" content="Togen — bridging material quantity estimation to production.">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 130 150'><rect x='10' y='40' width='14' height='70' rx='2' fill='%234A5568'/><rect x='106' y='40' width='14' height='70' rx='2' fill='%234A5568'/><path d='M 10 45 Q 65 5 120 45' stroke='%2300B4D8' stroke-width='10' fill='none' stroke-linecap='round'/><rect x='8' y='73' width='114' height='10' rx='2' fill='%2300B4D8'/><polygon points='20,90 35,90 42,107 35,124 20,124 27,107' fill='%2300B4D8' opacity='0.5'/><polygon points='50,90 65,90 72,107 65,124 50,124 57,107' fill='%2300B4D8' opacity='0.7'/><polygon points='80,90 95,90 102,107 95,124 80,124 87,107' fill='%2300B4D8' opacity='0.9'/></svg>">
<script>
  // Apply saved theme before first render to avoid flash
  (function(){
    var t = localStorage.getItem('theme');
    if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.classList.add('dark');
    }
  })();
</script>
<script src="https://cdn.tailwindcss.com"></script>
<script>tailwind.config = {
  darkMode: 'class',
  theme: {
    extend: {
      colors: {
        togen: {
          teal: '#00B4D8',
          'teal-dark': '#0098B7',
          slate: '#4A5568',
          dark: '#1A202C',
        }
      }
    }
  }
}</script>
<style>
  /* Tile/pane transparency (DVI-364). Default fully opaque; JS lowers the
     alpha only when the corporate-background gate passes. */
  :root { --togen-pane-alpha: 1; }
  .togen-pane { background-color: rgba(255, 255, 255, var(--togen-pane-alpha)) !important; }
  .dark .togen-pane { background-color: rgba(17, 24, 39, var(--togen-pane-alpha)) !important; }
  /* Print system disabled for non-admins (DVI-764): hide all Print buttons.
     A body class drives this so dynamically-rendered buttons are covered too. */
  body.togen-no-print .togen-print-btn { display: none !important; }
</style>
</head>
<body class="bg-gray-50 dark:bg-gray-950 min-h-screen font-sans">

<!-- ===== TOP NAVBAR ===== -->
<nav class="fixed top-0 left-0 right-0 z-50 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 h-14 flex items-center px-4">

  <!-- LEFT: Start menu -->
  <div class="flex items-center gap-2 flex-1 min-w-0">
    <div class="relative">
      <button id="start-menu-btn" onclick="toggleStartMenu(event)" title="Start"
              class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-semibold text-white bg-togen-teal hover:bg-togen-teal-dark transition-colors select-none">
        <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
        </svg>
        Start
      </button>
      <div id="start-menu"
           class="hidden absolute left-0 mt-2 w-52 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-visible z-50">
        {% if is_takeoff_workflow or is_togen_admin %}
        <button onclick="startMenuNew()"
                class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
          </svg>
          New
        </button>
        <div class="relative group">
          <button id="start-shared-btn" onclick="toggleStartSharedSubmenu(event)"
                  class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 border-t border-gray-100 dark:border-gray-700 justify-between">
            <span class="flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                <path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
              </svg>
              Projects
            </span>
            <svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
              <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
            </svg>
          </button>
          <div id="start-shared-submenu"
               class="hidden absolute left-full top-0 ml-1 w-64 max-h-80 overflow-y-auto bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 z-50">
            <div class="px-3 py-2 border-b border-gray-100 dark:border-gray-700">
              <input id="start-shared-search" type="text" placeholder="Search folders..."
                     oninput="filterStartSharedFolders(this.value)"
                     class="w-full text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-1 focus:ring-togen-teal"/>
            </div>
            <div id="start-shared-list">
              <p class="text-xs text-gray-400 dark:text-gray-500 px-4 py-3">Loading...</p>
            </div>
          </div>
        </div>
        <button onclick="startMenuSingleCarrierSheet()"
                class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 border-t border-gray-100 dark:border-gray-700">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
            <polyline points="14 2 14 8 20 8" stroke-linecap="round" stroke-linejoin="round"/>
          </svg>
          Single Carrier Sheet
        </button>
        {% endif %}
        {% if is_togen_mts or is_togen_admin %}
        <button onclick="startMenuMtsLabel()"
                class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 border-t border-gray-100 dark:border-gray-700">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <rect x="3" y="3" width="18" height="18" rx="2" stroke-linecap="round" stroke-linejoin="round"/>
            <line x1="7" y1="8" x2="17" y2="8" stroke-linecap="round"/>
            <line x1="7" y1="12" x2="17" y2="12" stroke-linecap="round"/>
            <line x1="7" y1="16" x2="13" y2="16" stroke-linecap="round"/>
          </svg>
          MTS Label
        </button>
        {% endif %}
        <button onclick="startMenuWorkOrder()"
                class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 border-t border-gray-100 dark:border-gray-700">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
          </svg>
          Work Order
        </button>
        {% if is_prod_run or is_togen_admin %}
        <button onclick="startMenuProductionRun()"
                class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 border-t border-gray-100 dark:border-gray-700">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"/>
          </svg>
          Production Run
        </button>
        {% endif %}
      </div>
    </div>
    <!-- Search launcher (DVI-534) — sits immediately left of Assistant. Reuses the
         same reusable #topbar-field inline bar (desktop) / overlay (mobile) as the
         Assistant so the graphic design matches exactly. -->
    {% if show_toolbar_search %}
    <button id="search-btn" onclick="searchIconClick()" title="Search"
            class="relative p-2 rounded-lg text-gray-400 dark:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
      <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
        <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
      </svg>
    </button>
    {% endif %}
    <!-- Assistant launcher (DVI-531) — sits immediately left of Sync. Desktop
         expands the reusable #topbar-field inline bar; mobile opens an overlay. -->
    {% if show_toolbar_assistant %}
    <button id="assistant-btn" data-available="{{ 'true' if assistant_available else 'false' }}"
            onclick="assistantIconClick()" title="Assistant"
            class="relative p-2 rounded-lg text-gray-400 dark:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
      <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
        <path stroke-linecap="round" stroke-linejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
      </svg>
      <!-- Status indicator (DVI-629), mirrors the Sync icon dot: green=connected,
           red=not connected to MCP, yellow=a background prompt/workflow is running. -->
      <span id="assistant-status-dot" class="absolute top-1 right-1 h-2.5 w-2.5 rounded-full border-2 border-white dark:border-gray-900 {{ 'bg-green-500' if assistant_available else 'bg-red-500' }}"></span>
    </button>
    {% endif %}
    <!-- Sync status indicator (only for Take-off Workflow or Admin users) -->
    {% if is_takeoff_workflow or is_togen_admin %}
    <button id="sync-status-btn" onclick="openTool('sync-admin', 'Sync Admin')" title="Sync Status"
            class="relative p-2 rounded-lg text-gray-400 dark:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
      <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
        <path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
      </svg>
      <span id="sync-status-dot" class="absolute top-1 right-1 h-2.5 w-2.5 rounded-full border-2 border-white dark:border-gray-900 bg-gray-400"></span>
    </button>
    {% endif %}
  </div>

  <!-- CENTER: Togen Bridge Mark logo -->
  <a href="/" onclick="if(_activeTool){backToHome();return false;}" class="flex items-center select-none absolute left-1/2 transform -translate-x-1/2" aria-label="Togen home">
    {% if branding_settings.logo_mode == 'corporate' and branding_has_logo %}
    <img src="/togen-admin/branding/logo.svg" height="38" class="h-[38px] w-auto dark:brightness-0 dark:invert" alt="Brand logo">
    {% else %}
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="20 25 360 135" height="38" role="img" aria-label="Togen">
      <defs>
        <linearGradient id="navGrad" x1="0%" y1="0%" x2="100%" y2="100%">
          <stop offset="0%" style="stop-color:#00B4D8;stop-opacity:1"/>
          <stop offset="100%" style="stop-color:#0077A8;stop-opacity:1"/>
        </linearGradient>
      </defs>
      <rect x="30" y="80" width="14" height="70" rx="2" fill="#4A5568"/>
      <rect x="94" y="80" width="14" height="70" rx="2" fill="#4A5568"/>
      <path d="M 30 85 Q 67 30 108 85" stroke="url(#navGrad)" stroke-width="8" fill="none" stroke-linecap="round"/>
      <rect x="28" y="114" width="90" height="8" rx="2" fill="url(#navGrad)"/>
      <polygon points="45,130 55,130 60,140 55,150 45,150 50,140" fill="#00B4D8" opacity="0.5"/>
      <polygon points="63,130 73,130 78,140 73,150 63,150 68,140" fill="#00B4D8" opacity="0.7"/>
      <polygon points="81,130 91,130 96,140 91,150 81,150 86,140" fill="#00B4D8" opacity="0.9"/>
      <text x="132" y="112" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="700" font-size="62" class="fill-gray-900 dark:fill-white" letter-spacing="-1">togen</text>
      <circle cx="154" cy="70" r="5" fill="#00B4D8"/>
    </svg>
    {% endif %}
  </a>

  <!-- Reusable top-bar inline field (DVI-531). Generic shell — open/close/auto-grow
       driven by topbarFieldOpen()/topbarFieldClose(). Built so the future Search
       field (DVI-534) reuses the identical look by passing a different config.
       Desktop only; expands across the header center over the logo. -->
  <div id="topbar-field" class="hidden absolute left-1/2 top-1.5 -translate-x-1/2 z-40 w-[min(42rem,calc(100%-13rem))]">
    <div class="flex items-end gap-1.5 px-3 py-1.5 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700">
      <button id="topbar-field-icon-btn" type="button" onclick="topbarFieldIconClick()"
              class="pointer-events-none mb-1 p-0.5 flex-shrink-0 rounded-md text-togen-teal focus:outline-none transition-colors">
        <svg id="topbar-field-icon" class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
        </svg>
      </button>
      <textarea id="topbar-field-input" rows="1"
                oninput="topbarFieldInput()" onkeydown="topbarFieldKeydown(event)"
                class="flex-1 resize-none bg-transparent text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none leading-5 py-1 max-h-48 overflow-y-auto"></textarea>
      <button id="topbar-field-submit" onclick="topbarFieldSubmit()" title="Send"
              class="mb-0.5 p-1.5 rounded-lg text-white bg-togen-teal hover:bg-togen-teal-dark transition-colors flex-shrink-0">
        <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
          <path d="M3.105 2.289a.75.75 0 00-.826.95l1.414 4.926A1.5 1.5 0 005.135 9.25h6.115a.75.75 0 010 1.5H5.135a1.5 1.5 0 00-1.442 1.085l-1.414 4.926a.75.75 0 00.826.95 28.897 28.897 0 0015.293-7.155.75.75 0 000-1.115A28.897 28.897 0 003.105 2.289z"/>
        </svg>
      </button>
      <button onclick="topbarFieldClose()" title="Close"
              class="mb-0.5 p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors flex-shrink-0">
        <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
          <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
        </svg>
      </button>
    </div>
    <!-- Response/dropdown panel anchored under the bar -->
    <div id="topbar-field-panel" class="hidden mt-2 max-h-80 overflow-y-auto bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-3 text-sm text-gray-700 dark:text-gray-200"></div>
  </div>

  <!-- RIGHT: DateTime, Refresh, Help, Theme, Profile -->
  <div class="flex items-center gap-2 flex-1 justify-end min-w-0">
    <!-- Date/Time widget (desktop only) -->
    {% if show_toolbar_calendar %}
    <div class="relative hidden md:block">
      <button id="datetime-btn" onclick="toggleDatetimeDropdown(event)" title="Calendar"
              class="flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors tabular-nums select-none">
        <svg class="h-4 w-4 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
          <line x1="16" y1="2" x2="16" y2="6"/>
          <line x1="8" y1="2" x2="8" y2="6"/>
          <line x1="3" y1="10" x2="21" y2="10"/>
        </svg>
        <span id="datetime-display"></span>
      </button>
      <div id="datetime-dropdown"
           class="hidden absolute right-0 mt-2 w-72 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 z-50 p-4">
        <div class="flex items-center justify-between mb-3">
          <button onclick="calPrev(event)" class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400 transition-colors">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
              <path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
            </svg>
          </button>
          <span id="cal-month-label" class="text-sm font-semibold text-gray-800 dark:text-gray-200"></span>
          <button onclick="calNext(event)" class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400 transition-colors">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
              <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
            </svg>
          </button>
        </div>
        <div id="cal-grid" class="grid grid-cols-7 gap-0 text-center text-xs"></div>
        <button onclick="openCalendarTool(event)"
                class="mt-3 w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white text-sm font-semibold transition-colors">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <rect x="3" y="4" width="18" height="18" rx="2"/>
            <line x1="16" y1="2" x2="16" y2="6"/>
            <line x1="8" y1="2" x2="8" y2="6"/>
            <line x1="3" y1="10" x2="21" y2="10"/>
          </svg>
          Open Calendar
        </button>
      </div>
    </div>
    {% endif %}

    <!-- Refresh button -->
    <button onclick="location.reload()" title="Refresh"
            class="p-2 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
      <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
        <path stroke-linecap="round" stroke-linejoin="round" d="M3 12l9-9 9 9"/>
        <path stroke-linecap="round" stroke-linejoin="round" d="M5 12v7a1 1 0 001 1h4v-4h4v4h4a1 1 0 001-1v-7"/>
        <path stroke-linecap="round" stroke-linejoin="round" d="M17.5 6.5a3 3 0 11-1.4 2.5" stroke-width="1.5"/>
        <path stroke-linecap="round" stroke-linejoin="round" d="M17.5 6.5l1.5-1.5m-1.5 1.5l1.5 1" stroke-width="1.5"/>
      </svg>
    </button>

    <!-- Help menu -->
    <div class="relative">
      <button id="help-btn" onclick="toggleHelpMenu(event)" title="Help"
              class="p-2 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
        <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"/>
          <circle cx="12" cy="12" r="10" fill="none"/>
        </svg>
      </button>
      <div id="help-menu"
           class="hidden absolute right-0 mt-2 w-44 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
        <button onclick="openNewsPage()"
                class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"/>
          </svg>
          News
        </button>
        <button onclick="openTutorialsPane()"
                class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 border-t border-gray-100 dark:border-gray-700">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
            <path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
          </svg>
          Tutorials
        </button>
        <button onclick="openKeyboardShortcuts()"
                class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 border-t border-gray-100 dark:border-gray-700">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
          </svg>
          Keyboard Shortcuts
          <span class="ml-auto text-xs text-gray-400 dark:text-gray-500">?</span>
        </button>
        <button onclick="openSupportRequest()"
                class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 border-t border-gray-100 dark:border-gray-700">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z"/>
          </svg>
          Support Request
        </button>
        <button onclick="openFeatureRequest()"
                class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 border-t border-gray-100 dark:border-gray-700">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
          </svg>
          Feature Request
        </button>
        <button onclick="openAbout()"
                class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 border-t border-gray-100 dark:border-gray-700">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
          </svg>
          About
        </button>
      </div>
    </div>

    <!-- Light/Dark mode toggle -->
    <button id="theme-toggle" onclick="toggleTheme()" title="Toggle light/dark mode"
            class="p-2 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
      <!-- Sun icon — shown in dark mode -->
      <svg id="icon-sun" class="hidden h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
        <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707M17.657 17.657l-.707-.707M6.343 6.343l-.707-.707M12 8a4 4 0 100 8 4 4 0 000-8z"/>
      </svg>
      <!-- Moon icon — shown in light mode -->
      <svg id="icon-moon" class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
        <path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
      </svg>
    </button>

    <!-- User profile icon + dropdown -->
    <div class="relative">
      <button id="profile-btn" onclick="toggleProfileMenu()" title="User menu"
              class="p-1.5 rounded-full bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors">
        <svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
          <path fill-rule="evenodd" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" clip-rule="evenodd"/>
        </svg>
      </button>
      <div id="profile-menu"
           class="hidden absolute right-0 mt-2 w-44 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
        <div class="px-4 py-3 border-b border-gray-100 dark:border-gray-700">
          <p class="text-xs text-gray-500 dark:text-gray-400">Signed in as</p>
          <p class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">{{ user_name }}</p>
        </div>
        <button onclick="openUserSettings()"
                class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
            <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
          </svg>
          User Settings
        </button>
        {% if is_togen_admin %}
        <button onclick="openTool('togen-admin','Togen Admin');toggleProfileMenu();"
                class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75"/>
          </svg>
          Togen Admin
        </button>
        {% endif %}
        <button onclick="handleLogout()"
                class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 border-t border-gray-100 dark:border-gray-700">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
          </svg>
          Logout
        </button>
      </div>
    </div>
  </div>
</nav>

<!-- ===== ASSISTANT MOBILE OVERLAY (DVI-531) — < md prompt dialog ===== -->
<div id="assistant-mobile-overlay" class="hidden fixed inset-0 z-[60] flex items-end sm:items-center justify-center bg-black/40 backdrop-blur-sm" onclick="assistantMobileClickAway(event)">
  <div class="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 mb-4 sm:mb-0" onclick="event.stopPropagation()">
    <div class="flex items-center justify-between bg-togen-teal rounded-t-2xl px-6 py-3">
      <h2 class="text-sm font-bold text-white uppercase tracking-wide">Assistant</h2>
      <button onclick="assistantCloseMobile()" class="text-white/80 hover:text-white transition-colors cursor-pointer">
        <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
          <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
        </svg>
      </button>
    </div>
    <div class="px-6 py-5 space-y-3">
      <!-- Unconfigured / disabled state -->
      <div id="assistant-mobile-unconfigured" class="hidden space-y-3">
        <p class="text-sm text-gray-600 dark:text-gray-300">The Assistant isn't connected yet. Add your API key on the User Settings → Assistant tab to start using it.</p>
        <button onclick="assistantOpenSettings()"
                class="px-4 py-2 text-sm bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-lg transition-colors">Open User Settings</button>
      </div>
      <!-- Active prompt body -->
      <div id="assistant-mobile-body" class="space-y-3">
        <div id="assistant-mobile-panel" class="hidden max-h-60 overflow-y-auto text-sm text-gray-700 dark:text-gray-200 p-3 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700"></div>
        <textarea id="assistant-mobile-input" rows="2" placeholder="Ask the Assistant…"
                  oninput="assistantMobileAutoGrow()"
                  onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();assistantMobileSend();}"
                  class="w-full resize-none px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal max-h-60 overflow-y-auto"></textarea>
        <div class="flex justify-end">
          <button onclick="assistantMobileSend()"
                  class="px-4 py-2 text-sm bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-lg transition-colors">Send</button>
        </div>
      </div>
    </div>
  </div>
</div>

<!-- ===== SEARCH MOBILE OVERLAY (DVI-534) — mobile twin of the inline field ===== -->
<div id="search-mobile-overlay" class="hidden fixed inset-0 z-[60] flex items-end sm:items-center justify-center bg-black/40 backdrop-blur-sm" onclick="searchMobileClickAway(event)">
  <div class="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 mb-4 sm:mb-0" onclick="event.stopPropagation()">
    <div class="flex items-center justify-between bg-togen-teal rounded-t-2xl px-6 py-3">
      <h2 class="text-sm font-bold text-white uppercase tracking-wide">Search</h2>
      <button onclick="searchCloseMobile()" class="text-white/80 hover:text-white transition-colors cursor-pointer">
        <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
          <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
        </svg>
      </button>
    </div>
    <div class="px-6 py-5 space-y-3">
      <div class="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800">
        <svg class="h-4 w-4 flex-shrink-0 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
        </svg>
        <input id="search-mobile-input" type="text" placeholder="Search projects & tools…"
               oninput="searchMobileInput()"
               onkeydown="if(event.key==='Enter'){event.preventDefault();searchMobileInput();}"
               class="flex-1 bg-transparent text-gray-900 dark:text-gray-100 text-sm focus:outline-none">
      </div>
      <div id="search-mobile-panel" class="hidden max-h-60 overflow-y-auto text-sm text-gray-700 dark:text-gray-200 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700"></div>
    </div>
  </div>
</div>

<!-- ===== TUTORIALS PANE — pushes main content down ===== -->
<div id="tutorials-pane" class="hidden bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 shadow-lg" style="margin-top: 56px;">
  <div class="max-w-5xl mx-auto px-4 py-6">
    <div class="flex items-center justify-between mb-4">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Tutorials</h2>
      <div class="flex items-center gap-1">
        <button onclick="openTutorialsPage()" class="p-1.5 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" title="Open fullscreen">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5"/>
          </svg>
        </button>
        <button onclick="closeTutorialsPane()" class="p-1.5 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" title="Close tutorials">
          <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
          </svg>
        </button>
      </div>
    </div>
    <div id="tutorials-grid" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
    </div>
    <p id="tutorials-empty" class="hidden text-sm text-gray-500 dark:text-gray-400 text-center py-6">No tutorials available yet. Check back soon!</p>
  </div>
</div>

<div id="main-content" class="max-w-screen-2xl mx-auto pt-24 pb-10 px-4">

  <!-- ===== HOME VIEW — tile grid ===== -->
  <div id="home-view">
    <div class="mb-6 flex items-end justify-between gap-3">
      <div class="flex items-baseline gap-3 flex-wrap">
        <h1 class="site-title-text text-3xl font-light text-togen-dark dark:text-white" style="font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;letter-spacing:0.05em;">{{ site_title|e }}</h1>
        <span class="site-tagline-text text-sm font-light text-togen-teal tracking-widest uppercase" style="font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;">{{ site_tagline|e }}</span>
      </div>
      <!-- Grid/List view toggle (DVI-512) -->
      <button type="button" id="tile-view-toggle" onclick="toggleTileView()"
              class="flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer">
        <span id="tile-view-toggle-icon"></span>
        <span id="tile-view-toggle-label"></span>
      </button>
    </div>

    <div id="tile-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
      <!-- Tiles are rendered by JS from the TOOLS registry for ordering support -->
    </div>
  </div>

  <!-- ===== TOOL VIEW — shown when a tile is selected ===== -->
  <div id="tool-view" class="hidden">
    <div class="mb-4">
      <button onclick="backToHome()" class="inline-flex items-center gap-1.5 text-sm text-togen-teal hover:text-togen-teal-dark transition-colors mb-3">
        <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
          <path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd"/>
        </svg>
        Back to Tools
      </button>
      <div class="flex items-baseline gap-3 flex-wrap">
        <h1 class="site-title-text text-3xl font-light text-togen-dark dark:text-white" style="font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;letter-spacing:0.05em;">{{ site_title|e }}</h1>
        <span class="site-tagline-text text-sm font-light text-togen-teal tracking-widest uppercase" style="font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;">{{ site_tagline|e }}</span>
      </div>
    </div>

    <div id="tool-title-bar" class="mb-5 flex items-center justify-between">
      <p id="tool-active-title" class="text-xs font-semibold uppercase tracking-widest text-togen-slate dark:text-gray-500" style="font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;"></p>
      <div class="flex items-center gap-2">
      <button type="button" id="sp-upload-btn" onclick="spToggleUploadPane()" class="hidden px-4 py-1.5 bg-white dark:bg-gray-800 border border-togen-teal text-togen-teal hover:bg-togen-teal/10 font-semibold rounded-xl text-xs transition-colors cursor-pointer inline-flex items-center gap-1.5">
        <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
          <path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L11 6.414V13a1 1 0 11-2 0V6.414L7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3z" clip-rule="evenodd"/>
          <path d="M3 14a1 1 0 011 1v1a1 1 0 001 1h10a1 1 0 001-1v-1a1 1 0 112 0v1a3 3 0 01-3 3H5a3 3 0 01-3-3v-1a1 1 0 011-1z"/>
        </svg>
        Upload Files
      </button>
      <button type="button" id="sp-open-btn" onclick="spOpenDialog()" class="hidden px-4 py-1.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-xs transition-colors cursor-pointer inline-flex items-center gap-1.5">
        <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
          <path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd"/>
          <path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z"/>
        </svg>
        Open
      </button>
      </div>
    </div>

    <!-- ===== TAKE-OFF BRIDGE TOOL CONTENT ===== -->
    <div id="tool-takeoff-bridge" class="tool-content hidden">

  <!-- ===== FOLDER SELECTION (expanded by default) ===== -->
  <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
    <div class="flex items-center justify-between px-6 py-4 cursor-pointer select-none"
         onclick="toggleSection('shared')">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Shared Folder Selection</h2>
      <svg id="chevron-shared" class="h-5 w-5 text-gray-500 dark:text-gray-400 transition-transform duration-200 rotate-180"
           xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
        <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
      </svg>
    </div>
    <div id="body-shared" class="px-6 pb-6">
      <p class="text-sm text-gray-500 dark:text-gray-400 mb-3">Select a published folder to load its CSV and files.</p>
      <input id="shared-search" type="text" placeholder="Filter folders…"
             oninput="filterSharedFolders(this.value)"
             class="w-full mb-2 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-xl
                    bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                    focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
      <div class="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
        <div id="shared-folders-list" class="max-h-64 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
          <p class="text-sm text-gray-400 dark:text-gray-500 px-4 py-3">Loading folders…</p>
        </div>
      </div>
      <div class="mt-3 flex items-center justify-between">
        <span id="folder-select-lbl" class="block text-sm text-gray-500 dark:text-gray-400"></span>
        <button id="shared-fb-btn" onclick="openSharedInFileBrowser()" class="hidden px-4 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer whitespace-nowrap">
          File Browser
        </button>
      </div>
    </div>
  </section>

  <!-- ===== ZIP UPLOAD (collapsed by default) ===== -->
  <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
    <div class="flex items-center justify-between px-6 py-4 cursor-pointer select-none"
         onclick="toggleSection('zip')">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Zip File Upload</h2>
      <svg id="chevron-zip" class="h-5 w-5 text-gray-500 dark:text-gray-400 transition-transform duration-200"
           xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
        <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
      </svg>
    </div>
    <div id="body-zip" class="hidden px-6 pb-6">
      <input type="file" id="zip-input" accept=".zip" class="hidden" onchange="onZipChosen(this)">
      <div id="zip-drop"
           onclick="document.getElementById('zip-input').click()"
           class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-8 text-center cursor-pointer
                  hover:border-togen-teal dark:hover:border-togen-teal hover:bg-togen-teal/5 dark:hover:bg-togen-teal/10 transition-colors">
        <svg class="mx-auto h-10 w-10 text-gray-400 dark:text-gray-500 mb-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
          <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
        </svg>
        <p class="text-sm font-medium text-gray-700 dark:text-gray-300">Drop a ZIP file here, or click to browse</p>
        <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ZIP must contain a CSV file and the PDF files for each item</p>
      </div>
      <span id="zip-lbl" class="mt-3 block text-sm text-gray-500 dark:text-gray-400"></span>
    </div>
  </section>

  <!-- ===== CSV UPLOAD (collapsed by default) ===== -->
  <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
    <div class="flex items-center justify-between px-6 py-4 cursor-pointer select-none"
         onclick="toggleSection('csv')">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Multi-Item — CSV Upload</h2>
      <svg id="chevron-csv" class="h-5 w-5 text-gray-500 dark:text-gray-400 transition-transform duration-200"
           xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
        <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
      </svg>
    </div>
    <div id="body-csv" class="hidden px-6 pb-6">
      <div class="mb-4">
        <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Choose CSV file</label>
        <input type="file" id="csv-input" accept=".csv" class="hidden" onchange="onCsvChosen(this)">
        <button id="csv-btn" onclick="document.getElementById('csv-input').click()"
                class="px-4 py-2 border-2 border-gray-400 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded-lg text-sm hover:border-gray-600 dark:hover:border-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors">
          Choose CSV…
        </button>
        <span id="csv-lbl" class="ml-3 text-sm text-gray-500 dark:text-gray-400"></span>
      </div>
    </div>
  </section>

  <!-- ===== ITEMS (shared — shown after zip or csv upload) ===== -->
  <div id="items-wrap" class="togen-pane hidden bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-4">
    <h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Upload files per item</h3>
    <div id="items-container" class="space-y-3"></div>
    <div class="mt-5 flex flex-wrap items-center gap-3">
      <button id="submit-btn" onclick="submitAll()"
              class="px-6 py-3 bg-togen-teal text-white font-semibold rounded-xl text-sm
                     disabled:opacity-40 disabled:cursor-not-allowed hover:enabled:bg-togen-teal-dark transition-colors">
        Submit All
      </button>
      <a id="dl-csv" href="#" class="hidden px-5 py-3 bg-purple-600 text-white font-semibold
                                      rounded-xl text-sm hover:bg-purple-700 transition-colors">
        Download Updated CSV
      </a>
      <button id="dl-zip-btn" onclick="downloadJobZip()"
              class="hidden px-5 py-3 bg-green-600 text-white font-semibold rounded-xl text-sm hover:bg-green-700 transition-colors">
        Download ZIP
      </button>
      <button id="publish-btn" onclick="publishJob()"
              class="hidden px-5 py-3 bg-orange-500 text-white font-semibold rounded-xl text-sm hover:bg-orange-600 transition-colors">
        Publish
      </button>
      <button id="publish-dup-btn" onclick="openDuplicateModal()"
              class="hidden px-5 py-3 bg-orange-500 text-white font-semibold rounded-xl text-sm hover:bg-orange-600 transition-colors">
        Publish Duplicate
      </button>
      <label id="create-all-wrap" class="hidden flex items-center gap-2 cursor-pointer select-none ml-auto">
        <input type="checkbox" id="create-all-cb" checked
               class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal
                      focus:ring-togen-teal dark:bg-gray-700"
               onchange="toggleCreateAll(this.checked)">
        <span class="text-sm font-medium text-togen-teal">Create all missing carrier sheets</span>
      </label>
    </div>
    <p id="publish-msg" class="hidden mt-2 text-sm font-medium"></p>
  </div>

    <!-- Publish Duplicate Popup Modal -->
    <div id="dup-modal-overlay" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onclick="dupModalClickAway(event)">
      <div class="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4" onclick="event.stopPropagation()">
        <div class="flex items-center justify-between bg-togen-teal rounded-t-2xl px-6 py-3">
          <h2 class="text-sm font-bold text-white uppercase tracking-wide">Publish Duplicate</h2>
          <button onclick="closeDuplicateModal()" class="text-white/80 hover:text-white transition-colors cursor-pointer">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
            </svg>
          </button>
        </div>
        <div class="px-6 py-5 space-y-4">
          <p class="text-xs text-gray-500 dark:text-gray-400">Copy this job — all documents, CSV, metadata, and changes — into a new folder. The new folder is created as-is; no further changes are applied.</p>
          <div>
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">New Folder Name</label>
            <input type="text" id="dup-folder-name" placeholder="Enter folder name…"
                   onkeydown="if(event.key==='Enter')submitDuplicate()"
                   class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
          </div>
          <p id="dup-modal-status" class="hidden text-xs font-medium"></p>
          <div class="flex justify-end">
            <button id="dup-confirm-btn" onclick="submitDuplicate()"
                    class="px-4 py-2 text-sm bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-lg transition-colors">
              Duplicate to New Folder
            </button>
          </div>
        </div>
      </div>
    </div>


    <!-- Project Metadata Popup Modal -->
    <div id="meta-modal-overlay" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onclick="metaModalClickAway(event)">
      <div id="meta-modal-box" class="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 flex overflow-hidden max-h-[90vh]" onclick="event.stopPropagation()">
        <div id="meta-modal-track" class="flex flex-shrink-0 w-full" style="transition: transform .25s ease;">

        <!-- ===== PROJECT INFO (FORM) PANEL ===== -->
        <div id="meta-panel-form" class="flex flex-col flex-shrink-0 w-full min-w-0">
        <div class="flex items-center justify-between bg-togen-teal px-6 py-3">
          <h2 id="meta-modal-title" class="text-sm font-bold text-white uppercase tracking-wide">Project Information</h2>
          <button onclick="closeMetaModal()" class="text-white/80 hover:text-white transition-colors cursor-pointer">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
            </svg>
          </button>
        </div>
        <div class="px-6 py-5 space-y-4 flex-1 overflow-y-auto">
          <p id="meta-modal-desc" class="text-xs text-gray-500 dark:text-gray-400">No project metadata found for this folder. Fill in the details below or close to skip.</p>
          <div class="flex items-center gap-2 flex-wrap">
            <button id="meta-ocr-btn" type="button" onclick="metaModalOcrDiscovery()"
                    class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/>
              </svg>
              OCR Discovery
            </button>
            <span id="meta-ocr-hint" class="text-xs text-gray-400 dark:text-gray-500">Read the project's documents to auto-fill empty fields.</span>
          </div>
          <div>
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Customer/Contractor Name</label>
            <input type="text" id="meta-customer-name" placeholder="Enter customer name…"
                   class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
          </div>
          <div>
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Project Name</label>
            <input type="text" id="meta-project-name" placeholder="Enter project name…"
                   class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
          </div>
          <div>
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Vendor Project Code <span class="text-gray-400 dark:text-gray-500">(optional)</span></label>
            <input type="text" id="meta-vendor-code" placeholder="Optional vendor code…"
                   class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
          </div>
          <div class="grid grid-cols-2 gap-3">
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Job Number <span class="text-gray-400 dark:text-gray-500">(optional)</span></label>
              <input type="text" id="meta-job-number" placeholder="e.g. 26-1132"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Production Date <span class="text-gray-400 dark:text-gray-500">(optional)</span></label>
              <input type="date" id="meta-production-date"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
          </div>
          <div class="grid grid-cols-2 gap-3">
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Spec Type <span class="text-gray-400 dark:text-gray-500">(optional)</span></label>
              <input type="text" id="meta-spec-type" placeholder="Spec type…"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Station <span class="text-gray-400 dark:text-gray-500">(optional)</span></label>
              <input type="text" id="meta-station" placeholder="Station…"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
          </div>
          <label id="meta-apply-wrap" class="hidden flex items-center gap-2 cursor-pointer select-none pt-1">
            <input type="checkbox" id="meta-apply-all" checked
                   class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal
                          focus:ring-togen-teal dark:bg-gray-700">
            <span class="text-sm font-medium text-gray-700 dark:text-gray-300">Apply to all items</span>
          </label>
          <p id="meta-modal-status" class="hidden text-xs font-medium"></p>
        </div>
        <div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
          <button id="meta-view-files-btn" type="button" onclick="metaOpenFiles()"
                  class="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-semibold rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors cursor-pointer">
            View Project Files
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
              <path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
            </svg>
          </button>
          <div class="flex items-center gap-3">
            <button onclick="closeMetaModal()" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors cursor-pointer">Cancel</button>
            <button id="meta-save-btn" onclick="submitMetaModal()" class="px-5 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-lg text-sm transition-colors cursor-pointer">Save</button>
          </div>
        </div>
        </div><!-- /meta-panel-form -->

        <!-- ===== FILE LIST PANEL ===== -->
        <div id="meta-panel-files" class="hidden flex-col flex-shrink-0 w-full min-w-0 border-l border-gray-200 dark:border-gray-700">
          <div class="flex items-center gap-2 bg-gray-100 dark:bg-gray-900 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
            <button id="meta-files-back" type="button" onclick="metaFilesBack()"
                    class="hidden text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 transition-colors cursor-pointer">
              <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                <path stroke-linecap="round" stroke-linejoin="round" d="M11 17l-5-5m0 0l5-5m-5 5h12"/>
              </svg>
            </button>
            <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200">Project Files</h3>
          </div>
          <div id="meta-files-list" class="flex-1 overflow-y-auto px-2 py-2 text-sm"></div>
        </div><!-- /meta-panel-files -->

        <!-- ===== PREVIEW PANEL ===== -->
        <div id="meta-panel-preview" class="hidden flex-col flex-shrink-0 w-full min-w-0 border-l border-gray-200 dark:border-gray-700">
          <div class="flex items-center gap-2 bg-gray-100 dark:bg-gray-900 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
            <button id="meta-preview-back" type="button" onclick="metaPreviewBack()"
                    class="hidden text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 transition-colors cursor-pointer">
              <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                <path stroke-linecap="round" stroke-linejoin="round" d="M11 17l-5-5m0 0l5-5m-5 5h12"/>
              </svg>
            </button>
            <h3 id="meta-preview-name" class="text-sm font-semibold text-gray-700 dark:text-gray-200 truncate flex-1">Preview</h3>
            <button id="meta-preview-ocr" type="button" onclick="metaPreviewOcr()"
                    class="hidden inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-semibold rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors cursor-pointer flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/>
              </svg>
              OCR this Document
            </button>
            <button id="meta-preview-region" type="button" onclick="_metaArmRegion('all')"
                    class="hidden inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-semibold rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors cursor-pointer flex-shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
                    title="Draw a box on the page to OCR just that area">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                <path stroke-linecap="round" stroke-linejoin="round" d="M4 8V6a2 2 0 012-2h2M4 16v2a2 2 0 002 2h2m8-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2"/>
              </svg>
              OCR Region
            </button>
            <button id="meta-preview-open" type="button" onclick="metaPreviewOpen()"
                    class="hidden inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-semibold rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors cursor-pointer flex-shrink-0">
              Open
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                <path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
              </svg>
            </button>
          </div>
          <p id="meta-preview-status" class="hidden px-4 py-2 text-xs font-medium border-b border-gray-200 dark:border-gray-700"></p>
          <div id="meta-preview-body" class="flex-1 overflow-auto bg-gray-50 dark:bg-gray-900"></div>
        </div><!-- /meta-panel-preview -->

        </div><!-- /meta-modal-track -->
      </div>
    </div>

    </div><!-- /tool-takeoff-bridge -->

    <!-- ===== SINGLE CARRIER SHEET TOOL CONTENT ===== -->
    <div id="tool-single-carrier-sheet" class="tool-content hidden">
  <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
    <div class="px-6 py-5">
    <div class="flex items-start justify-between mb-1">
      <div>
        <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Single Carrier Sheet</h2>
        <p class="text-sm text-gray-500 dark:text-gray-400 mb-5">Generate or Reprint an existing Carrier Sheet from Shop and Submittal drawings, or from a Project.</p>
      </div>
      <button onclick="reprintOpenDialog()"
              class="inline-flex items-center gap-1.5 px-4 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700
                     text-gray-700 dark:text-gray-300 font-medium rounded-xl text-sm transition-colors flex-shrink-0">
        <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"/>
        </svg>
        Reprint
      </button>
    </div>

    <!-- Preview -->
    <div id="preview-placeholder"
         class="flex flex-col items-center justify-center h-40 bg-gray-50 dark:bg-gray-800 rounded-xl
                border-2 border-dashed border-gray-200 dark:border-gray-700 mb-5">
      <p class="text-gray-400 dark:text-gray-500 text-sm">Preview appears here after generation</p>
    </div>
    <div id="preview-container" class="hidden mb-5">
      <embed id="preview-embed" type="application/pdf"
             class="w-full rounded-xl border border-gray-200 dark:border-gray-700" style="height:480px;" />
      <div class="mt-4 flex flex-wrap gap-3">
        <a id="download-btn" href="#"
           class="inline-flex items-center gap-2 px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark
                  text-white font-medium rounded-xl text-sm transition-colors">
          Download PDF
        </a>
        <button onclick="scsOpenViewer()"
                class="px-5 py-2.5 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"/>
            <path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"/>
          </svg>
          Open
        </button>
        <button onclick="scsPrint()"
                class="togen-print-btn px-5 py-2.5 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M5 4v3H4a2 2 0 00-2 2v3a2 2 0 002 2h1v2a1 1 0 001 1h8a1 1 0 001-1v-2h1a2 2 0 002-2V9a2 2 0 00-2-2h-1V4a1 1 0 00-1-1H6a1 1 0 00-1 1zm8 0H7v3h6V4zm0 8H7v4h6v-4z" clip-rule="evenodd"/>
          </svg>
          Print
        </button>
        <div class="relative">
          <button onclick="scsToggleWorkflowMenu(event)"
                  class="px-5 py-2.5 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-2">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"/>
            </svg>
            Workflow
          </button>
          <div id="scs-workflow-menu"
               class="hidden absolute right-0 top-full mt-2 w-56 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
            <button onclick="scsOpenInQrCreator()"
                    class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path d="M3 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm1 0v3h3V4H4z"/>
                <path d="M12 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1V4zm1 0v3h3V4h-3z"/>
                <path d="M3 13a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm1 0v3h3v-3H4z"/>
                <path d="M13 13h1v1h-1v-1zm2 0h1v1h-1v-1zm-2 2h1v1h-1v-1zm2 2h1v1h-1v-1zm2-2h1v1h-1v-1z"/>
              </svg>
              Open in Simple QR Creator
            </button>
            <button onclick="scsOpenFileBrowser()"
                    class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/>
              </svg>
              Open in File Browser
            </button>
          </div>
        </div>
        <button onclick="resetSingleForm()"
                class="px-5 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700
                       text-gray-700 dark:text-gray-300 font-medium rounded-xl text-sm transition-colors">
          Upload Another
        </button>
      </div>
    </div>

    <form id="single-form" class="space-y-4">
      <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
        <div>
          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
            Shop Drawing <span class="text-red-500 dark:text-red-400">*</span>
          </label>
          <input type="file" name="shop" accept=".pdf" required
                 class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-xl py-2 px-3
                        bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                        file:mr-3 file:py-1 file:px-3 file:rounded-lg file:border-0
                        file:text-sm file:font-medium file:bg-togen-teal/10 file:text-togen-teal
                        dark:file:bg-togen-teal/20 dark:file:text-togen-teal
                        hover:file:bg-togen-teal/20 dark:hover:file:bg-togen-teal/30
                        cursor-pointer focus:outline-none focus:ring-2 focus:ring-togen-teal">
        </div>
        <div>
          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
            Submittal Drawing <span id="submittal-required-star" class="hidden text-red-500 dark:text-red-400">*</span>
            <span id="submittal-optional-hint" class="text-gray-400 dark:text-gray-500">(optional)</span>
          </label>
          <input type="file" id="submittal-file" name="submittal" accept=".pdf"
                 class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-xl py-2 px-3
                        bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                        file:mr-3 file:py-1 file:px-3 file:rounded-lg file:border-0
                        file:text-sm file:font-medium file:bg-togen-teal/10 file:text-togen-teal
                        dark:file:bg-togen-teal/20 dark:file:text-togen-teal
                        hover:file:bg-togen-teal/20 dark:hover:file:bg-togen-teal/30
                        cursor-pointer focus:outline-none focus:ring-2 focus:ring-togen-teal">
        </div>
        <div>
          <div class="flex items-center justify-between mb-1">
            <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
              QR Carrier Sheet
              <span id="carrier-required-star" class="hidden text-red-500 dark:text-red-400">*</span>
            </label>
            <label class="flex items-center gap-1.5 cursor-pointer select-none">
              <input type="checkbox" id="carrier-create-cb" checked
                     class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal
                            focus:ring-togen-teal dark:bg-gray-700"
                     onchange="onCarrierCreateChange(this)">
              <span class="text-sm font-medium text-togen-teal">Create</span>
            </label>
          </div>
          <input type="file" id="carrier-file" name="carrier" accept=".pdf"
                 class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-xl py-2 px-3
                        bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                        file:mr-3 file:py-1 file:px-3 file:rounded-lg file:border-0
                        file:text-sm file:font-medium file:bg-togen-teal/10 file:text-togen-teal
                        dark:file:bg-togen-teal/20 dark:file:text-togen-teal
                        hover:file:bg-togen-teal/20 dark:hover:file:bg-togen-teal/30
                        cursor-pointer focus:outline-none focus:ring-2 focus:ring-togen-teal"
                 onchange="onCarrierFileChange(this)">
          <p id="carrier-create-hint" class="mt-1 text-xs text-togen-teal">Create mode: carrier sheet will be generated from your input.</p>
        </div>
      </div>
      <button type="submit" id="single-submit-btn"
              class="px-6 py-3 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold
                     rounded-xl text-sm disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
        Generate Carrier PDF
      </button>
    </form>

    <div id="single-status" class="hidden mt-4 flex items-center gap-3 p-4 rounded-xl border">
      <svg id="s-spinner" class="hidden animate-spin h-5 w-5 text-togen-teal flex-shrink-0"
           xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
        <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
        <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
      </svg>
      <svg id="s-check" class="hidden h-5 w-5 text-green-500 flex-shrink-0"
           xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
        <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
      </svg>
      <svg id="s-x" class="hidden h-5 w-5 text-red-500 flex-shrink-0"
           xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
        <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
      </svg>
      <span id="s-msg" class="text-sm font-medium"></span>
    </div>
    </div>

    <!-- Reprint preview (read-only mode) -->
    <div id="reprint-container" class="hidden">
      <div class="flex items-center justify-between mb-3">
        <div>
          <span class="text-xs font-medium text-togen-teal bg-togen-teal/10 dark:bg-togen-teal/20 px-2 py-0.5 rounded-full">Reprint</span>
          <span id="reprint-job-name" class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300"></span>
        </div>
        <button onclick="reprintExit()"
                class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors">
          &larr; Back to Upload
        </button>
      </div>
      <embed id="reprint-embed" type="application/pdf"
             class="w-full rounded-xl border border-gray-200 dark:border-gray-700" style="height:480px;" />
      <div class="mt-4 flex flex-wrap gap-3">
        <a id="reprint-download-btn" href="#"
           class="inline-flex items-center gap-2 px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark
                  text-white font-medium rounded-xl text-sm transition-colors">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
          </svg>
          Download PDF
        </a>
        <button onclick="scsOpenViewer()"
                class="px-5 py-2.5 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"/>
            <path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"/>
          </svg>
          Open
        </button>
        <button onclick="scsPrint()"
                class="togen-print-btn px-5 py-2.5 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M5 4v3H4a2 2 0 00-2 2v3a2 2 0 002 2h1v2a1 1 0 001 1h8a1 1 0 001-1v-2h1a2 2 0 002-2V9a2 2 0 00-2-2h-1V4a1 1 0 00-1-1H6a1 1 0 00-1 1zm8 0H7v3h6V4zm0 8H7v4h6v-4z" clip-rule="evenodd"/>
          </svg>
          Print
        </button>
        <div class="relative">
          <button onclick="scsToggleWorkflowMenu(event)"
                  class="px-5 py-2.5 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-2">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"/>
            </svg>
            Workflow
          </button>
          <div id="scs-reprint-workflow-menu"
               class="hidden absolute right-0 top-full mt-2 w-56 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
            <button onclick="scsOpenInQrCreator()"
                    class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path d="M3 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm1 0v3h3V4H4z"/>
                <path d="M12 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1V4zm1 0v3h3V4h-3z"/>
                <path d="M3 13a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm1 0v3h3v-3H4z"/>
                <path d="M13 13h1v1h-1v-1zm2 0h1v1h-1v-1zm-2 2h1v1h-1v-1zm2 2h1v1h-1v-1zm2-2h1v1h-1v-1z"/>
              </svg>
              Open in Simple QR Creator
            </button>
            <button onclick="scsOpenFileBrowser()"
                    class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/>
              </svg>
              Open in File Browser
            </button>
          </div>
        </div>
        <a id="reprint-shop-btn" href="#" target="_blank"
           class="hidden inline-flex items-center gap-2 px-5 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700
                  text-gray-700 dark:text-gray-300 font-medium rounded-xl text-sm transition-colors">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
            <path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
          </svg>
          Shop Drawing
        </a>
        <a id="reprint-submittal-btn" href="#" target="_blank"
           class="hidden inline-flex items-center gap-2 px-5 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700
                  text-gray-700 dark:text-gray-300 font-medium rounded-xl text-sm transition-colors">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
            <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
            <path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
          </svg>
          Submittal Drawing
        </a>
      </div>
    </div>

    </div>
  </section>
    </div><!-- /tool-single-carrier-sheet -->

<!-- ===== CREATE CARRIER SHEET DIALOG (DVI-305) ===== -->
<div id="create-carrier-overlay"
     class="hidden fixed inset-0 z-[70] flex items-start justify-center bg-black/50 overflow-y-auto py-8"
     onclick="if(event.target===this)closeCreateCarrierDialog()">
  <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-lg mx-4 my-auto"
       onclick="event.stopPropagation()">
    <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
      <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Create Carrier Sheet</h3>
      <button onclick="closeCreateCarrierDialog()"
              class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
        <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
          <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
        </svg>
      </button>
    </div>
    <form id="create-carrier-form" class="px-6 py-5 space-y-4" onsubmit="submitCreateCarrier(event)">
      <p class="text-xs text-gray-500 dark:text-gray-400">Fields marked <span class="text-red-500">*</span> are required.</p>
      <div class="flex items-center gap-2 flex-wrap">
        <button id="create-carrier-ocr-btn" type="button" onclick="createCarrierOcrDiscovery(true)"
                class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/>
          </svg>
          OCR Discovery
        </button>
        <span id="create-carrier-ocr-hint" class="hidden text-xs"></span>
      </div>

      <!-- Row 1: Job Number, Part Ref -->
      <div class="grid grid-cols-2 gap-3">
        <div>
          <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
            Job Number <span class="text-red-500">*</span>
          </label>
          <input type="text" name="job_number" required placeholder="e.g. 26-1132"
                 oninput="carrierComposeProjectName()"
                 class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-lg py-2 px-3
                        bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                        focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
        </div>
        <div>
          <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Part Ref.</label>
          <input type="text" name="part_ref" placeholder="e.g. P1"
                 class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-lg py-2 px-3
                        bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                        focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
        </div>
      </div>

      <!-- Row 2: Product Code, Weight -->
      <div class="grid grid-cols-2 gap-3">
        <div>
          <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
            Product Code <span class="text-red-500">*</span>
          </label>
          <input type="text" name="product_code" required placeholder="e.g. MBI-TCCB-10-JJ"
                 class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-lg py-2 px-3
                        bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                        focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
        </div>
        <div>
          <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Weight</label>
          <input type="text" name="weight" placeholder="e.g. 4254 lb"
                 class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-lg py-2 px-3
                        bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                        focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
        </div>
      </div>

      <!-- Row 3: Production Date, Structure ID -->
      <div class="grid grid-cols-2 gap-3">
        <div>
          <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Production Date</label>
          <input type="text" name="production_date" placeholder="e.g. 06/10/2026"
                 class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-lg py-2 px-3
                        bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                        focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
        </div>
        <div>
          <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Structure ID</label>
          <input type="text" name="structure_id" placeholder="e.g. MBW - INDOT 10FT TEMP. MBW"
                 class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-lg py-2 px-3
                        bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                        focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
        </div>
      </div>

      <!-- Row 4: Spec/Type, Structure Size -->
      <div class="grid grid-cols-2 gap-3">
        <div>
          <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Spec./Type</label>
          <input type="text" name="spec_type" placeholder=""
                 class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-lg py-2 px-3
                        bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                        focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
        </div>
        <div>
          <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Structure Size</label>
          <input type="text" name="structure_size" placeholder=""
                 class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-lg py-2 px-3
                        bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                        focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
        </div>
      </div>

      <!-- Station -->
      <div>
        <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Station</label>
        <input type="text" name="station" placeholder=""
               class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-lg py-2 px-3
                      bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                      focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
      </div>

      <!-- Customer/Contractor Name -->
      <div>
        <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
          Customer/Contractor Name <span class="text-red-500">*</span>
        </label>
        <input type="text" name="customer_name" required placeholder="e.g. Blankenberger Brothers, Inc."
               class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-lg py-2 px-3
                      bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                      focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
      </div>

      <!-- Vendor Project Code (optional) — used to help compose the Project Name -->
      <div>
        <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
          Vendor Project Code <span class="text-gray-400 dark:text-gray-500">(optional)</span>
        </label>
        <input type="text" name="vendor_project_code" placeholder="e.g. INDOT B43968-A"
               oninput="carrierComposeProjectName()"
               class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-lg py-2 px-3
                      bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                      focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
      </div>

      <!-- Project Name -->
      <div>
        <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
          Project Name <span class="text-red-500">*</span>
        </label>
        <input type="text" name="project_name" required placeholder="e.g. 26-1132 INDOT B43968-A Posey Co. Temporary Barrier Wall"
               class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-lg py-2 px-3
                      bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                      focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
      </div>

      <div id="create-carrier-error" class="hidden p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm"></div>

      <div class="flex justify-end gap-3 pt-1">
        <button type="button" onclick="closeCreateCarrierDialog()"
                class="px-5 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700
                       text-gray-700 dark:text-gray-300 font-medium rounded-xl text-sm transition-colors">
          Cancel
        </button>
        <button type="submit" id="create-carrier-submit-btn"
                class="px-6 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold
                       rounded-xl text-sm disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
          Generate Carrier Sheet
        </button>
      </div>
    </form>
  </div>
</div>

    <!-- ===== REPRINT CARRIER SHEET DIALOG (DVI-296) ===== -->
    <div id="reprint-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
      <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4 max-h-[80vh] flex flex-col">
        <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
          <div class="flex items-center gap-2">
            <button id="reprint-back-btn" type="button" onclick="reprintBackToProjects()" class="hidden text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
              <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                <path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
              </svg>
            </button>
            <h2 id="reprint-modal-title" class="text-lg font-semibold text-gray-800 dark:text-gray-100">Reprint Carrier Sheet</h2>
          </div>
          <button type="button" onclick="reprintCloseDialog()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
            </svg>
          </button>
        </div>
        <div class="px-6 py-3">
          <input id="reprint-search" type="text" placeholder="Filter projects…"
                 oninput="reprintFilterList(this.value)"
                 class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
        </div>
        <div id="reprint-list" class="flex-1 overflow-y-auto px-6 pb-4 divide-y divide-gray-100 dark:divide-gray-800">
          <p class="text-sm text-gray-400 dark:text-gray-500 py-3">Loading projects…</p>
        </div>
      </div>
    </div>

    <!-- ===== SCS SAVE TO PROJECT FOLDER DIALOG (DVI-309) ===== -->
    <div id="scs-fb-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
      <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4 max-h-[85vh] flex flex-col">
        <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Save to Project Folder</h2>
          <button type="button" onclick="scsFbClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
            </svg>
          </button>
        </div>
        <div class="px-6 py-3">
          <input id="scs-fb-search" type="text" placeholder="Filter projects…"
                 oninput="scsFbFilter(this.value)"
                 class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
        </div>
        <div id="scs-fb-list" class="flex-1 overflow-y-auto px-6 pb-3 divide-y divide-gray-100 dark:divide-gray-800" style="min-height:8rem;">
          <p class="text-sm text-gray-400 dark:text-gray-500 py-3">Loading projects…</p>
        </div>
        <div class="px-6 py-3 border-t border-gray-200 dark:border-gray-700">
          <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Product Code</label>
          <input id="scs-fb-product-code" type="text" placeholder="e.g. MBI-TCCB-10-JJ"
                 oninput="scsFbUpdateSaveState()"
                 class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-lg py-2 px-3
                        bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                        focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
        </div>
        <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
          <div class="flex justify-end gap-3">
            <button type="button" onclick="scsFbClose()"
                    class="px-5 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700
                           text-gray-700 dark:text-gray-300 font-medium rounded-xl text-sm transition-colors">
              Cancel
            </button>
            <button type="button" id="scs-fb-save-btn" onclick="scsFbSave()" disabled
                    class="px-6 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold
                           rounded-xl text-sm disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
              Save
            </button>
          </div>
          <p class="mt-2 text-xs text-gray-400 dark:text-gray-500 text-right">Save will save and update the project records.</p>
        </div>
      </div>
    </div>

    <!-- ===== SCS NO MATCHING PRODUCT CODE DIALOG (DVI-309) ===== -->
    <div id="scs-fb-nomatch-modal" class="hidden fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
      <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
        <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">No Matching Product Code</h2>
          <button type="button" onclick="scsFbNomatchCancel()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
            </svg>
          </button>
        </div>
        <div class="px-6 py-5">
          <p class="text-sm text-gray-700 dark:text-gray-300">No matching Product Code found, would you like to add a new product and line to the Project?</p>
        </div>
        <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
          <button type="button" onclick="scsFbNomatchCancel()"
                  class="px-5 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700
                         text-gray-700 dark:text-gray-300 font-medium rounded-xl text-sm transition-colors">
            Cancel
          </button>
          <button type="button" id="scs-fb-write-btn" onclick="scsFbWrite()"
                  class="px-6 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold
                         rounded-xl text-sm disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
            Write
          </button>
        </div>
      </div>
    </div>

    <!-- ===== PRODUCE PRODUCTION RUN TOOL CONTENT ===== -->
    <div id="tool-prod-run-merge" class="tool-content hidden">

      <!-- Tabs (DVI-528): Run = upload/merge view, History = past runs, Settings (right) -->
      <div class="flex items-center gap-1 border-b border-gray-200 dark:border-gray-700 mb-4">
        <button id="prodrun-tab-btn-run" onclick="prodRunSwitchTab('run')"
                class="px-4 py-2 text-sm font-medium border-b-2 border-togen-teal text-togen-teal -mb-px">Run</button>
        <button id="prodrun-tab-btn-history" onclick="prodRunSwitchTab('history')"
                class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 -mb-px">History</button>
        <button id="prodrun-tab-btn-settings" onclick="prodRunSwitchTab('settings')"
                class="ml-auto px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 -mb-px inline-flex items-center gap-1.5">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/>
          </svg>
          Settings
        </button>
      </div>

      <!-- ===== Run tab ===== -->
      <div id="prodrun-tab-run">

      <!-- CSV Upload Section -->
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Upload Production Run</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Upload a CSV or XLSX with drawing URLs in column O and label URLs in column P. Review auto-discovered items, then submit to merge.</p>
          <div class="flex items-center gap-4">
            <input type="file" id="prodrun-csv-input" accept=".csv,.xlsx" class="hidden" onchange="onProdRunCsvChosen(this)">
            <button id="prodrun-csv-btn"
                    onclick="document.getElementById('prodrun-csv-input').click()"
                    class="px-5 py-3 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
              <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
              </svg>
              Upload CSV / XLSX
            </button>
            <span id="prodrun-csv-lbl" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
        </div>
      </section>

      <!-- Items Review Section (hidden until CSV uploaded) -->
      <section id="prodrun-items-section" class="togen-pane hidden bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Review Items</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Items with auto-discovered URLs are shown in green. Click Override to replace a URL or upload a file.</p>
          <div id="prodrun-items-container" class="space-y-3">
            <!-- Item cards rendered by JS -->
          </div>
          <div class="mt-5 flex items-center gap-3">
            <button id="prodrun-submit-btn" onclick="prodRunSubmitAll()" disabled
                    class="px-5 py-3 bg-togen-teal text-white font-semibold rounded-xl text-sm hover:bg-togen-teal-dark transition-colors disabled:bg-gray-300 disabled:dark:bg-gray-700 disabled:text-gray-400 disabled:dark:text-gray-500 disabled:cursor-not-allowed">
              Submit
            </button>
            <span id="prodrun-submit-lbl" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
        </div>
      </section>

      <!-- Processing Status Section (hidden until submit) -->
      <section id="prodrun-status-section" class="togen-pane hidden bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-3">Processing</h2>
          <div id="prodrun-progress" class="space-y-3"></div>
        </div>
      </section>

      <!-- Results Section (hidden until processing complete) -->
      <section id="prodrun-results-section" class="togen-pane hidden bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-4">Results</h2>
          <div id="prodrun-results" class="space-y-4"></div>
        </div>
      </section>

      </div><!-- /prodrun-tab-run -->

      <!-- ===== History tab (DVI-528) ===== -->
      <div id="prodrun-tab-history" class="hidden">
        <!-- List view -->
        <div id="prodrun-history-listview">
          <div class="flex items-center justify-between mb-3 gap-2 flex-wrap">
            <p class="text-sm text-gray-500 dark:text-gray-400">Past production runs, latest first. Click a run to view its merged files.</p>
            <button onclick="prodRunLoadHistory()" title="Refresh"
                    class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-togen-teal hover:bg-togen-teal/10 rounded-lg transition-colors">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
              </svg>
              Refresh
            </button>
          </div>
          <div id="prodrun-history-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="prodrun-history-list" class="space-y-2"></div>
        </div>
        <!-- Detail view (merged files with preview buttons) -->
        <div id="prodrun-history-detailview" class="hidden">
          <button onclick="prodRunHistoryBack()"
                  class="inline-flex items-center gap-1 mb-3 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-togen-teal transition-colors">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/>
            </svg>
            Back to history
          </button>
          <div id="prodrun-history-detail" class="space-y-4"></div>
        </div>
      </div><!-- /prodrun-tab-history -->

      <!-- ===== Settings tab (DVI-528) ===== -->
      <div id="prodrun-tab-settings" class="hidden">
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
          <div class="px-6 py-4">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Settings</h2>
            <p class="text-sm text-gray-500 dark:text-gray-400">Production Run reads drawing URLs from column O and label URLs from column P of the uploaded CSV / XLSX, then merges each set into a single PDF. There are no configurable options yet.</p>
          </div>
        </section>
      </div><!-- /prodrun-tab-settings -->

    </div><!-- /tool-prod-run-merge -->

    <!-- ===== WORK ORDER REQUEST TOOL CONTENT ===== -->
    <div id="tool-work-order" class="tool-content hidden">

      <!-- Work Order Type Selector (shown on first open) -->
      <div id="wor-type-selector" class="flex flex-col items-center justify-center py-12">
        <h2 class="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2">Select Work Order Type</h2>
        <p class="text-sm text-gray-500 dark:text-gray-400 mb-8">Choose the type of work order you'd like to create.</p>
        <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 w-full max-w-4xl">
          <button type="button" data-wor-type="maintenance" onclick="worSelectType('maintenance')"
                  class="togen-pane group flex flex-col items-center gap-4 p-8 bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-md transition-all duration-200 text-center cursor-pointer">
            <div class="text-gray-400 dark:text-gray-500 group-hover:text-togen-teal transition-colors">
              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">
                <path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z" />
              </svg>
            </div>
            <div>
              <p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">Maintenance</p>
              <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Facility & equipment maintenance requests</p>
            </div>
          </button>
          <button type="button" data-wor-type="mobilemachine" onclick="worSelectType('mobilemachine')"
                  class="togen-pane group flex flex-col items-center gap-4 p-8 bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-md transition-all duration-200 text-center cursor-pointer">
            <div class="text-gray-400 dark:text-gray-500 group-hover:text-togen-teal transition-colors">
              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">
                <rect x="2" y="6" width="16" height="10" rx="2" />
                <circle cx="6" cy="18" r="2" />
                <circle cx="14" cy="18" r="2" />
                <path d="M18 10h2a2 2 0 012 2v4a2 2 0 01-2 2h-1" />
                <path d="M18 6h2l2 4" />
              </svg>
            </div>
            <div>
              <p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">Mobile Machine</p>
              <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Vehicle & mobile equipment work orders</p>
            </div>
          </button>
          <button type="button" data-wor-type="safety" onclick="worSelectType('safety')"
                  class="togen-pane group flex flex-col items-center gap-4 p-8 bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-md transition-all duration-200 text-center cursor-pointer">
            <div class="text-gray-400 dark:text-gray-500 group-hover:text-togen-teal transition-colors">
              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">
                <path d="M12 3l8 4v5c0 4.5-3.2 8.1-8 9-4.8-.9-8-4.5-8-9V7l8-4z" />
                <path d="M12 9v4" />
                <path d="M12 16.5h.01" />
              </svg>
            </div>
            <div>
              <p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">Safety</p>
              <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Safety concerns & hazard reports</p>
            </div>
          </button>
          <button type="button" data-wor-type="preventive" onclick="worSelectType('preventive')"
                  class="togen-pane group flex flex-col items-center gap-4 p-8 bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-md transition-all duration-200 text-center cursor-pointer">
            <div class="text-gray-400 dark:text-gray-500 group-hover:text-togen-teal transition-colors">
              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">
                <path d="M12 8v4l3 2" />
                <circle cx="12" cy="12" r="9" />
              </svg>
            </div>
            <div>
              <p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">Preventative Maintenance</p>
              <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Scheduled PM tasks & parts</p>
            </div>
          </button>
          <button type="button" data-wor-type="ithelpdesk" onclick="itOpenHelpDesk()"
                  class="togen-pane group flex flex-col items-center gap-4 p-8 bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-md transition-all duration-200 text-center cursor-pointer">
            <div class="text-gray-400 dark:text-gray-500 group-hover:text-togen-teal transition-colors">
              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">
                <path d="M4 14v-2a8 8 0 0116 0v2" />
                <rect x="2" y="14" width="4" height="6" rx="1" />
                <rect x="18" y="14" width="4" height="6" rx="1" />
                <path d="M20 20v1a3 3 0 01-3 3h-2" />
                <circle cx="12" cy="24" r="0.5" />
              </svg>
            </div>
            <div>
              <p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">IT Help Desk</p>
              <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">IT support tickets to Voyage Technology</p>
            </div>
          </button>
        </div>
      </div>

      <!-- Maintenance Work Order wrapper (hidden until type selected) -->
      <div id="wor-maintenance-wrapper" class="hidden">

      <!-- History button bar -->
      <div class="flex items-center gap-3 mb-4">
        <button type="button" onclick="worBackToTypeSelector()" class="px-3 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd"/></svg>
          Back
        </button>
        <button id="wor-history-btn" onclick="worToggleHistory()"
                class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
          </svg>
          Current/Recent Work Orders
        </button>
      </div>

      <!-- History panel (hidden by default) -->
      <section id="wor-history-section" class="togen-pane hidden bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-3">Work Orders: New or In Progress</h2>
          <div id="wor-history-list" class="space-y-2">
            <p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
          </div>
        </div>
      </section>

      <!-- Maintenance Work Order Form -->
      <!-- Edit mode banner (shown when admin is editing an existing WOR) -->
      <div id="wor-edit-banner" class="hidden bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-700 rounded-xl px-4 py-3 mb-4 flex items-center justify-between">
        <div class="flex items-center gap-2">
          <svg class="h-5 w-5 text-yellow-600 dark:text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/>
          </svg>
          <span class="text-sm font-medium text-yellow-800 dark:text-yellow-300">Editing: <span id="wor-edit-filename"></span></span>
        </div>
        <button type="button" onclick="worClearForm()" class="text-xs font-medium text-yellow-700 dark:text-yellow-400 hover:text-yellow-900 dark:hover:text-yellow-200 underline">Cancel edit</button>
      </div>

      <form id="wor-form" onsubmit="worSubmit(event)">

        <!-- Requestor Information -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Requestor Information</h2>
          </div>
          <div class="px-6 py-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Requestor Name</label>
              <input type="text" name="requestor_name" required
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Phone #</label>
              <input type="tel" name="phone"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email</label>
              <input type="email" name="email"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
          </div>
        </section>

        <!-- Request Details -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Request Details</h2>
          </div>
          <div class="px-6 py-4 space-y-4">
            <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Date of Request</label>
                <input type="date" name="date_of_request" required
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Location</label>
                <input type="text" name="location"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
            </div>
            <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Building</label>
                <input type="text" name="building"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Man Hole</label>
                <input type="text" name="man_hole"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Equipment / Asset / System Affected</label>
                <input type="text" name="equipment"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
            </div>
          </div>
        </section>

        <!-- Request Description -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Request Description</label>
            <textarea name="description" rows="5" required
                      class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal resize-y"></textarea>
          </div>
        </section>

        <!-- Type of Issue -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-3">Type of Issue (check all that apply)</p>
            <div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Automotive" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Automotive
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Grounds" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Grounds
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Plumbing" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Plumbing
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Custodial" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Custodial
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Cranes" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Cranes
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Concrete Forms" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Concrete Forms
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Telehandler/Forklift" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Telehandler/Forklift
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Machine" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Machine
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Electrical" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Electrical
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Tool Repair" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Tool Repair
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Safety" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Safety
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Other" id="wor-other-cb" onchange="document.getElementById('wor-other-text').disabled=!this.checked" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Other:
                <input type="text" name="issue_type_other" id="wor-other-text" disabled
                       class="flex-1 px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal disabled:opacity-40">
              </label>
            </div>
          </div>
        </section>

        <!-- Service Timing & Urgency -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
            <div class="space-y-4">
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Best Times for Service</label>
                <input type="text" name="best_times"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Desired Completion Date</label>
                <input type="date" name="desired_completion_date"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
            </div>
            <div>
              <p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Urgency (check one)</p>
              <div class="space-y-2">
                <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input type="radio" name="urgency" value="Low" class="text-togen-teal focus:ring-togen-teal"> Low
                </label>
                <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input type="radio" name="urgency" value="Medium" checked class="text-togen-teal focus:ring-togen-teal"> Medium
                </label>
                <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input type="radio" name="urgency" value="High" class="text-togen-teal focus:ring-togen-teal"> High
                </label>
              </div>
            </div>
          </div>
        </section>

        <!-- Notifications opt-in -->
        <div class="mb-4">
          <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
            <input type="checkbox" name="notify_enabled" value="1" class="text-togen-teal focus:ring-togen-teal">
            Enable notifications about this work order
          </label>
        </div>

        <!-- Submit -->
        <div class="flex items-center gap-3 mb-4">
          <button type="submit" id="wor-submit-btn"
                  class="px-6 py-3 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
            </svg>
            <span id="wor-submit-btn-text">Submit Work Order</span>
          </button>
          <button type="button" id="wor-make-recurring-submit-btn" onclick="worSubmitAndRecurring(event)"
                  class="px-6 py-3 bg-white dark:bg-gray-900 border border-indigo-400 dark:border-indigo-500 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
            </svg>
            Make Recurring
          </button>
          <span id="wor-submit-lbl" class="text-sm text-gray-500 dark:text-gray-400"></span>
        </div>

      </form>

      <!-- Preview pane (hidden until submit) -->
      <section id="wor-preview-section" class="togen-pane hidden bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <div class="flex items-center justify-between mb-3 flex-wrap gap-2">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Preview</h2>
            <div class="flex items-center gap-2 flex-wrap">
              <a id="wor-download-link" href="#" download
                 class="px-4 py-2 text-sm font-medium rounded-xl border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors inline-flex items-center gap-2">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
                </svg>
                Download PDF
              </a>
              <button onclick="worOpenViewer()"
                      class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-2">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"/>
                  <path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"/>
                </svg>
                Open
              </button>
              <div class="relative">
                <button onclick="worToggleWorkflowMenu(event)"
                        class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-2">
                  <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"/>
                  </svg>
                  Workflow
                </button>
                <div id="wor-workflow-menu"
                     class="hidden absolute right-0 top-full mt-2 w-56 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
                  <button onclick="worOpenInQrCreator()"
                          class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
                    <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                      <path d="M3 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm1 0v3h3V4H4z"/>
                      <path d="M12 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1V4zm1 0v3h3V4h-3z"/>
                      <path d="M3 13a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm1 0v3h3v-3H4z"/>
                      <path d="M13 13h1v1h-1v-1zm2 0h1v1h-1v-1zm-2 2h1v1h-1v-1zm2 2h1v1h-1v-1zm2-2h1v1h-1v-1z"/>
                    </svg>
                    Open in Simple QR Creator
                  </button>
                </div>
              </div>
            </div>
          </div>
          <iframe id="wor-preview-frame" class="w-full rounded-lg border border-gray-200 dark:border-gray-700" style="height:800px;" src="about:blank"></iframe>
        </div>
      </section>

      <!-- Clear row (always visible at bottom of page) -->
      <div class="flex items-center gap-3 mb-2 flex-wrap">
        <button type="button" onclick="worClearForm()"
                class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors whitespace-nowrap cursor-pointer">
          Clear / Submit Another
        </button>
      </div>

      </div><!-- /wor-maintenance-wrapper -->

      <!-- ===== SAFETY CONCERNS WORK ORDER WRAPPER ===== -->
      <div id="wor-safety-wrapper" class="hidden">

      <!-- History button bar -->
      <div class="flex items-center gap-3 mb-4">
        <button type="button" onclick="worBackToTypeSelector()" class="px-3 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd"/></svg>
          Back
        </button>
        <button id="safety-history-btn" onclick="safetyToggleHistory()"
                class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
          </svg>
          Current/Recent Work Orders
        </button>
      </div>

      <!-- History panel (hidden by default) -->
      <section id="safety-history-section" class="togen-pane hidden bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-3">Work Orders: New or In Progress</h2>
          <div id="safety-history-list" class="space-y-2">
            <p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
          </div>
        </div>
      </section>

      <!-- Safety Concerns Form -->
      <!-- Edit mode banner (shown when admin is editing an existing WOR) -->
      <div id="safety-edit-banner" class="hidden bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-700 rounded-xl px-4 py-3 mb-4 flex items-center justify-between">
        <div class="flex items-center gap-2">
          <svg class="h-5 w-5 text-yellow-600 dark:text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/>
          </svg>
          <span class="text-sm font-medium text-yellow-800 dark:text-yellow-300">Editing: <span id="safety-edit-filename"></span></span>
        </div>
        <button type="button" onclick="safetyClearForm()" class="text-xs font-medium text-yellow-700 dark:text-yellow-400 hover:text-yellow-900 dark:hover:text-yellow-200 underline">Cancel edit</button>
      </div>

      <form id="safety-form" onsubmit="safetySubmit(event)">

        <!-- Requestor Information -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Requestor Information</h2>
          </div>
          <div class="px-6 py-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Requestor Name</label>
              <input type="text" name="requestor_name" required
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Phone #</label>
              <input type="tel" name="phone"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email</label>
              <input type="email" name="email"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
          </div>
        </section>

        <!-- Request Details -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Request Details</h2>
          </div>
          <div class="px-6 py-4 space-y-4">
            <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Date of Request</label>
                <input type="date" name="date_of_request" required
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Location</label>
                <input type="text" name="location"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
            </div>
            <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Building</label>
                <input type="text" name="building"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Man Hole</label>
                <input type="text" name="man_hole"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Equipment / Asset / System Affected</label>
                <input type="text" name="equipment"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
            </div>
          </div>
        </section>

        <!-- Request Description -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Request Description</label>
            <textarea name="description" rows="5" required
                      class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal resize-y"></textarea>
          </div>
        </section>

        <!-- Type of Safety Issue -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-3">Type of Safety Issue (check all that apply)</p>
            <div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Slip/Trip/Fall" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Slip/Trip/Fall
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Isleway or Egress" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Isleway or Egress
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="General Safety" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> General Safety
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Lighting" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Lighting
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Near Miss" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Near Miss
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Concrete Forms" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Concrete Forms
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Mobile equipment" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Mobile equipment
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Machine" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Machine
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Electrical" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Electrical
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Tool/Cords" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Tool/Cords
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Building" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Building
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Other" id="safety-other-cb" onchange="document.getElementById('safety-other-text').disabled=!this.checked" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Other:
                <input type="text" name="issue_type_other" id="safety-other-text" disabled
                       class="flex-1 px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal disabled:opacity-40">
              </label>
            </div>
          </div>
        </section>

        <!-- Service Timing & Urgency -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
            <div class="space-y-4">
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Best Times for Service</label>
                <input type="text" name="best_times"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Desired Completion Date</label>
                <input type="date" name="desired_completion_date"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
            </div>
            <div>
              <p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Urgency (check one)</p>
              <div class="space-y-2">
                <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input type="radio" name="urgency" value="Low" class="text-togen-teal focus:ring-togen-teal"> Low
                </label>
                <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input type="radio" name="urgency" value="Medium" checked class="text-togen-teal focus:ring-togen-teal"> Medium
                </label>
                <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input type="radio" name="urgency" value="High" class="text-togen-teal focus:ring-togen-teal"> High
                </label>
              </div>
            </div>
          </div>
        </section>

        <!-- Notifications opt-in -->
        <div class="mb-4">
          <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
            <input type="checkbox" name="notify_enabled" value="1" class="text-togen-teal focus:ring-togen-teal">
            Enable notifications about this work order
          </label>
        </div>

        <!-- Submit -->
        <div class="flex items-center gap-3 mb-4">
          <button type="submit" id="safety-submit-btn"
                  class="px-6 py-3 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
            </svg>
            <span id="safety-submit-btn-text">Submit Work Order</span>
          </button>
          <button type="button" id="safety-make-recurring-submit-btn" onclick="safetySubmitAndRecurring(event)"
                  class="px-6 py-3 bg-white dark:bg-gray-900 border border-indigo-400 dark:border-indigo-500 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
            </svg>
            Make Recurring
          </button>
          <span id="safety-submit-lbl" class="text-sm text-gray-500 dark:text-gray-400"></span>
        </div>

      </form>

      <!-- Preview pane (hidden until submit) -->
      <section id="safety-preview-section" class="togen-pane hidden bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <div class="flex items-center justify-between mb-3 flex-wrap gap-2">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Preview</h2>
            <div class="flex items-center gap-2 flex-wrap">
              <a id="safety-download-link" href="#" download
                 class="px-4 py-2 text-sm font-medium rounded-xl border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors inline-flex items-center gap-2">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
                </svg>
                Download PDF
              </a>
              <button onclick="safetyOpenViewer()"
                      class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-2">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"/>
                  <path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"/>
                </svg>
                Open
              </button>
              <div class="relative">
                <button onclick="safetyToggleWorkflowMenu(event)"
                        class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-2">
                  <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"/>
                  </svg>
                  Workflow
                </button>
                <div id="safety-workflow-menu"
                     class="hidden absolute right-0 top-full mt-2 w-56 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
                  <button onclick="safetyOpenInQrCreator()"
                          class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
                    <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                      <path d="M3 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm1 0v3h3V4H4z"/>
                      <path d="M12 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1V4zm1 0v3h3V4h-3z"/>
                      <path d="M3 13a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm1 0v3h3v-3H4z"/>
                      <path d="M13 13h1v1h-1v-1zm2 0h1v1h-1v-1zm-2 2h1v1h-1v-1zm2 2h1v1h-1v-1zm2-2h1v1h-1v-1z"/>
                    </svg>
                    Open in Simple QR Creator
                  </button>
                </div>
              </div>
            </div>
          </div>
          <iframe id="safety-preview-frame" class="w-full rounded-lg border border-gray-200 dark:border-gray-700" style="height:800px;" src="about:blank"></iframe>
        </div>
      </section>

      <!-- Clear row (always visible at bottom of page) -->
      <div class="flex items-center gap-3 mb-2 flex-wrap">
        <button type="button" onclick="safetyClearForm()"
                class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors whitespace-nowrap cursor-pointer">
          Clear / Submit Another
        </button>
      </div>

      </div><!-- /wor-safety-wrapper -->

      <!-- ===== MOBILE MACHINE WORK ORDER WRAPPER ===== -->
      <div id="wor-mm-wrapper" class="hidden">

      <div class="flex items-center gap-3 mb-4">
        <button type="button" onclick="worBackToTypeSelector()" class="px-3 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd"/></svg>
          Back
        </button>
        <button id="mm-history-btn" onclick="mmToggleHistory()"
                class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
          </svg>
          Current/Recent Work Orders
        </button>
      </div>

      <section id="mm-history-section" class="togen-pane hidden bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-3">Mobile Machine Work Orders: New or In Progress</h2>
          <div id="mm-history-list" class="space-y-2">
            <p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
          </div>
        </div>
      </section>

      <div id="mm-edit-banner" class="hidden bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-700 rounded-xl px-4 py-3 mb-4 flex items-center justify-between">
        <div class="flex items-center gap-2">
          <svg class="h-5 w-5 text-yellow-600 dark:text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/>
          </svg>
          <span class="text-sm font-medium text-yellow-800 dark:text-yellow-300">Editing: <span id="mm-edit-filename"></span></span>
        </div>
        <button type="button" onclick="mmClearForm()" class="text-xs font-medium text-yellow-700 dark:text-yellow-400 hover:text-yellow-900 dark:hover:text-yellow-200 underline">Cancel edit</button>
      </div>

      <form id="mm-form" onsubmit="mmSubmit(event)">

        <!-- Machine Information -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Machine Information</h2>
          </div>
          <div class="px-6 py-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Machine</label>
              <input type="text" name="machine" required
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Asset #</label>
              <input type="text" name="asset_number"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
          </div>
        </section>

        <!-- Order Details -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Order Details</h2>
          </div>
          <div class="px-6 py-4 space-y-4">
            <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Order Date &amp; Time</label>
                <input type="datetime-local" name="order_date_time" required
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Date Promised</label>
                <input type="date" name="date_promised"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Date Delivered</label>
                <input type="date" name="date_delivered"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
            </div>
            <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Order Received By</label>
                <input type="text" name="order_received_by"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Make and Model</label>
                <input type="text" name="make_model"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
            </div>
          </div>
        </section>

        <!-- Vehicle Information -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Vehicle Information</h2>
          </div>
          <div class="px-6 py-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">VIN</label>
              <input type="text" name="vin"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Odometer Reading</label>
              <input type="text" name="odometer"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">License # and State</label>
              <input type="text" name="license_state"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Motor #</label>
              <input type="text" name="motor_number"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
          </div>
        </section>

        <!-- Service Type Checkboxes -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-3">Service Type (check all that apply)</p>
            <div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Lubricate" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Lubricate
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Change Oil" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Change Oil
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Transmission Service" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Transmission Service
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Battery Replacement / Check" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Battery Replacement / Check
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Differential Service" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Differential Service
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Flat Tire Repair" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Flat Tire Repair
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Wiper Replacement" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Wiper Replacement
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Boom Repair" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Boom Repair
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Fuel System" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Fuel System
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Tire Replacement" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Tire Replacement
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Brake Inspection / Replacement" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Brake Inspection / Replacement
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Coolant System Flush" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Coolant System Flush
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Air Filter Replacement" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Air Filter Replacement
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Spark Plug Replacement" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Spark Plug Replacement
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Exhaust System Check" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Exhaust System Check
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Electronics" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Electronics
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Suspension and Steering Check" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Suspension and Steering Check
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="service_type" value="Hydraulic repair / fill" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Hydraulic repair / fill
              </label>
            </div>
          </div>
        </section>

        <!-- Labor Description -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Labor Description</label>
            <textarea name="labor_description" rows="4"
                      class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal resize-y"></textarea>
            <div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3">
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Time</label>
                <input type="text" name="labor_time"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Labor Total</label>
                <input type="text" name="labor_total"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
            </div>
          </div>
        </section>

        <!-- Parts -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Parts</h2>
          </div>
          <div class="px-6 py-4">
            <div id="mm-parts-rows">
              <div class="grid grid-cols-12 gap-2 mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">
                <div class="col-span-3">Part Number</div>
                <div class="col-span-5">Part Name</div>
                <div class="col-span-2">Quantity</div>
                <div class="col-span-2">Amount</div>
              </div>
              <div class="mm-part-row grid grid-cols-12 gap-2 mb-2">
                <input type="text" name="part_number[]" class="col-span-3 px-2 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
                <input type="text" name="part_name[]" class="col-span-5 px-2 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
                <input type="text" name="part_qty[]" class="col-span-2 px-2 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
                <input type="text" name="part_amount[]" class="col-span-2 px-2 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
            </div>
            <button type="button" onclick="mmAddPartRow()" class="mt-2 px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">+ Add Part Row</button>
            <div class="flex items-center gap-6 mt-4">
              <div class="ml-auto">
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Parts Total</label>
                <input type="text" name="parts_total"
                       class="w-32 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
            </div>
          </div>
        </section>

        <!-- Repair / Operator Manual (DVI-462). Authorization is now captured and
             processed within the Work Order Admin tool, so the Authorization pane
             was removed from this form. -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Repair / Operator Manual</h2>
          </div>
          <div class="px-6 py-4 space-y-3">
            <p class="text-xs text-gray-500 dark:text-gray-400">Attach a repair or operator manual (PDF preferred). It is associated with the machine above and made available in the Document Viewer.</p>

            <input type="file" id="mm-manual-file" name="manual" accept=".pdf,.doc,.docx,.png,.jpg,.jpeg" class="hidden" onchange="mmManualFileChosen()">
            <input type="hidden" id="mm-manual-existing" name="manual_existing">
            <input type="hidden" id="mm-manual-existing-name" name="manual_existing_name">

            <div id="mm-manual-none" class="flex items-center gap-3">
              <button type="button" onclick="mmOpenManualPicker()"
                      class="px-4 py-2 text-sm font-medium rounded-xl border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors inline-flex items-center gap-2">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" clip-rule="evenodd"/>
                </svg>
                Attach Manual
              </button>
              <span class="text-xs text-gray-400 dark:text-gray-500">No manual attached</span>
            </div>

            <div id="mm-manual-attached" class="hidden flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
              <div class="flex items-center gap-2 min-w-0">
                <svg class="h-5 w-5 text-togen-teal flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/>
                </svg>
                <span id="mm-manual-attached-name" class="text-sm text-gray-700 dark:text-gray-200 truncate"></span>
              </div>
              <div class="flex items-center gap-2 flex-shrink-0">
                <a id="mm-manual-open-link" href="#" target="_blank" rel="noopener"
                   class="hidden px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Open</a>
                <button type="button" onclick="mmDetachManual()"
                        class="px-3 py-1 text-xs font-medium rounded-lg border border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">Detach</button>
              </div>
            </div>
          </div>
        </section>

        <!-- Notifications opt-in -->
        <div class="mb-4">
          <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
            <input type="checkbox" name="notify_enabled" value="1" class="text-togen-teal focus:ring-togen-teal">
            Enable notifications about this work order
          </label>
        </div>

        <!-- Submit -->
        <div class="flex items-center gap-3 mb-4">
          <button type="submit" id="mm-submit-btn"
                  class="px-6 py-3 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
            </svg>
            <span id="mm-submit-btn-text">Submit Work Order</span>
          </button>
          <button type="button" id="mm-make-recurring-submit-btn" onclick="mmSubmitAndRecurring(event)"
                  class="px-6 py-3 bg-white dark:bg-gray-900 border border-indigo-400 dark:border-indigo-500 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
            </svg>
            Make Recurring
          </button>
          <span id="mm-submit-lbl" class="text-sm text-gray-500 dark:text-gray-400"></span>
        </div>

      </form>

      <!-- Preview pane -->
      <section id="mm-preview-section" class="togen-pane hidden bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <div class="flex items-center justify-between mb-3 flex-wrap gap-2">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Preview</h2>
            <div class="flex items-center gap-2 flex-wrap">
              <a id="mm-download-link" href="#" download
                 class="px-4 py-2 text-sm font-medium rounded-xl border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors inline-flex items-center gap-2">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
                </svg>
                Download PDF
              </a>
              <button onclick="mmOpenViewer()"
                      class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-2">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"/>
                  <path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"/>
                </svg>
                Open
              </button>
              <div class="relative">
                <button onclick="mmToggleWorkflowMenu(event)"
                        class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-2">
                  <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"/>
                  </svg>
                  Workflow
                </button>
                <div id="mm-workflow-menu"
                     class="hidden absolute right-0 top-full mt-2 w-56 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
                  <button onclick="mmOpenInQrCreator()"
                          class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
                    <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                      <path d="M3 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm1 0v3h3V4H4z"/>
                      <path d="M12 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1V4zm1 0v3h3V4h-3z"/>
                      <path d="M3 13a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm1 0v3h3v-3H4z"/>
                      <path d="M13 13h1v1h-1v-1zm2 0h1v1h-1v-1zm-2 2h1v1h-1v-1zm2 2h1v1h-1v-1zm2-2h1v1h-1v-1z"/>
                    </svg>
                    Open in Simple QR Creator
                  </button>
                </div>
              </div>
            </div>
          </div>
          <iframe id="mm-preview-frame" class="w-full rounded-lg border border-gray-200 dark:border-gray-700" style="height:800px;" src="about:blank"></iframe>
        </div>
      </section>

      <!-- Clear row -->
      <div class="flex items-center gap-3 mb-2 flex-wrap">
        <button type="button" onclick="mmClearForm()"
                class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors whitespace-nowrap cursor-pointer">
          Clear / Submit Another
        </button>
      </div>

      </div><!-- /wor-mm-wrapper -->

      <!-- ===== PREVENTIVE MAINTENANCE WORK ORDER WRAPPER ===== -->
      <div id="wor-preventive-wrapper" class="hidden">

      <div class="flex items-center gap-3 mb-4">
        <button type="button" onclick="worBackToTypeSelector()" class="px-3 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd"/></svg>
          Back
        </button>
        <button id="pm-history-btn" onclick="pmToggleHistory()"
                class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
          </svg>
          Current/Recent Work Orders
        </button>
      </div>

      <section id="pm-history-section" class="togen-pane hidden bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-3">Preventative Maintenance Work Orders: New or In Progress</h2>
          <div id="pm-history-list" class="space-y-2">
            <p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
          </div>
        </div>
      </section>

      <div id="pm-edit-banner" class="hidden bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-700 rounded-xl px-4 py-3 mb-4 flex items-center justify-between">
        <div class="flex items-center gap-2">
          <svg class="h-5 w-5 text-yellow-600 dark:text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/>
          </svg>
          <span class="text-sm font-medium text-yellow-800 dark:text-yellow-300">Editing: <span id="pm-edit-filename"></span></span>
        </div>
        <button type="button" onclick="pmClearForm()" class="text-xs font-medium text-yellow-700 dark:text-yellow-400 hover:text-yellow-900 dark:hover:text-yellow-200 underline">Cancel edit</button>
      </div>

      <form id="pm-form" onsubmit="pmSubmit(event)">
        <!-- Requestor Information -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Requestor Information</h2>
          </div>
          <div class="px-6 py-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Requestor Name</label>
              <input type="text" name="requestor_name" required
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Phone #</label>
              <input type="tel" name="phone"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email</label>
              <input type="email" name="email"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
          </div>
        </section>

        <!-- Request Details -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Request Details</h2>
          </div>
          <div class="px-6 py-4 space-y-4">
            <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Date of Request</label>
                <input type="date" name="date_of_request" required
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Location</label>
                <input type="text" name="location"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
            </div>
            <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Building</label>
                <input type="text" name="building"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Man Hole</label>
                <input type="text" name="man_hole"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Equipment / Asset / System Affected</label>
                <div class="relative" id="pm-eas-combo">
                  <input type="text" name="equipment" id="pm-eas-input" autocomplete="off"
                         role="combobox" aria-autocomplete="list" aria-expanded="false"
                         oninput="pmEasOnInput()" onfocus="pmEasOnFocus()"
                         class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
                  <div id="pm-eas-dropdown" class="hidden absolute z-30 left-0 right-0 mt-1 max-h-60 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 shadow-lg text-sm"></div>
                </div>
              </div>
            </div>
          </div>
        </section>

        <!-- Request Description -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Request Description</label>
            <textarea name="description" rows="5" required
                      class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal resize-y"></textarea>
          </div>
        </section>

        <!-- Type of Issue -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-3">Type of Issue (check all that apply)</p>
            <div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Automotive" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Automotive
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Grounds" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Grounds
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Plumbing" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Plumbing
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Custodial" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Custodial
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Cranes" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Cranes
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Concrete Forms" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Concrete Forms
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Telehandler/Forklift" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Telehandler/Forklift
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Machine" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Machine
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Electrical" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Electrical
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Tool Repair" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Tool Repair
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Safety" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Safety
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input type="checkbox" name="issue_type" value="Other" id="pm-other-cb" onchange="document.getElementById('pm-other-text').disabled=!this.checked" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Other:
                <input type="text" name="issue_type_other" id="pm-other-text" disabled
                       class="flex-1 px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal disabled:opacity-40">
              </label>
            </div>
          </div>
        </section>

        <!-- Service Timing & Urgency -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
            <div class="space-y-4">
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Best Times for Service</label>
                <input type="text" name="best_times"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Desired Completion Date</label>
                <input type="date" name="desired_completion_date"
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
            </div>
            <div>
              <p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Urgency (check one)</p>
              <div class="space-y-2">
                <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input type="radio" name="urgency" value="Low" class="text-togen-teal focus:ring-togen-teal"> Low
                </label>
                <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input type="radio" name="urgency" value="Medium" checked class="text-togen-teal focus:ring-togen-teal"> Medium
                </label>
                <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input type="radio" name="urgency" value="High" class="text-togen-teal focus:ring-togen-teal"> High
                </label>
              </div>
            </div>
          </div>
        </section>

        <!-- Repair / Operator Manual (DVI-452) -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Repair / Operator Manual</h2>
          </div>
          <div class="px-6 py-4 space-y-3">
            <p class="text-xs text-gray-500 dark:text-gray-400">Attach a repair or operator manual (PDF preferred). It is associated with the Equipment / Asset / System Affected above and made available in the Document Viewer.</p>

            <!-- Auto-suggest: a manual was previously provided for this equipment -->
            <div id="pm-manual-suggest" class="hidden bg-blue-50 dark:bg-blue-900/20 border border-blue-300 dark:border-blue-700 rounded-xl px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
              <div class="flex items-center gap-2 text-sm text-blue-800 dark:text-blue-300 min-w-0">
                <svg class="h-5 w-5 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
                </svg>
                <span>A manual (<span id="pm-manual-suggest-name" class="font-semibold"></span>) was previously provided for this equipment.</span>
              </div>
              <div class="flex items-center gap-2 flex-shrink-0">
                <button type="button" onclick="pmAcceptSuggestedManual()" class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors">Include it</button>
                <button type="button" onclick="pmDismissSuggestedManual()" class="px-3 py-1.5 text-xs font-medium rounded-lg border border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors">Dismiss</button>
              </div>
            </div>

            <input type="file" id="pm-manual-file" name="manual" accept=".pdf,.doc,.docx,.png,.jpg,.jpeg" class="hidden" onchange="pmManualFileChosen()">
            <input type="hidden" id="pm-manual-existing" name="manual_existing">
            <input type="hidden" id="pm-manual-existing-name" name="manual_existing_name">

            <div id="pm-manual-none" class="flex items-center gap-3">
              <button type="button" onclick="pmOpenManualPicker()"
                      class="px-4 py-2 text-sm font-medium rounded-xl border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors inline-flex items-center gap-2">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" clip-rule="evenodd"/>
                </svg>
                Attach Manual
              </button>
              <span class="text-xs text-gray-400 dark:text-gray-500">No manual attached</span>
            </div>

            <div id="pm-manual-attached" class="hidden flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
              <div class="flex items-center gap-2 min-w-0">
                <svg class="h-5 w-5 text-togen-teal flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/>
                </svg>
                <span id="pm-manual-attached-name" class="text-sm text-gray-700 dark:text-gray-200 truncate"></span>
              </div>
              <div class="flex items-center gap-2 flex-shrink-0">
                <a id="pm-manual-open-link" href="#" target="_blank" rel="noopener"
                   class="hidden px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Open</a>
                <button type="button" onclick="pmDetachManual()"
                        class="px-3 py-1 text-xs font-medium rounded-lg border border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">Detach</button>
              </div>
            </div>
          </div>
        </section>

        <!-- Parts -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Parts</h2>
          </div>
          <div class="px-6 py-4">
            <div id="pm-parts-rows">
              <div class="grid grid-cols-12 gap-2 mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">
                <div class="col-span-3">Part Number</div>
                <div class="col-span-5">Part Name</div>
                <div class="col-span-2">Quantity</div>
                <div class="col-span-2">Amount</div>
              </div>
              <div class="pm-part-row grid grid-cols-12 gap-2 mb-2">
                <input type="text" name="part_number[]" class="col-span-3 px-2 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
                <input type="text" name="part_name[]" class="col-span-5 px-2 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
                <input type="text" name="part_qty[]" class="col-span-2 px-2 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
                <input type="text" name="part_amount[]" class="col-span-2 px-2 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
            </div>
            <button type="button" onclick="pmAddPartRow()" class="mt-2 px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">+ Add Part Row</button>
            <div class="flex items-center gap-6 mt-4">
              <div class="ml-auto">
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Parts Total</label>
                <input type="text" name="parts_total"
                       class="w-32 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
            </div>
          </div>
        </section>

        <!-- Notifications opt-in -->
        <div class="mb-4">
          <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
            <input type="checkbox" name="notify_enabled" value="1" class="text-togen-teal focus:ring-togen-teal">
            Enable notifications about this work order
          </label>
        </div>

        <!-- Submit -->
        <div class="flex items-center gap-3 mb-4">
          <button type="submit" id="pm-submit-btn"
                  class="px-6 py-3 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
            </svg>
            <span id="pm-submit-btn-text">Submit Work Order</span>
          </button>
          <button type="button" id="pm-make-recurring-submit-btn" onclick="pmSubmitAndRecurring(event)"
                  class="px-6 py-3 bg-white dark:bg-gray-900 border border-indigo-400 dark:border-indigo-500 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
            </svg>
            Make Recurring
          </button>
          <span id="pm-submit-lbl" class="text-sm text-gray-500 dark:text-gray-400"></span>
        </div>

      </form>

      <!-- Preview pane -->
      <section id="pm-preview-section" class="togen-pane hidden bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <div class="flex items-center justify-between mb-3 flex-wrap gap-2">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Preview</h2>
            <div class="flex items-center gap-2 flex-wrap">
              <a id="pm-download-link" href="#" download
                 class="px-4 py-2 text-sm font-medium rounded-xl border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors inline-flex items-center gap-2">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
                </svg>
                Download PDF
              </a>
              <button onclick="pmOpenViewer()"
                      class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-2">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"/>
                  <path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"/>
                </svg>
                Open
              </button>
              <button id="pm-open-manual-btn" onclick="pmOpenManual()"
                      class="hidden px-4 py-2 text-sm font-medium rounded-xl border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors inline-flex items-center gap-2">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z"/>
                </svg>
                Open Manual
              </button>
              <div class="relative">
                <button onclick="pmToggleWorkflowMenu(event)"
                        class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-2">
                  <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"/>
                  </svg>
                  Workflow
                </button>
                <div id="pm-workflow-menu"
                     class="hidden absolute right-0 top-full mt-2 w-64 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
                  <button onclick="pmOpenInQrCreator()"
                          class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
                    <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                      <path d="M3 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm1 0v3h3V4H4z"/>
                      <path d="M12 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1V4zm1 0v3h3V4h-3z"/>
                      <path d="M3 13a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm1 0v3h3v-3H4z"/>
                      <path d="M13 13h1v1h-1v-1zm2 0h1v1h-1v-1zm-2 2h1v1h-1v-1zm2 2h1v1h-1v-1zm2-2h1v1h-1v-1z"/>
                    </svg>
                    Open in Simple QR Creator
                  </button>
                  <button id="pm-manual-qr-entry" onclick="pmOpenManualInQrCreator()"
                          class="hidden w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 border-t border-gray-100 dark:border-gray-700">
                    <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                      <path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z"/>
                    </svg>
                    Open Manual in Simple QR Creator
                  </button>
                </div>
              </div>
            </div>
          </div>
          <iframe id="pm-preview-frame" class="w-full rounded-lg border border-gray-200 dark:border-gray-700" style="height:800px;" src="about:blank"></iframe>
        </div>
      </section>

      <!-- Clear row -->
      <div class="flex items-center gap-3 mb-2 flex-wrap">
        <button type="button" onclick="pmClearForm()"
                class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors whitespace-nowrap cursor-pointer">
          Clear / Submit Another
        </button>
      </div>

      </div><!-- /wor-preventive-wrapper -->

    </div><!-- /tool-work-order -->

    <!-- ===== BOTS TOOL CONTENT (DVI-803) ===== -->
    <div id="tool-bots" class="tool-content hidden">

      <!-- Bot selector (shown when more than one bot is available; a single bot
           skips straight to its page). Tiles are rendered by JS from the BOTS
           registry so future bots only need a registry entry + a page div. -->
      <div id="bots-selector" class="flex flex-col items-center justify-center py-12 hidden">
        <h2 class="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2">Select a Bot</h2>
        <p class="text-sm text-gray-500 dark:text-gray-400 mb-8">Choose a bot to open.</p>
        <div id="bots-selector-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 w-full max-w-4xl"></div>
      </div>

      <!-- ===== EMAIL BOT PAGE (DVI-817 P4) ===== -->
      <div id="bot-email-bot" class="hidden">
        <!-- Header row: back button + bot identity + Run now button -->
        <div class="flex items-center gap-3 mb-6">
          <button type="button" onclick="botsShowSelector()"
                  class="bots-back-btn hidden px-3 py-1.5 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer">
            &larr; All Bots
          </button>
          <div class="flex items-center gap-2 flex-1 min-w-0">
            <span class="text-togen-teal shrink-0">
              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-7 w-7">
                <rect x="4" y="8" width="16" height="11" rx="2" />
                <path d="M12 4v4" />
                <circle cx="12" cy="3" r="1" />
                <circle cx="9" cy="13" r="1" fill="currentColor" stroke="none" />
                <circle cx="15" cy="13" r="1" fill="currentColor" stroke="none" />
                <path d="M9 16.5h6" />
              </svg>
            </span>
            <div class="flex flex-col min-w-0">
              <h2 class="text-xl font-semibold text-gray-800 dark:text-gray-100">Winston</h2>
              <span class="text-xs text-gray-500 dark:text-gray-400">Email Bot</span>
            </div>
          </div>
          <!-- Run now button -->
          <button type="button" id="winston-run-now-btn" onclick="winstonOpenRunNow()"
                  class="shrink-0 px-3 py-1.5 text-sm font-medium rounded-xl bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer inline-flex items-center gap-1.5">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
            </svg>
            Run now
          </button>
        </div>

        <!-- Tab bar -->
        <div class="flex flex-nowrap overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden border-b border-gray-200 dark:border-gray-700 mb-4">
          <button id="winston-tab-status" onclick="winstonSwitchTab('status')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-togen-teal text-togen-teal cursor-pointer">
            Status
          </button>
          <button id="winston-tab-schedules" onclick="winstonSwitchTab('schedules')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
            Schedules
          </button>
          <button id="winston-tab-datelogic" onclick="winstonSwitchTab('datelogic')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
            Logic
          </button>
          <button id="winston-tab-history" onclick="winstonSwitchTab('history')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
            History
          </button>
          <button id="winston-tab-diagnostics" onclick="winstonSwitchTab('diagnostics')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
            Diagnostics
          </button>
          <button id="winston-tab-settings" onclick="winstonSwitchTab('settings')"
                  class="ml-auto flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
            Settings
          </button>
        </div>

        <!-- ===== STATUS TAB ===== -->
        <div id="winston-panel-status">
          <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
            <div class="px-6 py-5">
              <div class="flex items-center justify-between mb-4">
                <div>
                  <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Recent Runs</h3>
                  <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Status of the most recent pipeline runs, with outputs produced.</p>
                </div>
                <button type="button" onclick="winstonLoadStatus()"
                        class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
                  <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
                  </svg>
                  Refresh
                </button>
              </div>
              <div id="winston-status-loading" class="hidden text-center py-8">
                <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
                </svg>
              </div>
              <div id="winston-status-empty" class="hidden text-center py-8">
                <p class="text-sm text-gray-500 dark:text-gray-400">No runs yet. Use <strong>Run now</strong> or add a schedule.</p>
              </div>
              <div id="winston-status-error" class="hidden text-center py-8">
                <p id="winston-status-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
              </div>
              <div id="winston-status-list" class="space-y-3"></div>
            </div>
          </section>
        </div>

        <!-- ===== SETTINGS TAB ===== -->
        <div id="winston-panel-settings" class="hidden">
          <!-- Mailbox + ClickUp key pane -->
          <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
            <div class="px-6 py-5">
              <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Configuration</h3>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">Core credentials and mailbox for the Winston pipeline.</p>
              <div id="winston-cfg-loading" class="text-center py-6">
                <svg class="animate-spin h-6 w-6 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
                </svg>
              </div>
              <div id="winston-cfg-body" class="hidden space-y-4">
                <!-- Mailbox -->
                <label class="block">
                  <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Draft mailbox</span>
                  <input type="email" id="winston-cfg-mailbox" placeholder="sales@example.com"
                         class="w-full max-w-sm px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
                  <span class="text-xs text-gray-400 dark:text-gray-500">Mailbox where quote draft emails are created.</span>
                </label>
                <!-- ClickUp key -->
                <div>
                  <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">ClickUp API key</span>
                  <div class="flex items-center gap-2">
                    <span id="winston-cfg-clickup-badge" class="text-xs font-medium px-2 py-0.5 rounded-full"></span>
                    <button type="button" id="winston-cfg-clickup-edit-btn" onclick="winstonToggleClickupEdit()"
                            class="text-xs text-togen-teal hover:underline cursor-pointer">Update</button>
                  </div>
                  <div id="winston-cfg-clickup-edit" class="hidden mt-2 flex gap-2 max-w-sm">
                    <input type="password" id="winston-cfg-clickup-key" placeholder="pk_…"
                           class="flex-1 px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
                    <button type="button" onclick="winstonSaveCfgClickup()"
                            class="px-3 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">Save</button>
                  </div>
                  <p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">Also configurable via <code class="font-mono">CLICKUP_API_KEY</code> env var.</p>
                </div>
                <!-- Azure app config -->
                <div>
                  <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Azure app (Graph)</span>
                  <div class="flex items-center gap-2">
                    <span id="winston-cfg-azure-badge" class="text-xs font-medium px-2 py-0.5 rounded-full"></span>
                    <button type="button" onclick="winstonToggleEdit('azure')"
                            class="text-xs text-togen-teal hover:underline cursor-pointer">Update</button>
                  </div>
                  <div id="winston-cfg-azure-edit" class="hidden mt-2 space-y-2 max-w-sm">
                    <input type="text" id="winston-cfg-azure-client-id" placeholder="Client ID"
                           class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
                    <input type="text" id="winston-cfg-azure-tenant-id" placeholder="Tenant ID"
                           class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
                    <input type="password" id="winston-cfg-azure-secret" placeholder="Client Secret (leave blank to keep)"
                           class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
                    <button type="button" onclick="winstonSaveCfgAzure()"
                            class="px-3 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">Save</button>
                  </div>
                  <p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">Falls back to <code class="font-mono">AZURE_CLIENT_ID</code> / <code class="font-mono">AZURE_CLIENT_SECRET</code> / <code class="font-mono">AZURE_TENANT_ID</code> env vars.</p>
                </div>
                <!-- ClickUp list ID -->
                <div>
                  <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">ClickUp list ID</span>
                  <div class="flex items-center gap-2">
                    <span id="winston-cfg-clickuplist-value" class="text-sm text-gray-600 dark:text-gray-300 font-mono"></span>
                    <button type="button" onclick="winstonToggleEdit('clickuplist')"
                            class="text-xs text-togen-teal hover:underline cursor-pointer">Update</button>
                  </div>
                  <div id="winston-cfg-clickuplist-edit" class="hidden mt-2 flex gap-2 max-w-sm">
                    <input type="text" id="winston-cfg-clickuplist-input" placeholder="901234567890"
                           class="flex-1 px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
                    <button type="button" onclick="winstonSaveCfgField('clickuplist','clickup_list_id')"
                            class="px-3 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">Save</button>
                  </div>
                  <p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">Falls back to <code class="font-mono">CLICKUP_LIST_ID</code> env var.</p>
                </div>
                <!-- OneDrive user -->
                <div>
                  <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">OneDrive user</span>
                  <div class="flex items-center gap-2">
                    <span id="winston-cfg-onedrive-user" class="text-sm text-gray-600 dark:text-gray-300"></span>
                    <button type="button" onclick="winstonToggleEdit('onedrive')"
                            class="text-xs text-togen-teal hover:underline cursor-pointer">Update</button>
                  </div>
                  <div id="winston-cfg-onedrive-edit" class="hidden mt-2 flex gap-2 max-w-sm">
                    <input type="email" id="winston-cfg-onedrive-input" placeholder="togen@icastinc.com"
                           class="flex-1 px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
                    <button type="button" onclick="winstonSaveCfgField('onedrive','onedrive_user')"
                            class="px-3 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">Save</button>
                  </div>
                  <p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">Falls back to <code class="font-mono">WINSTON_ONEDRIVE_USER</code> env var.</p>
                </div>
                <!-- Takeoffs folder -->
                <div>
                  <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Takeoffs folder</span>
                  <div class="flex items-center gap-2">
                    <span id="winston-cfg-takeoffs-folder" class="text-sm text-gray-600 dark:text-gray-300 font-mono"></span>
                    <button type="button" onclick="winstonToggleEdit('takeoffs')"
                            class="text-xs text-togen-teal hover:underline cursor-pointer">Update</button>
                  </div>
                  <div id="winston-cfg-takeoffs-edit" class="hidden mt-2 flex gap-2 max-w-sm">
                    <input type="text" id="winston-cfg-takeoffs-input" placeholder="Infrastructure Precast Inc_ - Takeoffs"
                           class="flex-1 px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
                    <button type="button" onclick="winstonSaveCfgField('takeoffs','takeoffs_folder')"
                            class="px-3 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">Save</button>
                  </div>
                  <p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">Falls back to <code class="font-mono">WINSTON_TAKEOFFS_FOLDER</code> env var.</p>
                </div>
                <!-- Save mailbox button -->
                <div class="pt-2 flex items-center gap-3">
                  <button type="button" onclick="winstonSaveCfgMailbox()"
                          class="px-4 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">
                    Save mailbox
                  </button>
                  <span id="winston-cfg-status" class="text-xs text-gray-500 dark:text-gray-400"></span>
                </div>
              </div>
            </div>
          </section>

          <!-- Recipients pane -->
          <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
            <div class="px-6 py-5">
              <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Report Recipients</h3>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">Emails sent a summary after each pipeline run.</p>
              <div class="space-y-4">
                <!-- To -->
                <div>
                  <div class="flex items-center justify-between gap-2 mb-1">
                    <span class="text-sm font-medium text-gray-700 dark:text-gray-300">To</span>
                    <button type="button" onclick="winstonRecipientAdd('to')"
                            class="shrink-0 inline-flex items-center justify-center h-6 w-6 rounded-md bg-togen-teal hover:bg-togen-teal/90 text-white transition-colors cursor-pointer">
                      <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg>
                    </button>
                  </div>
                  <input type="email" id="winston-rcp-to-input" placeholder="name@example.com"
                         onkeydown="if(event.key==='Enter'){event.preventDefault();winstonRecipientAdd('to');}"
                         class="hidden w-full mb-2 px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-togen-teal/40">
                  <ul id="winston-rcp-to-list" class="border border-gray-100 dark:border-gray-800 rounded-lg divide-y divide-gray-100 dark:divide-gray-800 px-3 min-h-[2rem]"></ul>
                </div>
                <!-- CC -->
                <div>
                  <div class="flex items-center justify-between gap-2 mb-1">
                    <span class="text-sm font-medium text-gray-700 dark:text-gray-300">CC</span>
                    <button type="button" onclick="winstonRecipientAdd('cc')"
                            class="shrink-0 inline-flex items-center justify-center h-6 w-6 rounded-md bg-togen-teal hover:bg-togen-teal/90 text-white transition-colors cursor-pointer">
                      <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg>
                    </button>
                  </div>
                  <input type="email" id="winston-rcp-cc-input" placeholder="name@example.com"
                         onkeydown="if(event.key==='Enter'){event.preventDefault();winstonRecipientAdd('cc');}"
                         class="hidden w-full mb-2 px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-togen-teal/40">
                  <ul id="winston-rcp-cc-list" class="border border-gray-100 dark:border-gray-800 rounded-lg divide-y divide-gray-100 dark:divide-gray-800 px-3 min-h-[2rem]"></ul>
                </div>
              </div>
              <div class="mt-4 flex items-center gap-3">
                <button type="button" onclick="winstonSaveRecipients()"
                        class="px-4 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">
                  Save
                </button>
                <span id="winston-rcp-status" class="text-xs text-gray-500 dark:text-gray-400"></span>
              </div>
            </div>
          </section>

          <!-- Quotes folder paths — link only, don't duplicate -->
          <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
            <div class="px-6 py-5">
              <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Quotes folder paths</h3>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Completed PDF output destinations are configured in two places:</p>
              <ul class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
                <li>
                  <span class="font-medium">Shared (all users):</span>
                  configured in
                  <button type="button" onclick="openTool('togen-admin'); setTimeout(function(){togenadminSetTab('documents');},200)"
                          class="text-togen-teal hover:underline font-medium cursor-pointer">
                    Togen Admin &rarr; Documents &rarr; Quotes
                  </button>
                </li>
                <li>
                  <span class="font-medium">Personal (per user):</span>
                  configured in
                  <button type="button" onclick="openTool('user-settings'); setTimeout(function(){userSettingsSetTab('documents');},200)"
                          class="text-togen-teal hover:underline font-medium cursor-pointer">
                    User Settings &rarr; Documents
                  </button>
                </li>
              </ul>
            </div>
          </section>

          <!-- Diagnostics retention pane -->
          <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mt-4">
            <div class="px-6 py-5">
              <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Diagnostics Retention</h3>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">How much diagnostic history Winston keeps. Older runs and logs are pruned automatically.</p>
              <div class="space-y-4 max-w-sm">
                <label class="block">
                  <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Max runs retained</span>
                  <input type="number" id="winston-ret-max-runs" min="1" step="1"
                         class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
                  <span class="text-xs text-gray-400 dark:text-gray-500">Oldest runs beyond this count are deleted.</span>
                </label>
                <label class="block">
                  <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Max log lines per run</span>
                  <input type="number" id="winston-ret-max-log-lines" min="1" step="1"
                         class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
                  <span class="text-xs text-gray-400 dark:text-gray-500">Oldest lines beyond this cap are dropped during a run.</span>
                </label>
                <label class="block">
                  <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Retention days (optional)</span>
                  <input type="number" id="winston-ret-retention-days" min="1" step="1" placeholder="Unlimited"
                         class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
                  <span class="text-xs text-gray-400 dark:text-gray-500">Runs older than this are deleted. Leave blank for no age limit.</span>
                </label>
              </div>
              <div class="pt-4 flex items-center gap-3">
                <button type="button" onclick="winstonSaveRetention()"
                        class="px-4 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">
                  Save retention
                </button>
                <span id="winston-ret-status" class="text-xs text-gray-500 dark:text-gray-400"></span>
              </div>
            </div>
          </section>
        </div>

        <!-- ===== SCHEDULES TAB ===== -->
        <div id="winston-panel-schedules" class="hidden">
          <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
            <div class="px-6 py-5">
              <div class="flex items-center justify-between mb-4">
                <div>
                  <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Pipeline Schedules</h3>
                  <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Automatic daily runs of the quote pipeline.</p>
                </div>
                <button type="button" onclick="winstonOpenSched()"
                        class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer inline-flex items-center gap-1.5">
                  <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
                  </svg>
                  Add
                </button>
              </div>
              <div id="winston-sched-loading" class="hidden text-center py-8">
                <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
                </svg>
              </div>
              <div id="winston-sched-empty" class="hidden text-center py-8">
                <p class="text-sm text-gray-500 dark:text-gray-400">No schedules yet. Click <span class="font-medium">Add</span> to create one.</p>
              </div>
              <div id="winston-sched-error" class="hidden text-center py-8">
                <p id="winston-sched-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
              </div>
              <div id="winston-sched-table-wrap" class="hidden overflow-x-auto">
                <table class="w-full text-sm">
                  <thead>
                    <tr class="border-b border-gray-200 dark:border-gray-700 text-left">
                      <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Name</th>
                      <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Logic</th>
                      <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Cadence</th>
                      <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Time</th>
                      <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Enabled</th>
                      <th class="pb-2 font-semibold text-gray-600 dark:text-gray-300 text-right">Actions</th>
                    </tr>
                  </thead>
                  <tbody id="winston-sched-tbody" class="divide-y divide-gray-100 dark:divide-gray-800"></tbody>
                </table>
              </div>
            </div>
          </section>
        </div>

        <!-- ===== RUNS HISTORY TAB ===== -->
        <div id="winston-panel-history" class="hidden">
          <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
            <div class="px-6 py-5">
              <div class="flex items-center justify-between mb-4">
                <div>
                  <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">History</h3>
                  <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Per-run records for the Winston quote pipeline.</p>
                </div>
                <button type="button" onclick="winstonLoadHistory()"
                        class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
                  <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
                  </svg>
                  Refresh
                </button>
              </div>
              <div id="winston-hist-loading" class="hidden text-center py-8">
                <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
                </svg>
              </div>
              <div id="winston-hist-empty" class="hidden text-center py-8">
                <p class="text-sm text-gray-500 dark:text-gray-400">No runs yet. Use <strong>Run now</strong> or add a schedule.</p>
              </div>
              <div id="winston-hist-error" class="hidden text-center py-8">
                <p id="winston-hist-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
              </div>
              <div id="winston-hist-table-wrap" class="hidden overflow-x-auto">
                <table class="w-full text-sm">
                  <thead>
                    <tr class="border-b border-gray-200 dark:border-gray-700 text-left">
                      <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300 whitespace-nowrap">Run at</th>
                      <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Date</th>
                      <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Status</th>
                      <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300 text-right">Found</th>
                      <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300 text-right">Drafts</th>
                      <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300 text-right">Routed</th>
                      <th class="pb-2 font-semibold text-gray-600 dark:text-gray-300">Error</th>
                    </tr>
                  </thead>
                  <tbody id="winston-hist-tbody" class="divide-y divide-gray-100 dark:divide-gray-800"></tbody>
                </table>
              </div>
            </div>
          </section>
        </div>

        <!-- ===== DIAGNOSTICS TAB ===== -->
        <div id="winston-panel-diagnostics" class="hidden">
          <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
            <div class="px-6 py-5">
              <div class="flex flex-wrap items-end justify-between gap-3 mb-4">
                <div class="min-w-0">
                  <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Diagnostics</h3>
                  <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Step-by-step pipeline flow with per-step logs and downloadable artifacts.</p>
                </div>
                <label class="block">
                  <span class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Run</span>
                  <select id="winston-diag-run-select" onchange="winstonSelectDiagRun(this.value)"
                          class="px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40 min-w-[16rem]">
                  </select>
                </label>
              </div>
              <div id="winston-diag-loading" class="hidden text-center py-8">
                <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
                </svg>
              </div>
              <div id="winston-diag-empty" class="hidden text-center py-8">
                <p class="text-sm text-gray-500 dark:text-gray-400">No runs to diagnose yet.</p>
              </div>
              <div id="winston-diag-error" class="hidden text-center py-8">
                <p id="winston-diag-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
              </div>
              <div id="winston-diag-flow"></div>
            </div>
          </section>
        </div>

        <!-- ===== DATE LOGIC TAB ===== -->
        <div id="winston-panel-datelogic" class="hidden">
          <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
            <div class="px-6 py-5">
              <div class="flex flex-wrap items-start justify-between gap-3 mb-4">
                <div class="min-w-0">
                  <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Logic pipelines</h3>
                  <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Each pipeline runs the same processes in the linear order below; the logic only sets which date(s) get processed. Renaming a process changes its display label only.</p>
                </div>
                <button type="button" onclick="winstonOpenNewDateLogic()"
                        class="flex-shrink-0 px-3 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">
                  + New Logic pipeline
                </button>
              </div>
              <div id="winston-dl-loading" class="hidden text-center py-8">
                <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
                </svg>
              </div>
              <div id="winston-dl-error" class="hidden text-center py-8">
                <p id="winston-dl-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
              </div>
              <div id="winston-dl-list" class="space-y-4"></div>
            </div>
          </section>
        </div>
      </div>

      <!-- ===== WINSTON: New Date Logic overlay ===== -->
      <div id="winston-dl-overlay" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40">
        <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md">
          <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">New Logic pipeline</h3>
          </div>
          <div class="px-6 py-5 space-y-4">
            <label class="block">
              <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</span>
              <input type="text" id="winston-dl-name" maxlength="120" placeholder="e.g. Two business days ago"
                     class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal" />
            </label>
            <label class="block">
              <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Base date rule</span>
              <select id="winston-dl-base-rule"
                      class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal">
              </select>
              <p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">Determines which date(s) the pipeline processes when it runs.</p>
            </label>
            <p id="winston-dl-form-error" class="hidden text-xs text-red-600 dark:text-red-400"></p>
          </div>
          <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
            <button type="button" onclick="winstonCloseNewDateLogic()"
                    class="px-4 py-2 text-sm font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">
              Cancel
            </button>
            <button type="button" onclick="winstonSaveNewDateLogic()"
                    class="px-4 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">
              Create
            </button>
          </div>
        </div>
      </div>

      <!-- ===== WINSTON: Process detail overlay (read-only) ===== -->
      <div id="winston-dl-detail-overlay" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40" onclick="if(event.target===this)winstonCloseProcessDetail()">
        <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg max-h-[85vh] overflow-y-auto">
          <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between gap-3">
            <div class="min-w-0">
              <h3 id="winston-dld-title" class="text-base font-semibold text-gray-800 dark:text-gray-100"></h3>
              <p id="winston-dld-seq" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"></p>
              <p id="winston-dld-defnote" class="hidden text-xs text-gray-400 dark:text-gray-500 mt-0.5"></p>
            </div>
            <button type="button" onclick="winstonCloseProcessDetail()" class="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer" title="Close">
              <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path></svg>
            </button>
          </div>
          <div class="px-6 py-5 space-y-4">
            <div>
              <p class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-1">What it does</p>
              <p id="winston-dld-summary" class="text-sm text-gray-700 dark:text-gray-200"></p>
            </div>
            <div>
              <p class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-1">System</p>
              <p id="winston-dld-system" class="text-sm text-gray-700 dark:text-gray-200"></p>
            </div>
            <div>
              <p class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-1">Queries</p>
              <p id="winston-dld-queries" class="text-sm text-gray-700 dark:text-gray-200"></p>
            </div>
            <div>
              <p class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-1">Data it pulls</p>
              <p id="winston-dld-pulls" class="text-sm text-gray-700 dark:text-gray-200"></p>
            </div>
            <div>
              <p class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-1">Data it pushes to the next step</p>
              <p id="winston-dld-pushes" class="text-sm text-gray-700 dark:text-gray-200"></p>
            </div>
          </div>
          <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
            <button type="button" onclick="winstonCloseProcessDetail()"
                    class="px-4 py-2 text-sm font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">
              Close
            </button>
          </div>
        </div>
      </div>

      <!-- ===== WINSTON: Schedule add/edit overlay ===== -->
      <div id="winston-sched-overlay" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40">
        <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md">
          <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
            <h3 id="winston-sched-dlg-title" class="text-base font-semibold text-gray-800 dark:text-gray-100">Add Schedule</h3>
          </div>
          <div class="px-6 py-5 space-y-4">
            <label class="block">
              <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</span>
              <input type="text" id="winston-sched-name" maxlength="120" placeholder="Daily quotes"
                     class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal" />
            </label>
            <label class="block">
              <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Logic</span>
              <select id="winston-sched-profile"
                      class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal">
                <option value="prev_biz">Previous business day (7am pattern)</option>
                <option value="today">Today's date (11am / 3pm pattern)</option>
              </select>
            </label>
            <label class="block">
              <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Cadence</span>
              <select id="winston-sched-cadence" onchange="winstonSchedCadenceChanged()"
                      class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal">
                <option value="daily">Every day</option>
                <option value="weekdays">Weekdays (Mon–Fri)</option>
                <option value="custom">Custom days</option>
              </select>
            </label>
            <div id="winston-sched-custom-wrap" class="hidden">
              <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Days of week</span>
              <div class="flex flex-wrap gap-2">
                <label class="flex items-center gap-1 text-sm cursor-pointer"><input type="checkbox" value="0" class="winston-day-cb rounded"> Mon</label>
                <label class="flex items-center gap-1 text-sm cursor-pointer"><input type="checkbox" value="1" class="winston-day-cb rounded"> Tue</label>
                <label class="flex items-center gap-1 text-sm cursor-pointer"><input type="checkbox" value="2" class="winston-day-cb rounded"> Wed</label>
                <label class="flex items-center gap-1 text-sm cursor-pointer"><input type="checkbox" value="3" class="winston-day-cb rounded"> Thu</label>
                <label class="flex items-center gap-1 text-sm cursor-pointer"><input type="checkbox" value="4" class="winston-day-cb rounded"> Fri</label>
                <label class="flex items-center gap-1 text-sm cursor-pointer"><input type="checkbox" value="5" class="winston-day-cb rounded"> Sat</label>
                <label class="flex items-center gap-1 text-sm cursor-pointer"><input type="checkbox" value="6" class="winston-day-cb rounded"> Sun</label>
              </div>
            </div>
            <div class="flex gap-3">
              <label class="flex-1">
                <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hour (0–23)</span>
                <input type="number" id="winston-sched-hour" min="0" max="23" value="6"
                       class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal" />
              </label>
              <label class="flex-1">
                <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Minute (0–59)</span>
                <input type="number" id="winston-sched-minute" min="0" max="59" value="0"
                       class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal" />
              </label>
            </div>
            <label class="flex items-center gap-2 cursor-pointer">
              <input type="checkbox" id="winston-sched-enabled" checked class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
              <span class="text-sm text-gray-700 dark:text-gray-300">Enabled</span>
            </label>
            <p id="winston-sched-form-error" class="hidden text-xs text-red-600 dark:text-red-400"></p>
          </div>
          <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
            <button type="button" onclick="winstonCloseSched()"
                    class="px-4 py-2 text-sm font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">
              Cancel
            </button>
            <button type="button" onclick="winstonSaveSched()"
                    class="px-4 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">
              Save
            </button>
          </div>
        </div>
      </div>

      <!-- ===== WINSTON: Run now overlay ===== -->
      <div id="winston-run-overlay" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40">
        <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-sm">
          <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Run Winston now</h3>
          </div>
          <div class="px-6 py-5 space-y-4">
            <label class="block">
              <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Quote date</span>
              <input type="date" id="winston-run-date"
                     class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal" />
              <p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">ClickUp tasks will be pulled for this date.</p>
            </label>
            <p id="winston-run-form-error" class="hidden text-xs text-red-600 dark:text-red-400"></p>
            <div id="winston-run-progress" class="hidden">
              <div class="flex items-center gap-2 text-sm text-togen-teal">
                <svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
                </svg>
                <span>Pipeline running…</span>
              </div>
              <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">This may take a minute. You can close this dialog — check the History tab for the result.</p>
            </div>
          </div>
          <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
            <button type="button" id="winston-run-cancel-btn" onclick="winstonCloseRunNow()"
                    class="px-4 py-2 text-sm font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">
              Cancel
            </button>
            <button type="button" id="winston-run-submit-btn" onclick="winstonSubmitRunNow()"
                    class="px-4 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">
              Run
            </button>
          </div>
        </div>
      </div>

    </div><!-- /tool-bots -->

    <!-- ===== WORK ORDER ADMIN TOOL CONTENT ===== -->
    <div id="tool-wor-admin" class="tool-content hidden">

      <!-- Activity header panel (DVI-382) -->
      <div id="woradmin-activity-panel" class="hidden mb-4 togen-pane rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
        <div class="flex items-center justify-between px-4 py-2.5">
          <div class="flex items-center gap-2">
            <svg class="h-4 w-4 text-togen-teal" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/>
            </svg>
            <span class="text-sm font-semibold text-gray-700 dark:text-gray-200">Activity</span>
          </div>
          <button id="woradmin-activity-toggle" type="button" onclick="woradminToggleActivity()"
                  class="text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-togen-teal transition-colors inline-flex items-center gap-1 cursor-pointer">
            <span id="woradmin-activity-toggle-label">Hide</span>
            <svg id="woradmin-activity-chevron" class="h-3.5 w-3.5 transition-transform" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
            </svg>
          </button>
        </div>
        <div id="woradmin-activity-body" class="px-4 pb-3">
          <div class="flex items-center justify-between gap-4 flex-wrap">
            <div id="woradmin-activity-chips" class="flex items-center gap-2 flex-wrap"></div>
            <div id="woradmin-activity-spark" class="flex items-center gap-2" title="Submissions, last 7 days"></div>
          </div>
          <div id="woradmin-activity-duesoon" class="hidden mt-3"></div>
        </div>
      </div>

      <!-- Type tabs -->
      <div class="flex flex-nowrap overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden border-b border-gray-200 dark:border-gray-700 mb-4">
        <button onclick="woradminSetTab('all')" id="woradmin-tab-all"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 border-togen-teal text-togen-teal transition-colors cursor-pointer">All</button>
        <button onclick="woradminSetTab('maintenance')" id="woradmin-tab-maintenance" data-wor-type="maintenance"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors cursor-pointer">Maintenance</button>
        <button onclick="woradminSetTab('mobilemachine')" id="woradmin-tab-mobilemachine" data-wor-type="mobilemachine"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors cursor-pointer">Mobile Machine</button>
        <button onclick="woradminSetTab('safety')" id="woradmin-tab-safety" data-wor-type="safety"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors cursor-pointer">Safety</button>
        <button onclick="woradminSetTab('preventive')" id="woradmin-tab-preventive" data-wor-type="preventive"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors cursor-pointer">PMs</button>
        <button onclick="woradminSetTab('recurring')" id="woradmin-tab-recurring"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors cursor-pointer">Recurring</button>
        <button onclick="woradminSetTab('calendar')" id="woradmin-tab-calendar"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors cursor-pointer">Calendar</button>
        <button onclick="woradminSetTab('settings')" id="woradmin-tab-settings"
                class="ml-auto flex-shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors cursor-pointer">Settings</button>
      </div>

      <!-- List view: filters, work order list, preview (hidden on Settings tab) -->
      <div id="woradmin-listview">

      <!-- Status filter checkboxes -->
      <div class="flex items-center gap-4 mb-4 flex-wrap">
        <span class="text-sm font-medium text-gray-600 dark:text-gray-400">Show:</span>
        <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
          <input type="checkbox" id="woradmin-filter-new" checked onchange="woradminApplyFilter()"
                 class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> New
        </label>
        <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
          <input type="checkbox" id="woradmin-filter-inprogress" checked onchange="woradminApplyFilter()"
                 class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> In Progress
        </label>
        <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
          <input type="checkbox" id="woradmin-filter-completed" onchange="woradminApplyFilter()"
                 class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Completed
        </label>
        <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
          <input type="checkbox" id="woradmin-filter-cancelled" onchange="woradminApplyFilter()"
                 class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Cancelled
        </label>
        <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
          <input type="checkbox" id="woradmin-filter-recurring" onchange="woradminApplyFilter()"
                 class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Recurring
        </label>
        <div class="ml-auto flex items-center gap-2">
          <!-- Filter button + menu (DVI-457) -->
          <div class="relative">
            <button id="woradmin-filter-btn" onclick="woradminToggleFilterMenu(event)" class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-1.5">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" clip-rule="evenodd"/>
              </svg>
              Filter
              <span id="woradmin-filter-count" class="hidden inline-flex items-center justify-center min-w-[1.1rem] h-[1.1rem] px-1 rounded-full bg-togen-teal text-white text-[0.65rem] font-semibold"></span>
            </button>
            <div id="woradmin-filter-menu" class="hidden absolute right-0 top-full mt-2 w-72 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 z-50 p-4 space-y-4">
              <div>
                <label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">By Submitting User</label>
                <select id="woradmin-filter-user" onchange="woradminApplyFilter()"
                        class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-togen-teal/40">
                  <option value="">All users</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 mb-1.5">By Date Range</label>
                <div class="flex items-center gap-2">
                  <div class="flex-1">
                    <span class="block text-[0.65rem] text-gray-500 dark:text-gray-400 mb-0.5">From</span>
                    <input type="date" id="woradmin-filter-date-from" onchange="woradminApplyFilter()"
                           class="w-full px-2 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-togen-teal/40">
                  </div>
                  <div class="flex-1">
                    <span class="block text-[0.65rem] text-gray-500 dark:text-gray-400 mb-0.5">To</span>
                    <input type="date" id="woradmin-filter-date-to" onchange="woradminApplyFilter()"
                           class="w-full px-2 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-togen-teal/40">
                  </div>
                </div>
              </div>
              <div class="flex items-center justify-between pt-1 border-t border-gray-100 dark:border-gray-700">
                <button onclick="woradminClearFilters()" class="text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-togen-teal transition-colors">Clear filters</button>
                <button onclick="woradminToggleFilterMenu(event)" class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors">Done</button>
              </div>
            </div>
          </div>
          <button onclick="woradminLoadList()" class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-1.5">
            <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
            </svg>
            Refresh
          </button>
        </div>
      </div>

      <!-- Work order list -->
      <section id="woradmin-list-section" class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-3">Work Order Requests</h2>
          <div id="woradmin-list" class="space-y-2">
            <p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
          </div>
        </div>
      </section>

      <!-- Preview pane (shared, hidden until a WOR is selected) -->
      <section id="woradmin-preview-section" class="togen-pane hidden bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <div class="flex items-center justify-between mb-3 flex-wrap gap-2">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Preview</h2>
            <div class="flex items-center gap-2 flex-wrap">
              <a id="woradmin-download-link" href="#" download
                 class="px-3 py-1.5 text-xs font-medium rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors inline-flex items-center gap-1.5">
                <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
                </svg>
                Download
              </a>
              <button id="woradmin-edit-btn" onclick="woradminEditInWorTool()"
                      class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-1.5">
                <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/>
                </svg>
                Open in Work Order Request
              </button>
              <button onclick="woradminOpenViewer()"
                      class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-1.5">
                <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"/>
                  <path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"/>
                </svg>
                Open
              </button>
              <button id="woradmin-open-manual-btn" onclick="woradminOpenManual()"
                      class="hidden px-3 py-1.5 text-xs font-medium rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors inline-flex items-center gap-1.5">
                <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z"/>
                </svg>
                Open Manual
              </button>
              <button id="woradmin-attach-manual-btn" onclick="woradminAttachManual()"
                      class="hidden px-3 py-1.5 text-xs font-medium rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors inline-flex items-center gap-1.5">
                <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" clip-rule="evenodd"/>
                </svg>
                <span id="woradmin-attach-manual-label">Attach Manual</span>
              </button>
              <button onclick="woradminPrint()"
                      class="togen-print-btn px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-1.5">
                <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M5 4v3H4a2 2 0 00-2 2v3a2 2 0 002 2h1v2a1 1 0 001 1h8a1 1 0 001-1v-2h1a2 2 0 002-2V9a2 2 0 00-2-2h-1V4a1 1 0 00-1-1H6a1 1 0 00-1 1zm8 0H7v3h6V4zm0 8H7v4h6v-4z" clip-rule="evenodd"/>
                </svg>
                Print
              </button>
              <div class="relative">
                <button onclick="woradminToggleShareMenu(event)"
                        class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-1.5">
                  <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path d="M15 8a3 3 0 10-2.977-2.63l-4.94 2.47a3 3 0 100 4.319l4.94 2.47a3 3 0 10.895-1.789l-4.94-2.47a3.027 3.027 0 000-.74l4.94-2.47C12.456 7.68 13.19 8 14 8h1z"/>
                  </svg>
                  Share
                </button>
                <div id="woradmin-share-menu"
                     class="hidden absolute right-0 top-full mt-2 w-56 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
                  <button onclick="woradminOpenEmailDialog()"
                          class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
                    <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                      <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
                      <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
                    </svg>
                    Email
                  </button>
                </div>
              </div>
              <div class="relative">
                <button onclick="woradminToggleWorkflowMenu(event)"
                        class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-1.5">
                  <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"/>
                  </svg>
                  Workflow
                </button>
                <div id="woradmin-workflow-menu"
                     class="hidden absolute right-0 top-full mt-2 w-64 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
                  <button onclick="woradminOpenInQrCreator()"
                          class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
                    <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                      <path d="M3 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm1 0v3h3V4H4z"/>
                      <path d="M12 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1V4zm1 0v3h3V4h-3z"/>
                      <path d="M3 13a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm1 0v3h3v-3H4z"/>
                      <path d="M13 13h1v1h-1v-1zm2 0h1v1h-1v-1zm-2 2h1v1h-1v-1zm2 2h1v1h-1v-1zm2-2h1v1h-1v-1z"/>
                    </svg>
                    Open in Simple QR Creator
                  </button>
                  <button id="woradmin-manual-qr-entry" onclick="woradminOpenManualInQrCreator()"
                          class="hidden w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 border-t border-gray-100 dark:border-gray-700">
                    <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                      <path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z"/>
                    </svg>
                    Open Manual in Simple QR Creator
                  </button>
                </div>
              </div>
              <button onclick="woradminClosePreview()"
                      class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
                Close
              </button>
            </div>
          </div>
          <p id="woradmin-notify-note" class="hidden text-xs text-togen-teal dark:text-teal-300 mb-3"></p>
          <iframe id="woradmin-preview-frame" class="w-full rounded-lg border border-gray-200 dark:border-gray-700" style="height:800px;" src="about:blank"></iframe>
        </div>
      </section>

      </div><!-- /woradmin-listview -->

      <!-- Settings view: per-user WO notifications (reuses the User Settings grid component) -->
      <div id="woradmin-settingsview" class="hidden">
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Work Order Notifications</h2>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Choose which email updates you receive for work orders. As a Work Order Admin, these apply to all work orders submitted by any user. All notifications are off by default.</p>
            <div class="overflow-x-auto">
              <table class="w-full text-sm border-collapse">
                <thead>
                  <tr class="text-gray-500 dark:text-gray-400">
                    <th class="text-left font-medium py-2 pr-4">Work Order Type</th>
                    <th class="text-center font-medium px-2 py-2">New</th>
                    <th class="text-center font-medium px-2 py-2">Status Change</th>
                    <th class="text-center font-medium px-2 py-2">Completed</th>
                    <th class="text-center font-medium px-2 py-2">1 Day Before Due<sup class="text-togen-teal">*</sup></th>
                    <th class="text-center font-medium px-2 py-2">Overdue<sup class="text-togen-teal">*</sup></th>
                  </tr>
                </thead>
                <tbody id="woradmin-notify-grid">
                  <!-- populated by JS -->
                </tbody>
              </table>
            </div>
            <p class="text-xs text-gray-400 dark:text-gray-500 mt-3"><sup class="text-togen-teal">*</sup> Applies only to recurring work orders.</p>
            <div class="mt-4 flex items-center gap-3">
              <button id="woradmin-notify-save-btn" onclick="woradminSaveNotifications()"
                      class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
                </svg>
                Save
              </button>
              <span id="woradmin-notify-save-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
            </div>
          </div>
        </section>

        <!-- Policy pane (DVI-597): company-wide enforced notifications -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Policy</h2>
            <div class="mt-3">
              <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Policy Enforced Notifications</h3>
              <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">The requesting user of a work order will be notified according to the selections below as a matter of policy. When a checkbox is enabled for a work order type and status, the requesting user is always emailed for that event &mdash; in addition to any per-work-order notification they opted into on the Work Order Request, and regardless of the per-entry notify setting in this admin tool.</p>
              <div class="overflow-x-auto">
                <table class="w-full text-sm border-collapse">
                  <thead>
                    <tr class="text-gray-500 dark:text-gray-400">
                      <th class="text-left font-medium py-2 pr-4">Work Order Type</th>
                      <th class="text-center font-medium px-2 py-2">New</th>
                      <th class="text-center font-medium px-2 py-2">Status Change</th>
                      <th class="text-center font-medium px-2 py-2">Completed</th>
                      <th class="text-center font-medium px-2 py-2">1 Day Before Due<sup class="text-togen-teal">*</sup></th>
                      <th class="text-center font-medium px-2 py-2">Overdue<sup class="text-togen-teal">*</sup></th>
                    </tr>
                  </thead>
                  <tbody id="woradmin-policy-grid">
                    <!-- populated by JS -->
                  </tbody>
                </table>
              </div>
              <p class="text-xs text-gray-400 dark:text-gray-500 mt-3"><sup class="text-togen-teal">*</sup> Applies only to recurring work orders.</p>
              <div class="mt-4 flex items-center gap-3">
                <button id="woradmin-policy-save-btn" onclick="woradminSavePolicy()"
                        class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
                  <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
                  </svg>
                  Save
                </button>
                <span id="woradmin-policy-save-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
              </div>
            </div>
          </div>
        </section>

        <!-- Primary recipients pane (DVI-405) -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Primary</h2>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Primary recipients receive <strong>every</strong> notification for a work order type. All of their notification checkboxes for that type are enforced on and locked in their User Settings.</p>
            <div id="woradmin-primary-lists" class="space-y-5">
              <!-- populated by JS -->
            </div>
            <span id="woradmin-primary-status" class="block mt-3 text-sm"></span>
          </div>
        </section>
      </div><!-- /woradmin-settingsview -->

      <!-- Calendar view (DVI-746): Work Order date events only, Day-focused. -->
      <div id="woradmin-calendarview" class="hidden">
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2 flex items-center justify-between flex-wrap gap-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Work Order Calendar</h2>
            <div class="flex items-center gap-2">
              <div class="inline-flex rounded-lg overflow-hidden border border-white/30" role="group" aria-label="Calendar view">
                <button id="woracal-view-month" onclick="woracalSetView('month')"
                        class="px-3 py-1 text-xs font-semibold text-white transition-colors">Month</button>
                <button id="woracal-view-week" onclick="woracalSetView('week')"
                        class="px-3 py-1 text-xs font-semibold text-white transition-colors border-l border-white/30">Week</button>
                <button id="woracal-view-day" onclick="woracalSetView('day')"
                        class="px-3 py-1 text-xs font-semibold text-white transition-colors border-l border-white/30">Day</button>
              </div>
              <button onclick="openTool('calendar', 'Calendar')"
                      class="px-3 py-1 text-xs font-semibold rounded-lg bg-white/15 hover:bg-white/25 text-white border border-white/30 transition-colors inline-flex items-center gap-1.5">
                <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
                Open Full Calendar
              </button>
            </div>
          </div>
          <div class="px-6 py-5">
            <!-- Nav row: prev / period label / next -->
            <div class="flex items-center justify-between mb-5">
              <button onclick="woracalPrev()" aria-label="Previous"
                      class="p-2 rounded-xl text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-togen-teal transition-colors">
                <svg class="h-9 w-9" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
              </button>
              <div class="text-center">
                <div id="woracal-label" class="text-xl font-bold text-gray-800 dark:text-gray-100"></div>
                <button onclick="woracalToday()" class="mt-1 text-xs font-medium text-togen-teal hover:underline">Today</button>
              </div>
              <button onclick="woracalNext()" aria-label="Next"
                      class="p-2 rounded-xl text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-togen-teal transition-colors">
                <svg class="h-9 w-9" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
              </button>
            </div>
            <span id="woracal-status" class="block text-center text-xs text-gray-400 dark:text-gray-500 mb-3"></span>
            <!-- Month view -->
            <div id="woracal-month-view" class="hidden">
              <div class="grid grid-cols-7 gap-1 mb-1 text-center">
                <div class="py-1 text-xs font-semibold text-gray-400 dark:text-gray-500">Sun</div>
                <div class="py-1 text-xs font-semibold text-gray-400 dark:text-gray-500">Mon</div>
                <div class="py-1 text-xs font-semibold text-gray-400 dark:text-gray-500">Tue</div>
                <div class="py-1 text-xs font-semibold text-gray-400 dark:text-gray-500">Wed</div>
                <div class="py-1 text-xs font-semibold text-gray-400 dark:text-gray-500">Thu</div>
                <div class="py-1 text-xs font-semibold text-gray-400 dark:text-gray-500">Fri</div>
                <div class="py-1 text-xs font-semibold text-gray-400 dark:text-gray-500">Sat</div>
              </div>
              <div id="woracal-month-grid" class="grid grid-cols-7 gap-1"></div>
            </div>
            <!-- Week view -->
            <div id="woracal-week-view" class="hidden space-y-2"></div>
            <!-- Day view -->
            <div id="woracal-day-view" class="hidden"></div>
          </div>
        </section>
      </div><!-- /woradmin-calendarview -->

    </div><!-- /tool-wor-admin -->

    <!-- ===== RECURRING WORK ORDER MODAL (shared) ===== -->
    <div id="recurring-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
      <div class="absolute inset-0 bg-black/40" onclick="recurringModalClose()"></div>
      <div class="relative bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 p-6">
        <h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-4">Make Recurring</h3>

        <div class="space-y-4">
          <!-- Frequency -->
          <div>
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Frequency</label>
            <div class="flex gap-2 mb-2">
              <button type="button" onclick="recurringSetFreq(30)" id="recurring-freq-30"
                      class="flex-1 px-3 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-togen-teal hover:text-togen-teal transition-colors">30 Days</button>
              <button type="button" onclick="recurringSetFreq(90)" id="recurring-freq-90"
                      class="flex-1 px-3 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-togen-teal hover:text-togen-teal transition-colors">90 Days</button>
              <button type="button" onclick="recurringSetFreq(180)" id="recurring-freq-180"
                      class="flex-1 px-3 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-togen-teal hover:text-togen-teal transition-colors">180 Days</button>
            </div>
            <div class="flex items-center gap-2">
              <span class="text-xs text-gray-500 dark:text-gray-400">or</span>
              <input type="number" id="recurring-freq-custom" min="1" placeholder="Custom days"
                     oninput="recurringSetFreq(parseInt(this.value)||0)"
                     class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              <span class="text-xs text-gray-500 dark:text-gray-400">days</span>
            </div>
          </div>

          <!-- Start Date -->
          <div>
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Start Date</label>
            <input type="date" id="recurring-start-date"
                   class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
          </div>

          <!-- End Date -->
          <div>
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">End Date <span class="text-gray-400 dark:text-gray-500">(optional — leave blank to run indefinitely)</span></label>
            <input type="date" id="recurring-end-date"
                   class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
          </div>

          <p id="recurring-status" class="text-sm"></p>
        </div>

        <div class="flex justify-end gap-3 mt-6">
          <button type="button" onclick="recurringModalClose()"
                  class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">Cancel</button>
          <button type="button" onclick="recurringModalSave()" id="recurring-save-btn"
                  class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors">Save</button>
        </div>
      </div>
    </div>

    <!-- ===== SYNC ADMIN TOOL CONTENT ===== -->
    <div id="tool-sync-admin" class="tool-content hidden">

      <!-- Log viewer section -->
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <div class="flex items-center justify-between mb-3 flex-wrap gap-2">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">rclone Sync Log</h2>
            <div class="flex items-center gap-2">
              <label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer select-none">
                <input type="checkbox" id="syncadmin-auto-refresh" checked
                       class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
                Auto-refresh
              </label>
              <button onclick="syncadminLoadLog()"
                      class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-1.5">
                <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
                </svg>
                Refresh
              </button>
            </div>
          </div>
          <div id="syncadmin-log-container"
               class="bg-gray-950 text-green-400 font-mono text-xs leading-relaxed rounded-xl p-4 overflow-y-auto border border-gray-800"
               style="height: 480px; scroll-behavior: smooth;">
            <p class="text-gray-500">Loading log...</p>
          </div>
        </div>
      </section>

      <!-- Force Resync — always visible -->
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <div class="flex items-start gap-3">
            <svg class="h-6 w-6 text-togen-teal mt-0.5 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
            </svg>
            <div class="flex-1">
              <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Force Resync</h3>
              <p class="text-sm text-gray-600 dark:text-gray-400 mt-1">Stop any running rclone process, remove the lock file, and run a fresh rclone sync.</p>
              <button id="syncadmin-force-resync-btn" onclick="syncadminForceResync()"
                      class="mt-3 px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
                </svg>
                Force Resync
              </button>
              <p id="syncadmin-force-resync-status" class="text-sm mt-2"></p>
            </div>
          </div>
        </div>
      </section>

      <!-- Resync alert & button (hidden by default) -->
      <section id="syncadmin-resync-section" class="hidden bg-red-50 dark:bg-red-900/20 rounded-2xl shadow-sm border border-red-300 dark:border-red-700 mb-4">
        <div class="px-6 py-4">
          <div class="flex items-start gap-3">
            <svg class="h-6 w-6 text-red-500 dark:text-red-400 mt-0.5 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
            </svg>
            <div class="flex-1">
              <h3 class="text-sm font-semibold text-red-700 dark:text-red-300">Bisync Aborted</h3>
              <p class="text-sm text-red-600 dark:text-red-400 mt-1">The sync process has aborted and requires a resync to recover.</p>
              <button id="syncadmin-resync-btn" onclick="syncadminRunResync()"
                      class="mt-3 px-4 py-2 text-sm font-medium rounded-xl bg-red-600 hover:bg-red-700 text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
                </svg>
                Run Resync
              </button>
              <p id="syncadmin-resync-status" class="text-sm mt-2"></p>
            </div>
          </div>
        </div>
      </section>

      <!-- Lock file error alert & button (hidden by default) -->
      <section id="syncadmin-lock-section" class="hidden bg-red-50 dark:bg-red-900/20 rounded-2xl shadow-sm border border-red-300 dark:border-red-700 mb-4">
        <div class="px-6 py-4">
          <div class="flex items-start gap-3">
            <svg class="h-6 w-6 text-red-500 dark:text-red-400 mt-0.5 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/>
            </svg>
            <div class="flex-1">
              <h3 class="text-sm font-semibold text-red-700 dark:text-red-300">Lock File Error</h3>
              <p class="text-sm text-red-600 dark:text-red-400 mt-1">A prior lock file is preventing bisync from running. The lock file must be removed and a fresh sync started.</p>
              <button id="syncadmin-lock-fix-btn" onclick="syncadminFixLockError()"
                      class="mt-3 px-4 py-2 text-sm font-medium rounded-xl bg-red-600 hover:bg-red-700 text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
                </svg>
                Correct Lock Error
              </button>
              <p id="syncadmin-lock-fix-status" class="text-sm mt-2"></p>
            </div>
          </div>
        </div>
      </section>

      <!-- Support Request — shown when any error is detected -->
      <section id="syncadmin-support-section" class="hidden bg-yellow-50 dark:bg-yellow-900/20 rounded-2xl shadow-sm border border-yellow-300 dark:border-yellow-700 mb-4">
        <div class="px-6 py-4">
          <div class="flex items-start gap-3">
            <svg class="h-6 w-6 text-yellow-500 dark:text-yellow-400 mt-0.5 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
            </svg>
            <div class="flex-1">
              <h3 class="text-sm font-semibold text-yellow-700 dark:text-yellow-300">Errors Detected</h3>
              <p class="text-sm text-yellow-600 dark:text-yellow-400 mt-1">Errors were found in the sync log. Submit a support request to get help resolving the issue.</p>
              <button id="syncadmin-support-btn" onclick="syncadminOpenSupportRequest()"
                      class="mt-3 px-4 py-2 text-sm font-medium rounded-xl bg-yellow-600 hover:bg-yellow-700 text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
                  <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
                </svg>
                Support Request
              </button>
            </div>
          </div>
        </div>
      </section>

    </div><!-- /tool-sync-admin -->

    <div id="tool-togen-admin" class="tool-content hidden">

      <!-- Tab bar -->
      <div class="flex flex-nowrap overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden border-b border-gray-200 dark:border-gray-700 mb-4">
        <button id="togenadmin-tab-general" onclick="togenadminSwitchTab('general')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          General
        </button>
        <button id="togenadmin-tab-branding" onclick="togenadminSwitchTab('branding')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Interface
        </button>
        <button id="togenadmin-tab-tools" onclick="togenadminSwitchTab('tools')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Tools
        </button>
        <button id="togenadmin-tab-agents" onclick="togenadminSwitchTab('agents')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Agents
        </button>
        <button id="togenadmin-tab-documents" onclick="togenadminSwitchTab('documents')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Documents
        </button>
        <button id="togenadmin-tab-printing" onclick="togenadminSwitchTab('printing')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Printing
        </button>
        <button id="togenadmin-tab-history" onclick="togenadminSwitchTab('history')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          History
        </button>
        <button id="togenadmin-tab-notifications" onclick="togenadminSwitchTab('notifications')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Notifications
        </button>
        <button id="togenadmin-tab-backup" onclick="togenadminSwitchTab('backup')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Backup
        </button>
        <button id="togenadmin-tab-app" onclick="togenadminSwitchTab('app')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-togen-teal text-togen-teal cursor-pointer">
          App
        </button>
        <button id="togenadmin-tab-assistant" onclick="togenadminSwitchTab('assistant')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Assistant
        </button>
      </div>

      <!-- General panel -->
      <div id="togenadmin-panel-general" class="hidden">

        <!-- Title pane (DVI-555) -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <div class="flex items-center justify-between mb-1">
              <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Title</h2>
              <button onclick="openTitleEdit()"
                      class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-1.5 cursor-pointer">
                <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/>
                </svg>
                Edit
              </button>
            </div>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Title and tagline shown at the top of every page.</p>
            <div class="flex items-baseline gap-3 flex-wrap">
              <span id="togenadmin-title-preview" class="text-2xl font-light text-togen-dark dark:text-white" style="font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;letter-spacing:0.05em;">{{ site_title|e }}</span>
              <span id="togenadmin-tagline-preview" class="text-sm font-light text-togen-teal tracking-widest uppercase" style="font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;">{{ site_tagline|e }}</span>
            </div>
          </div>
        </section>

        <!-- About pane (DVI-412) -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <div class="flex items-center justify-between mb-1">
              <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">About</h2>
              <button onclick="openAboutEdit()"
                      class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-1.5 cursor-pointer">
                <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/>
                </svg>
                Edit
              </button>
            </div>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Text shown in the Help menu &rarr; About dialog. Supports Markdown.</p>
            <div id="togenadmin-about-preview" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">{{ about_html|safe }}</div>
          </div>
        </section>
      </div>

      <!-- Branding panel (DVI-357) -->
      <div id="togenadmin-panel-branding" class="hidden">

        <!-- Logo section -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Logo</h2>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Choose the logo shown in the top navigation bar.</p>
            <div class="space-y-2">
              <label class="flex items-center gap-3 cursor-pointer">
                <input type="radio" name="branding-logo-mode" value="togen" class="text-togen-teal focus:ring-togen-teal">
                <span class="text-sm text-gray-700 dark:text-gray-300">Togen Default</span>
              </label>
              <label id="branding-logo-corporate-label" class="flex items-center gap-3 cursor-pointer" title="">
                <input type="radio" name="branding-logo-mode" value="corporate" class="text-togen-teal focus:ring-togen-teal">
                <span class="text-sm text-gray-700 dark:text-gray-300">Corporate Brand</span>
                <span id="branding-logo-missing" class="hidden text-xs text-gray-400 dark:text-gray-500 italic">(logo asset not uploaded)</span>
              </label>
            </div>
          </div>
        </section>

        <!-- Background section -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Background</h2>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-2">Choose the application background style. These are the company <strong>default</strong> settings; users may override them in their own User Settings.</p>
            <p class="text-xs text-gray-400 dark:text-gray-500 mb-4">Note: Corporate Brand background is not applied on mobile devices.</p>
            <div class="space-y-2">
              <label class="flex items-center gap-3 cursor-pointer">
                <input type="radio" name="branding-bg-mode" value="togen_default" class="text-togen-teal focus:ring-togen-teal">
                <span class="text-sm text-gray-700 dark:text-gray-300">Togen Default</span>
              </label>
              <label class="flex items-center gap-3 cursor-pointer">
                <input type="radio" name="branding-bg-mode" value="flat" class="text-togen-teal focus:ring-togen-teal">
                <span class="text-sm text-gray-700 dark:text-gray-300">Flat</span>
              </label>
              <label id="branding-bg-corporate-label" class="flex items-center gap-3 cursor-pointer" title="">
                <input type="radio" name="branding-bg-mode" value="corporate" class="text-togen-teal focus:ring-togen-teal">
                <span class="text-sm text-gray-700 dark:text-gray-300">Corporate Brand</span>
                <span id="branding-bg-missing" class="hidden text-xs text-gray-400 dark:text-gray-500 italic">(background assets not uploaded)</span>
              </label>
            </div>

            <!-- Pane transparency (DVI-366): separate light/dark, only meaningful over corporate bg -->
            <div id="branding-opacity-block" class="mt-5 pt-4 border-t border-gray-100 dark:border-gray-700">
              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tile/Pane Opacity</label>
              <p id="branding-opacity-hint" class="text-xs text-gray-400 dark:text-gray-500 mb-3">Lets the corporate background show through panes. Only applies when Corporate Brand is selected.</p>
              <div class="space-y-3">
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Light Mode</label>
                  <div class="flex items-center gap-4 max-w-md">
                    <input type="range" id="branding-opacity-light-slider" min="40" max="100" step="1" value="100"
                           oninput="_brandingSyncOpacity('light', 'slider')"
                           class="flex-1 accent-togen-teal cursor-pointer">
                    <div class="flex items-center gap-1">
                      <input type="number" id="branding-opacity-light-number" min="40" max="100" step="1" value="100"
                             onchange="_brandingSyncOpacity('light', 'number')"
                             class="w-16 px-2 py-1 text-sm text-right rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200">
                      <span class="text-sm text-gray-500 dark:text-gray-400">%</span>
                    </div>
                  </div>
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Dark Mode</label>
                  <div class="flex items-center gap-4 max-w-md">
                    <input type="range" id="branding-opacity-dark-slider" min="40" max="100" step="1" value="100"
                           oninput="_brandingSyncOpacity('dark', 'slider')"
                           class="flex-1 accent-togen-teal cursor-pointer">
                    <div class="flex items-center gap-1">
                      <input type="number" id="branding-opacity-dark-number" min="40" max="100" step="1" value="100"
                             onchange="_brandingSyncOpacity('dark', 'number')"
                             class="w-16 px-2 py-1 text-sm text-right rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200">
                      <span class="text-sm text-gray-500 dark:text-gray-400">%</span>
                    </div>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </section>

        <!-- Tile Layout section (DVI-512) -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Tile Layout</h2>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Default number of columns for the Home tile grid. This is the company <strong>default</strong>; users may override it in their own User Settings. Mobile and tablet always collapse to 1 and 2 columns.</p>
            <div class="flex items-center gap-3 max-w-md">
              <label for="branding-tile-columns" class="text-sm font-medium text-gray-700 dark:text-gray-300">Columns wide</label>
              <select id="branding-tile-columns"
                      class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 cursor-pointer">
                <option value="3">3</option>
                <option value="4">4</option>
                <option value="5">5</option>
                <option value="6">6</option>
                <option value="7">7</option>
                <option value="8">8</option>
                <option value="9">9</option>
                <option value="unlimited">Unlimited (auto-fit)</option>
              </select>
            </div>
            <p class="text-sm text-gray-500 dark:text-gray-400 mt-4 mb-2">Side padding adds equal open space on the left and right of the Home tile grid, as a percentage of the available width. Applies on desktop only (large screens); mobile and tablet stay full-width.</p>
            <div class="flex items-center gap-3 max-w-md">
              <label for="branding-tile-padding" class="text-sm font-medium text-gray-700 dark:text-gray-300">Side padding (%)</label>
              <input type="number" id="branding-tile-padding" min="0" max="40" step="1" value="0"
                     class="w-24 px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200">
            </div>
          </div>
        </section>

        <!-- Save -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <div class="flex items-center gap-3">
              <button onclick="togenadminSaveBranding()"
                      class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
                </svg>
                Save
              </button>
              <span id="togenadmin-branding-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
            </div>
          </div>
        </section>
      </div>

      <!-- App panel -->
      <div id="togenadmin-panel-app" class="hidden">

      <!-- Version info section -->
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <div class="flex items-center justify-between mb-4">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Application Version</h2>
            <button onclick="togenadminLoadVersion()"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-1.5">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
              </svg>
              Refresh
            </button>
          </div>
          <!-- Scheduled Update toggle (DVI-768) -->
          <div class="mb-4 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-3">
            <label class="flex items-center gap-2 cursor-pointer">
              <input type="checkbox" id="togenadmin-sched-update-cb" onchange="togenadminScheduleToggle()"
                     class="h-4 w-4 rounded border-gray-300 text-togen-teal focus:ring-togen-teal">
              <span class="text-sm font-medium text-gray-700 dark:text-gray-300">Scheduled Update</span>
            </label>
            <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">When checked, the update is applied automatically at 2:00 AM the next morning (Central).</p>
            <p id="togenadmin-sched-update-status" class="hidden text-sm mt-2 text-togen-teal"></p>
          </div>

          <div id="togenadmin-loading" class="text-sm text-gray-500 dark:text-gray-400">Loading version info...</div>
          <div id="togenadmin-info" class="hidden space-y-3">
            <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
              <div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 border border-gray-200 dark:border-gray-700">
                <p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1">Deployed Version</p>
                <p id="togenadmin-local-hash" class="text-sm font-mono text-gray-800 dark:text-gray-200">—</p>
                <p id="togenadmin-local-date" class="text-xs text-gray-500 dark:text-gray-400 mt-1">—</p>
                <p id="togenadmin-local-msg" class="text-xs text-gray-600 dark:text-gray-300 mt-1 italic">—</p>
              </div>
              <div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 border border-gray-200 dark:border-gray-700">
                <p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1">Latest on GitHub</p>
                <p id="togenadmin-remote-hash" class="text-sm font-mono text-gray-800 dark:text-gray-200">—</p>
                <p id="togenadmin-remote-date" class="text-xs text-gray-500 dark:text-gray-400 mt-1">—</p>
                <p id="togenadmin-remote-msg" class="text-xs text-gray-600 dark:text-gray-300 mt-1 italic">—</p>
              </div>
            </div>

            <!-- Up to date banner -->
            <div id="togenadmin-uptodate" class="hidden bg-green-50 dark:bg-green-900/20 rounded-xl p-4 border border-green-300 dark:border-green-700">
              <div class="flex items-center gap-2">
                <svg class="h-5 w-5 text-green-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
                </svg>
                <p class="text-sm font-medium text-green-700 dark:text-green-300">Application is up to date</p>
              </div>
            </div>

            <!-- Update available banner + button -->
            <div id="togenadmin-update-available" class="hidden bg-yellow-50 dark:bg-yellow-900/20 rounded-xl p-4 border border-yellow-300 dark:border-yellow-700">
              <div class="flex items-start gap-3">
                <svg class="h-5 w-5 text-yellow-500 mt-0.5 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
                </svg>
                <div class="flex-1">
                  <p class="text-sm font-semibold text-yellow-700 dark:text-yellow-300">Update Available</p>
                  <p class="text-sm text-yellow-600 dark:text-yellow-400 mt-1">A newer version is available on GitHub. Click below to pull the latest changes and restart the application.</p>
                  <button id="togenadmin-update-btn" onclick="togenadminPromptUpdateVersion()"
                          class="mt-3 px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
                    <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                      <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
                    </svg>
                    Update
                  </button>
                  <p id="togenadmin-update-status" class="text-sm mt-2"></p>
                </div>
              </div>
            </div>
          </div>

          <!-- Error state -->
          <div id="togenadmin-error" class="hidden bg-red-50 dark:bg-red-900/20 rounded-xl p-4 border border-red-300 dark:border-red-700">
            <p class="text-sm text-red-600 dark:text-red-400" id="togenadmin-error-msg"></p>
          </div>
        </div>
      </section>

      <!-- Update version prompt modal (DVI-554) -->
      <div id="togenadmin-update-version-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
        <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 w-full max-w-sm mx-4">
          <h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Published Version</h3>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Set the published version number to record with this update. The application will update and restart on Apply.</p>
          <input id="togenadmin-update-version-input" type="text"
                 class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-togen-teal"
                 onkeydown="if(event.key==='Enter')togenadminUpdateVersionApply()">
          <p id="togenadmin-update-version-error" class="hidden text-sm text-red-500 mt-2"></p>
          <div class="flex justify-end gap-3 mt-4">
            <button onclick="togenadminUpdateVersionCancel()" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">Cancel</button>
            <button onclick="togenadminUpdateVersionApply()" class="px-4 py-2 text-sm bg-togen-teal hover:bg-togen-dark text-white font-semibold rounded-lg transition-colors">Apply</button>
          </div>
        </div>
      </div>

      <!-- Scheduled update version prompt modal (DVI-768) -->
      <div id="togenadmin-sched-version-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
        <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 w-full max-w-sm mx-4">
          <h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Schedule Update</h3>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Enter the version number to display to users once the update completes. The update will be applied automatically at 2:00 AM the next morning.</p>
          <input id="togenadmin-sched-version-input" type="text"
                 class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-togen-teal"
                 onkeydown="if(event.key==='Enter')togenadminScheduleApply()">
          <p id="togenadmin-sched-version-error" class="hidden text-sm text-red-500 mt-2"></p>
          <div class="flex justify-end gap-3 mt-4">
            <button onclick="togenadminScheduleCancel()" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">Cancel</button>
            <button onclick="togenadminScheduleApply()" class="px-4 py-2 text-sm bg-togen-teal hover:bg-togen-dark text-white font-semibold rounded-lg transition-colors">Schedule</button>
          </div>
        </div>
      </div>

      <!-- Public Version admin pane (DVI-267) -->
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Version</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Edit the public version text displayed in the About dialog.</p>
          <input type="text" id="togenadmin-public-version" value=""
                 class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal mb-3">
          <div class="flex items-center gap-3">
            <button onclick="togenadminSavePublicVersion()"
                    class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
              </svg>
              Save
            </button>
            <span id="togenadmin-public-version-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
        </div>
      </section>

      </div><!-- /togenadmin-panel-app -->

      <!-- Documents panel -->
      <div id="togenadmin-panel-documents" class="hidden">

      <!-- Sync Admin pane -->
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-4">Sync Admin</h2>

          <!-- Sync Service stop/start (DVI-412) -->
          <div class="flex items-start gap-3 mb-4 pb-4 border-b border-gray-100 dark:border-gray-700">
            <svg class="h-6 w-6 text-togen-teal mt-0.5 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path d="M2 5a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm0 8a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2v-2z"/>
            </svg>
            <div class="flex-1">
              <div class="flex items-center gap-2">
                <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Sync Service</h3>
                <span id="syncadmin-service-state" class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
                  <span class="inline-block h-1.5 w-1.5 rounded-full bg-gray-400"></span>Checking…
                </span>
              </div>
              <p class="text-sm text-gray-600 dark:text-gray-400 mt-1">Stop or start the rclone sync service running on the host.</p>
              <button id="syncadmin-service-btn" onclick="syncadminToggleService()" disabled
                      class="mt-3 px-4 py-2 text-sm font-medium rounded-xl bg-gray-300 dark:bg-gray-700 text-white transition-colors inline-flex items-center gap-2 cursor-pointer opacity-50">
                <span id="syncadmin-service-btn-label">Loading…</span>
              </button>
              <p id="syncadmin-service-status" class="text-sm mt-2"></p>
            </div>
          </div>

          <!-- Force Resync — always visible -->
          <div class="flex items-start gap-3 mb-4">
            <svg class="h-6 w-6 text-togen-teal mt-0.5 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
            </svg>
            <div class="flex-1">
              <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Force Resync</h3>
              <p class="text-sm text-gray-600 dark:text-gray-400 mt-1">Stop any running rclone process, remove the lock file, and run a fresh rclone sync.</p>
              <button id="togenadmin-force-resync-btn" onclick="syncadminForceResync()"
                      class="mt-3 px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
                </svg>
                Force Resync
              </button>
              <p id="togenadmin-force-resync-status" class="text-sm mt-2"></p>
            </div>
          </div>

          <!-- Correct Lock Error — shown when lock error detected -->
          <div id="togenadmin-lock-section" class="hidden bg-red-50 dark:bg-red-900/20 rounded-xl p-4 border border-red-300 dark:border-red-700 mb-4">
            <div class="flex items-start gap-3">
              <svg class="h-6 w-6 text-red-500 dark:text-red-400 mt-0.5 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/>
              </svg>
              <div class="flex-1">
                <h3 class="text-sm font-semibold text-red-700 dark:text-red-300">Lock File Error</h3>
                <p class="text-sm text-red-600 dark:text-red-400 mt-1">A prior lock file is preventing bisync from running.</p>
                <button id="togenadmin-lock-fix-btn" onclick="syncadminFixLockError()"
                        class="mt-3 px-4 py-2 text-sm font-medium rounded-xl bg-red-600 hover:bg-red-700 text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
                  <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
                  </svg>
                  Correct Lock Error
                </button>
                <p id="togenadmin-lock-fix-status" class="text-sm mt-2"></p>
              </div>
            </div>
          </div>

          <!-- Support Request — shown when any error detected -->
          <div id="togenadmin-support-section" class="hidden bg-yellow-50 dark:bg-yellow-900/20 rounded-xl p-4 border border-yellow-300 dark:border-yellow-700">
            <div class="flex items-start gap-3">
              <svg class="h-6 w-6 text-yellow-500 dark:text-yellow-400 mt-0.5 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
              </svg>
              <div class="flex-1">
                <h3 class="text-sm font-semibold text-yellow-700 dark:text-yellow-300">Errors Detected</h3>
                <p class="text-sm text-yellow-600 dark:text-yellow-400 mt-1">Errors were found in the sync log. Submit a support request to get help.</p>
                <button id="togenadmin-support-btn" onclick="syncadminOpenSupportRequest()"
                        class="mt-3 px-4 py-2 text-sm font-medium rounded-xl bg-yellow-600 hover:bg-yellow-700 text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
                  <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
                    <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
                  </svg>
                  Support Request
                </button>
              </div>
            </div>
          </div>
        </div>
      </section>

      <!-- Shared URL Prefix pane (DVI-271) -->
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Shared URL Prefix</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Select the host used for shared file links, QR code generation, and document sharing. QR codes and CSV URLs will resolve to <code class="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded">{host}/shared/…</code></p>
          <select id="togenadmin-url-prefix"
                  class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal mb-3">
          </select>
          <div class="flex items-center gap-3">
            <button onclick="togenadminSaveUrlPrefix()"
                    class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
              </svg>
              Save
            </button>
            <span id="togenadmin-url-prefix-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
        </div>
      </section>

      <!-- OCR Backend pane (DVI-497) -->
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">OCR Backend</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Choose the engine used to recognize text in uploaded PDFs and manuals. Additional backends can be added in a future release.</p>
          <select id="ocr-backend-select-admin" onchange="_ocrUpdateBackendDesc('admin')"
                  class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal mb-2">
          </select>
          <p id="ocr-backend-desc-admin" class="text-xs text-gray-500 dark:text-gray-400 mb-3"></p>
          <div class="flex items-center gap-3">
            <button id="ocr-backend-save-admin" onclick="ocrSaveBackend('admin')"
                    class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
              Save
            </button>
            <span id="ocr-backend-status-admin" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
        </div>
      </section>

      <!-- Quotes pane (DVI-808): shared quote output location -->
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Quotes</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Set the shared OneDrive path where completed quotes are stored by default. Individual users can override this with their own path in User Settings &rarr; Documents.</p>
          <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Shared Quotes Location</label>
          <input id="togenadmin-quotes-location" type="text" placeholder="e.g. Infrastructure Precast Inc_ - Completed Quotes"
                 class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal mb-3">
          <p class="text-xs text-gray-400 dark:text-gray-500 mb-3">Enter the folder name or path within the tenant OneDrive drive. This value is read by the Winston pipeline when routing completed quotes.</p>
          <div class="flex items-center gap-3">
            <button onclick="togenadminSaveQuotesLocation()"
                    class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
              </svg>
              Save
            </button>
            <span id="togenadmin-quotes-location-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
        </div>
      </section>

      </div><!-- /togenadmin-panel-documents -->

      <!-- Tools panel -->
      <div id="togenadmin-panel-tools" class="hidden">

      <!-- Toolbars visibility admin pane (DVI-616) -->
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Toolbars Enabled</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Control which top-bar functions appear. Enable for <strong>Admins</strong> to show the function to Togen Admin members only; enable for <strong>Users</strong> to show it to everyone.</p>
          <div class="flex items-center gap-6 pr-2 mb-2 justify-end text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
            <span class="w-16 text-center">Admins</span>
            <span class="w-16 text-center">Users</span>
          </div>
          <div id="togenadmin-toolbars-list" class="space-y-3">
            <!-- populated by JS -->
          </div>
          <div class="mt-4 flex items-center gap-3">
            <button id="togenadmin-toolbars-save-btn" onclick="togenadminSaveToolbarVisibility()"
                    class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
              </svg>
              Save
            </button>
            <span id="togenadmin-toolbars-save-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
        </div>
      </section>

      <!-- Tools visibility admin pane (DVI-254) -->
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Tools Enabled</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Select which tools are enabled in production. Togen Admin members always see all tools. Ensure proper group membership for tools that require specific Azure AD group membership.</p>
          <div id="togenadmin-tools-list" class="space-y-3">
            <!-- populated by JS -->
          </div>
          <div class="mt-4 flex items-center gap-3">
            <button id="togenadmin-tools-save-btn" onclick="togenadminSaveToolVisibility()"
                    class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
              </svg>
              Save
            </button>
            <span id="togenadmin-tools-save-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
        </div>
      </section>

      </div><!-- /togenadmin-panel-tools -->

      <!-- Agents panel (DVI-548): monitor enrolled agents by type -->
      <div id="togenadmin-panel-agents" class="hidden">

      <!-- Vision agents pane (DVI-672: Status / Enroll subtabs) -->
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-2">Vision</h2>

          <!-- Subtab bar -->
          <div class="flex flex-nowrap gap-1 border-b border-gray-200 dark:border-gray-700 mb-4">
            <button id="tga-vision-subtab-status" onclick="togenadminAgentSubtab('vision','status')"
                    class="px-4 py-2 text-sm font-medium border-b-2 border-togen-teal text-togen-teal cursor-pointer">Status</button>
            <button id="tga-vision-subtab-enroll" onclick="togenadminAgentSubtab('vision','enroll')"
                    class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">Enroll</button>
          </div>

          <!-- Status panel -->
          <div id="tga-vision-panel-status">
            <div class="flex items-center justify-between mb-1">
              <p class="text-sm text-gray-500 dark:text-gray-400">Enrolled Vision agents and machines awaiting approval. Use the Enroll tab to onboard and approve machines.</p>
              <button onclick="togenadminLoadAgents()"
                      class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer">Refresh</button>
            </div>

            <!-- Pending approvals -->
            <div class="mb-5 mt-4">
              <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-2">Pending approvals</h3>
              <div id="togenadmin-vision-pending-empty" class="hidden text-xs text-gray-500 dark:text-gray-400">No machines awaiting approval.</div>
              <ul id="togenadmin-vision-pending-list" class="space-y-2"></ul>
            </div>

            <!-- Enrolled agents -->
            <div>
              <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-2">Enrolled agents</h3>
              <div id="togenadmin-vision-loading" class="hidden text-xs text-gray-500 dark:text-gray-400">Loading agents...</div>
              <div id="togenadmin-vision-empty" class="hidden text-xs text-gray-500 dark:text-gray-400">No agents enrolled yet.</div>
              <ul id="togenadmin-vision-list" class="hidden space-y-2"></ul>
            </div>
          </div>

          <!-- Enroll panel (mirrors Vision tool &rarr; Enroll tab) -->
          <div id="tga-vision-panel-enroll" class="hidden">
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Onboard a Vision agent, then approve or adopt machines below. This mirrors the Vision tool &rarr; Enroll tab.</p>
            <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">

              <!-- Quick-enroll card -->
              <div class="rounded-xl border border-gray-200 dark:border-gray-700 p-4">
                <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Quick-enroll a machine</h3>
                <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Generate a one-time token, then run the command on the target box (PowerShell, admin). The agent enrolls as <span class="font-medium text-green-600 dark:text-green-400">active</span> immediately — no approval needed.</p>
                <button id="tga-ve-quick-btn" onclick="togenadminVisionQuickEnroll()"
                        class="mt-3 px-3 py-1.5 text-xs font-medium bg-togen-teal hover:bg-togen-teal-dark text-white rounded-lg transition-colors cursor-pointer">Generate enroll command</button>
                <div id="tga-ve-quick-result" class="hidden mt-3 rounded-xl border border-togen-teal/40 bg-togen-teal/5 dark:bg-togen-teal/10 px-3 py-3">
                  <p class="text-xs font-medium text-gray-700 dark:text-gray-200">Run on the target machine — token is shown only once.</p>
                  <div class="mt-2 flex items-center gap-2">
                    <code id="tga-ve-quick-cmd" class="flex-1 break-all text-xs bg-white dark:bg-gray-900 rounded-md px-2 py-1.5 border border-gray-200 dark:border-gray-700 text-gray-800 dark:text-gray-100"></code>
                    <button onclick="visionCopyText('tga-ve-quick-cmd')" class="px-2 py-1.5 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Copy</button>
                  </div>
                  <p id="tga-ve-quick-ttl" class="mt-2 text-xs text-gray-500 dark:text-gray-400"></p>
                  <div class="mt-3 flex flex-wrap items-center gap-2">
                    <a id="tga-ve-quick-ps1" href="#" download="bootstrap.ps1"
                       class="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Download .ps1</a>
                    <a href="/enroll/agent.zip" download
                       class="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Download ZIP</a>
                  </div>
                </div>
              </div>

              <!-- Open-enroll card -->
              <div class="rounded-xl border border-gray-200 dark:border-gray-700 p-4">
                <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Open-enroll (fleet rollout)</h3>
                <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">A reusable installer for mass deployment. Machines self-enroll as <span class="font-medium text-amber-600 dark:text-amber-400">pending</span> and appear under Pending approvals below.</p>
                <p class="mt-3 text-xs font-medium text-gray-700 dark:text-gray-200">Shared secret</p>
                <div class="mt-1 flex items-center gap-2">
                  <code id="tga-ve-secret" class="flex-1 break-all text-xs bg-white dark:bg-gray-900 rounded-md px-2 py-1.5 border border-gray-200 dark:border-gray-700 text-gray-800 dark:text-gray-100">…</code>
                  <button onclick="visionCopyText('tga-ve-secret')" class="px-2 py-1.5 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Copy</button>
                  <button id="tga-ve-rotate-btn" onclick="togenadminVisionRotateSecret()" class="px-2 py-1.5 text-xs rounded-md border border-amber-300 dark:border-amber-800 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 cursor-pointer">Rotate</button>
                </div>
                <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Rotating invalidates the prior secret immediately; already-enrolled agents are unaffected.</p>
                <div class="mt-3 flex flex-wrap items-center gap-2">
                  <a href="/enroll/bootstrap.ps1" download="bootstrap.ps1"
                     class="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Download installer .ps1</a>
                  <a href="/enroll/agent.zip" download
                     class="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Download ZIP</a>
                </div>
              </div>
            </div>

            <!-- Upgrade existing machine card (DVI-693) -->
            <div class="rounded-xl border border-blue-200 dark:border-blue-800 bg-blue-50/40 dark:bg-blue-900/10 p-4 mb-6">
              <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Upgrade existing machine</h3>
              <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
                Already-enrolled machines can be upgraded in-place — no enrollment token needed.
                Downloads the latest agent ZIP, stops the tasks, replaces files, generates a machine-level
                SSH key at <code class="text-xs">C:\ProgramData\Togen\id_togen_vision</code> (if absent),
                uploads the public key to this server, and restarts.
              </p>
              <ol class="mt-2 text-xs text-gray-600 dark:text-gray-400 list-decimal ml-4 space-y-0.5">
                <li>Run <strong>upgrade.ps1</strong> as Administrator on the target machine.</li>
                <li>The public key is uploaded automatically. Approving the agent then writes it to the rsync server.</li>
              </ol>
              <div class="mt-3 flex flex-wrap items-center gap-2">
                <a href="/enroll/upgrade.ps1" download="upgrade.ps1"
                   class="px-2 py-1 text-xs rounded-md border border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/30 cursor-pointer">Download upgrade.ps1</a>
                <a href="/enroll/agent.zip" download
                   class="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Download ZIP</a>
                <button type="button" onclick="enrollShowUploadCmd('tga-ve')"
                   class="px-2 py-1 text-xs rounded-md border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/30 cursor-pointer">Generate update command</button>
              </div>
              <div id="tga-ve-upload-cmd-wrap" class="hidden mt-2">
                <p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Run this on an already-enrolled machine missing its SSH key:</p>
                <div class="flex items-center gap-2">
                  <code id="tga-ve-upload-cmd" class="flex-1 px-2 py-1 text-xs rounded-md bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 font-mono break-all"></code>
                  <button type="button" onclick="visionCopyText('tga-ve-upload-cmd')"
                     class="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Copy</button>
                </div>
              </div>
            </div>

            <!-- Pending approvals -->
            <div class="mb-6">
              <div class="flex items-center justify-between mb-2">
                <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Pending approvals</h3>
                <button onclick="togenadminVisionEnrollLoad()"
                        class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer">Refresh</button>
              </div>
              <div id="tga-ve-pending-empty" class="hidden text-xs text-gray-500 dark:text-gray-400">No machines awaiting approval.</div>
              <ul id="tga-ve-pending-list" class="space-y-2"></ul>
            </div>

            <!-- Enrolled agents -->
            <div>
              <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-2">Enrolled agents</h3>
              <div id="tga-ve-loading" class="hidden text-xs text-gray-500 dark:text-gray-400">Loading agents...</div>
              <div id="tga-ve-empty" class="hidden text-xs text-gray-500 dark:text-gray-400">No agents enrolled yet.</div>
              <ul id="tga-ve-list" class="hidden space-y-2"></ul>
            </div>
          </div>
        </div>
      </section>

      <!-- Print agents pane (DVI-667; DVI-672: Status / Enroll subtabs) -->
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-2">Print</h2>

          <!-- Subtab bar -->
          <div class="flex flex-nowrap gap-1 border-b border-gray-200 dark:border-gray-700 mb-4">
            <button id="tga-print-subtab-status" onclick="togenadminAgentSubtab('print','status')"
                    class="px-4 py-2 text-sm font-medium border-b-2 border-togen-teal text-togen-teal cursor-pointer">Status</button>
            <button id="tga-print-subtab-enroll" onclick="togenadminAgentSubtab('print','enroll')"
                    class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">Enroll</button>
          </div>

          <!-- Status panel -->
          <div id="tga-print-panel-status">
            <div class="flex items-center justify-between mb-1">
              <p class="text-sm text-gray-500 dark:text-gray-400">Enrolled Print agents and machines awaiting approval. Approve or deny machines below, then choose which discovered printers Togen may use.</p>
              <button onclick="togenadminLoadPrintAgents()"
                      class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer">Refresh</button>
            </div>

            <!-- Pending approvals -->
            <div class="mb-5 mt-4">
              <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-2">Pending approvals</h3>
              <div id="togenadmin-print-pending-empty" class="hidden text-xs text-gray-500 dark:text-gray-400">No machines awaiting approval.</div>
              <ul id="togenadmin-print-pending-list" class="space-y-2"></ul>
            </div>

            <!-- Enrolled agents -->
            <div>
              <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-2">Enrolled agents</h3>
              <div id="togenadmin-print-loading" class="hidden text-xs text-gray-500 dark:text-gray-400">Loading agents...</div>
              <div id="togenadmin-print-empty" class="hidden text-xs text-gray-500 dark:text-gray-400">No agents enrolled yet.</div>
              <ul id="togenadmin-print-list" class="hidden space-y-2"></ul>
            </div>
          </div>

          <!-- Enroll panel: Print Agent onboarding workflow -->
          <div id="tga-print-panel-enroll" class="hidden">
            <ol class="text-xs text-gray-600 dark:text-gray-300 list-decimal pl-5 space-y-1 mb-5">
              <li>Generate an enroll command (or copy the open-enroll secret) below.</li>
              <li>On the target Windows machine, open <span class="font-medium">PowerShell as Administrator</span> and run the command. It downloads the Print Agent bundle and installs a Scheduled Task that runs the agent at logon.</li>
              <li>Quick-enroll machines start <span class="font-medium text-green-600 dark:text-green-400">active</span>; open-enroll machines start <span class="font-medium text-amber-600 dark:text-amber-400">pending</span> and appear under the <span class="font-medium">Status</span> tab for approval.</li>
              <li>After the agent reports its printers, open the agent's <span class="font-medium">Edit</span> dialog (Status tab) to choose which printers Togen may use.</li>
            </ol>

            <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
              <!-- Quick-enroll card -->
              <div class="rounded-xl border border-gray-200 dark:border-gray-700 p-4">
                <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Quick-enroll a machine</h3>
                <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Generate a one-time token, then run the command on the target box (PowerShell, admin). The agent enrolls as <span class="font-medium text-green-600 dark:text-green-400">active</span> immediately — no approval needed.</p>
                <button id="tga-pe-quick-btn" onclick="togenadminPrintQuickEnroll()"
                        class="mt-3 px-3 py-1.5 text-xs font-medium bg-togen-teal hover:bg-togen-teal-dark text-white rounded-lg transition-colors cursor-pointer">Generate enroll command</button>
                <div id="tga-pe-quick-result" class="hidden mt-3 rounded-xl border border-togen-teal/40 bg-togen-teal/5 dark:bg-togen-teal/10 px-3 py-3">
                  <p class="text-xs font-medium text-gray-700 dark:text-gray-200">Run on the target machine — token is shown only once.</p>
                  <div class="mt-2 flex items-center gap-2">
                    <code id="tga-pe-quick-cmd" class="flex-1 break-all text-xs bg-white dark:bg-gray-900 rounded-md px-2 py-1.5 border border-gray-200 dark:border-gray-700 text-gray-800 dark:text-gray-100"></code>
                    <button onclick="visionCopyText('tga-pe-quick-cmd')" class="px-2 py-1.5 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Copy</button>
                  </div>
                  <p id="tga-pe-quick-ttl" class="mt-2 text-xs text-gray-500 dark:text-gray-400"></p>
                  <div class="mt-3 flex flex-wrap items-center gap-2">
                    <a id="tga-pe-quick-ps1" href="#" download="install-print.ps1"
                       class="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Download .ps1</a>
                    <a href="/enroll/print-agent.zip" download
                       class="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Download ZIP</a>
                  </div>
                </div>
              </div>

              <!-- Open-enroll card -->
              <div class="rounded-xl border border-gray-200 dark:border-gray-700 p-4">
                <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Open-enroll (fleet rollout)</h3>
                <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">A reusable installer for mass deployment. Machines self-enroll as <span class="font-medium text-amber-600 dark:text-amber-400">pending</span> and appear under the Status tab.</p>
                <p class="mt-3 text-xs font-medium text-gray-700 dark:text-gray-200">Shared secret</p>
                <div class="mt-1 flex items-center gap-2">
                  <code id="tga-pe-secret" class="flex-1 break-all text-xs bg-white dark:bg-gray-900 rounded-md px-2 py-1.5 border border-gray-200 dark:border-gray-700 text-gray-800 dark:text-gray-100">…</code>
                  <button onclick="visionCopyText('tga-pe-secret')" class="px-2 py-1.5 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Copy</button>
                  <button id="tga-pe-rotate-btn" onclick="togenadminPrintRotateSecret()" class="px-2 py-1.5 text-xs rounded-md border border-amber-300 dark:border-amber-800 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 cursor-pointer">Rotate</button>
                </div>
                <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Rotating invalidates the prior secret immediately; already-enrolled agents are unaffected.</p>
                <div class="mt-3 flex flex-wrap items-center gap-2">
                  <a href="/enroll/print-bootstrap.ps1" download="install-print.ps1"
                     class="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Download installer .ps1</a>
                  <a href="/enroll/print-agent.zip" download
                     class="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Download ZIP</a>
                </div>
              </div>
            </div>
          </div>
        </div>
      </section>

      </div><!-- /togenadmin-panel-agents -->

      <!-- Print agent printers Edit modal (DVI-667) -->
      <div id="print-printers-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
        <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-3xl max-h-[85vh] flex flex-col">
          <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
            <div>
              <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Configure printers</h3>
              <p id="print-printers-modal-sub" class="text-xs text-gray-500 dark:text-gray-400"></p>
            </div>
            <button onclick="printPrintersModalClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer">
              <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
            </button>
          </div>
          <div class="px-6 py-4 overflow-y-auto flex-1">
            <div id="print-printers-empty" class="hidden text-sm text-gray-500 dark:text-gray-400">No printers discovered for this agent yet. They appear after the agent checks in.</div>
            <div id="print-printers-table-wrap" class="hidden overflow-x-auto">
              <table class="w-full text-sm">
                <thead>
                  <tr class="text-left text-xs text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
                    <th class="py-2 pr-3">Printer</th>
                    <th class="py-2 px-2 text-center">Include</th>
                    <th class="py-2 px-2 text-center">Documents</th>
                    <th class="py-2 px-2 text-center">Labels</th>
                    <th class="py-2 pl-2">Published name</th>
                  </tr>
                </thead>
                <tbody id="print-printers-tbody"></tbody>
              </table>
            </div>
          </div>
          <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-3">
            <span id="print-printers-save-status" class="text-sm text-gray-500 dark:text-gray-400 mr-auto"></span>
            <button onclick="printPrintersModalClose()" class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">Cancel</button>
            <button id="print-printers-save-btn" onclick="printPrintersSave()" class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors cursor-pointer">Save</button>
          </div>
        </div>
      </div>

      <!-- Printing panel (DVI-548): default printer selection -->
      <div id="togenadmin-panel-printing" class="hidden">

      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Default Printers</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Choose the default document and label printers. The dropdowns list printers made available by Print agents.</p>

          <!-- Enable Print system (DVI-764) -->
          <label class="flex items-start gap-2 mb-4 cursor-pointer max-w-md">
            <input id="togenadmin-printing-enabled" type="checkbox" onchange="togenadminSavePrinting()"
                   class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
            <span class="text-sm text-gray-700 dark:text-gray-300">
              <span class="font-medium">Enable the Print system</span>
              <span class="block text-xs text-gray-500 dark:text-gray-400">When off, Print buttons are hidden for regular users. Togen admins always retain access.</span>
            </span>
          </label>

          <div class="space-y-4 max-w-md">
            <div>
              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Default document printer</label>
              <select id="togenadmin-printing-document"
                      class="w-full rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-800 dark:text-gray-100 px-3 py-2 focus:ring-togen-teal focus:border-togen-teal cursor-pointer"></select>
            </div>
            <div>
              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Default label printer</label>
              <select id="togenadmin-printing-label"
                      class="w-full rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-800 dark:text-gray-100 px-3 py-2 focus:ring-togen-teal focus:border-togen-teal cursor-pointer"></select>
            </div>
          </div>

          <p id="togenadmin-printing-empty-hint" class="hidden mt-3 text-xs text-gray-400 dark:text-gray-500 italic">No printers available yet. Printers will appear here once Print agents are wired up.</p>

          <div class="mt-4 flex items-center gap-3">
            <button id="togenadmin-printing-save-btn" onclick="togenadminSavePrinting()"
                    class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
              </svg>
              Save
            </button>
            <span id="togenadmin-printing-save-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
        </div>
      </section>

      </div><!-- /togenadmin-panel-printing -->

      <!-- History capture panel (DVI-789) -->
      <div id="togenadmin-panel-history" class="hidden">

      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">History Capture</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Control which tools record events to the shared history log, visible to Togen Admins in the Auditor → History tab.</p>

          <label class="flex items-start gap-2 mb-4 cursor-pointer max-w-md">
            <input id="togenadmin-history-capture-print" type="checkbox" checked
                   class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
            <span class="text-sm text-gray-700 dark:text-gray-300">
              <span class="font-medium">Capture Print history</span>
              <span class="block text-xs text-gray-500 dark:text-gray-400">Show print job events in the Auditor History → Print sub-tab (read from the existing print jobs log).</span>
            </span>
          </label>

          <label class="flex items-start gap-2 mb-4 cursor-pointer max-w-md">
            <input id="togenadmin-history-capture-wor-admin" type="checkbox" checked
                   class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
            <span class="text-sm text-gray-700 dark:text-gray-300">
              <span class="font-medium">Capture Work Order Admin history</span>
              <span class="block text-xs text-gray-500 dark:text-gray-400">Record WO creation, status changes, and edits in the Auditor History → Work Order Admin sub-tab.</span>
            </span>
          </label>

          <label class="flex items-start gap-2 mb-4 cursor-pointer max-w-md">
            <input id="togenadmin-history-capture-rpm" type="checkbox" checked
                   class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
            <span class="text-sm text-gray-700 dark:text-gray-300">
              <span class="font-medium">Capture Repair Parts Manager history</span>
              <span class="block text-xs text-gray-500 dark:text-gray-400">Record part lifecycle events (requested, approved, ordered, stocked) in the Auditor History → Repair Parts Manager sub-tab.</span>
            </span>
          </label>

          <label class="flex items-start gap-2 mb-4 cursor-pointer max-w-md">
            <input id="togenadmin-history-capture-single-carrier-sheet" type="checkbox" checked
                   class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
            <span class="text-sm text-gray-700 dark:text-gray-300">
              <span class="font-medium">Capture Single Carrier Sheet history</span>
              <span class="block text-xs text-gray-500 dark:text-gray-400">Record carrier sheet generation events in the Auditor History → Carrier Sheet sub-tab.</span>
            </span>
          </label>

          <label class="flex items-start gap-2 mb-4 cursor-pointer max-w-md">
            <input id="togenadmin-history-capture-mts-label" type="checkbox" checked
                   class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
            <span class="text-sm text-gray-700 dark:text-gray-300">
              <span class="font-medium">Capture MTS Labels history</span>
              <span class="block text-xs text-gray-500 dark:text-gray-400">Record label batch creation events in the Auditor History → MTS Labels sub-tab.</span>
            </span>
          </label>

          <label class="flex items-start gap-2 mb-4 cursor-pointer max-w-md">
            <input id="togenadmin-history-capture-file-browser" type="checkbox" checked
                   class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
            <span class="text-sm text-gray-700 dark:text-gray-300">
              <span class="font-medium">Capture File Browser history</span>
              <span class="block text-xs text-gray-500 dark:text-gray-400">Record upload, download, rename, and delete events in the Auditor History → File Browser sub-tab.</span>
            </span>
          </label>

          <label class="flex items-start gap-2 mb-4 cursor-pointer max-w-md">
            <input id="togenadmin-history-capture-ocr-tool" type="checkbox" checked
                   class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
            <span class="text-sm text-gray-700 dark:text-gray-300">
              <span class="font-medium">Capture OCR Tool history</span>
              <span class="block text-xs text-gray-500 dark:text-gray-400">Record document OCR runs (file name, page count) in the Auditor History → OCR Tool sub-tab.</span>
            </span>
          </label>

          <label class="flex items-start gap-2 mb-4 cursor-pointer max-w-md">
            <input id="togenadmin-history-capture-takeoff-bridge" type="checkbox" checked
                   class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
            <span class="text-sm text-gray-700 dark:text-gray-300">
              <span class="font-medium">Capture Take-off Bridge history</span>
              <span class="block text-xs text-gray-500 dark:text-gray-400">Record region OCR events in the Auditor History → Take-off Bridge sub-tab.</span>
            </span>
          </label>

          <label class="flex items-start gap-2 mb-4 cursor-pointer max-w-md">
            <input id="togenadmin-history-capture-project-builder" type="checkbox" checked
                   class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
            <span class="text-sm text-gray-700 dark:text-gray-300">
              <span class="font-medium">Capture Project Builder history</span>
              <span class="block text-xs text-gray-500 dark:text-gray-400">Record project creation, editing, importing, and drawing attachment events in the Auditor History → Project Builder sub-tab.</span>
            </span>
          </label>

          <div class="mt-4 flex items-center gap-3">
            <button id="togenadmin-history-save-btn" onclick="togenadminSaveHistory()"
                    class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
              </svg>
              Save
            </button>
            <span id="togenadmin-history-save-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
        </div>
      </section>

      </div><!-- /togenadmin-panel-history -->

      <!-- Notifications panel -->
      <div id="togenadmin-panel-notifications" class="hidden">

      <!-- News Subscriptions pane (DVI-271) -->
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <div class="flex items-center justify-between mb-1">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">News Subscriptions</h2>
            <div class="flex items-center gap-2">
              <span id="togenadmin-subs-count" class="text-xs font-medium text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded-full"></span>
              <button onclick="togenadminOpenAddSub()"
                      class="p-1.5 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer"
                      title="Add subscriber">
                <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
                </svg>
              </button>
              <button onclick="togenadminToggleSubsList()" id="togenadmin-subs-toggle"
                      class="p-1.5 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer"
                      title="Expand list">
                <svg id="togenadmin-subs-chevron" class="h-5 w-5 transition-transform" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
                </svg>
              </button>
            </div>
          </div>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-3">Users subscribed to news email updates.</p>
          <div id="togenadmin-subs-list" class="hidden">
            <div id="togenadmin-subs-loading" class="text-sm text-gray-500 dark:text-gray-400">Loading subscribers...</div>
            <ul id="togenadmin-subs-emails" class="divide-y divide-gray-100 dark:divide-gray-700 max-h-64 overflow-y-auto"></ul>
            <p id="togenadmin-subs-empty" class="hidden text-sm text-gray-400 dark:text-gray-500 italic py-2">No subscribers yet.</p>
          </div>
        </div>
      </section>

      </div><!-- /togenadmin-panel-notifications -->

      <!-- Backup panel (DVI-412) -->
      <div id="togenadmin-panel-backup" class="hidden">

        <!-- Service pane -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <div class="flex items-center justify-between mb-1">
              <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Service</h2>
              <span id="togenadmin-backup-state" class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
                <span class="inline-block h-1.5 w-1.5 rounded-full bg-gray-400"></span>Inactive
              </span>
            </div>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">On-demand rsync backup of the Togen data set. The backup service is not yet wired up.</p>
            <button id="togenadmin-backup-run-btn" disabled
                    class="px-4 py-2 text-sm font-medium rounded-xl bg-gray-300 dark:bg-gray-700 text-white inline-flex items-center gap-2 opacity-50 cursor-not-allowed">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/>
              </svg>
              Run now
            </button>
          </div>
        </section>

        <!-- History pane -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">History</h2>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Recent backup runs. Click a run to view its log.</p>
            <div id="togenadmin-backup-history" class="divide-y divide-gray-100 dark:divide-gray-800">
              <p class="text-sm text-gray-500 dark:text-gray-400 py-2">No backup history yet.</p>
            </div>
            <button id="togenadmin-backup-more-btn" onclick="togenadminBackupLoadMore()" disabled
                    class="hidden mt-3 px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer">
              More
            </button>
          </div>
        </section>
      </div><!-- /togenadmin-panel-backup -->

      <!-- Assistant panel (DVI-530) -->
      <div id="togenadmin-panel-assistant" class="hidden">

        <!-- Global settings pane -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Assistant</h2>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Configure the company-wide Assistant connection and the default context all users start from.</p>

            <label class="flex items-start gap-3 cursor-pointer py-3 border-t border-gray-100 dark:border-gray-700">
              <input type="checkbox" id="togenadmin-assistant-enabled"
                     class="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
              <span class="min-w-0">
                <span class="block text-sm font-medium text-gray-800 dark:text-gray-100">Enabled</span>
                <span class="block text-xs text-gray-500 dark:text-gray-400">Turn the Assistant feature on. When off, no users can use the Assistant.</span>
              </span>
            </label>

            <label class="flex items-start gap-3 cursor-pointer py-3 border-t border-gray-100 dark:border-gray-700">
              <input type="checkbox" id="togenadmin-assistant-accessible"
                     class="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
              <span class="min-w-0">
                <span class="block text-sm font-medium text-gray-800 dark:text-gray-100">Accessible to users</span>
                <span class="block text-xs text-gray-500 dark:text-gray-400">When on, regular users without their own API key fall back to the company connection below. When off, only admins (or users with their own key) can use the Assistant.</span>
              </span>
            </label>

            <!-- Company API key -->
            <div class="py-3 border-t border-gray-100 dark:border-gray-700 max-w-md">
              <div class="flex items-center justify-between gap-3 mb-1">
                <label class="block text-sm font-medium text-gray-800 dark:text-gray-100">Company API key</label>
                <span id="togenadmin-assistant-key-state" class="text-xs text-gray-400 dark:text-gray-500">Not configured</span>
              </div>
              <input id="togenadmin-assistant-key" type="password" autocomplete="off" placeholder="Enter API key"
                     class="w-full px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200">
              <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Stored securely and never shown again. Leave blank to keep the current key.</p>
            </div>

            <!-- Default context md -->
            <div class="py-3 border-t border-gray-100 dark:border-gray-700">
              <div class="flex items-center justify-between gap-3 mb-1">
                <label class="block text-sm font-medium text-gray-800 dark:text-gray-100">Default context</label>
                <button id="togenadmin-assistant-context-edit" type="button" onclick="togenadminEditAssistantContext()"
                        class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors inline-flex items-center gap-1.5 cursor-pointer">
                  <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/>
                  </svg>
                  Edit
                </button>
              </div>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-2">The base context (Markdown) the Assistant uses for every user, unless a user overrides it in their own settings.</p>
              <textarea id="togenadmin-assistant-context" rows="10" disabled
                        class="w-full px-3 py-2 text-sm font-mono rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 disabled:opacity-70 disabled:cursor-not-allowed"></textarea>
            </div>

            <div class="mt-4 flex items-center gap-3">
              <button id="togenadmin-assistant-save-btn" onclick="togenadminSaveAssistant()" type="button"
                      class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
                </svg>
                Save
              </button>
              <span id="togenadmin-assistant-save-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
            </div>
          </div>
        </section>

      </div><!-- /togenadmin-panel-assistant -->

    </div><!-- /tool-togen-admin -->

    <!-- ===== SUPPORT REQUEST TOOL CONTENT ===== -->
    <div id="tool-project-builder" class="tool-content hidden">

      <!-- ===== PROJECT INFORMATION ===== -->
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
          <h2 class="text-sm font-bold text-white uppercase tracking-wide">Project Information</h2>
        </div>
        <div class="px-6 py-4">
          <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Customer/Contractor Name <span class="text-red-500">*</span></label>
              <input type="text" id="sp-customer-name" required placeholder="Enter customer name…"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Project Name <span class="text-red-500">*</span></label>
              <input type="text" id="sp-project-name" required placeholder="Enter project name…"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Vendor Project Code</label>
              <input type="text" id="sp-vendor-project-code" placeholder="Optional vendor code…"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
          </div>
          <p id="sp-name-warning" class="hidden text-xs text-yellow-600 dark:text-yellow-400 mt-2 font-medium"></p>
        </div>
      </section>

      <!-- ===== PROJECT FILES / UPLOAD (DVI-661) ===== -->
      <section id="sp-upload-pane" class="togen-pane hidden bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="bg-togen-teal rounded-t-2xl px-6 py-2 flex items-center justify-between">
          <h2 class="text-sm font-bold text-white uppercase tracking-wide">Project Files</h2>
          <button type="button" onclick="spToggleUploadPane()" class="text-white/80 hover:text-white text-xs font-medium">Hide</button>
        </div>
        <div class="px-6 py-4">
          <div id="sp-dropzone" onclick="document.getElementById('sp-upload-input').click()"
               class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl px-4 py-8 text-center cursor-pointer hover:border-togen-teal transition-colors">
            <svg class="h-8 w-8 mx-auto text-gray-400 dark:text-gray-500 mb-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L11 6.414V13a1 1 0 11-2 0V6.414L7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3z" clip-rule="evenodd"/>
              <path d="M3 14a1 1 0 011 1v1a1 1 0 001 1h10a1 1 0 001-1v-1a1 1 0 112 0v1a3 3 0 01-3 3H5a3 3 0 01-3-3v-1a1 1 0 011-1z"/>
            </svg>
            <p class="text-sm text-gray-500 dark:text-gray-400">Drag &amp; drop files here, or <span class="text-togen-teal font-semibold">click to browse</span></p>
            <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Any file type accepted. PDFs &amp; images can be OCR'd to discover project info.</p>
          </div>
          <input type="file" id="sp-upload-input" multiple class="hidden">
          <div id="sp-upload-list" class="mt-3 space-y-1"></div>
          <div class="mt-3 flex items-center gap-3 flex-wrap">
            <button type="button" id="sp-ocr-discover-btn" onclick="spOcrDiscover()" class="hidden px-4 py-1.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-xs transition-colors cursor-pointer inline-flex items-center gap-1.5">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/></svg>
              OCR Discovery
            </button>
            <button type="button" id="sp-ask-assistant-btn" onclick="spAskAssistantAboutDocs()" class="hidden px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl text-xs transition-colors cursor-pointer inline-flex items-center gap-1.5">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M10 2a6 6 0 00-6 6c0 1.887.87 3.57 2.232 4.674.41.332.605.79.605 1.236V15a1 1 0 001 1h4.326a1 1 0 001-1v-1.09c0-.446.195-.904.605-1.236A5.99 5.99 0 0016 8a6 6 0 00-6-6z"/><path d="M7 17a1 1 0 011-1h4a1 1 0 110 2H8a1 1 0 01-1-1z"/></svg>
              Ask Assistant about these documents
            </button>
            <span id="sp-upload-status" class="text-xs text-gray-500 dark:text-gray-400"></span>
          </div>
        </div>
      </section>

      <!-- ===== PRODUCT LINE FORM ===== -->
      <section id="sp-form-section" class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
          <h2 class="text-sm font-bold text-white uppercase tracking-wide">Product Line Entry</h2>
        </div>
        <div class="px-6 py-4">
          <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Transaction ID <span class="text-red-500">*</span></label>
              <input type="text" id="sp-field-tranId" required
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Structure</label>
              <input type="text" id="sp-field-structure"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Item</label>
              <input type="text" id="sp-field-item"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Quantity</label>
              <input type="text" id="sp-field-quantity"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Sales Price</label>
              <input type="text" id="sp-field-salesPrice"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Weight</label>
              <input type="text" id="sp-field-weight"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Item Size</label>
              <input type="text" id="sp-field-itemSize"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Part Reference</label>
              <input type="text" id="sp-field-partRef"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div class="sm:col-span-2">
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Description</label>
              <input type="text" id="sp-field-description"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
          </div>
          <div class="mt-4 flex items-center gap-3">
            <button type="button" onclick="spAddProduct()"
                    class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
              </svg>
              Add Product
            </button>
            <span id="sp-add-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
        </div>
      </section>

      <!-- ===== ADDED PRODUCTS TABLE ===== -->
      <section id="sp-products-section" class="togen-pane hidden bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="flex items-center justify-between px-6 py-4 cursor-pointer select-none"
             onclick="toggleSection('sp-products')">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">
            Added Products (<span id="sp-product-count">0</span>)
          </h2>
          <svg id="chevron-sp-products" class="h-5 w-5 text-gray-500 dark:text-gray-400 transition-transform duration-200 rotate-180"
               xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
          </svg>
        </div>
        <div id="body-sp-products" class="px-6 pb-6">
          <div class="overflow-x-auto">
            <table class="w-full text-sm text-left">
              <thead>
                <tr class="border-b border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 uppercase">
                  <th class="py-2 pr-3">#</th>
                  <th class="py-2 pr-3">Item</th>
                  <th class="py-2 pr-3">Part Ref</th>
                  <th class="py-2 pr-3">Qty</th>
                  <th class="py-2 pr-3">Description</th>
                  <th class="py-2 pr-3"></th>
                </tr>
              </thead>
              <tbody id="sp-products-tbody" class="text-gray-700 dark:text-gray-300"></tbody>
            </table>
          </div>
        </div>
      </section>

      <!-- ===== FOLDER NAME ===== -->
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
          <h2 class="text-sm font-bold text-white uppercase tracking-wide">Folder Name</h2>
        </div>
        <div class="px-6 py-4">
          <input type="text" id="sp-folder-name"
                 placeholder="Auto-generated from fields above…"
                 class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
          <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Format: Transaction ID - Vendor Project Code - Project Name - Project Number. Editable before submit.</p>
          <p id="sp-import-disclaimer" class="hidden text-xs text-amber-700 dark:text-amber-300 mt-2 font-medium">Imported projects must be duplicated to follow project numbering conventions. No changes to the original imported project are made here.</p>
        </div>
      </section>

      <!-- ===== NOTIFICATIONS ===== -->
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <label class="flex items-start gap-3 cursor-pointer select-none">
            <input type="checkbox" id="sp-notify"
                   class="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
            <span>
              <span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Enable notifications about this project or files</span>
              <span class="block text-xs text-gray-400 dark:text-gray-500 mt-0.5">Adds a folder watcher (like File Browser → Settings) that emails you when files in this project change. Manage it under User Settings → Notifications.</span>
            </span>
          </label>
        </div>
      </section>

      <!-- ===== SUBMIT / ACTIONS ===== -->
      <div class="flex items-center gap-3 mb-4 flex-wrap">
        <button type="button" id="sp-submit-btn" onclick="spSubmit()"
                class="px-6 py-3 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
          <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
          </svg>
          <span id="sp-submit-label">Submit</span>
        </button>
        <button type="button" id="sp-bridge-btn" onclick="spGoToBridge()" class="hidden px-6 py-3 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
          <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"/>
          </svg>
          Move to Take-off Bridge
        </button>
        <button type="button" id="sp-filebrowser-btn" onclick="spOpenInFileBrowser()" class="hidden px-6 py-3 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
          <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd"/>
            <path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z"/>
          </svg>
          Open in File Browser
        </button>
        <button type="button" onclick="spReset()"
                class="px-6 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer">
          Reset
        </button>
        <span id="sp-submit-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
      </div>

    </div><!-- /tool-project-builder -->

    <!-- ===== PROJECT BUILDER OPEN DIALOG ===== -->
    <div id="sp-open-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
      <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4 max-h-[80vh] flex flex-col">
        <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Open Existing Project</h2>
          <button type="button" onclick="spCloseDialog()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
            </svg>
          </button>
        </div>
        <div class="px-6 py-3">
          <input id="sp-open-search" type="text" placeholder="Filter projects…"
                 oninput="spFilterOpenList(this.value)"
                 class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
        </div>
        <div id="sp-open-list" class="flex-1 overflow-y-auto px-6 pb-4 divide-y divide-gray-100 dark:divide-gray-800">
          <p class="text-sm text-gray-400 dark:text-gray-500 py-3">Loading projects…</p>
        </div>
      </div>
    </div>

    <!-- ===== PROJECT BUILDER EDIT PRODUCT DIALOG ===== -->
    <div id="sp-edit-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
      <div class="absolute inset-0" onclick="spCloseEditDialog()"></div>
      <div class="relative bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto">
        <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Edit Product</h2>
          <button type="button" onclick="spCloseEditDialog()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
            </svg>
          </button>
        </div>
        <div class="px-6 py-4">
          <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Transaction ID <span class="text-red-500">*</span></label>
              <input type="text" id="sp-edit-tranId" required
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Structure</label>
              <input type="text" id="sp-edit-structure"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Item</label>
              <input type="text" id="sp-edit-item"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Quantity</label>
              <input type="text" id="sp-edit-quantity"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Sales Price</label>
              <input type="text" id="sp-edit-salesPrice"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Weight</label>
              <input type="text" id="sp-edit-weight"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Item Size</label>
              <input type="text" id="sp-edit-itemSize"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Part Reference</label>
              <input type="text" id="sp-edit-partRef"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div class="sm:col-span-2">
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Description</label>
              <input type="text" id="sp-edit-description"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
          </div>
          <div class="mt-5 pt-4 border-t border-gray-200 dark:border-gray-700">
            <p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">Drawings</p>
            <p id="sp-edit-draw-note" class="hidden text-xs text-gray-500 dark:text-gray-400 mb-2">
              Save the project first — drawings attach to an existing project folder.
            </p>
            <div id="sp-edit-draw-zones" class="grid grid-cols-1 sm:grid-cols-2 gap-3">
              <div id="sp-edit-shop-zone"
                   ondragover="spDrawingDragOver(event,'shop')" ondragleave="spDrawingDragLeave('shop')" ondrop="spDrawingDrop(event,'shop')"
                   class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg px-3 py-3 transition-colors">
                <div class="flex items-center justify-between mb-1">
                  <span class="text-xs font-medium text-gray-700 dark:text-gray-200">Shop Drawing</span>
                  <button type="button" id="sp-edit-shop-detach" onclick="spDrawingDetach('shop')" title="Detach"
                          class="hidden w-5 h-5 flex items-center justify-center rounded-full text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 text-xs font-bold">✕</button>
                </div>
                <p id="sp-edit-shop-status" class="text-xs text-gray-400 dark:text-gray-500 mb-2 break-all">No file attached</p>
                <button type="button" onclick="document.getElementById('sp-edit-shop-input').click()"
                        class="px-3 py-1.5 text-xs font-medium border-2 border-gray-400 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
                  Add / Replace
                </button>
                <input type="file" id="sp-edit-shop-input" class="hidden"
                       accept=".pdf,.png,.jpg,.jpeg,.gif,.tif,.tiff,.doc,.docx,.xls,.xlsx,.dwg,.dxf"
                       onchange="spDrawingUpload('shop', this)">
              </div>
              <div id="sp-edit-submittal-zone"
                   ondragover="spDrawingDragOver(event,'submittal')" ondragleave="spDrawingDragLeave('submittal')" ondrop="spDrawingDrop(event,'submittal')"
                   class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg px-3 py-3 transition-colors">
                <div class="flex items-center justify-between mb-1">
                  <span class="text-xs font-medium text-gray-700 dark:text-gray-200">Submittal</span>
                  <button type="button" id="sp-edit-submittal-detach" onclick="spDrawingDetach('submittal')" title="Detach"
                          class="hidden w-5 h-5 flex items-center justify-center rounded-full text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 text-xs font-bold">✕</button>
                </div>
                <p id="sp-edit-submittal-status" class="text-xs text-gray-400 dark:text-gray-500 mb-2 break-all">No file attached</p>
                <button type="button" onclick="document.getElementById('sp-edit-submittal-input').click()"
                        class="px-3 py-1.5 text-xs font-medium border-2 border-gray-400 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
                  Add / Replace
                </button>
                <input type="file" id="sp-edit-submittal-input" class="hidden"
                       accept=".pdf,.png,.jpg,.jpeg,.gif,.tif,.tiff,.doc,.docx,.xls,.xlsx,.dwg,.dxf"
                       onchange="spDrawingUpload('submittal', this)">
              </div>
            </div>
          </div>
          <div class="mt-5 flex items-center justify-between">
            <span id="sp-edit-status" class="text-sm text-red-600 dark:text-red-400"></span>
            <button type="button" onclick="spSaveEditProduct()"
                    class="px-6 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
              </svg>
              Save
            </button>
          </div>
        </div>
      </div>
    </div>

    <div id="tool-support-request" class="tool-content hidden">

      <form id="sr-form" onsubmit="srSubmit(event)">

        <!-- Requestor Information -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Requestor Information</h2>
          </div>
          <div class="px-6 py-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Name</label>
              <input type="text" name="sr_name" required
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email (from SSO)</label>
              <input type="email" name="sr_email" required
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal" readonly>
            </div>
          </div>
        </section>

        <!-- Issue Description -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Describe Issue</h2>
          </div>
          <div class="px-6 py-4">
            <textarea name="sr_description" rows="5" required placeholder="Describe the issue you are experiencing..."
                      class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal resize-y"></textarea>
          </div>
        </section>

        <!-- Context Captured -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Captured Context</h2>
          </div>
          <div class="px-6 py-4 space-y-3">
            <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Date &amp; Time</label>
                <input type="text" name="sr_datetime" readonly
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Active Tool</label>
                <input type="text" name="sr_active_tool" readonly
                       class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm">
              </div>
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Application State (JSON)</label>
              <textarea name="sr_app_state" rows="4" readonly
                        class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-xs font-mono focus:outline-none resize-y"></textarea>
            </div>
          </div>
        </section>

        <!-- Send To -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Send To Email</label>
            <input type="email" name="sr_send_to" value="icast.voyagetech.com@email.autotask.net"
                   class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Default: icast.voyagetech.com@email.autotask.net — you may change this address.</p>
          </div>
        </section>

        <!-- Action buttons -->
        <div class="flex items-center gap-3 mb-4 flex-wrap">
          <button type="submit" id="sr-submit-btn"
                  class="px-6 py-3 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
              <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
            </svg>
            Submit
          </button>
          <button type="button" onclick="srCancel()"
                  class="px-6 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer">
            Cancel
          </button>
          <button type="button" onclick="location.reload()"
                  class="px-6 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer inline-flex items-center gap-2">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
            </svg>
            Reload
          </button>
          <span id="sr-submit-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
        </div>

      </form>

    </div><!-- /tool-support-request -->

    <!-- ===== FEATURE REQUEST TOOL CONTENT (DVI-416) ===== -->
    <div id="tool-feature-request" class="tool-content hidden">

      <form id="fr-form" onsubmit="frSubmit(event)">

        <!-- Purpose disclaimer -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <p class="text-sm text-gray-600 dark:text-gray-300">
              The purpose of this submission is to request additional features or tools.
              If you have an issue with an existing feature or tool, please submit a Support Request instead.
            </p>
            <button type="button" onclick="openSupportRequest()"
                    class="mt-3 px-4 py-2 border border-togen-teal text-togen-teal dark:text-togen-teal font-semibold rounded-lg text-sm hover:bg-togen-teal hover:text-white transition-colors cursor-pointer inline-flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                <path stroke-linecap="round" stroke-linejoin="round" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z"/>
              </svg>
              Support Request
            </button>
          </div>
        </section>

        <!-- Requestor Information -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Requestor Information</h2>
          </div>
          <div class="px-6 py-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Name</label>
              <input type="text" name="fr_name" required
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email (from SSO)</label>
              <input type="email" name="fr_email" required
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal" readonly>
            </div>
          </div>
        </section>

        <!-- Describe Feature -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Describe Feature</h2>
          </div>
          <div class="px-6 py-4">
            <textarea name="fr_description" rows="5" required placeholder="Describe the feature or tool you would like to request..."
                      class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal resize-y"></textarea>
          </div>
        </section>

        <!-- Who may benefit -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Who may use or benefit from this feature?</h2>
          </div>
          <div class="px-6 py-4">
            <textarea name="fr_beneficiaries" rows="5" required placeholder="Describe who may use or benefit from this feature..."
                      class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal resize-y"></textarea>
          </div>
        </section>

        <!-- Send To -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-4">
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Send To Email</label>
            <input type="email" name="fr_send_to" value="icast.voyagetech.com@email.autotask.net"
                   class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Default: icast.voyagetech.com@email.autotask.net — you may change this address.</p>
          </div>
        </section>

        <!-- Action buttons -->
        <div class="flex items-center gap-3 mb-4 flex-wrap">
          <button type="submit" id="fr-submit-btn"
                  class="px-6 py-3 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
              <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
            </svg>
            Submit
          </button>
          <button type="button" onclick="frCancel()"
                  class="px-6 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer">
            Cancel
          </button>
          <button type="button" onclick="location.reload()"
                  class="px-6 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer inline-flex items-center gap-2">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
            </svg>
            Reload
          </button>
          <span id="fr-submit-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
        </div>

      </form>

    </div><!-- /tool-feature-request -->

    <!-- ===== IT HELP DESK TOOL CONTENT (DVI-413) ===== -->
    <div id="tool-it-help-desk" class="tool-content hidden">

      <form id="ithd-form" onsubmit="itSubmit(event)">

        <!-- Requestor Information -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Requestor Information</h2>
          </div>
          <div class="px-6 py-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Name</label>
              <input type="text" name="ithd_name" required
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email (from SSO)</label>
              <input type="email" name="ithd_email" required
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal" readonly>
            </div>
          </div>
        </section>

        <!-- Issue Description -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Describe Issue</h2>
          </div>
          <div class="px-6 py-4">
            <textarea name="ithd_description" rows="5" required placeholder="Describe the issue you are experiencing..."
                      class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal resize-y"></textarea>
          </div>
        </section>

        <!-- Issue Related To -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Issue Related To</h2>
          </div>
          <div class="px-6 py-4 grid grid-cols-2 sm:grid-cols-3 gap-3">
            <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
              <input type="checkbox" name="ithd_related" value="Computer" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Computer
            </label>
            <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
              <input type="checkbox" name="ithd_related" value="Software" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Software
            </label>
            <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
              <input type="checkbox" name="ithd_related" value="Networking" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Networking
            </label>
            <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
              <input type="checkbox" name="ithd_related" value="Service" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Service
            </label>
            <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
              <input type="checkbox" name="ithd_related" value="Printing" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Printing
            </label>
            <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
              <input type="checkbox" name="ithd_related" value="Security" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Security
            </label>
            <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
              <input type="checkbox" name="ithd_related" value="Accounts" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Accounts
            </label>
            <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
              <input type="checkbox" name="ithd_related" value="Files" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Files
            </label>
            <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
              <input type="checkbox" name="ithd_related" value="Togen" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Togen
            </label>
            <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
              <input type="checkbox" name="ithd_related" value="Other" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Other
            </label>
          </div>
        </section>

        <!-- Description + Submit -->
        <div class="mb-4">
          <p class="text-sm text-gray-600 dark:text-gray-300 mb-4">Submit an IT Help Desk ticket to your friendly IT service provider, Voyage Technology. If you have an urgent issue, please call or text 270-298-9111 or toll-free 800-618-9844.</p>
          <div class="flex items-center gap-3 flex-wrap">
            <button type="submit" id="ithd-submit-btn"
                    class="px-6 py-3 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
              <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
                <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
              </svg>
              Submit
            </button>
            <button type="button" onclick="itCancel()"
                    class="px-6 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer">
              Cancel
            </button>
            <span id="ithd-submit-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
        </div>

      </form>

    </div><!-- /tool-it-help-desk -->

    <!-- ===== USER SETTINGS TOOL CONTENT (DVI-274) ===== -->
    <div id="tool-user-settings" class="tool-content hidden">

      <!-- Tab bar (DVI-530/DVI-549): All (default) / Account / Interface / Notifications / Tools / Assistant -->
      <div class="flex flex-nowrap overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden border-b border-gray-200 dark:border-gray-700 mb-4">
        <button id="us-tab-all" onclick="usSwitchTab('all')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-togen-teal text-togen-teal cursor-pointer">
          All
        </button>
        <button id="us-tab-account" onclick="usSwitchTab('account')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Account
        </button>
        <button id="us-tab-interface" onclick="usSwitchTab('interface')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Interface
        </button>
        <button id="us-tab-printing" onclick="usSwitchTab('printing')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Printing
        </button>
        <button id="us-tab-notifications" onclick="usSwitchTab('notifications')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Notifications
        </button>
        <button id="us-tab-tools" onclick="usSwitchTab('tools')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Tools
        </button>
        <button id="us-tab-assistant" onclick="usSwitchTab('assistant')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Assistant
        </button>
        <button id="us-tab-documents" onclick="usSwitchTab('documents')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Documents
        </button>
      </div>

      <!-- Account pane (DVI-549): SSO identity + add-account affordance -->
      <section id="us-pane-account" class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Account</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Your single sign-on identity.</p>

          <div class="flex items-center gap-4 p-4 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
            <div class="flex-shrink-0 h-12 w-12 rounded-full bg-togen-teal flex items-center justify-center text-white">
              <svg class="h-7 w-7" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
                <path fill-rule="evenodd" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" clip-rule="evenodd"/>
              </svg>
            </div>
            <div class="min-w-0">
              <p class="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">{{ user_name }}</p>
              <p class="text-sm text-gray-500 dark:text-gray-400 truncate">{{ user_email }}</p>
              {% if user_tenant %}
              <span class="inline-flex items-center gap-1 mt-1 rounded-full px-2 py-0.5 text-xs font-medium bg-togen-teal/10 text-togen-teal">
                {{ user_tenant }}
              </span>
              {% endif %}
            </div>
          </div>

          <!-- Linked login accounts -->
          <div class="mt-4">
            <div class="flex items-center justify-between mb-2">
              <p class="text-sm font-medium text-gray-800 dark:text-gray-100">Login accounts</p>
              <button id="us-account-add" type="button" onclick="usAccountAdd()" title="Add login account"
                      class="inline-flex items-center justify-center h-8 w-8 rounded-full border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-togen-teal transition-colors cursor-pointer">
                <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                  <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
                </svg>
              </button>
            </div>
            <div class="flex items-center justify-between p-3 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
              <span class="text-sm text-gray-700 dark:text-gray-300 truncate">{{ user_email }}</span>
              <span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-400">Primary</span>
            </div>
            <p id="us-account-add-note" class="hidden mt-2 text-xs text-amber-700 dark:text-amber-400">
              Only the iCast tenant has been configured. Speak to your Togen admin to authorize additional tenants.
            </p>
          </div>
        </div>
      </section>

      <!-- User Interface pane -->
      <section id="us-pane-interface" class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">User Interface</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Customize the appearance of the application.</p>
          <div class="flex items-center justify-between p-3 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
            <div class="flex items-center gap-3">
              <svg class="h-5 w-5 text-gray-500 dark:text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                <path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
              </svg>
              <span class="text-sm font-medium text-gray-800 dark:text-gray-100">Dark Mode</span>
            </div>
            <button id="us-theme-toggle" onclick="usToggleTheme()" type="button"
                    class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-togen-teal focus:ring-offset-2 dark:focus:ring-offset-gray-900 cursor-pointer"
                    role="switch" aria-checked="false">
              <span class="sr-only">Toggle dark mode</span>
              <span id="us-theme-dot" class="inline-block h-4 w-4 rounded-full bg-white transition-transform translate-x-1"></span>
            </button>
          </div>

          <!-- Background override (DVI-364) -->
          <div class="mt-4 p-3 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
            <div class="flex items-center justify-between gap-3 mb-3">
              <div>
                <p class="text-sm font-medium text-gray-800 dark:text-gray-100">Background</p>
                <p class="text-xs text-gray-500 dark:text-gray-400">Override the company default background for your account.</p>
              </div>
              <select id="us-bg-mode" onchange="usUpdateBgOpacityEnabled()"
                      class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 cursor-pointer">
                <option value="">Use company default</option>
                <option value="togen_default">Togen Default</option>
                <option value="flat">Flat</option>
                <option value="corporate">Corporate Brand</option>
                <option value="custom">Custom</option>
              </select>
            </div>
            <div id="us-bg-opacity-block">
              <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Tile/Pane Opacity</label>
              <p class="text-xs text-gray-400 dark:text-gray-500 mb-2">Only applies when your effective background is Corporate Brand or Custom (desktop only).</p>
              <div class="space-y-3">
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Light Mode</label>
                  <div class="flex items-center gap-4 max-w-md">
                    <input type="range" id="us-bg-opacity-light-slider" min="40" max="100" step="1" value="100"
                           oninput="usSyncBgOpacity('light', 'slider')"
                           class="flex-1 accent-togen-teal cursor-pointer">
                    <div class="flex items-center gap-1">
                      <input type="number" id="us-bg-opacity-light-number" min="40" max="100" step="1" value="100"
                             onchange="usSyncBgOpacity('light', 'number')"
                             class="w-16 px-2 py-1 text-sm text-right rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200">
                      <span class="text-sm text-gray-500 dark:text-gray-400">%</span>
                    </div>
                  </div>
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Dark Mode</label>
                  <div class="flex items-center gap-4 max-w-md">
                    <input type="range" id="us-bg-opacity-dark-slider" min="40" max="100" step="1" value="100"
                           oninput="usSyncBgOpacity('dark', 'slider')"
                           class="flex-1 accent-togen-teal cursor-pointer">
                    <div class="flex items-center gap-1">
                      <input type="number" id="us-bg-opacity-dark-number" min="40" max="100" step="1" value="100"
                             onchange="usSyncBgOpacity('dark', 'number')"
                             class="w-16 px-2 py-1 text-sm text-right rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200">
                      <span class="text-sm text-gray-500 dark:text-gray-400">%</span>
                    </div>
                  </div>
                </div>
              </div>
            </div>
            <!-- Custom background uploader (DVI-518): shown only when "Custom" is selected -->
            <div id="us-bg-custom-block" class="hidden mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
              <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Custom background image</label>
              <p class="text-xs text-gray-400 dark:text-gray-500 mb-2">Upload your own image (PNG, JPG, WEBP, GIF, or SVG, up to 8&nbsp;MB) to use as your interface background.</p>
              <div id="us-bg-custom-drop" onclick="document.getElementById('us-bg-custom-file').click()"
                   class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl px-4 py-6 text-center cursor-pointer hover:border-togen-teal transition-colors">
                <svg class="h-8 w-8 mx-auto text-gray-400 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
                  <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 7.5 7.5 12M12 7.5V21"/>
                </svg>
                <p class="text-sm text-gray-600 dark:text-gray-300">Drag &amp; drop an image here, or <span class="text-togen-teal font-medium">browse</span></p>
                <input id="us-bg-custom-file" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml" class="hidden" onchange="usCustomBgFileChosen(this.files)">
              </div>
              <div id="us-bg-custom-preview-wrap" class="hidden mt-3 flex items-center gap-3">
                <img id="us-bg-custom-preview" alt="Custom background preview" class="h-20 w-32 rounded-lg border border-gray-200 dark:border-gray-700 object-cover bg-gray-100 dark:bg-gray-800">
                <button id="us-bg-custom-remove" type="button" onclick="usRemoveCustomBg()"
                        class="px-3 py-1.5 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer">
                  Remove
                </button>
              </div>
              <span id="us-bg-custom-status" class="block mt-2 text-sm text-gray-500 dark:text-gray-400"></span>
            </div>
            <div class="mt-3 flex items-center gap-3">
              <button id="us-bg-save-btn" onclick="usSaveBackground()" type="button"
                      class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
                </svg>
                Save
              </button>
              <span id="us-bg-save-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
            </div>
          </div>

          <!-- Tile Layout override (DVI-512) -->
          <div class="mt-4 p-3 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
            <div class="flex items-center justify-between gap-3">
              <div>
                <p class="text-sm font-medium text-gray-800 dark:text-gray-100">Tile Layout</p>
                <p class="text-xs text-gray-500 dark:text-gray-400">Number of columns for the Home tile grid. Mobile and tablet always collapse to 1 and 2 columns.</p>
              </div>
              <select id="us-tile-columns" onchange="usSaveTileLayout()"
                      class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 cursor-pointer">
                <option value="">Use company default</option>
                <option value="3">3</option>
                <option value="4">4</option>
                <option value="5">5</option>
                <option value="6">6</option>
                <option value="7">7</option>
                <option value="8">8</option>
                <option value="9">9</option>
                <option value="unlimited">Unlimited (auto-fit)</option>
              </select>
            </div>
            <div class="flex items-center justify-between gap-3 mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
              <div>
                <p class="text-sm font-medium text-gray-800 dark:text-gray-100">Side padding (%)</p>
                <p class="text-xs text-gray-500 dark:text-gray-400">Open space on the left and right of the grid. Applies on desktop only.</p>
              </div>
              <div class="flex items-center gap-2">
                <label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400 cursor-pointer">
                  <input type="checkbox" id="us-tile-padding-default" onchange="usTogglePaddingDefault()"
                         class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
                  Use company default
                </label>
                <input type="number" id="us-tile-padding" min="0" max="40" step="1" value="0" onchange="usSaveTileLayout()"
                       class="w-20 px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200">
              </div>
            </div>
            <span id="us-tile-layout-status" class="block mt-2 text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
        </div>
      </section>

      <!-- Printing pane (DVI-668 / DVI-657 P2): per-user default printer overrides -->
      <section id="us-pane-printing" class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">User Default Printers</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Choose your personal default document and label printers. Leave a dropdown on <span class="font-medium">Use Default</span> to inherit the company default set by your Togen admin.</p>

          <div class="space-y-4 max-w-md">
            <div>
              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Default document printer</label>
              <select id="us-printing-document" onchange="usSavePrinting()"
                      class="w-full rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-800 dark:text-gray-100 px-3 py-2 focus:ring-togen-teal focus:border-togen-teal cursor-pointer"></select>
            </div>
            <div>
              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Default label printer</label>
              <select id="us-printing-label" onchange="usSavePrinting()"
                      class="w-full rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-800 dark:text-gray-100 px-3 py-2 focus:ring-togen-teal focus:border-togen-teal cursor-pointer"></select>
            </div>
          </div>

          <p id="us-printing-empty-hint" class="hidden mt-3 text-xs text-gray-400 dark:text-gray-500 italic">No printers available yet. Printers appear here once your Togen admin includes them from a Print agent.</p>
          <span id="us-printing-save-status" class="block mt-3 text-sm text-gray-500 dark:text-gray-400"></span>
        </div>
      </section>

      <!-- Notifications pane (DVI-335) -->
      <section id="us-pane-notifications" class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Notifications</h2>

          <!-- Togen News subscription (DVI-385) — available to all users -->
          <div class="flex items-center gap-3 py-3 mb-4 border-b border-gray-100 dark:border-gray-700">
            <input type="checkbox" id="us-news-subscribe" onchange="usToggleNewsSubscription()"
                   class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
            <div class="min-w-0">
              <label for="us-news-subscribe" class="block text-sm font-medium text-gray-800 dark:text-gray-100 cursor-pointer">Togen News</label>
              <p class="text-xs text-gray-500 dark:text-gray-400">Receive Togen News notification emails about product updates and announcements.</p>
            </div>
            <span id="us-news-subscribe-status" class="ml-auto text-xs text-gray-400 dark:text-gray-500"></span>
          </div>

          <!-- SCADA notifications (DVI-465) — mirrors SCADA View > Settings > Notifications -->
          <div id="us-scada-notify-section" class="hidden py-3 mb-4 border-b border-gray-100 dark:border-gray-700">
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">SCADA Notifications</h3>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-3">Email preferences for the SCADA View tool. These mirror the options under SCADA View &rarr; Settings &rarr; Notifications.</p>
            <div class="space-y-3">
              <label class="flex items-start gap-3 cursor-pointer">
                <input type="checkbox" id="us-scada-notify-stale" onchange="scadaSaveNotif('stale_data', this.checked, 'us-scada-notify-status')"
                       class="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
                <span class="min-w-0">
                  <span class="block text-sm font-medium text-gray-800 dark:text-gray-100">Stale data</span>
                  <span class="block text-xs text-gray-500 dark:text-gray-400">Notify me when no new SCADA data has arrived within the configured window.</span>
                </span>
              </label>
              <label class="flex items-start gap-3 cursor-pointer">
                <input type="checkbox" id="us-scada-notify-oor" onchange="scadaSaveNotif('out_of_range', this.checked, 'us-scada-notify-status')"
                       class="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
                <span class="min-w-0">
                  <span class="block text-sm font-medium text-gray-800 dark:text-gray-100">Sensor out of range</span>
                  <span class="block text-xs text-gray-500 dark:text-gray-400">Notify me when a sensor with notifications enabled reports a value outside its range.</span>
                </span>
              </label>
            </div>
            <span id="us-scada-notify-status" class="text-xs text-gray-500 dark:text-gray-400 mt-3 block"></span>
          </div>

          <!-- File Browser folder-watcher notifications (DVI-471) — one toggle per watcher -->
          <div id="us-fb-notify-section" class="hidden py-3 mb-4 border-b border-gray-100 dark:border-gray-700">
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">File Browser</h3>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-3">Folder watchers you set up in File Browser &rarr; Settings. Toggle each to control whether you receive change emails; remove one with the &minus; icon.</p>
            <div id="us-fb-watchers-list" class="space-y-2"></div>
            <p id="us-fb-watchers-empty" class="hidden text-sm text-gray-400 dark:text-gray-500 italic">No folder watchers yet. Add one in File Browser &rarr; Settings &rarr; Notifications.</p>
            <span id="us-fb-notify-status" class="text-xs text-gray-500 dark:text-gray-400 mt-2 block"></span>
          </div>

          <!-- Empty-state placeholder for users without Work Order Admin access -->
          <p id="us-notify-empty" class="hidden text-sm text-gray-500 dark:text-gray-400">Work order notification types are only available to Work Order Admins.</p>
          <div id="us-notify-wo-section">
          <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Work Order Notifications</h3>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Choose which email updates you receive for work orders. As a Work Order Admin, these apply to all work orders submitted by any user. All notifications are off by default.</p>
          <p id="us-notify-primary-note" class="hidden text-sm text-togen-teal dark:text-teal-300 mb-4">You are a <strong>Primary</strong> recipient for one or more work order types. All notifications for those types are enforced on and cannot be changed.</p>
          <div class="overflow-x-auto">
            <table class="w-full text-sm border-collapse">
              <thead>
                <tr class="text-gray-500 dark:text-gray-400">
                  <th class="text-left font-medium py-2 pr-4">Work Order Type</th>
                  <th class="text-center font-medium px-2 py-2">New</th>
                  <th class="text-center font-medium px-2 py-2">Status Change</th>
                  <th class="text-center font-medium px-2 py-2">Completed</th>
                  <th class="text-center font-medium px-2 py-2">1 Day Before Due<sup class="text-togen-teal">*</sup></th>
                  <th class="text-center font-medium px-2 py-2">Overdue<sup class="text-togen-teal">*</sup></th>
                </tr>
              </thead>
              <tbody id="us-notify-grid">
                <!-- populated by JS -->
              </tbody>
            </table>
          </div>
          <p class="text-xs text-gray-400 dark:text-gray-500 mt-3"><sup class="text-togen-teal">*</sup> Applies only to recurring work orders.</p>
          <div class="mt-4 flex items-center gap-3">
            <button id="us-notify-save-btn" onclick="usSaveNotifications()"
                    class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
              </svg>
              Save
            </button>
            <span id="us-notify-save-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
          </div><!-- /us-notify-wo-section -->

          <!-- Repair Parts notifications (DVI-614) — available to all users -->
          <div id="us-rpm-notify-section" class="mt-6 pt-5 border-t border-gray-100 dark:border-gray-700">
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Repair Parts Notifications</h3>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Choose which repair-part email updates you receive, by Work Order type. All notifications are off by default.</p>
            <div class="overflow-x-auto">
              <table class="w-full text-sm border-collapse">
                <thead>
                  <tr class="text-gray-500 dark:text-gray-400">
                    <th class="text-left font-medium py-2 pr-4">Work Order Type</th>
                    <th class="text-center font-medium px-2 py-2">Awaiting Approval</th>
                    <th class="text-center font-medium px-2 py-2">Approved</th>
                    <th class="text-center font-medium px-2 py-2">Ordered</th>
                    <th class="text-center font-medium px-2 py-2">Delivered</th>
                    <th class="text-center font-medium px-2 py-2">Stocked</th>
                  </tr>
                </thead>
                <tbody id="us-rpm-notify-grid"><!-- populated by JS --></tbody>
              </table>
            </div>
            <div class="mt-4 flex items-center gap-3">
              <button id="us-rpm-notify-save-btn" onclick="rpmNotifSave('us-rpm-notify-grid')"
                      class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
                </svg>
                Save
              </button>
              <span id="us-rpm-notify-save-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
            </div>
          </div>
        </div>
      </section>

      <!-- Tools pane -->
      <section id="us-pane-tools" class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <div class="flex items-start justify-between gap-3 mb-1">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Tools</h2>
            <div class="relative shrink-0">
              <button id="us-tools-add-btn" type="button" onclick="usToggleAddMenu(event)" aria-haspopup="true" aria-expanded="false"
                      class="inline-flex items-center justify-center h-8 w-8 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-togen-teal transition-colors cursor-pointer"
                      title="Add an item to the list">
                <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
                </svg>
                <span class="sr-only">Add item</span>
              </button>
              <div id="us-tools-add-menu" class="hidden absolute right-0 mt-1 w-48 z-20 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
                <button type="button" onclick="usAddSeparator()"
                        class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
                  <svg class="h-4 w-4 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/>
                  </svg>
                  Separator
                </button>
              </div>
            </div>
          </div>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Customize which tools are visible and their display order on the home page. Drag to reorder. Changes apply only to your account.</p>
          <div id="us-tools-list" class="space-y-2">
            <!-- populated by JS -->
          </div>
          <div class="mt-4 flex items-center gap-3">
            <button id="us-tools-save-btn" onclick="usSaveSettings()"
                    class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
              </svg>
              Save
            </button>
            <button id="us-tools-reset-btn" onclick="usResetSettings()"
                    class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-2 cursor-pointer">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
              </svg>
              Reset to Defaults
            </button>
            <span id="us-tools-save-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
        </div>
      </section>

      <!-- Assistant pane (DVI-530) -->
      <section id="us-pane-assistant" class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Assistant</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Connect the Assistant to a language model provider with your own API key, and customize the context it uses.</p>

          <!-- Disabled banner: feature off globally -->
          <p id="us-assistant-disabled" class="hidden text-sm text-gray-600 dark:text-gray-300 p-3 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">The Assistant is currently disabled by your administrator.</p>

          <div id="us-assistant-body">
            <!-- Connection -->
            <div class="p-3 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
              <div class="flex items-center justify-between gap-3 mb-3">
                <p class="text-sm font-medium text-gray-800 dark:text-gray-100">Connection</p>
                <span id="us-assistant-conn-state" class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">
                  <span class="inline-block h-1.5 w-1.5 rounded-full bg-gray-400"></span>Not configured
                </span>
              </div>
              <div class="space-y-3 max-w-md">
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Provider</label>
                  <select id="us-assistant-provider" onchange="usAssistantProviderChanged()"
                          class="w-full px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 cursor-pointer"></select>
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Model</label>
                  <select id="us-assistant-model"
                          class="w-full px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 cursor-pointer"></select>
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">API key</label>
                  <input id="us-assistant-key" type="password" autocomplete="off" placeholder="Enter API key"
                         class="w-full px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200">
                  <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Your key is stored securely and never shown again. Leave blank to keep your current key.</p>
                  <!-- Provider API-key docs link (DVI-629), populated per selected provider. -->
                  <p id="us-assistant-docs-wrap" class="hidden text-xs mt-1">
                    <a id="us-assistant-docs" href="#" target="_blank" rel="noopener noreferrer"
                       class="inline-flex items-center gap-1 text-togen-teal hover:underline">
                      <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                        <path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
                      </svg>
                      <span id="us-assistant-docs-label">How to create an API key</span>
                    </a>
                  </p>
                </div>
              </div>
              <div class="mt-3 flex items-center gap-3">
                <button id="us-assistant-conn-save-btn" onclick="usSaveAssistantConnection()" type="button"
                        class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
                  <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
                  </svg>
                  Save
                </button>
                <span id="us-assistant-conn-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
              </div>
            </div>

            <!-- Context override -->
            <div class="mt-4 p-3 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
              <p class="text-sm font-medium text-gray-800 dark:text-gray-100 mb-1">Context</p>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Override the default context the Assistant uses. Leave blank to use the company default.</p>
              <textarea id="us-assistant-context" rows="8"
                        class="w-full px-3 py-2 text-sm font-mono rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200"></textarea>
              <div class="mt-3 flex items-center gap-3">
                <button id="us-assistant-context-save-btn" onclick="usSaveAssistantContext()" type="button"
                        class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
                  <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
                  </svg>
                  Save
                </button>
                <span id="us-assistant-context-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
              </div>
            </div>
          </div><!-- /us-assistant-body -->
        </div>
      </section>

      <!-- Documents pane (DVI-808): per-user quotes location -->
      <section id="us-pane-documents" class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
        <div class="px-6 py-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Documents</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Configure where your personal documents are stored in OneDrive.</p>

          <!-- Quotes location -->
          <div class="mb-4">
            <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">My Quotes Location</label>
            <input id="us-documents-quotes-location" type="text" placeholder="e.g. My Documents/Quotes"
                   class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">
            <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Your personal OneDrive path for completed quotes. Leave blank to use the shared company location set by your admin.</p>
          </div>
          <div class="flex items-center gap-3">
            <button onclick="usSaveDocuments()" type="button"
                    class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
              </svg>
              Save
            </button>
            <span id="us-documents-save-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
        </div>
      </section>

    </div><!-- /tool-user-settings -->

    <!-- ===== EMAIL TOOL CONTENT (DVI-470) ===== -->
    <div id="tool-email" class="tool-content hidden">
      <div class="flex items-center justify-between mb-4 flex-wrap gap-2">
        <p id="email-mailbox-label" class="text-sm text-gray-500 dark:text-gray-400"></p>
        <button onclick="emailLoadMessages()"
                class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-togen-teal hover:bg-togen-teal/10 rounded-lg transition-colors">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
          </svg>
          Refresh
        </button>
      </div>
      <!-- Filters (DVI-510): toggle chips across the top. Attachments selected by default. -->
      <div class="flex items-center gap-2 mb-3 flex-wrap">
        <span class="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wide">Filters</span>
        <button id="email-filter-attachments" onclick="emailToggleFilter('attachments')"
                class="inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-colors"></button>
        <button id="email-filter-workflow" onclick="emailToggleFilter('workflow')"
                class="inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-colors"></button>
      </div>
      <div id="email-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
      <div id="email-list" class="space-y-3"></div>
    </div><!-- /tool-email -->

    <!-- ===== FILE BROWSER TOOL CONTENT ===== -->
    <div id="tool-file-browser" class="tool-content hidden">

      <!-- Tabs (DVI-470): Projects = current file view, Attachments = inbox attachments -->
      <!-- DVI-785: scrolls horizontally on mobile (matches Work Order Admin tab bar) -->
      <div class="flex flex-nowrap overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden items-center gap-1 border-b border-gray-200 dark:border-gray-700 mb-4">
        {% if is_takeoff_workflow or is_togen_admin %}
        <button id="fb-tab-btn-projects" onclick="fbSwitchTab('projects')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 border-togen-teal text-togen-teal -mb-px">Projects</button>
        {% endif %}
        <button id="fb-tab-btn-attachments" onclick="fbSwitchTab('attachments')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 -mb-px">Attachments</button>
        {% if is_togen_vision or is_togen_admin %}
        <button id="fb-tab-btn-vision" onclick="fbSwitchTab('vision')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 -mb-px">Vision</button>
        {% endif %}
        {% if is_prod_run or is_togen_admin %}
        <button id="fb-tab-btn-prodrun" onclick="fbSwitchTab('prodrun')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 -mb-px">Prod Run</button>
        {% endif %}
        {% if is_togen_scada or is_togen_admin %}
        <button id="fb-tab-btn-scada" onclick="fbSwitchTab('scada')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 -mb-px">SCADA</button>
        {% endif %}
        <button id="fb-tab-btn-onedrive" onclick="fbSwitchTab('onedrive')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 -mb-px">OneDrive</button>
        {% if is_takeoff_workflow or is_togen_admin %}
        <button id="fb-tab-btn-settings" onclick="fbSwitchTab('settings')"
                class="flex-shrink-0 whitespace-nowrap ml-auto px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 -mb-px inline-flex items-center gap-1.5">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/>
          </svg>
          Settings
        </button>
        {% endif %}
      </div>

      {% if is_takeoff_workflow or is_togen_admin %}
      <div id="fb-tab-projects">

      <!-- Top action buttons bar (mirror of bottom) -->
      <div class="flex items-center gap-3 mb-3 flex-wrap">
        <button onclick="document.getElementById('fb-upload-input').click()"
                class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
          </svg>
          Upload
        </button>

        <button id="fbt-download-btn" onclick="fbDownload()" disabled
                class="px-5 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
          </svg>
          Download
        </button>

        <button id="fbt-open-btn" onclick="fbOpenViewer()" disabled
                class="hidden px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"/>
            <path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"/>
          </svg>
          Open
        </button>

        <div id="fbt-share-wrapper" class="hidden relative">
          <button id="fbt-share-btn" onclick="fbtToggleShareMenu(event)" disabled
                  class="px-5 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path d="M15 8a3 3 0 10-2.977-2.63l-4.94 2.47a3 3 0 100 4.319l4.94 2.47a3 3 0 10.895-1.789l-4.94-2.47a3.027 3.027 0 000-.74l4.94-2.47C13.456 7.68 14.19 8 15 8z"/>
            </svg>
            Share
          </button>
          <div id="fbt-share-menu"
               class="hidden absolute left-0 top-full mt-2 w-48 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
            <button onclick="fbCopyLink()"
                    class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z"/>
                <path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z"/>
              </svg>
              Copy Link
            </button>
            <button onclick="fbEmailLink()"
                    class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 border-t border-gray-100 dark:border-gray-700">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
                <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
              </svg>
              Email
            </button>
          </div>
        </div>

        <button id="fbt-print-btn" onclick="fbPrint()" disabled
                class="togen-print-btn hidden px-5 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M5 4v3H4a2 2 0 00-2 2v3a2 2 0 002 2h1v2a1 1 0 001 1h8a1 1 0 001-1v-2h1a2 2 0 002-2V9a2 2 0 00-2-2h-1V4a1 1 0 00-1-1H6a1 1 0 00-1 1zm8 0H7v3h6V4zm0 8H7v4h6v-4z" clip-rule="evenodd"/>
          </svg>
          Print
        </button>

        <div id="fbt-workflow-wrapper" class="hidden relative">
          <button id="fbt-workflow-btn" onclick="fbtToggleWorkflowMenu(event)" disabled
                  class="px-5 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"/>
            </svg>
            Workflow
          </button>
          <div id="fbt-workflow-menu"
               class="hidden absolute left-0 top-full mt-2 w-56 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
            <button id="fbt-wf-qr" onclick="fbOpenInQrCreator()"
                    class="hidden w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path d="M3 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm1 0v3h3V4H4z"/>
                <path d="M12 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1V4zm1 0v3h3V4h-3z"/>
                <path d="M3 13a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm1 0v3h3v-3H4z"/>
                <path d="M13 13h1v1h-1v-1zm2 0h1v1h-1v-1zm-2 2h1v1h-1v-1zm2 2h1v1h-1v-1zm2-2h1v1h-1v-1z"/>
              </svg>
              Open in Simple QR Creator
            </button>
            <button id="fbt-wf-bridge" onclick="fbOpenInBridge()"
                    class="hidden w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"/>
              </svg>
              Open in Take-off Bridge
            </button>
            <button id="fbt-wf-pb" onclick="fbOpenInProjectBuilder()"
                    class="hidden w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V8z" clip-rule="evenodd"/>
              </svg>
              Open in Project Builder
            </button>
          </div>
        </div>

        <button id="fbt-rename-btn" onclick="fbRenamePrompt()" disabled
                class="px-5 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/>
          </svg>
          Rename
        </button>

        <button id="fbt-delete-btn" onclick="fbDeleteConfirm()" disabled
                class="px-5 py-2.5 border border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 font-semibold rounded-xl text-sm hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors cursor-pointer inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/>
          </svg>
          Delete
        </button>

        <span id="fbt-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
      </div>

      <!-- Breadcrumb / Back bar -->
      <div class="flex items-center gap-2 mb-3">
        <button id="fb-back-btn" onclick="fbGoBack()" class="hidden inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-togen-teal hover:bg-togen-teal/10 rounded-lg transition-colors">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd"/>
          </svg>
          Back
        </button>
        <nav id="fb-breadcrumb" class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1 flex-wrap"></nav>
        <div class="ml-auto">
          <button onclick="fbRefresh()" title="Refresh" class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-togen-teal hover:bg-togen-teal/10 rounded-lg transition-colors">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
            </svg>
            Refresh
          </button>
        </div>
      </div>

      <!-- Main layout: file list + preview pane -->
      <div class="flex gap-4" style="min-height: calc(100vh - 280px);">

        <!-- File list pane -->
        <div id="fb-list-pane" class="togen-pane flex-1 bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden">
          <!-- Sort & search toolbar -->
          <div class="flex items-center gap-2 px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex-wrap">
            <input id="fb-search" type="text" placeholder="Search files…" oninput="fbApplyFilterSort()"
                   class="flex-1 min-w-[140px] px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
                          bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                          focus:outline-none focus:ring-2 focus:ring-togen-teal placeholder-gray-400 dark:placeholder-gray-500">
            <div class="flex items-center gap-1">
              <label class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">Sort by</label>
              <select id="fb-sort-field" onchange="_fbSortField=this.value;fbApplyFilterSort()"
                      class="px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-togen-teal">
                <option value="date">Date</option>
                <option value="name">Name</option>
              </select>
              <button id="fb-sort-dir-btn" onclick="fbToggleSortDir()" title="Toggle sort direction"
                      class="p-1.5 rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
                <svg id="fb-sort-desc-icon" class="h-4 w-4 text-gray-500 dark:text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
                </svg>
                <svg id="fb-sort-asc-icon" class="hidden h-4 w-4 text-gray-500 dark:text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clip-rule="evenodd"/>
                </svg>
              </button>
            </div>
          </div>
          <div id="fb-list" class="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
            <p class="text-sm text-gray-400 dark:text-gray-500 px-4 py-3">Loading…</p>
          </div>

          <!-- Drop zone overlay (hidden by default) -->
          <div id="fb-drop-overlay" class="hidden absolute inset-0 bg-togen-teal/10 dark:bg-togen-teal/20 border-2 border-dashed border-togen-teal rounded-2xl z-10 flex items-center justify-center pointer-events-none">
            <p class="text-togen-teal font-semibold text-lg">Drop files here to upload</p>
          </div>
        </div>

        <!-- Preview pane (hidden when nothing selected) -->
        <div id="fb-preview-pane" class="togen-pane hidden w-96 bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden">
          <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
            <span id="fb-preview-title" class="text-sm font-semibold text-gray-700 dark:text-gray-200 truncate">Preview</span>
            <button onclick="fbClosePreview()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
            </button>
          </div>
          <div id="fb-preview-content" class="flex-1 overflow-auto p-4">
          </div>
        </div>

      </div>

      <!-- Action buttons bar -->
      <div class="flex items-center gap-3 mt-4 flex-wrap">
        <input type="file" id="fb-upload-input" multiple class="hidden" onchange="fbUploadFiles(this.files)">
        <button onclick="document.getElementById('fb-upload-input').click()"
                class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
          </svg>
          Upload
        </button>

        <button id="fb-download-btn" onclick="fbDownload()" disabled
                class="px-5 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
          </svg>
          Download
        </button>

        <button id="fb-open-btn" onclick="fbOpenViewer()" disabled
                class="hidden px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"/>
            <path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"/>
          </svg>
          Open
        </button>

        <div id="fb-share-wrapper" class="hidden relative">
          <button id="fb-share-btn" onclick="fbToggleShareMenu(event)" disabled
                  class="px-5 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path d="M15 8a3 3 0 10-2.977-2.63l-4.94 2.47a3 3 0 100 4.319l4.94 2.47a3 3 0 10.895-1.789l-4.94-2.47a3.027 3.027 0 000-.74l4.94-2.47C13.456 7.68 14.19 8 15 8z"/>
            </svg>
            Share
          </button>
          <div id="fb-share-menu"
               class="hidden absolute left-0 bottom-full mb-2 w-48 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
            <button onclick="fbCopyLink()"
                    class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z"/>
                <path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z"/>
              </svg>
              Copy Link
            </button>
            <button onclick="fbEmailLink()"
                    class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2 border-t border-gray-100 dark:border-gray-700">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
                <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
              </svg>
              Email
            </button>
          </div>
        </div>

        <button id="fb-print-btn" onclick="fbPrint()" disabled
                class="togen-print-btn hidden px-5 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M5 4v3H4a2 2 0 00-2 2v3a2 2 0 002 2h1v2a1 1 0 001 1h8a1 1 0 001-1v-2h1a2 2 0 002-2V9a2 2 0 00-2-2h-1V4a1 1 0 00-1-1H6a1 1 0 00-1 1zm8 0H7v3h6V4zm0 8H7v4h6v-4z" clip-rule="evenodd"/>
          </svg>
          Print
        </button>

        <div id="fb-workflow-wrapper" class="hidden relative">
          <button id="fb-workflow-btn" onclick="fbToggleWorkflowMenu(event)" disabled
                  class="px-5 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"/>
            </svg>
            Workflow
          </button>
          <div id="fb-workflow-menu"
               class="hidden absolute left-0 bottom-full mb-2 w-56 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
            <button id="fb-wf-qr" onclick="fbOpenInQrCreator()"
                    class="hidden w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path d="M3 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm1 0v3h3V4H4z"/>
                <path d="M12 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1V4zm1 0v3h3V4h-3z"/>
                <path d="M3 13a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm1 0v3h3v-3H4z"/>
                <path d="M13 13h1v1h-1v-1zm2 0h1v1h-1v-1zm-2 2h1v1h-1v-1zm2 2h1v1h-1v-1zm2-2h1v1h-1v-1z"/>
              </svg>
              Open in Simple QR Creator
            </button>
            <button id="fb-wf-bridge" onclick="fbOpenInBridge()"
                    class="hidden w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"/>
              </svg>
              Open in Take-off Bridge
            </button>
            <button id="fb-wf-pb" onclick="fbOpenInProjectBuilder()"
                    class="hidden w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V8z" clip-rule="evenodd"/>
              </svg>
              Open in Project Builder
            </button>
          </div>
        </div>

        <button id="fb-rename-btn" onclick="fbRenamePrompt()" disabled
                class="px-5 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/>
          </svg>
          Rename
        </button>

        <button id="fb-delete-btn" onclick="fbDeleteConfirm()" disabled
                class="px-5 py-2.5 border border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 font-semibold rounded-xl text-sm hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors cursor-pointer inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/>
          </svg>
          Delete
        </button>

        <span id="fb-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
      </div>

      <!-- DVI-783: per-file upload progress panel -->
      <div id="fb-upload-panel" class="hidden mt-3 border border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 overflow-hidden">
        <div class="flex items-center justify-between px-4 py-2 bg-gray-50 dark:bg-gray-900/40 border-b border-gray-200 dark:border-gray-700">
          <span id="fb-upload-panel-title" class="text-sm font-semibold text-gray-700 dark:text-gray-200"></span>
          <button type="button" onclick="fbCloseUploadPanel()" id="fb-upload-panel-close"
                  class="hidden text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors" title="Dismiss">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
            </svg>
          </button>
        </div>
        <div id="fb-upload-rows" class="divide-y divide-gray-100 dark:divide-gray-700/60 max-h-72 overflow-y-auto"></div>
      </div>

      </div><!-- /fb-tab-projects -->
      {% endif %}

      <!-- Attachments tab (DVI-470): inbox emails with attachments -->
      <div id="fb-tab-attachments" class="hidden">
        <div class="flex items-center justify-between mb-3 flex-wrap gap-2">
          <p class="text-sm text-gray-500 dark:text-gray-400">Emails with attachments from your inbox. Use an attachment's Workflow or Share menu for actions.</p>
          <button onclick="emailLoadMessages()"
                  class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-togen-teal hover:bg-togen-teal/10 rounded-lg transition-colors">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
            </svg>
            Refresh
          </button>
        </div>
        <div id="fb-att-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
        <div id="fb-att-list" class="space-y-3"></div>
      </div><!-- /fb-tab-attachments -->

      {% if is_togen_vision or is_togen_admin %}
      <!-- Vision tab (DVI-507): Recordings gallery for Vision-group / Togen-admin users -->
      <div id="fb-tab-vision" class="hidden">
        <div class="flex items-center justify-between mb-4 gap-3 flex-wrap">
          <div class="flex items-center gap-3 flex-wrap">
            <div class="flex items-center gap-2">
              <label for="fbv-rec-machine" class="text-sm text-gray-600 dark:text-gray-300">Machine</label>
              <select id="fbv-rec-machine" onchange="fbVisionLoadRecordings()"
                      class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </select>
            </div>
            <div class="flex items-center gap-2">
              <label for="fbv-rec-from" class="text-sm text-gray-600 dark:text-gray-300">From</label>
              <input type="datetime-local" id="fbv-rec-from"
                     class="px-2 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div class="flex items-center gap-2">
              <label for="fbv-rec-to" class="text-sm text-gray-600 dark:text-gray-300">To</label>
              <input type="datetime-local" id="fbv-rec-to"
                     class="px-2 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <button onclick="fbVisionLoadRecordings()"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer">Search</button>
            <button onclick="fbVisionRecordingsClear()"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer">Clear</button>
            <button onclick="fbVisionLoad()" title="Refresh"
                    class="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-togen-teal hover:bg-togen-teal/10 rounded-lg transition-colors">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
              </svg>
              Refresh
            </button>
          </div>
          <span id="fbv-rec-time" class="text-sm text-gray-500 dark:text-gray-400"></span>
        </div>
        <div id="fbv-rec-loading" class="hidden text-center py-8">
          <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
          </svg>
          <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading recordings...</p>
        </div>
        <div id="fbv-rec-empty" class="hidden text-center py-8">
          <p class="text-sm text-gray-500 dark:text-gray-400">No recordings found for this machine and time range.</p>
        </div>
        <div id="fbv-rec-error" class="hidden text-center py-8">
          <p id="fbv-rec-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
        </div>
        <div id="fbv-rec-grid" class="hidden grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3"></div>
      </div><!-- /fb-tab-vision -->
      {% endif %}

      {% if is_prod_run or is_togen_admin %}
      <!-- Prod Run tab (DVI-528): Production Run history; click opens it in the Production Run tool -->
      <div id="fb-tab-prodrun" class="hidden">
        <div class="flex items-center justify-between mb-3 gap-2 flex-wrap">
          <p class="text-sm text-gray-500 dark:text-gray-400">Production Run history, latest first. Click a run to open it in the Production Run tool.</p>
          <button onclick="fbProdRunLoad()" title="Refresh"
                  class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-togen-teal hover:bg-togen-teal/10 rounded-lg transition-colors">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
            </svg>
            Refresh
          </button>
        </div>
        <div id="fb-prodrun-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
        <div id="fb-prodrun-list" class="space-y-2"></div>
      </div><!-- /fb-tab-prodrun -->
      {% endif %}

      {% if is_togen_scada or is_togen_admin %}
      <!-- SCADA tab (DVI-541): mailbox log emails + produced XLSX reports, newest first -->
      <div id="fb-tab-scada" class="hidden">
        <div class="flex items-center justify-between mb-4 gap-3 flex-wrap">
          <div class="flex items-center gap-3 flex-wrap">
            <div class="flex items-center gap-2">
              <label for="fb-scada-type" class="text-sm text-gray-600 dark:text-gray-300">Show</label>
              <select id="fb-scada-type" onchange="fbScadaRender()"
                      class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">
                <option value="all">Logs &amp; Reports</option>
                <option value="logs">Logs only</option>
                <option value="reports">Reports only</option>
              </select>
            </div>
            <div class="flex items-center gap-2">
              <label for="fb-scada-from" class="text-sm text-gray-600 dark:text-gray-300">From</label>
              <input type="date" id="fb-scada-from" onchange="fbScadaRender()"
                     class="px-2 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div class="flex items-center gap-2">
              <label for="fb-scada-to" class="text-sm text-gray-600 dark:text-gray-300">To</label>
              <input type="date" id="fb-scada-to" onchange="fbScadaRender()"
                     class="px-2 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <button onclick="fbScadaClear()"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer">Clear</button>
            <button onclick="fbScadaLoad()" title="Refresh"
                    class="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-togen-teal hover:bg-togen-teal/10 rounded-lg transition-colors">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
              </svg>
              Refresh
            </button>
          </div>
          <span id="fb-scada-time" class="text-sm text-gray-500 dark:text-gray-400"></span>
        </div>

        <div id="fb-scada-loading" class="hidden text-center py-8">
          <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
          </svg>
          <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading SCADA logs and reports…</p>
        </div>
        <div id="fb-scada-empty" class="hidden text-center py-8">
          <p class="text-sm text-gray-500 dark:text-gray-400">No logs or reports match these filters.</p>
        </div>
        <div id="fb-scada-error" class="hidden text-center py-8">
          <p id="fb-scada-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
        </div>

        <div id="fb-scada-table-wrap" class="hidden overflow-x-auto">
          <table class="w-full text-sm">
            <thead>
              <tr class="border-b border-gray-200 dark:border-gray-700 text-left">
                <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Date</th>
                <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Type</th>
                <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Name</th>
                <th class="pb-2 font-semibold text-gray-600 dark:text-gray-300"></th>
              </tr>
            </thead>
            <tbody id="fb-scada-tbody" class="divide-y divide-gray-100 dark:divide-gray-800"></tbody>
          </table>
        </div>

        <!-- Expanded log detail -->
        <div id="fb-scada-detail" class="hidden mt-4">
          <div class="flex items-center gap-2 mb-3">
            <button onclick="fbScadaCloseDetail()"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">
              &larr; Back to list
            </button>
            <span id="fb-scada-detail-subject" class="text-sm font-semibold text-gray-800 dark:text-gray-100"></span>
          </div>
          <div id="fb-scada-detail-body" class="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-mono overflow-x-auto max-h-[60vh] overflow-y-auto border border-gray-200 dark:border-gray-700"></div>
        </div>
      </div><!-- /fb-tab-scada -->
      {% endif %}

      <!-- OneDrive tab (DVI-810): browse signed-in user's OneDrive via app-only Graph -->
      <div id="fb-tab-onedrive" class="hidden">
        <div class="flex items-center gap-3 mb-4">
          <div id="fb-onedrive-breadcrumb" class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 flex-1 min-w-0 flex-wrap">
            <button onclick="fbOneDriveNavigate(null, 'root')" class="hover:text-togen-teal transition-colors cursor-pointer">OneDrive</button>
          </div>
          <button onclick="fbOneDriveRefresh()" class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-togen-teal hover:bg-togen-teal/10 rounded-lg transition-colors cursor-pointer">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
            </svg>
            Refresh
          </button>
        </div>
        <div id="fb-onedrive-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
        <div id="fb-onedrive-list" class="space-y-1"></div>
      </div><!-- /fb-tab-onedrive -->

      {% if is_takeoff_workflow or is_togen_admin %}
      <!-- Settings tab (DVI-471): Sync Status + Notifications (folder watchers) -->
      <div id="fb-tab-settings" class="hidden space-y-4">

        <!-- Sync Status pane -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
          <div class="px-6 py-4">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Sync Status</h2>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Current status of the shared file sync. Use Sync Now to trigger an immediate sync.</p>
            <div class="flex items-center gap-3 flex-wrap">
              <span id="fb-sync-dot" class="inline-block h-3 w-3 rounded-full bg-gray-300 dark:bg-gray-600"></span>
              <span id="fb-sync-state" class="text-sm font-medium text-gray-700 dark:text-gray-200">Loading…</span>
              <button onclick="fbSyncStatusLoad()" title="Refresh status"
                      class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-togen-teal hover:bg-togen-teal/10 rounded-lg transition-colors">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
                </svg>
                Refresh
              </button>
              <button onclick="openTool('sync-admin', 'Sync Admin')" title="Open Sync Admin with full sync log"
                      class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-togen-teal hover:bg-togen-teal/10 rounded-lg transition-colors">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/>
                </svg>
                Sync Admin
              </button>
              <button id="fb-sync-now-btn" onclick="fbSyncNow()"
                      class="ml-auto px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
                </svg>
                Sync Now
              </button>
            </div>
            <span id="fb-sync-status-msg" class="text-sm text-gray-500 dark:text-gray-400 mt-3 block"></span>
          </div>
        </section>

        <!-- Notifications (folder watchers) pane -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
          <div class="px-6 py-4">
            <div class="flex items-center justify-between gap-3 mb-1">
              <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Notifications</h2>
              <button onclick="fbWatcherPickerOpen()" title="Add folder watcher"
                      class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-lg text-sm transition-colors cursor-pointer">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
                </svg>
                Add
              </button>
            </div>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Watch a folder in the shared Projects tree. You'll get an email (sent to your sign-in address) whenever anything in that folder changes, checked every 15 minutes.</p>
            <div id="fb-watchers-list" class="space-y-2"></div>
            <p id="fb-watchers-empty" class="hidden text-sm text-gray-400 dark:text-gray-500 italic">No folder watchers yet. Click <span class="font-medium">Add</span> to watch a folder.</p>
            <span id="fb-watchers-status" class="text-sm text-gray-500 dark:text-gray-400 mt-3 block"></span>
          </div>
        </section>

      </div><!-- /fb-tab-settings -->
      {% endif %}

      <!-- Folder picker modal (DVI-471): navigate the Projects tree and pick a folder to watch -->
      <div id="fb-watcher-picker-modal" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
        <div onclick="fbWatcherPickerClose()" class="absolute inset-0 bg-black/50"></div>
        <div class="relative bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4 overflow-hidden flex flex-col" style="max-height: 80vh;">
          <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Choose a folder to watch</h2>
            <button onclick="fbWatcherPickerClose()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
              <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
              </svg>
            </button>
          </div>
          <div class="px-6 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2">
            <button id="fb-picker-up" onclick="fbWatcherPickerUp()" disabled
                    class="inline-flex items-center gap-1 px-2 py-1 text-sm font-medium text-togen-teal hover:bg-togen-teal/10 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd"/>
              </svg>
              Up
            </button>
            <span id="fb-picker-crumb" class="text-sm text-gray-500 dark:text-gray-400 truncate">Projects</span>
          </div>
          <div id="fb-picker-list" class="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800 min-h-[120px]"></div>
          <div class="px-6 py-3 border-t border-gray-200 dark:border-gray-700">
            <label class="flex items-center gap-2 cursor-pointer">
              <input id="fb-picker-expire-cb" type="checkbox" onchange="fbPickerExpireToggle()"
                     class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
              <span class="text-sm text-gray-700 dark:text-gray-300">Stop watching on a specific date</span>
            </label>
            <div id="fb-picker-expire-row" class="hidden mt-3 flex items-center gap-2">
              <label for="fb-picker-expire-date" class="text-sm text-gray-500 dark:text-gray-400">Termination date</label>
              <input id="fb-picker-expire-date" type="date"
                     class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
          </div>
          <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between gap-3">
            <span id="fb-picker-target" class="text-sm text-gray-500 dark:text-gray-400 truncate">Projects (root)</span>
            <button id="fb-picker-select-btn" onclick="fbWatcherPickerSelect()"
                    class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed">
              Watch this folder
            </button>
          </div>
        </div>
      </div>

      <!-- Folder watcher edit modal (expiration) -->
      <div id="fb-watcher-edit-modal" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
        <div onclick="fbWatcherEditClose()" class="absolute inset-0 bg-black/50"></div>
        <div class="relative bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-sm mx-4 p-6">
          <h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Edit folder watcher</h3>
          <p id="fb-watcher-edit-label" class="text-sm text-gray-500 dark:text-gray-400 truncate mb-4"></p>
          <label class="flex items-center gap-2 cursor-pointer">
            <input id="fb-watcher-edit-cb" type="checkbox" onchange="fbWatcherEditToggle()"
                   class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
            <span class="text-sm text-gray-700 dark:text-gray-300">Stop watching on a specific date</span>
          </label>
          <div id="fb-watcher-edit-row" class="hidden mt-3 flex items-center gap-2">
            <label for="fb-watcher-edit-date" class="text-sm text-gray-500 dark:text-gray-400">Termination date</label>
            <input id="fb-watcher-edit-date" type="date"
                   class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-togen-teal">
          </div>
          <p id="fb-watcher-edit-status" class="hidden text-sm text-red-500 mt-3"></p>
          <div class="flex justify-end gap-3 mt-5">
            <button onclick="fbWatcherEditClose()" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">Cancel</button>
            <button onclick="fbWatcherEditSave()" class="px-4 py-2 text-sm bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-lg transition-colors">Save</button>
          </div>
        </div>
      </div>

      <!-- Folder watcher remove confirmation (email 1-click link) -->
      <div id="fb-watcher-remove-modal" class="hidden fixed inset-0 z-[110] flex items-center justify-center">
        <div onclick="fbWatcherRemoveClose()" class="absolute inset-0 bg-black/50"></div>
        <div class="relative bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-sm mx-4 p-6">
          <h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-2">Remove folder watcher</h3>
          <p id="fb-watcher-remove-msg" class="text-sm text-gray-500 dark:text-gray-400 mb-4">Remove this folder watcher? You will stop receiving change notifications for it.</p>
          <div class="flex justify-end gap-3">
            <button onclick="fbWatcherRemoveClose()" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">Cancel</button>
            <button id="fb-watcher-remove-btn" onclick="fbWatcherRemoveConfirm()" class="px-4 py-2 text-sm bg-red-600 hover:bg-red-700 text-white font-semibold rounded-lg transition-colors">Remove</button>
          </div>
        </div>
      </div>

      <!-- Rename modal -->
      <div id="fb-rename-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
        <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 w-full max-w-sm mx-4">
          <h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-4">Rename</h3>
          <input id="fb-rename-input" type="text"
                 class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-togen-teal"
                 onkeydown="if(event.key==='Enter')fbRenameSubmit()">
          <div class="flex justify-end gap-3 mt-4">
            <button onclick="fbRenameCancel()" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">Cancel</button>
            <button onclick="fbRenameSubmit()" class="px-4 py-2 text-sm bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-lg transition-colors">Rename</button>
          </div>
        </div>
      </div>

      <!-- Delete confirmation modal -->
      <div id="fb-delete-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
        <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-6 w-full max-w-sm mx-4">
          <h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-2">Confirm Delete</h3>
          <p id="fb-delete-msg" class="text-sm text-gray-500 dark:text-gray-400 mb-4"></p>
          <div class="flex justify-end gap-3">
            <button onclick="fbDeleteCancel()" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">Cancel</button>
            <button onclick="fbDeleteSubmit()" class="px-4 py-2 text-sm bg-red-600 hover:bg-red-700 text-white font-semibold rounded-lg transition-colors">Delete</button>
          </div>
        </div>
      </div>

      <!-- Email link modal -->
      <div id="fb-email-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
        <div onclick="fbEmailClose()" class="absolute inset-0 bg-black/50"></div>
        <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-sm mx-4 overflow-hidden">
          <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Email Document Link</h2>
            <button onclick="fbEmailClose()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
              <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
              </svg>
            </button>
          </div>
          <div class="px-6 py-4">
            <label for="fb-email-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Send to</label>
            <input type="email" id="fb-email-input" value="{{ user_email }}"
                   class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal mb-3">
            <div class="flex items-center gap-3">
              <button id="fb-email-send-btn" onclick="fbEmailSend()"
                      class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
                  <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
                </svg>
                Send Email
              </button>
              <span id="fb-email-status" class="text-sm text-gray-700 dark:text-gray-300"></span>
            </div>
          </div>
        </div>
      </div>

    </div><!-- /tool-file-browser -->

    <!-- ===== EMAIL ATTACHMENT ACTION MODALS (DVI-470) — shared by Email tool + File Browser ===== -->
    <!-- Workflow menu -->
    <div id="email-workflow-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
      <div onclick="emailCloseModals()" class="absolute inset-0 bg-black/50"></div>
      <div class="relative bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-5 w-full max-w-sm mx-4">
        <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Workflow</h3>
        <p id="email-workflow-name" class="text-xs text-gray-500 dark:text-gray-400 mb-3 truncate"></p>
        <div class="space-y-1">
          <button id="email-wf-copy" onclick="emailWfCopyToPath()" class="hidden w-full text-left px-3 py-2 text-sm rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">Copy to path with File Browser</button>
          <button id="email-wf-manuals" onclick="emailWfAddToManuals()" class="hidden w-full text-left px-3 py-2 text-sm rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">Add to Equipment Manuals</button>
          <button id="email-wf-prodrun" onclick="emailWfOpenWith('prod-run-merge','Production Run')" class="hidden w-full text-left px-3 py-2 text-sm rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">Open with Production Run</button>
          <button id="email-wf-pb" onclick="emailWfOpenWith('project-builder','Project Builder')" class="hidden w-full text-left px-3 py-2 text-sm rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">Open with Project Builder</button>
          <button id="email-wf-bridge" onclick="emailWfOpenWith('takeoff-bridge','Take-off Bridge')" class="hidden w-full text-left px-3 py-2 text-sm rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">Open with Take-off Bridge</button>
        </div>
        <div class="flex justify-end mt-4">
          <button onclick="emailCloseModals()" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">Cancel</button>
        </div>
      </div>
    </div>

    <!-- Attachment preview (DVI-510) -->
    <div id="email-preview-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
      <div onclick="emailCloseModals()" class="absolute inset-0 bg-black/60"></div>
      <div class="relative bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-4xl mx-4 flex flex-col" style="max-height:90vh">
        <div class="flex items-center justify-between gap-3 p-4 border-b border-gray-200 dark:border-gray-700">
          <p id="email-preview-name" class="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate"></p>
          <div class="flex items-center gap-2 flex-shrink-0">
            <a id="email-preview-download" href="#" target="_blank" rel="noopener"
               class="px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Open in new tab</a>
            <button onclick="emailCloseModals()" class="px-3 py-1 text-xs text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">Close</button>
          </div>
        </div>
        <div id="email-preview-body" class="flex-1 overflow-auto bg-gray-50 dark:bg-gray-950 p-2 flex items-center justify-center min-h-[40vh]"></div>
      </div>
    </div>

    <!-- Folder picker (Save / Copy to path) -->
    <div id="email-save-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
      <div onclick="emailCloseModals()" class="absolute inset-0 bg-black/50"></div>
      <div class="relative bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-5 w-full max-w-md mx-4">
        <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Save to Projects</h3>
        <p id="email-save-name" class="text-xs text-gray-500 dark:text-gray-400 mb-2 truncate"></p>
        <nav id="email-save-breadcrumb" class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1 flex-wrap mb-2"></nav>
        <div id="email-save-folders" class="border border-gray-200 dark:border-gray-700 rounded-xl max-h-64 overflow-auto divide-y divide-gray-100 dark:divide-gray-800"></div>
        <p id="email-save-status" class="text-sm text-gray-500 dark:text-gray-400 mt-2"></p>
        <div class="flex justify-end gap-3 mt-4">
          <button onclick="emailCloseModals()" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">Cancel</button>
          <button id="email-save-confirm-btn" onclick="emailSaveConfirm()" class="px-4 py-2 text-sm bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-lg transition-colors">Save here</button>
        </div>
      </div>
    </div>

    <!-- Share via email -->
    <div id="email-share-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
      <div onclick="emailCloseModals()" class="absolute inset-0 bg-black/50"></div>
      <div class="relative bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-5 w-full max-w-sm mx-4">
        <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Share via Email</h3>
        <p id="email-share-name" class="text-xs text-gray-500 dark:text-gray-400 mb-3 truncate"></p>
        <input id="email-share-input" type="email" placeholder="recipient@example.com"
               class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-togen-teal"
               onkeydown="if(event.key==='Enter')emailShareSend()">
        <p id="email-share-status" class="text-sm text-gray-500 dark:text-gray-400 mt-2"></p>
        <div class="flex justify-end gap-3 mt-4">
          <button onclick="emailCloseModals()" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">Cancel</button>
          <button id="email-share-send-btn" onclick="emailShareSend()" class="px-4 py-2 text-sm bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-lg transition-colors">Send</button>
        </div>
      </div>
    </div>

    <!-- Equipment name (Add to Equipment Manuals) -->
    <div id="email-equip-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
      <div onclick="emailCloseModals()" class="absolute inset-0 bg-black/50"></div>
      <div class="relative bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-5 w-full max-w-sm mx-4">
        <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Add to Equipment Manuals</h3>
        <p id="email-equip-name" class="text-xs text-gray-500 dark:text-gray-400 mb-3 truncate"></p>
        <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Equipment / Asset / System name</label>
        <input id="email-equip-input" type="text"
               class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-togen-teal"
               onkeydown="if(event.key==='Enter')emailEquipSubmit()">
        <p id="email-equip-status" class="text-sm text-gray-500 dark:text-gray-400 mt-2"></p>
        <div class="flex justify-end gap-3 mt-4">
          <button onclick="emailCloseModals()" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors">Cancel</button>
          <button id="email-equip-submit-btn" onclick="emailEquipSubmit()" class="px-4 py-2 text-sm bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-lg transition-colors">Add</button>
        </div>
      </div>
    </div>

    <!-- ===== NEWS TOOL CONTENT (DVI-267) ===== -->
    <div id="tool-news" class="tool-content hidden">

      <!-- Subscribe / Unsubscribe bar -->
      <div class="flex items-center justify-between mb-4">
        <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">News</h2>
        <div class="flex items-center gap-3">
          <button id="news-subscribe-btn" onclick="newsToggleSubscribe()"
                  class="px-4 py-2 text-sm font-medium rounded-xl border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
              <path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
            </svg>
            <span id="news-subscribe-label">Subscribe</span>
          </button>
        </div>
      </div>

      {% if is_togen_admin %}
      <!-- New Post form (admin only) -->
      <section id="news-compose" class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-6">
        <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
          <h2 class="text-sm font-bold text-white uppercase tracking-wide">New Post</h2>
        </div>
        <div class="px-6 py-4 space-y-3">
          <input type="text" id="news-title" placeholder="Post title..."
                 class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
          <textarea id="news-body" rows="5" placeholder="Post content... (Markdown supported: # heading, **bold**, *italic*, - list, [link](https://...))"
                    class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal resize-y"></textarea>
          <p class="text-xs text-gray-400 dark:text-gray-500">Supports Markdown &mdash; headings, <strong>bold</strong>, <em>italic</em>, lists, and links. The same formatting is used in subscriber emails.</p>
          <div class="flex items-center gap-3">
            <button onclick="newsCreatePost()"
                    class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
              </svg>
              Publish
            </button>
            <button onclick="newsOpenSchedule()"
                    class="px-5 py-2.5 border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
              </svg>
              Schedule
            </button>
            <span id="news-post-status" class="text-sm"></span>
          </div>
        </div>
      </section>
      {% endif %}

      <!-- Posts feed -->
      <div id="news-feed" class="space-y-4">
        <p class="text-sm text-gray-500 dark:text-gray-400 text-center py-8">Loading news...</p>
      </div>
    </div><!-- /tool-news -->

    <!-- ===== SIMPLE QR CREATOR (DVI-270) ===== -->
    <div id="tool-simple-qr" class="tool-content hidden">
      <div class="max-w-lg mx-auto space-y-6">

        <!-- URL Input -->
        <div class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Create QR Code</h2>
          </div>
          <div class="px-6 py-4 space-y-4">
            <div>
              <label for="sqr-url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">URL</label>
              <input type="url" id="sqr-url" placeholder="https://example.com"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div class="flex items-center gap-3">
              <button id="sqr-generate-btn" onclick="sqrGenerate()"
                      class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
                </svg>
                Generate
              </button>
              <span id="sqr-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
            </div>
          </div>
        </div>

        <!-- QR Preview -->
        <div id="sqr-preview-wrapper" class="togen-pane hidden bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
          <div class="px-6 py-6 flex flex-col items-center gap-4">
            <img id="sqr-preview-img" src="" alt="QR Code" class="w-64 h-64 rounded-lg border border-gray-200 dark:border-gray-700">
            <p id="sqr-preview-url" class="text-xs text-gray-500 dark:text-gray-400 text-center break-all max-w-full"></p>
          </div>
          <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-center gap-3">
            <button onclick="sqrCopyToClipboard()"
                    class="px-5 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer inline-flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z"/>
                <path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z"/>
              </svg>
              Copy
            </button>
            <button onclick="sqrEmail()"
                    class="px-5 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer inline-flex items-center gap-2">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
                <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
              </svg>
              Email
            </button>
          </div>
        </div>

      </div>
    </div><!-- /tool-simple-qr -->

    <!-- ===== EQUIPMENT MANUALS TOOL CONTENT (DVI-456) ===== -->
    <div id="tool-equipment-manuals" class="tool-content hidden">
      <div class="max-w-3xl mx-auto space-y-6">
        <div class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2 flex items-center justify-between">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Equipment Manuals</h2>
            <button onclick="emOpenUploadModal()"
                    class="px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors inline-flex items-center gap-1.5">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
              </svg>
              Upload
            </button>
          </div>
          <div class="px-6 py-4">
            <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">All manuals uploaded here or attached to Preventative Maintenance work orders. The Equipment / Asset / System name is used to auto-match a manual when a Preventative Maintenance work order names the same equipment.</p>
            <div class="relative mb-3">
              <input type="search" id="em-search-input" placeholder="Search inside all manuals…"
                     oninput="emSearchDebounced()" onkeydown="if(event.key==='Enter'){event.preventDefault();emRunSearch();}"
                     class="w-full pl-9 pr-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              <svg class="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/>
              </svg>
              <p class="text-[11px] text-gray-400 dark:text-gray-500 mt-1">Searches OCR'd text across every manual. Open a result, then use Ctrl-F to find the term within the document.</p>
            </div>
            <div class="flex items-center justify-end mb-3">
              <button id="em-view-toggle" onclick="emToggleView()"
                      class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-1.5">
                <span id="em-view-toggle-icon" aria-hidden="true"></span>
                <span id="em-view-toggle-label">Grid view</span>
              </button>
            </div>
            <div id="em-list">
              <p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
            </div>
          </div>
        </div>
      </div>

      <!-- Upload modal -->
      <div id="em-upload-modal" class="hidden fixed inset-0 z-[60] flex items-center justify-center bg-black/50 px-4">
        <div class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h3 class="text-sm font-bold text-white uppercase tracking-wide">Upload Manual</h3>
          </div>
          <div class="px-6 py-4 space-y-4">
            <div>
              <label for="em-upload-equipment" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Equipment / Asset / System</label>
              <input type="text" id="em-upload-equipment" placeholder="e.g. Conveyor Line 3"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
              <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Used to auto-attribute this manual to matching Preventative Maintenance work orders.</p>
            </div>
            <div>
              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Manual file</label>
              <input type="file" id="em-upload-file" accept=".pdf,.doc,.docx,.png,.jpg,.jpeg"
                     class="w-full text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-togen-teal file:text-white hover:file:bg-togen-teal-dark">
              <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">PDF preferred. Allowed: PDF, DOC, DOCX, PNG, JPG.</p>
            </div>
            <p id="em-upload-status" class="text-sm hidden"></p>
          </div>
          <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-3">
            <button onclick="emCloseUploadModal()"
                    class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Cancel</button>
            <button id="em-upload-submit" onclick="emSubmitUpload()"
                    class="px-5 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors">Upload</button>
          </div>
        </div>
      </div>

      <!-- Edit Equipment/Asset/System name modal -->
      <div id="em-edit-modal" class="hidden fixed inset-0 z-[60] flex items-center justify-center bg-black/50 px-4">
        <div class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h3 class="text-sm font-bold text-white uppercase tracking-wide">Edit Equipment / Asset / System</h3>
          </div>
          <div class="px-6 py-4 space-y-3">
            <p class="text-xs text-gray-500 dark:text-gray-400" id="em-edit-file-label"></p>
            <div>
              <label for="em-edit-equipment" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Equipment / Asset / System</label>
              <input type="text" id="em-edit-equipment"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <p id="em-edit-status" class="text-sm hidden"></p>
          </div>
          <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-3">
            <button onclick="emCloseEditModal()" class="px-4 py-2 text-sm font-medium rounded-xl border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Cancel</button>
            <button id="em-edit-submit" onclick="emSubmitEdit()" class="px-5 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors">Save</button>
          </div>
        </div>
      </div>
    </div><!-- /tool-equipment-manuals -->

    <!-- ===== OCR TOOL CONTENT (DVI-490) ===== -->
    <div id="tool-ocr-tool" class="tool-content hidden">
      <div class="max-w-5xl mx-auto space-y-6">

        <!-- Tabs (DVI-497, DVI-646): Upload + Projects (Take-off/admin) on the
             left; Regions + Settings right-justified (Regions left of Settings) -->
        <div class="flex items-center gap-1 border-b border-gray-200 dark:border-gray-700">
          <button id="ocr-tab-btn-upload" onclick="ocrSwitchTab('upload')"
                  class="px-4 py-2 text-sm font-medium border-b-2 border-togen-teal text-togen-teal -mb-px">Upload</button>
          {% if is_takeoff_workflow or is_togen_admin %}
          <button id="ocr-tab-btn-projects" onclick="ocrSwitchTab('projects')"
                  class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 -mb-px">Projects</button>
          {% endif %}
          <button id="ocr-tab-btn-regions" onclick="ocrSwitchTab('regions')"
                  class="ml-auto px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 -mb-px">Regions</button>
          <button id="ocr-tab-btn-settings" onclick="ocrSwitchTab('settings')"
                  class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 -mb-px">Settings</button>
        </div>

        <!-- Upload tab -->
        <div id="ocr-tab-upload">
        <div class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">OCR</h2>
          </div>
          <div class="px-6 py-5 space-y-4">
            <p class="text-xs text-gray-500 dark:text-gray-400">Upload a scanned or image-only PDF. We add an invisible text layer so the document becomes searchable and its text can be selected and copied.</p>
            <div>
              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">PDF file</label>
              <input type="file" id="ocr-file" accept=".pdf"
                     class="w-full text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-togen-teal file:text-white hover:file:bg-togen-teal-dark">
            </div>
            <div>
              <label for="ocr-mode" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">OCR mode</label>
              <select id="ocr-mode"
                      class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
                <option value="skip-text">Skip pages that already have text (default)</option>
                <option value="redo-ocr">Redo OCR (replace prior OCR text layer)</option>
                <option value="force-ocr">Force OCR (rasterize and OCR every page)</option>
              </select>
            </div>
            <button id="ocr-submit-btn" onclick="ocrSubmit()"
                    class="w-full px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors">
              Make searchable
            </button>
            <div id="ocr-progress" class="hidden flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
              <svg class="animate-spin h-5 w-5 text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
              </svg>
              <span id="ocr-progress-text">Processing… this can take a while for large PDFs.</span>
            </div>
            <div id="ocr-result" class="hidden p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-300 dark:border-green-700">
              <p class="text-sm text-green-700 dark:text-green-400 mb-3">Your searchable PDF is ready.</p>
              <a id="ocr-download-link" href="#"
                 class="inline-flex items-center gap-2 px-4 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-lg text-sm transition-colors">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v8.586l2.293-2.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V4a1 1 0 011-1z" clip-rule="evenodd"/>
                  <path d="M3 15a1 1 0 011 1v1a1 1 0 001 1h10a1 1 0 001-1v-1a1 1 0 112 0v1a3 3 0 01-3 3H5a3 3 0 01-3-3v-1a1 1 0 011-1z"/>
                </svg>
                Download searchable PDF
              </a>
            </div>
            <div id="ocr-error" class="hidden p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm"></div>
          </div>
        </div>
        </div><!-- /ocr-tab-upload -->

        <!-- Settings tab: OCR Backend selector pane (DVI-497) -->
        <div id="ocr-tab-settings" class="hidden">
          <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
            <div class="px-6 py-4">
              <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">OCR Backend</h2>
              <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Choose the engine used to recognize text in uploaded PDFs and manuals. Additional backends can be added in a future release.</p>
              <select id="ocr-backend-select-tool" onchange="_ocrUpdateBackendDesc('tool')"
                      class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal mb-2">
              </select>
              <p id="ocr-backend-desc-tool" class="text-xs text-gray-500 dark:text-gray-400 mb-3"></p>
              <div class="flex items-center gap-3">
                <button id="ocr-backend-save-tool" onclick="ocrSaveBackend('tool')"
                        class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
                  Save
                </button>
                <span id="ocr-backend-status-tool" class="text-sm text-gray-500 dark:text-gray-400"></span>
              </div>
              <p id="ocr-backend-admin-note-tool" class="hidden text-xs text-gray-400 dark:text-gray-500 mt-2">Only Togen admins can change the OCR backend.</p>
            </div>
          </section>
        </div><!-- /ocr-tab-settings -->

        <!-- Projects tab (DVI-646): browse the shared Projects tree, OCR a file,
             then view / search / save the searchable result. -->
        {% if is_takeoff_workflow or is_togen_admin %}
        <div id="ocr-tab-projects" class="hidden">
          <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">

            <!-- Left: file browser -->
            <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 flex flex-col">
              <div class="bg-togen-teal rounded-t-2xl px-6 py-2 flex items-center justify-between">
                <h2 class="text-sm font-bold text-white uppercase tracking-wide">Projects</h2>
                <button id="ocr-proj-back-btn" onclick="ocrProjGoBack()"
                        class="hidden text-xs text-white/90 hover:text-white">&larr; Back</button>
              </div>
              <div class="px-4 py-2 border-b border-gray-100 dark:border-gray-800">
                <div id="ocr-proj-breadcrumb" class="text-xs text-gray-500 dark:text-gray-400 truncate"></div>
              </div>
              <div id="ocr-proj-list" class="flex-1 overflow-y-auto max-h-96 divide-y divide-gray-100 dark:divide-gray-800"></div>
              <div class="px-4 py-3 border-t border-gray-100 dark:border-gray-800 flex items-center gap-3">
                <select id="ocr-proj-mode"
                        class="px-2 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-xs focus:outline-none focus:ring-2 focus:ring-togen-teal">
                  <option value="skip-text">Skip pages with text</option>
                  <option value="redo-ocr">Redo OCR</option>
                  <option value="force-ocr">Force OCR</option>
                </select>
                <button id="ocr-proj-process-btn" onclick="ocrProjProcess()" disabled
                        class="flex-1 px-4 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
                  Process (OCR)
                </button>
              </div>
              <div id="ocr-proj-selected" class="hidden px-4 pb-3 text-xs text-gray-500 dark:text-gray-400 truncate"></div>
            </section>

            <!-- Right: result view -->
            <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 flex flex-col">
              <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
                <h2 class="text-sm font-bold text-white uppercase tracking-wide">Searchable Document</h2>
              </div>
              <div class="px-4 py-4 flex-1 flex flex-col gap-3">
                <div id="ocr-proj-progress" class="hidden flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
                  <svg class="animate-spin h-5 w-5 text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                    <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                    <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
                  </svg>
                  <span>Processing… this can take a while for large documents.</span>
                </div>
                <div id="ocr-proj-error" class="hidden p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm"></div>
                <div id="ocr-proj-placeholder" class="text-sm text-gray-400 dark:text-gray-500 py-12 text-center">
                  Select a file on the left and press <span class="font-medium">Process (OCR)</span> to make it text-selectable and searchable.
                </div>

                <div id="ocr-proj-result" class="hidden flex-1 flex flex-col gap-3">
                  <!-- Toolbar -->
                  <div class="flex flex-wrap items-center gap-2">
                    <button onclick="ocrProjToggleSearch()"
                            class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 inline-flex items-center gap-1.5">
                      <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/></svg>
                      Search
                    </button>
                    <button onclick="ocrProjOpenViewer()"
                            class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 inline-flex items-center gap-1.5">
                      <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"/><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"/></svg>
                      Open in Viewer
                    </button>
                  </div>

                  <!-- Expandable search bar -->
                  <div id="ocr-proj-searchbar" class="hidden flex items-center gap-2">
                    <input id="ocr-proj-search-input" type="text" placeholder="Search this document…"
                           onkeydown="if(event.key==='Enter'){event.preventDefault();ocrProjSearch();}"
                           class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
                    <button onclick="ocrProjSearch()" title="Search"
                            class="px-3 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white rounded-lg inline-flex items-center">
                      <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
                    </button>
                  </div>
                  <div id="ocr-proj-search-status" class="hidden text-xs text-gray-500 dark:text-gray-400"></div>

                  <!-- Document view -->
                  <iframe id="ocr-proj-frame" class="w-full flex-1 min-h-96 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800" title="Searchable document"></iframe>

                  <!-- Save / replace controls -->
                  <div class="border-t border-gray-100 dark:border-gray-800 pt-3 space-y-2">
                    <label class="block text-xs font-medium text-gray-700 dark:text-gray-300">Save as (in the project folder)</label>
                    <div class="flex items-center gap-2">
                      <input id="ocr-proj-save-name" type="text"
                             class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
                      <span class="text-xs text-gray-400 dark:text-gray-500">.pdf</span>
                    </div>
                    <div class="flex flex-wrap items-center gap-2">
                      <button onclick="ocrProjSave(false)"
                              class="px-4 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors">
                        Save to project
                      </button>
                      <button onclick="ocrProjReplace()"
                              class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 font-semibold rounded-xl text-sm transition-colors">
                        Replace existing
                      </button>
                      <span id="ocr-proj-save-status" class="text-xs text-gray-500 dark:text-gray-400"></span>
                    </div>
                  </div>
                </div>
              </div>
            </section>

          </div>
        </div><!-- /ocr-tab-projects -->
        {% endif %}

        <!-- Regions tab (DVI-646): stubbed for now. -->
        <div id="ocr-tab-regions" class="hidden">
          <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
            <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
              <h2 class="text-sm font-bold text-white uppercase tracking-wide">Regions</h2>
            </div>
            <div class="px-6 py-12 text-center">
              <p class="text-sm text-gray-500 dark:text-gray-400">Region-based OCR is coming soon. You'll be able to draw a box on a document and extract just that area.</p>
            </div>
          </section>
        </div><!-- /ocr-tab-regions -->

      </div>
    </div><!-- /tool-ocr-tool -->

    <!-- ===== CALENDAR TOOL CONTENT (DVI-469) ===== -->
    <div id="tool-calendar" class="tool-content hidden">
      <div class="max-w-4xl mx-auto space-y-6">
        <div class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2 flex items-center justify-between">
            <h2 class="text-sm font-bold text-white uppercase tracking-wide">Calendar</h2>
            <div class="inline-flex rounded-lg overflow-hidden border border-white/30" role="group" aria-label="Calendar view">
              <button id="cal-view-month" onclick="calToolSetView('month')"
                      class="px-3 py-1 text-xs font-semibold text-white transition-colors">Month</button>
              <button id="cal-view-week" onclick="calToolSetView('week')"
                      class="px-3 py-1 text-xs font-semibold text-white transition-colors border-l border-white/30">Week</button>
              <button id="cal-view-day" onclick="calToolSetView('day')"
                      class="px-3 py-1 text-xs font-semibold text-white transition-colors border-l border-white/30">Day</button>
            </div>
          </div>
          <div class="px-6 py-5">
            <!-- Source filters (DVI-556). More sources get added as Togen tools
                 begin contributing calendar entries. -->
            <div class="flex items-center flex-wrap gap-2 mb-4">
              <span class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mr-1">Filters</span>
              <label class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-gray-200 dark:border-gray-700 cursor-pointer select-none">
                <input id="cal-filter-microsoft" type="checkbox" checked onchange="calToolReload()"
                       class="h-3.5 w-3.5 rounded text-togen-teal focus:ring-togen-teal border-gray-300">
                <span class="inline-block h-2.5 w-2.5 rounded-sm bg-[#0078d4]"></span>
                <span class="text-xs font-medium text-gray-700 dark:text-gray-300">Microsoft</span>
              </label>
              <label id="cal-filter-workorder-wrap" class="hidden inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-gray-200 dark:border-gray-700 cursor-pointer select-none">
                <input id="cal-filter-workorder" type="checkbox" checked onchange="calToolReload()"
                       class="h-3.5 w-3.5 rounded text-togen-teal focus:ring-togen-teal border-gray-300">
                <span class="inline-block h-2.5 w-2.5 rounded-sm bg-[#0d9488]"></span>
                <span class="text-xs font-medium text-gray-700 dark:text-gray-300">Work Orders</span>
              </label>
              <span id="cal-tool-status" class="text-xs text-gray-400 dark:text-gray-500 ml-1"></span>
            </div>
            <!-- Nav row: large prev / period label / large next -->
            <div class="flex items-center justify-between mb-5">
              <button onclick="calToolPrev()" aria-label="Previous"
                      class="p-2 rounded-xl text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-togen-teal transition-colors">
                <svg class="h-9 w-9" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                  <path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
                </svg>
              </button>
              <div class="text-center">
                <div id="cal-tool-label" class="text-xl font-bold text-gray-800 dark:text-gray-100"></div>
                <button onclick="calToolToday()" class="mt-1 text-xs font-medium text-togen-teal hover:underline">Today</button>
              </div>
              <button onclick="calToolNext()" aria-label="Next"
                      class="p-2 rounded-xl text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-togen-teal transition-colors">
                <svg class="h-9 w-9" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                  <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
                </svg>
              </button>
            </div>
            <!-- Month view -->
            <div id="cal-month-view">
              <div class="grid grid-cols-7 gap-1 mb-1 text-center">
                <div class="py-1 text-xs font-semibold text-gray-400 dark:text-gray-500">Sun</div>
                <div class="py-1 text-xs font-semibold text-gray-400 dark:text-gray-500">Mon</div>
                <div class="py-1 text-xs font-semibold text-gray-400 dark:text-gray-500">Tue</div>
                <div class="py-1 text-xs font-semibold text-gray-400 dark:text-gray-500">Wed</div>
                <div class="py-1 text-xs font-semibold text-gray-400 dark:text-gray-500">Thu</div>
                <div class="py-1 text-xs font-semibold text-gray-400 dark:text-gray-500">Fri</div>
                <div class="py-1 text-xs font-semibold text-gray-400 dark:text-gray-500">Sat</div>
              </div>
              <div id="cal-month-grid" class="grid grid-cols-7 gap-1"></div>
            </div>
            <!-- Week view (linear list) -->
            <div id="cal-week-view" class="hidden space-y-2"></div>
            <!-- Day view (linear list) -->
            <div id="cal-day-view" class="hidden"></div>
          </div>
        </div>
      </div>
    </div><!-- /tool-calendar -->

    <!-- ===== CALENDAR DAY POPUP (DVI-630 interaction) =====
         Clicking a day in month/week view expands that day into a focused
         daily list. Work Order rows inside are clickable to open the WO popup. -->
    <div id="cal-day-modal" class="hidden fixed inset-0 z-[65] flex items-center justify-center bg-black/50 px-4"
         onclick="if(event.target===this)calToolCloseDay()">
      <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg max-h-[80vh] flex flex-col">
        <div class="bg-togen-teal rounded-t-2xl px-5 py-3 flex items-center justify-between">
          <h3 id="cal-day-modal-title" class="text-sm font-bold text-white uppercase tracking-wide">Day</h3>
          <button onclick="calToolCloseDay()" aria-label="Close" class="text-white/80 hover:text-white">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
          </button>
        </div>
        <div id="cal-day-modal-body" class="px-5 py-4 overflow-y-auto space-y-1.5"></div>
      </div>
    </div>

    <!-- ===== CALENDAR WORK ORDER POPUP (DVI-630 interaction) =====
         A clickable WO event expands into its details with an "Open in WOA"
         button that focuses the Work Order in the Work Order Admin tool. -->
    <div id="cal-wo-modal" class="hidden fixed inset-0 z-[66] flex items-center justify-center bg-black/50 px-4"
         onclick="if(event.target===this)calToolCloseWO()">
      <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md">
        <div class="bg-togen-teal rounded-t-2xl px-5 py-3 flex items-center justify-between">
          <h3 class="text-sm font-bold text-white uppercase tracking-wide">Work Order</h3>
          <button onclick="calToolCloseWO()" aria-label="Close" class="text-white/80 hover:text-white">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
          </button>
        </div>
        <div id="cal-wo-modal-body" class="px-5 py-4 space-y-2"></div>
        <div class="px-5 pb-5 pt-1">
          <button id="cal-wo-open-woa-btn" onclick="calToolWoOpenInWOA()"
                  class="w-full inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-semibold rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
            Open in WOA
          </button>
        </div>
      </div>
    </div>

    <!-- ===== REUSABLE EQUIPMENT MANUAL PICKER (DVI-462) =====
         Shared "Add from Equipment Manual" popup used by Work Order Admin, the
         Preventative Maintenance form, and the Mobile Machine form. Lists existing
         manuals (click to attach) and supports uploading a new one (file picker or
         drag-and-drop) with an Equipment/Asset/System name. The caller passes a
         callback that receives the chosen manual descriptor. -->
    <div id="manual-picker-modal" class="hidden fixed inset-0 z-[70] flex items-center justify-center bg-black/50 px-4">
      <div class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg max-h-[85vh] flex flex-col">
        <div class="bg-togen-teal rounded-t-2xl px-6 py-2 flex items-center justify-between">
          <h3 class="text-sm font-bold text-white uppercase tracking-wide">Add from Equipment Manual</h3>
          <button onclick="manualPickerClose()" class="text-white/80 hover:text-white" aria-label="Close">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
          </button>
        </div>
        <div class="px-6 py-4 overflow-y-auto">
          <!-- Upload / drag-drop zone -->
          <div id="manual-picker-drop" class="mb-4 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-600 px-4 py-5 text-center cursor-pointer hover:border-togen-teal transition-colors"
               onclick="manualPickerChooseFile()">
            <svg class="h-7 w-7 mx-auto text-gray-400 mb-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zM4 15a1 1 0 011 1v1a1 1 0 001 1h8a1 1 0 001-1v-1a1 1 0 112 0v1a3 3 0 01-3 3H6a3 3 0 01-3-3v-1a1 1 0 011-1z" clip-rule="evenodd"/></svg>
            <p class="text-sm font-medium text-gray-700 dark:text-gray-300">Upload a manual</p>
            <p class="text-xs text-gray-400 dark:text-gray-500">Click to choose a file or drag &amp; drop here. PDF, DOC, DOCX, PNG, JPG.</p>
          </div>
          <input type="file" id="manual-picker-file" accept=".pdf,.doc,.docx,.png,.jpg,.jpeg" class="hidden" onchange="manualPickerFileChosen()">

          <!-- Upload EAS-name sub-step (shown after a file is chosen) -->
          <div id="manual-picker-upload-step" class="hidden mb-4 rounded-xl border border-togen-teal/40 bg-togen-teal/5 dark:bg-togen-teal/10 px-4 py-3 space-y-2">
            <p class="text-sm text-gray-700 dark:text-gray-300">File: <span id="manual-picker-upload-name" class="font-semibold"></span></p>
            <label for="manual-picker-upload-eq" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Equipment / Asset / System name</label>
            <input type="text" id="manual-picker-upload-eq" placeholder="e.g. Conveyor Line 3"
                   class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            <div class="flex items-center justify-end gap-2 pt-1">
              <button onclick="manualPickerCancelUpload()" class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Cancel</button>
              <button id="manual-picker-upload-confirm" onclick="manualPickerConfirmUpload()" class="px-4 py-1.5 text-xs font-semibold rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors">Attach</button>
            </div>
          </div>

          <p class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-2">Existing manuals</p>
          <div id="manual-picker-list">
            <p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
          </div>
          <p id="manual-picker-status" class="text-sm mt-2 hidden"></p>
        </div>
      </div>
    </div><!-- /manual-picker-modal -->

    <!-- ===== WORK ORDER ADMIN — EMAIL SHARE DIALOG (DVI-746) =====
         Opened from the preview Share menu. Collects a recipient address and
         emails the selected Work Order PDF. -->
    <div id="woradmin-email-modal" class="hidden fixed inset-0 z-[70] flex items-center justify-center bg-black/50 px-4"
         onclick="if(event.target===this)woradminCloseEmailDialog()">
      <div class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md">
        <div class="bg-togen-teal rounded-t-2xl px-6 py-3 flex items-center justify-between">
          <h3 class="text-sm font-bold text-white uppercase tracking-wide">Email Work Order</h3>
          <button onclick="woradminCloseEmailDialog()" class="text-white/80 hover:text-white" aria-label="Close">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
          </button>
        </div>
        <div class="px-6 py-5 space-y-3">
          <label for="woradmin-email-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Send to</label>
          <input type="email" id="woradmin-email-input" value="{{ user_email }}" placeholder="name@example.com"
                 class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-togen-teal/40"/>
          <p id="woradmin-email-status" class="text-sm"></p>
          <div class="flex items-center justify-end gap-2 pt-1">
            <button type="button" onclick="woradminCloseEmailDialog()"
                    class="px-3 py-1.5 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Cancel</button>
            <button type="button" id="woradmin-email-btn" onclick="woradminEmailPdf()"
                    class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 whitespace-nowrap cursor-pointer">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
                <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
              </svg>
              Send Email
            </button>
          </div>
        </div>
      </div>
    </div><!-- /woradmin-email-modal -->

    <!-- ===== MTS LABEL CREATOR TOOL CONTENT ===== -->
    <div id="tool-mts-label" class="tool-content hidden">
      <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
        <div class="px-6 py-5">
          <div class="flex items-start justify-between mb-1">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Made To Stock Label Creator</h2>
            <button onclick="mtsOpenProductLookup()"
                    class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg
                           border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300
                           hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors flex-shrink-0">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
              </svg>
              Product Lookup
            </button>
          </div>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-5">Create a stock product label with the following information and a QR code when a drawing file is provided.</p>

          <!-- Preview area -->
          <div id="mts-preview-placeholder"
               class="flex flex-col items-center justify-center h-40 bg-gray-50 dark:bg-gray-800 rounded-xl
                      border-2 border-dashed border-gray-200 dark:border-gray-700 mb-5">
            <p class="text-gray-400 dark:text-gray-500 text-sm">Label preview appears here after generation</p>
          </div>
          <div id="mts-preview-container" class="hidden mb-5">
            <embed id="mts-preview-embed" type="application/pdf"
                   class="w-full rounded-xl border border-gray-200 dark:border-gray-700" style="height:480px;" />
            <div class="mt-4 flex flex-wrap gap-3">
              <a id="mts-download-btn" href="#"
                 class="inline-flex items-center gap-2 px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark
                        text-white font-medium rounded-xl text-sm transition-colors">
                Download PDF
              </a>
              <button onclick="mtsReset()"
                      class="px-5 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700
                             text-gray-700 dark:text-gray-300 font-medium rounded-xl text-sm transition-colors">
                Create Another
              </button>

              <div class="relative">
                <button onclick="mtsToggleShareMenu(event)"
                        class="px-5 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300
                               font-medium rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700
                               transition-colors cursor-pointer inline-flex items-center gap-2">
                  <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path d="M15 8a3 3 0 10-2.977-2.63l-4.94 2.47a3 3 0 100 4.319l4.94 2.47a3 3 0 10.895-1.789l-4.94-2.47a3.027 3.027 0 000-.74l4.94-2.47C13.456 7.68 14.19 8 15 8z"/>
                  </svg>
                  Share
                </button>
                <div id="mts-share-menu"
                     class="hidden absolute left-0 top-full mt-2 w-48 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
                  <button onclick="mtsEmailLabel()"
                          class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
                    <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                      <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
                      <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
                    </svg>
                    Email
                  </button>
                </div>
              </div>

              <button onclick="mtsPrintLabel()"
                      class="togen-print-btn px-5 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300
                             font-medium rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700
                             transition-colors cursor-pointer inline-flex items-center gap-2">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M5 4v3H4a2 2 0 00-2 2v3a2 2 0 002 2h1v2a1 1 0 001 1h8a1 1 0 001-1v-2h1a2 2 0 002-2V9a2 2 0 00-2-2h-1V4a1 1 0 00-1-1H6a1 1 0 00-1 1zm8 0H7v3h6V4zm0 8H7v4h6v-4z" clip-rule="evenodd"/>
                </svg>
                Print
              </button>

              <div class="relative">
                <button onclick="mtsToggleWorkflowMenu(event)"
                        class="px-5 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300
                               font-medium rounded-xl text-sm hover:bg-gray-100 dark:hover:bg-gray-700
                               transition-colors cursor-pointer inline-flex items-center gap-2">
                  <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"/>
                  </svg>
                  Workflow
                </button>
                <div id="mts-workflow-menu"
                     class="hidden absolute left-0 top-full mt-2 w-56 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">
                  <button onclick="mtsOpenInQrCreator()"
                          class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2">
                    <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                      <path d="M3 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm1 0v3h3V4H4z"/>
                      <path d="M12 4a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1V4zm1 0v3h3V4h-3z"/>
                      <path d="M3 13a1 1 0 011-1h3a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm1 0v3h3v-3H4z"/>
                      <path d="M13 13h1v1h-1v-1zm2 0h1v1h-1v-1zm-2 2h1v1h-1v-1zm2 2h1v1h-1v-1zm2-2h1v1h-1v-1z"/>
                    </svg>
                    Open in Simple QR Creator
                  </button>
                </div>
              </div>
            </div>
          </div>

          <!-- Form -->
          <form id="mts-form" class="space-y-4" onsubmit="return false;">
            <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
              <div>
                <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
                  Product Code <span class="text-red-500 dark:text-red-400">*</span>
                </label>
                <input type="text" id="mts-product-code" placeholder="e.g. ABC-1234" required
                       oninput="mtsOnProductCodeInput()"
                       class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-xl py-2.5 px-3
                              bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                              focus:outline-none focus:ring-2 focus:ring-togen-teal
                              placeholder-gray-400 dark:placeholder-gray-500">
                <p id="mts-product-status" class="text-xs mt-1 text-gray-400 dark:text-gray-500 hidden"></p>
              </div>
              <div>
                <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
                  Description <span class="text-red-500 dark:text-red-400">*</span>
                </label>
                <input type="text" id="mts-description" placeholder="Product description" required
                       oninput="mtsCheckForm()"
                       class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-xl py-2.5 px-3
                              bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                              focus:outline-none focus:ring-2 focus:ring-togen-teal
                              placeholder-gray-400 dark:placeholder-gray-500">
              </div>
              <div>
                <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
                  Weight <span class="text-red-500 dark:text-red-400">*</span>
                </label>
                <input type="text" id="mts-weight" placeholder="e.g. 25 lbs" required
                       oninput="mtsCheckForm()"
                       class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-xl py-2.5 px-3
                              bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                              focus:outline-none focus:ring-2 focus:ring-togen-teal
                              placeholder-gray-400 dark:placeholder-gray-500">
              </div>
              <div>
                <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
                  Date <span class="text-red-500 dark:text-red-400">*</span>
                </label>
                <input type="date" id="mts-date" required
                       oninput="mtsCheckForm()"
                       class="block w-full text-sm border border-gray-300 dark:border-gray-600 rounded-xl py-2.5 px-3
                              bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                              focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </div>
            </div>

            <!-- Drawing upload -->
            <div class="border border-gray-200 dark:border-gray-700 rounded-xl p-4">
              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Drawing (PDF)</label>
              <div id="mts-drawing-drop"
                   class="flex flex-col items-center justify-center h-24 bg-gray-50 dark:bg-gray-800 rounded-xl
                          border-2 border-dashed border-gray-300 dark:border-gray-600 cursor-pointer
                          hover:border-togen-teal dark:hover:border-togen-teal transition-colors"
                   onclick="document.getElementById('mts-drawing-input').click()"
                   ondragover="event.preventDefault(); this.classList.add('border-togen-teal');"
                   ondragleave="this.classList.remove('border-togen-teal');"
                   ondrop="event.preventDefault(); this.classList.remove('border-togen-teal'); mtsOnDrawingDrop(event);">
                <svg class="h-8 w-8 text-gray-400 dark:text-gray-500 mb-1" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
                  <path stroke-linecap="round" stroke-linejoin="round" d="M12 16V4m0 0l-4 4m4-4l4 4M4 20h16"/>
                </svg>
                <p class="text-xs text-gray-400 dark:text-gray-500">Click or drag & drop a PDF drawing</p>
              </div>
              <input type="file" id="mts-drawing-input" accept=".pdf" class="hidden" onchange="mtsOnDrawingSelected(this)">
              <div id="mts-drawing-info" class="hidden mt-2 flex items-center gap-2">
                <svg class="h-4 w-4 text-green-500 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
                </svg>
                <span id="mts-drawing-name" class="text-sm text-gray-600 dark:text-gray-400"></span>
                <button type="button" onclick="mtsRemoveDrawing()" class="text-red-400 hover:text-red-600 text-xs ml-2">&times; Remove</button>
              </div>
              <div id="mts-drawing-existing" class="hidden mt-2 flex items-center gap-2">
                <svg class="h-4 w-4 text-blue-500 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/>
                </svg>
                <a id="mts-drawing-link" href="#" target="_blank" class="text-sm text-togen-teal hover:underline">View existing drawing</a>
              </div>
            </div>

            <div class="flex items-center gap-3">
              <button type="button" id="mts-submit-btn" onclick="mtsSubmit()" disabled
                      class="px-6 py-3 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold
                             rounded-xl text-sm disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
                Submit
              </button>
            </div>
          </form>

          <!-- Status -->
          <div id="mts-status" class="hidden mt-4 flex items-center gap-3 p-4 rounded-xl border">
            <svg id="mts-spinner" class="hidden animate-spin h-5 w-5 text-togen-teal flex-shrink-0"
                 xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
              <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
              <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
            </svg>
            <svg id="mts-check" class="hidden h-5 w-5 text-green-500 flex-shrink-0"
                 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
            </svg>
            <svg id="mts-x" class="hidden h-5 w-5 text-red-500 flex-shrink-0"
                 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
            </svg>
            <span id="mts-msg" class="text-sm font-medium"></span>
          </div>
        </div>
      </section>

      <!-- Product Lookup Popup -->
      <div id="mts-lookup-overlay" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40 dark:bg-black/60"
           onclick="if(event.target===this)mtsCloseProductLookup()">
        <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4 max-h-[80vh] flex flex-col">
          <div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between flex-shrink-0">
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Product Lookup</h3>
            <button onclick="mtsCloseProductLookup()"
                    class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
              <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
              </svg>
            </button>
          </div>
          <div class="px-5 py-3 border-b border-gray-100 dark:border-gray-700 flex-shrink-0">
            <input type="text" id="mts-lookup-filter" placeholder="Filter by product code or description..."
                   oninput="mtsFilterProducts()"
                   class="w-full text-sm px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600
                          bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300
                          focus:outline-none focus:ring-2 focus:ring-togen-teal
                          placeholder-gray-400 dark:placeholder-gray-500">
          </div>
          <div id="mts-lookup-list" class="overflow-y-auto flex-1 min-h-0">
            <p class="text-sm text-gray-400 dark:text-gray-500 px-5 py-4 text-center">Loading products...</p>
          </div>
        </div>
      </div>
      <!-- MTS Email Popup -->
      <div id="mts-email-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
        <div onclick="mtsEmailClose()" class="absolute inset-0 bg-black/50"></div>
        <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-sm mx-4 overflow-hidden">
          <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Email Label</h2>
            <button onclick="mtsEmailClose()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
              <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
              </svg>
            </button>
          </div>
          <div class="px-6 py-4">
            <label for="mts-email-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Send to</label>
            <input type="email" id="mts-email-input" value="{{ user_email }}"
                   class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal mb-3">
            <div class="flex items-center gap-3">
              <button id="mts-email-send-btn" onclick="mtsEmailSend()"
                      class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
                  <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
                </svg>
                Send Email
              </button>
              <span id="mts-email-status" class="text-sm text-gray-700 dark:text-gray-300"></span>
            </div>
          </div>
        </div>
      </div>

    </div><!-- /tool-mts-label -->

    <!-- ===== SCADA VIEW TOOL CONTENT ===== -->
    <div id="tool-scada-view" class="tool-content hidden">
      <!-- Tab bar -->
      <div id="scada-tabbar" class="flex flex-nowrap overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden border-b border-gray-200 dark:border-gray-700 mb-4">
        <button id="scada-tab-status" onclick="scadaSwitchTab('status')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-togen-teal text-togen-teal cursor-pointer">
          Status
        </button>
        <button id="scada-tab-history" onclick="scadaSwitchTab('history')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          History
        </button>
        <button id="scada-tab-sensors" onclick="scadaSwitchTab('sensors')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Sensors
        </button>
        <button id="scada-tab-relays" onclick="scadaSwitchTab('relays')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Relays
        </button>
        <button id="scada-tab-logs" onclick="scadaSwitchTab('logs')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Logs
        </button>
        <button id="scada-tab-reporting" onclick="scadaSwitchTab('reporting')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Reporting
        </button>
        <button id="scada-tab-schedules" onclick="scadaSwitchTab('schedules')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Schedules
        </button>
        <button id="scada-tab-settings" onclick="scadaSwitchTab('settings')"
                class="flex-shrink-0 whitespace-nowrap ml-auto px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer inline-flex items-center gap-1.5">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
          </svg>
          Settings
        </button>
      </div>

      <!-- Status tab content -->
      <div id="scada-panel-status">
        <div class="flex items-center justify-between mb-4">
          <div>
            <span id="scada-status-time" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
          <button onclick="scadaLoadStatus()"
                  class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
            <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
            </svg>
            Refresh
          </button>
        </div>

        <div id="scada-status-loading" class="hidden text-center py-8">
          <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
          </svg>
          <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading status...</p>
        </div>

        <div id="scada-status-empty" class="hidden text-center py-8">
          <svg class="h-12 w-12 mx-auto text-gray-300 dark:text-gray-600 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
            <path stroke-linecap="round" stroke-linejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
          </svg>
          <p class="text-sm text-gray-500 dark:text-gray-400">No status data available yet.</p>
        </div>

        <div id="scada-status-error" class="hidden text-center py-8">
          <svg class="h-12 w-12 mx-auto text-red-300 dark:text-red-600 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
            <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
          </svg>
          <p id="scada-status-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
        </div>

        <div id="scada-status-grid" class="hidden grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
        </div>
      </div>

      <!-- History tab content -->
      <div id="scada-panel-history" class="hidden">
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
          <div class="px-6 py-5">
            <div class="flex items-center justify-between mb-4">
              <div>
                <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Sensor History</h3>
                <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Recent trend per enabled sensor. Click a row for the full graph and recorded events.</p>
              </div>
              <button onclick="scadaLoadHistory(scadaRenderHistoryList)"
                      class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
                <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
                </svg>
                Refresh
              </button>
            </div>

            <div id="scada-history-loading" class="hidden text-center py-8">
              <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
              </svg>
              <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading history...</p>
            </div>

            <div id="scada-history-empty" class="hidden text-center py-8">
              <p class="text-sm text-gray-500 dark:text-gray-400">No sensor history available yet.</p>
            </div>

            <div id="scada-history-list" class="space-y-2"></div>
          </div>
        </section>
      </div>

      <!-- Sensors tab content (merged Range + Enabled) -->
      <div id="scada-panel-sensors" class="hidden">
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
          <div class="px-6 py-5">
            <div class="mb-4 flex items-start justify-between gap-3">
              <div class="min-w-0">
                <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Sensors</h3>
                <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Toggle a sensor to show or hide it on the Status tab. Use the edit icon to rename a sensor and set its alert range.</p>
              </div>
              <button type="button" onclick="scadaOpenBulkEdit()" title="Bulk edit all sensors"
                      class="shrink-0 p-2 rounded-lg text-gray-400 hover:text-togen-teal hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer">
                <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>
              </button>
            </div>
            <div id="scada-sensors-list" class="space-y-2">
              <p class="text-sm text-gray-400 dark:text-gray-500 text-center py-4">Open the Status tab first to discover sensors.</p>
            </div>
          </div>
        </section>
      </div>

      <!-- Relays tab content -->
      <div id="scada-panel-relays" class="hidden">
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
          <div class="px-6 py-5">
            <div class="mb-4">
              <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Relays</h3>
              <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Toggle a relay to show or hide it on the Status tab. Use the edit icon to rename a relay.</p>
            </div>
            <div id="scada-relays-list" class="space-y-2">
              <p class="text-sm text-gray-400 dark:text-gray-500 text-center py-4">Open the Status tab first to discover relays.</p>
            </div>
          </div>
        </section>
      </div>

      <!-- Settings tab content -->
      <div id="scada-panel-settings" class="hidden">
        <!-- Notifications tile (per-user; mirrored in User Settings) -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mb-4">
          <div class="px-6 py-5">
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Notifications</h3>
            <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">These email preferences apply to your account. They mirror the SCADA options under User Settings &rarr; Notifications.</p>
            <div class="space-y-3">
              <label class="flex items-start gap-3 cursor-pointer">
                <input type="checkbox" id="scada-notify-stale" onchange="scadaSaveNotif('stale_data', this.checked, 'scada-notify-status')"
                       class="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
                <span class="min-w-0">
                  <span class="block text-sm font-medium text-gray-800 dark:text-gray-100">Stale data</span>
                  <span class="block text-xs text-gray-500 dark:text-gray-400">Notify me when no new SCADA data has arrived within the configured window.</span>
                </span>
              </label>
              <label class="flex items-start gap-3 cursor-pointer">
                <input type="checkbox" id="scada-notify-oor" onchange="scadaSaveNotif('out_of_range', this.checked, 'scada-notify-status')"
                       class="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
                <span class="min-w-0">
                  <span class="block text-sm font-medium text-gray-800 dark:text-gray-100">Sensor out of range</span>
                  <span class="block text-xs text-gray-500 dark:text-gray-400">Notify me when a sensor with notifications enabled reports a value outside its range.</span>
                </span>
              </label>
            </div>
            <span id="scada-notify-status" class="text-xs text-gray-500 dark:text-gray-400 mt-3 block"></span>
          </div>
        </section>

        <!-- Data Collector tile (shared/global config) -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
          <div class="px-6 py-5">
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Data Collector</h3>
            <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">Controls how the collector polls for new SCADA data. Applies to all users.</p>
            <div class="space-y-4">
              <label class="flex items-center justify-between gap-3">
                <span class="text-sm text-gray-700 dark:text-gray-300">How often to check for new data</span>
                <span class="inline-flex items-center gap-1.5">
                  <input type="number" id="scada-collector-interval" min="1" value="15"
                         class="w-20 px-2 py-1 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal" />
                  <span class="text-xs text-gray-500 dark:text-gray-400">min</span>
                </span>
              </label>
              <label class="flex items-center justify-between gap-3">
                <span class="text-sm text-gray-700 dark:text-gray-300">How long without new data before notification</span>
                <span class="inline-flex items-center gap-1.5">
                  <input type="number" id="scada-collector-stale" min="1" value="60"
                         class="w-20 px-2 py-1 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal" />
                  <span class="text-xs text-gray-500 dark:text-gray-400">min</span>
                </span>
              </label>
            </div>
            <div class="mt-4 flex items-center gap-3">
              <button onclick="scadaSaveCollector()"
                      class="px-4 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal-dark transition-colors cursor-pointer">
                Save
              </button>
              <span id="scada-collector-status" class="text-xs text-gray-500 dark:text-gray-400"></span>
            </div>
          </div>
        </section>

        <!-- Report Notifications tile (DVI-487) — schedule grid + global lists -->
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mt-4">
          <div class="px-6 py-5">
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Report Notifications</h3>
            <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">Control who receives scheduled SCADA reports. Enforced recipients receive every scheduled report; external recipients receive a report only when that schedule's external toggle is on.</p>

            <!-- (a) Per-schedule grid -->
            <div class="mb-6">
              <h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Schedules</h4>
              <div id="scada-rn-sched-empty" class="hidden text-sm text-gray-400 dark:text-gray-500 italic py-2">No report schedules yet. Create one on the Schedules tab.</div>
              <div id="scada-rn-sched-wrap" class="hidden overflow-x-auto">
                <table class="w-full text-sm">
                  <thead>
                    <tr class="border-b border-gray-200 dark:border-gray-700 text-left">
                      <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Schedule</th>
                      <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300 whitespace-nowrap">User notifications</th>
                      <th class="pb-2 font-semibold text-gray-600 dark:text-gray-300 whitespace-nowrap">External recipients</th>
                    </tr>
                  </thead>
                  <tbody id="scada-rn-sched-tbody"></tbody>
                </table>
              </div>
            </div>

            <!-- (b) Enforced recipients -->
            <div class="mb-6 pt-4 border-t border-gray-100 dark:border-gray-800">
              <div class="flex items-center justify-between gap-2 mb-1">
                <h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Enforced recipients</h4>
                <button type="button" title="Add email" onclick="scadaRecipientAdd('enforced')"
                        class="shrink-0 inline-flex items-center justify-center h-7 w-7 rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors cursor-pointer">
                  <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg>
                </button>
              </div>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Receive every scheduled report.</p>
              <input type="email" id="scada-rn-enforced-input" placeholder="name@example.com"
                     onkeydown="if(event.key==='Enter'){event.preventDefault();scadaRecipientAdd('enforced');}"
                     class="hidden w-full mb-2 px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-togen-teal/40">
              <ul id="scada-rn-enforced-list" class="border border-gray-100 dark:border-gray-800 rounded-lg divide-y divide-gray-100 dark:divide-gray-800 px-3"></ul>
            </div>

            <!-- (c) External recipients -->
            <div class="pt-4 border-t border-gray-100 dark:border-gray-800">
              <div class="flex items-center justify-between gap-2 mb-1">
                <h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">External recipients</h4>
                <button type="button" title="Add email" onclick="scadaRecipientAdd('external')"
                        class="shrink-0 inline-flex items-center justify-center h-7 w-7 rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors cursor-pointer">
                  <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg>
                </button>
              </div>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Receive a report only when a schedule's external toggle is on.</p>
              <input type="email" id="scada-rn-external-input" placeholder="name@example.com"
                     onkeydown="if(event.key==='Enter'){event.preventDefault();scadaRecipientAdd('external');}"
                     class="hidden w-full mb-2 px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-togen-teal/40">
              <ul id="scada-rn-external-list" class="border border-gray-100 dark:border-gray-800 rounded-lg divide-y divide-gray-100 dark:divide-gray-800 px-3"></ul>
            </div>

            <span id="scada-rn-status" class="text-xs text-gray-500 dark:text-gray-400 mt-3 block"></span>
          </div>
        </section>
      </div>

      <!-- Edit dialog (sensor or relay) -->
      <div id="scada-edit-modal" class="hidden fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4">
        <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md">
          <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
            <h3 id="scada-edit-title" class="text-base font-semibold text-gray-800 dark:text-gray-100">Edit Sensor</h3>
            <p id="scada-edit-label" class="text-xs text-gray-400 dark:text-gray-500 mt-0.5"></p>
          </div>
          <div class="px-6 py-5 space-y-4">
            <label class="block">
              <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Display name</span>
              <input type="text" id="scada-edit-name"
                     class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal" />
            </label>
            <div id="scada-edit-range-block" class="space-y-4">
              <div class="flex gap-3">
                <label class="flex-1">
                  <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Low <span id="scada-edit-unit-low" class="text-xs text-gray-400"></span></span>
                  <input type="number" id="scada-edit-low"
                         class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal" />
                </label>
                <label class="flex-1">
                  <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">High <span id="scada-edit-unit-high" class="text-xs text-gray-400"></span></span>
                  <input type="number" id="scada-edit-high"
                         class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal" />
                </label>
              </div>
              <label class="flex items-center gap-2 cursor-pointer">
                <input type="checkbox" id="scada-edit-reporting"
                       class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
                <span class="text-sm text-gray-700 dark:text-gray-300">Disable reporting (exclude from Home tile summary)</span>
              </label>
            </div>
          </div>
          <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
            <button onclick="scadaCloseEdit()"
                    class="px-4 py-2 text-sm font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">
              Cancel
            </button>
            <button onclick="scadaSaveEdit()"
                    class="px-4 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal-dark transition-colors cursor-pointer">
              Save
            </button>
          </div>
        </div>
      </div>

      <!-- Bulk-edit dialog (all sensors at once) -->
      <div id="scada-bulk-modal" class="hidden fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4">
        <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md">
          <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Bulk Edit Sensors</h3>
            <p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">Apply alert ranges and notifications to every sensor.</p>
          </div>
          <div class="px-6 py-5 space-y-4">
            <div class="rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 px-3 py-2">
              <p class="text-xs text-amber-700 dark:text-amber-300">This will overwrite individual sensor settings.</p>
            </div>
            <div>
              <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Temperature range <span class="text-xs text-gray-400">(°F)</span></span>
              <div class="flex gap-3">
                <input type="number" id="scada-bulk-temp-low" placeholder="Low"
                       class="flex-1 w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal" />
                <input type="number" id="scada-bulk-temp-high" placeholder="High"
                       class="flex-1 w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal" />
              </div>
            </div>
            <div>
              <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Humidity range <span class="text-xs text-gray-400">(%RH)</span></span>
              <div class="flex gap-3">
                <input type="number" id="scada-bulk-hum-low" placeholder="Low"
                       class="flex-1 w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal" />
                <input type="number" id="scada-bulk-hum-high" placeholder="High"
                       class="flex-1 w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal" />
              </div>
            </div>
            <label class="flex items-center gap-2 cursor-pointer">
              <input type="checkbox" id="scada-bulk-notify"
                     class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer">
              <span class="text-sm text-gray-700 dark:text-gray-300">Enable out-of-range notifications for all sensors</span>
            </label>
          </div>
          <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
            <button onclick="scadaCloseBulkEdit()"
                    class="px-4 py-2 text-sm font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">
              Cancel
            </button>
            <button onclick="scadaSaveBulkEdit()"
                    class="px-4 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal-dark transition-colors cursor-pointer">
              Apply to all
            </button>
          </div>
        </div>
      </div>

      <!-- Logs tab content -->
      <div id="scada-panel-logs" class="hidden">
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
          <div class="px-6 py-5">
            <div class="flex items-center justify-between mb-4">
              <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Temperature Sensor Logs</h3>
              <div class="flex items-center gap-3">
                <span id="scada-logs-status" class="text-xs text-gray-500 dark:text-gray-400"></span>
                <button onclick="scadaLoadLogs()"
                        class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
                  <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
                  </svg>
                  Refresh
                </button>
              </div>
            </div>

            <!-- Log entries container -->
            <div id="scada-logs-loading" class="hidden text-center py-8">
              <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
              </svg>
              <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading logs...</p>
            </div>

            <div id="scada-logs-empty" class="hidden text-center py-8">
              <svg class="h-12 w-12 mx-auto text-gray-300 dark:text-gray-600 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
                <path stroke-linecap="round" stroke-linejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
              </svg>
              <p class="text-sm text-gray-500 dark:text-gray-400">No log entries found.</p>
              <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Logs are retrieved from the SCADA mailbox daily.</p>
            </div>

            <div id="scada-logs-error" class="hidden text-center py-8">
              <svg class="h-12 w-12 mx-auto text-red-300 dark:text-red-600 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
                <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
              </svg>
              <p id="scada-logs-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
            </div>

            <div id="scada-logs-table-wrap" class="hidden overflow-x-auto">
              <table class="w-full text-sm">
                <thead>
                  <tr class="border-b border-gray-200 dark:border-gray-700 text-left">
                    <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Date</th>
                    <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Subject</th>
                    <th class="pb-2 font-semibold text-gray-600 dark:text-gray-300">Preview</th>
                  </tr>
                </thead>
                <tbody id="scada-logs-tbody" class="divide-y divide-gray-100 dark:divide-gray-800">
                </tbody>
              </table>
            </div>

            <!-- Expanded log detail -->
            <div id="scada-log-detail" class="hidden mt-4">
              <div class="flex items-center gap-2 mb-3">
                <button onclick="scadaCloseDetail()"
                        class="px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">
                  &larr; Back to list
                </button>
                <span id="scada-detail-subject" class="text-sm font-semibold text-gray-800 dark:text-gray-100"></span>
              </div>
              <div id="scada-detail-body" class="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-mono overflow-x-auto max-h-[60vh] overflow-y-auto border border-gray-200 dark:border-gray-700">
              </div>
            </div>

          </div>
        </section>
      </div>

      <!-- Reporting tab content -->
      <div id="scada-panel-reporting" class="hidden">
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
          <div class="px-6 py-5">
            <div class="flex items-center justify-between mb-4">
              <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Log Reports</h3>
              <button onclick="scadaOpenReportDownload()"
                      class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer inline-flex items-center gap-1.5">
                <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v6.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 111.414-1.414L9 10.586V4a1 1 0 011-1zm-7 13a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd" />
                </svg>
                Download XLSX
              </button>
            </div>

            <!-- Filter bar -->
            <div class="flex flex-wrap items-end gap-3 mb-4">
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">From</label>
                <input type="date" id="scada-report-from"
                       class="px-2 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">To</label>
                <input type="date" id="scada-report-to"
                       class="px-2 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100">
              </div>
              <div class="relative">
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Sensors</label>
                <button type="button" onclick="scadaToggleSensorMenu()" id="scada-report-sensors-btn"
                        class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 cursor-pointer inline-flex items-center gap-1.5 min-w-[10rem] justify-between">
                  <span id="scada-report-sensors-label">All sensors</span>
                  <svg class="h-4 w-4 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
                </button>
                <div id="scada-report-sensors-menu"
                     class="hidden absolute z-20 mt-1 max-h-60 overflow-y-auto w-56 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg p-2 space-y-1">
                </div>
              </div>
              <button onclick="scadaLoadReport()"
                      class="px-3 py-1.5 text-sm font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer">
                Apply
              </button>
              <span id="scada-report-earliest" class="text-xs text-gray-400 dark:text-gray-500 ml-auto self-center"></span>
            </div>

            <div id="scada-report-loading" class="hidden text-center py-8">
              <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
              </svg>
              <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading report...</p>
            </div>

            <div id="scada-report-empty" class="hidden text-center py-8">
              <p class="text-sm text-gray-500 dark:text-gray-400">No log data for the selected range.</p>
            </div>

            <div id="scada-report-error" class="hidden text-center py-8">
              <p id="scada-report-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
            </div>

            <div id="scada-report-grid" class="hidden space-y-6"></div>
          </div>
        </section>
      </div>

      <!-- Report download filter dialog -->
      <div id="scada-report-dl-overlay" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
        <div class="absolute inset-0 bg-black/40" onclick="scadaCloseReportDownload()"></div>
        <div class="relative bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md p-6">
          <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-4">Download Report</h3>
          <div class="space-y-4">
            <div class="grid grid-cols-2 gap-3">
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">From</label>
                <input type="date" id="scada-report-dl-from"
                       class="w-full px-2 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100">
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">To</label>
                <input type="date" id="scada-report-dl-to"
                       class="w-full px-2 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100">
              </div>
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Sensors</label>
              <div id="scada-report-dl-sensors" class="max-h-40 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 p-2 space-y-1"></div>
            </div>
            <p id="scada-report-dl-earliest" class="text-xs text-gray-400 dark:text-gray-500"></p>
          </div>
          <div class="flex justify-end gap-2 mt-6">
            <button onclick="scadaCloseReportDownload()"
                    class="px-3 py-1.5 text-sm font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">
              Cancel
            </button>
            <button onclick="scadaDoReportDownload()"
                    class="px-3 py-1.5 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">
              Download
            </button>
          </div>
        </div>
      </div>

      <!-- Schedules tab content (DVI-484) -->
      <div id="scada-panel-schedules" class="hidden">
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
          <div class="px-6 py-5">
            <div class="flex items-center justify-between mb-4">
              <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Report Schedules</h3>
              <button onclick="scadaOpenSchedule()"
                      class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer inline-flex items-center gap-1.5">
                <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
                </svg>
                Add
              </button>
            </div>

            <div id="scada-sched-loading" class="hidden text-center py-8">
              <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
              </svg>
              <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading schedules...</p>
            </div>

            <div id="scada-sched-empty" class="hidden text-center py-8">
              <p class="text-sm text-gray-500 dark:text-gray-400">No schedules yet. Click <span class="font-medium">Add</span> to create one.</p>
            </div>

            <div id="scada-sched-error" class="hidden text-center py-8">
              <p id="scada-sched-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
            </div>

            <div id="scada-sched-table-wrap" class="hidden overflow-x-auto">
              <table class="w-full text-sm">
                <thead>
                  <tr class="border-b border-gray-200 dark:border-gray-700 text-left">
                    <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Name</th>
                    <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Cadence</th>
                    <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Sensors</th>
                    <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Notify</th>
                    <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300 text-right">Actions</th>
                  </tr>
                </thead>
                <tbody id="scada-sched-tbody" class="divide-y divide-gray-100 dark:divide-gray-800"></tbody>
              </table>
            </div>
          </div>
        </section>
      </div>

      <!-- Schedule add/edit dialog (DVI-484) -->
      <div id="scada-sched-overlay" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
        <div class="absolute inset-0 bg-black/40" onclick="scadaCloseSchedule()"></div>
        <div class="relative bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md p-6">
          <h3 id="scada-sched-title" class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-4">Add Schedule</h3>
          <div class="space-y-4">
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Name</label>
              <input type="text" id="scada-sched-name" maxlength="120" placeholder="Weekly report"
                     class="w-full px-2 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Cadence</label>
              <select id="scada-sched-cadence" onchange="scadaSchedCadenceChanged()"
                      class="w-full px-2 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100">
                <option value="last_week">Last week</option>
                <option value="last_month">Last month</option>
                <option value="custom">Custom (last N days)</option>
              </select>
            </div>
            <div id="scada-sched-custom-wrap" class="hidden">
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Custom days</label>
              <input type="number" id="scada-sched-custom-days" min="1" value="7"
                     class="w-full px-2 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100">
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Sensors</label>
              <div id="scada-sched-sensors" class="max-h-40 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 p-2 space-y-1"></div>
              <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Leave all unchecked to include every sensor.</p>
            </div>
            <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
              <input type="checkbox" id="scada-sched-notify" class="rounded">
              <span>Email this report to user notification recipients when it runs</span>
            </label>
            <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
              <input type="checkbox" id="scada-sched-external" class="rounded">
              <span>Also send to external recipients</span>
            </label>
            <p id="scada-sched-form-error" class="hidden text-xs text-red-600 dark:text-red-400"></p>
          </div>
          <div class="flex justify-end gap-2 mt-6">
            <button onclick="scadaCloseSchedule()"
                    class="px-3 py-1.5 text-sm font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">
              Cancel
            </button>
            <button onclick="scadaSaveSchedule()"
                    class="px-3 py-1.5 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">
              Save
            </button>
          </div>
        </div>
      </div>

      <!-- Sensor detail view (shown over the tabs from Status tiles / History rows) -->
      <div id="scada-sensor-detail" class="hidden">
        <div class="flex items-center gap-2 mb-4">
          <button onclick="scadaCloseSensor()"
                  class="px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">
            &larr; Back
          </button>
          <span id="scada-sensor-name" class="text-base font-semibold text-gray-800 dark:text-gray-100"></span>
        </div>
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
          <div class="px-6 py-5">
            <div id="scada-sensor-graph" class="w-full mb-5"></div>
            <h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Recorded events</h4>
            <div id="scada-sensor-events" class="overflow-x-auto max-h-[40vh] overflow-y-auto"></div>
          </div>
        </section>
      </div>
    </div><!-- /tool-scada-view -->

    <!-- ===== VISION TOOL CONTENT ===== -->
    <div id="tool-vision" class="tool-content hidden">
      <!-- Alerts banner (DVI-424) -->
      <div id="vision-alerts-banner" class="hidden mb-4 rounded-xl border border-amber-200 dark:border-amber-900/40 bg-amber-50 dark:bg-amber-900/20 px-4 py-3">
        <div class="flex items-center justify-between">
          <div class="flex items-center gap-2 text-sm font-medium text-amber-800 dark:text-amber-300">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
              <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
            </svg>
            <span id="vision-alerts-count">Alerts</span>
          </div>
          <div class="flex items-center gap-3">
            <button onclick="visionDismissAllAlerts()" id="vision-alerts-dismiss-all"
                    class="hidden text-xs font-medium text-amber-700 dark:text-amber-300 hover:underline cursor-pointer">Dismiss All</button>
            <button onclick="visionToggleAlerts()" id="vision-alerts-toggle"
                    class="text-xs font-medium text-amber-700 dark:text-amber-300 hover:underline cursor-pointer">Show</button>
          </div>
        </div>
        <ul id="vision-alerts-list" class="hidden mt-2 space-y-1 text-xs text-amber-800 dark:text-amber-200"></ul>
      </div>

      <!-- Tab bar -->
      <div id="vision-tabbar" class="flex flex-nowrap overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden border-b border-gray-200 dark:border-gray-700 mb-4">
        <button id="vision-tab-collector" onclick="visionSwitchTab('collector')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-togen-teal text-togen-teal cursor-pointer">
          Collector
        </button>
        <button id="vision-tab-activity" onclick="visionSwitchTab('activity')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Activity
        </button>
        <button id="vision-tab-recordings" onclick="visionSwitchTab('recordings')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Recordings
        </button>
        {% if is_togen_admin %}
        <button id="vision-tab-logs" onclick="visionSwitchTab('logs')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Logs
        </button>
        {% endif %}
        {% if is_togen_admin %}
        <button id="vision-tab-policy" onclick="visionSwitchTab('policy')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Policy
        </button>
        <button id="vision-tab-enroll" onclick="visionSwitchTab('enroll')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Enroll
        </button>
        {% endif %}
        <button id="vision-tab-settings" onclick="visionSwitchTab('settings')"
                class="ml-auto flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Settings
        </button>
      </div>

      <!-- Collector tab content -->
      <div id="vision-panel-collector">
        <!-- DVI-742: Collector filters — online / out of date / backend. -->
        <div class="flex items-center flex-wrap gap-x-4 gap-y-2 mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
          <span class="text-xs font-medium text-gray-500 dark:text-gray-400">Filters</span>
          <label class="inline-flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300 cursor-pointer">
            <input type="checkbox" id="vision-filter-online" onchange="visionApplyFilters()"
                   class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
            Online only
          </label>
          <label class="inline-flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300 cursor-pointer">
            <input type="checkbox" id="vision-filter-outdated" onchange="visionApplyFilters()"
                   class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
            Out of date
          </label>
          <label class="inline-flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
            <span>Backend</span>
            <select id="vision-filter-backend" onchange="visionApplyFilters()"
                    class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-togen-teal cursor-pointer">
              <option value="all">All</option>
              <option value="video">Video</option>
              <option value="screenshot">Screenshots</option>
            </select>
          </label>
          <button onclick="visionClearFilters()"
                  class="ml-auto text-xs text-togen-teal hover:underline cursor-pointer">Clear filters</button>
        </div>
        <div class="flex items-center justify-between mb-4">
          <div class="flex items-center gap-2">
            <span id="vision-collector-time" class="text-sm text-gray-500 dark:text-gray-400"></span>
            <span id="vision-mock-badge" class="hidden inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300">Mock data</span>
          </div>
          <div class="flex items-center gap-2">
            <!-- DVI-742: Sort options (Activity / Alphabetical / Status / Out of date). -->
            <div class="relative">
              <button id="vision-sort-btn" onclick="visionToggleSortMenu(event)"
                      class="px-3 py-1.5 text-xs font-medium rounded-lg bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer inline-flex items-center gap-1.5">
                <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M3 3a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h8a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h4a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd" />
                </svg>
                <span id="vision-sort-label">Activity</span>
                <svg class="h-3 w-3 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
                </svg>
              </button>
              <div id="vision-sort-menu" class="hidden absolute left-0 mt-1 w-44 z-20 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
                <button data-sort="activity" onclick="visionSetSort('activity')" class="vision-sort-item w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer flex items-center justify-between gap-2"><span>Activity</span><span class="vision-sort-check hidden text-togen-teal">&#10003;</span></button>
                <button data-sort="alpha" onclick="visionSetSort('alpha')" class="vision-sort-item w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer flex items-center justify-between gap-2"><span>Alphabetically</span><span class="vision-sort-check hidden text-togen-teal">&#10003;</span></button>
                <button data-sort="status" onclick="visionSetSort('status')" class="vision-sort-item w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer flex items-center justify-between gap-2"><span>Status (online first)</span><span class="vision-sort-check hidden text-togen-teal">&#10003;</span></button>
                <button data-sort="outdated" onclick="visionSetSort('outdated')" class="vision-sort-item w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer flex items-center justify-between gap-2"><span>Out of date first</span><span class="vision-sort-check hidden text-togen-teal">&#10003;</span></button>
              </div>
            </div>
            <!-- DVI-717: Collector view options (Large Tiles / Medium / Compact / List). -->
            <div class="relative">
              <button id="vision-view-btn" onclick="visionToggleViewMenu(event)"
                      class="px-3 py-1.5 text-xs font-medium rounded-lg bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer inline-flex items-center gap-1.5">
                <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
                </svg>
                <span id="vision-view-label">Large Tiles</span>
                <svg class="h-3 w-3 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
                </svg>
              </button>
              <div id="vision-view-menu" class="hidden absolute left-0 mt-1 w-44 z-20 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
                <button data-view="large" onclick="visionSetView('large')" class="vision-view-item w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer flex items-center justify-between gap-2"><span>Large Tiles</span><span class="vision-view-check hidden text-togen-teal">&#10003;</span></button>
                <button data-view="medium" onclick="visionSetView('medium')" class="vision-view-item w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer flex items-center justify-between gap-2"><span>Medium Tiles</span><span class="vision-view-check hidden text-togen-teal">&#10003;</span></button>
                <button data-view="compact" onclick="visionSetView('compact')" class="vision-view-item w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer flex items-center justify-between gap-2"><span>Compact</span><span class="vision-view-check hidden text-togen-teal">&#10003;</span></button>
                <button data-view="list" onclick="visionSetView('list')" class="vision-view-item w-full text-left px-3 py-1.5 text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer flex items-center justify-between gap-2"><span>List</span><span class="vision-view-check hidden text-togen-teal">&#10003;</span></button>
              </div>
            </div>
            <button onclick="visionLoadCollectors()"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
              </svg>
              Refresh
            </button>
          </div>
        </div>

        <div id="vision-collector-loading" class="hidden text-center py-8">
          <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
          </svg>
          <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading collectors...</p>
        </div>

        <div id="vision-collector-empty" class="hidden text-center py-8">
          <svg class="h-12 w-12 mx-auto text-gray-300 dark:text-gray-600 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
            <rect x="2" y="3" width="20" height="14" rx="2" />
            <line x1="8" y1="21" x2="16" y2="21" />
            <line x1="12" y1="17" x2="12" y2="21" />
          </svg>
          <p class="text-sm text-gray-500 dark:text-gray-400">No collectors have checked in yet.</p>
        </div>

        <div id="vision-collector-error" class="hidden text-center py-8">
          <svg class="h-12 w-12 mx-auto text-red-300 dark:text-red-600 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
            <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
          </svg>
          <p id="vision-collector-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
        </div>

        <div id="vision-collector-grid" class="hidden grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
        </div>
      </div>

      <!-- Activity tab content (DVI-424): per-machine telemetry timeline -->
      <div id="vision-panel-activity" class="hidden">
        <div class="flex items-center justify-between mb-4 gap-3 flex-wrap">
          <div class="flex items-center gap-2">
            <label for="vision-activity-machine" class="text-sm text-gray-600 dark:text-gray-300">Machine</label>
            <select id="vision-activity-machine" onchange="visionLoadActivity()"
                    class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </select>
          </div>
          <span id="vision-activity-time" class="text-sm text-gray-500 dark:text-gray-400"></span>
        </div>

        <div id="vision-activity-loading" class="hidden text-center py-8">
          <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
          </svg>
          <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading activity...</p>
        </div>
        <div id="vision-activity-empty" class="hidden text-center py-8">
          <p class="text-sm text-gray-500 dark:text-gray-400">No activity recorded for this machine yet.</p>
        </div>
        <div id="vision-activity-error" class="hidden text-center py-8">
          <p id="vision-activity-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
        </div>
        <div id="vision-activity-content" class="hidden space-y-6"></div>
      </div>

      <!-- Recordings tab content (DVI-427): searchable screenshot gallery -->
      <div id="vision-panel-recordings" class="hidden">
        <div class="flex items-center justify-between mb-4 gap-3 flex-wrap">
          <div class="flex items-center gap-3 flex-wrap">
            <div class="flex items-center gap-2">
              <label for="vision-rec-machine" class="text-sm text-gray-600 dark:text-gray-300">Machine</label>
              <select id="vision-rec-machine"
                      class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">
              </select>
            </div>
            <div class="flex items-center gap-2">
              <label for="vision-rec-from" class="text-sm text-gray-600 dark:text-gray-300">From</label>
              <input type="datetime-local" id="vision-rec-from"
                     class="px-2 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div class="flex items-center gap-2">
              <label for="vision-rec-to" class="text-sm text-gray-600 dark:text-gray-300">To</label>
              <input type="datetime-local" id="vision-rec-to"
                     class="px-2 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <button onclick="visionLoadRecordings()"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer">Search</button>
            <button onclick="visionRecordingsClear()"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer">Clear</button>
          </div>
          <span id="vision-rec-time" class="text-sm text-gray-500 dark:text-gray-400"></span>
        </div>
        <div id="vision-rec-loading" class="hidden text-center py-8">
          <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
          </svg>
          <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading recordings...</p>
        </div>
        <div id="vision-rec-empty" class="hidden text-center py-8">
          <p class="text-sm text-gray-500 dark:text-gray-400">No recordings found for this machine and time range.</p>
        </div>
        <div id="vision-rec-error" class="hidden text-center py-8">
          <p id="vision-rec-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
        </div>
        <div id="vision-rec-grid" class="hidden grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3"></div>
      </div>

      {% if is_togen_admin %}
      <!-- Logs tab content (DVI-742): every uploaded/requested agent log; mirrors SCADA View > Logs -->
      <div id="vision-panel-logs" class="hidden">
        <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
          <div class="px-6 py-5">
            <div class="flex items-center justify-between mb-4">
              <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Agent Logs</h3>
              <div class="flex items-center gap-3">
                <span id="vision-logs-status" class="text-xs text-gray-500 dark:text-gray-400"></span>
                <button onclick="visionLoadLogs()"
                        class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
                  <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                    <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
                  </svg>
                  Refresh
                </button>
              </div>
            </div>

            <div id="vision-logs-loading" class="hidden text-center py-8">
              <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
              </svg>
              <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading logs...</p>
            </div>

            <div id="vision-logs-empty" class="hidden text-center py-8">
              <svg class="h-12 w-12 mx-auto text-gray-300 dark:text-gray-600 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
                <path stroke-linecap="round" stroke-linejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
              </svg>
              <p class="text-sm text-gray-500 dark:text-gray-400">No agent logs uploaded yet.</p>
              <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Use the <span class="font-medium">Upload log</span> command on a collector to request the latest log from an agent.</p>
            </div>

            <div id="vision-logs-error" class="hidden text-center py-8">
              <svg class="h-12 w-12 mx-auto text-red-300 dark:text-red-600 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
                <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
              </svg>
              <p id="vision-logs-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
            </div>

            <div id="vision-logs-table-wrap" class="hidden overflow-x-auto">
              <table class="w-full text-sm">
                <thead>
                  <tr class="border-b border-gray-200 dark:border-gray-700 text-left">
                    <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Received</th>
                    <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Agent</th>
                    <th class="pb-2 font-semibold text-gray-600 dark:text-gray-300">Log file</th>
                  </tr>
                </thead>
                <tbody id="vision-logs-tbody" class="divide-y divide-gray-100 dark:divide-gray-800">
                </tbody>
              </table>
            </div>
          </div>
        </section>
      </div>
      {% endif %}

      {% if is_togen_admin %}
      <!-- Policy tab content (DVI-427): per-machine capture policy -->
      <div id="vision-panel-policy" class="hidden">
        <div class="flex items-center justify-between mb-4 gap-3 flex-wrap">
          <div class="flex items-center gap-2">
            <label for="vision-policy-target" class="text-sm text-gray-600 dark:text-gray-300">Scope</label>
            <select id="vision-policy-target" onchange="visionPolicyRenderForm()"
                    class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </select>
          </div>
          <span id="vision-policy-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
        </div>
        <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
          The <strong>Default</strong> policy applies to every machine; a per-machine scope overrides it.
          Changes are delivered to agents on their next check-in (via <code>config_delta</code>). The
          exclusion list names apps/URLs the agent must never capture (enforced capture-side).
        </p>
        <div id="vision-policy-form" class="space-y-4 max-w-2xl"></div>
        <div class="flex items-center gap-3 mt-5 max-w-2xl">
          <button id="vision-policy-save-btn" onclick="visionPolicySave()"
                  class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer">Save policy</button>
          <button id="vision-policy-reset-btn" onclick="visionPolicyResetOverride()"
                  class="hidden px-4 py-2.5 text-sm rounded-xl border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Remove override</button>
          <span id="vision-policy-save-status" class="text-sm text-gray-700 dark:text-gray-300"></span>
        </div>
      </div>

      <!-- Enroll tab content (DVI-437): onboarding, open-enroll, approvals, agent management -->
      <div id="vision-panel-enroll" class="hidden">
        <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">

          <!-- Quick-enroll card -->
          <div class="rounded-xl border border-gray-200 dark:border-gray-700 p-4">
            <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Quick-enroll a machine</h3>
            <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Generate a one-time token, then run the command on the target box (PowerShell, admin). The agent enrolls as <span class="font-medium text-green-600 dark:text-green-400">active</span> immediately — no approval needed.</p>
            <button id="vision-quick-enroll-btn" onclick="visionQuickEnroll()"
                    class="mt-3 px-3 py-1.5 text-xs font-medium bg-togen-teal hover:bg-togen-teal-dark text-white rounded-lg transition-colors cursor-pointer">
              Generate enroll command
            </button>
            <div id="vision-quick-enroll-result" class="hidden mt-3 rounded-xl border border-togen-teal/40 bg-togen-teal/5 dark:bg-togen-teal/10 px-3 py-3">
              <p class="text-xs font-medium text-gray-700 dark:text-gray-200">Run on the target machine — token is shown only once.</p>
              <div class="mt-2 flex items-center gap-2">
                <code id="vision-quick-enroll-cmd" class="flex-1 break-all text-xs bg-white dark:bg-gray-900 rounded-md px-2 py-1.5 border border-gray-200 dark:border-gray-700 text-gray-800 dark:text-gray-100"></code>
                <button onclick="visionCopyText('vision-quick-enroll-cmd')" class="px-2 py-1.5 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Copy</button>
              </div>
              <p id="vision-quick-enroll-ttl" class="mt-2 text-xs text-gray-500 dark:text-gray-400"></p>
              <div class="mt-3 flex flex-wrap items-center gap-2">
                <a id="vision-quick-enroll-ps1" href="#" download="bootstrap.ps1"
                   class="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Download .ps1</a>
                <a href="/enroll/agent.zip" download
                   class="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Download ZIP</a>
              </div>
            </div>
          </div>

          <!-- Open-enroll card -->
          <div class="rounded-xl border border-gray-200 dark:border-gray-700 p-4">
            <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Open-enroll (fleet rollout)</h3>
            <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">A reusable installer for mass deployment. Machines self-enroll as <span class="font-medium text-amber-600 dark:text-amber-400">pending</span> and appear under Pending approvals below.</p>
            <p class="mt-3 text-xs font-medium text-gray-700 dark:text-gray-200">Shared secret</p>
            <div class="mt-1 flex items-center gap-2">
              <code id="vision-open-enroll-secret" class="flex-1 break-all text-xs bg-white dark:bg-gray-900 rounded-md px-2 py-1.5 border border-gray-200 dark:border-gray-700 text-gray-800 dark:text-gray-100">…</code>
              <button onclick="visionCopyText('vision-open-enroll-secret')" class="px-2 py-1.5 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Copy</button>
              <button id="vision-rotate-secret-btn" onclick="visionRotateSecret()" class="px-2 py-1.5 text-xs rounded-md border border-amber-300 dark:border-amber-800 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 cursor-pointer">Rotate</button>
            </div>
            <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Rotating invalidates the prior secret immediately; already-enrolled agents are unaffected.</p>
            <div class="mt-3 flex flex-wrap items-center gap-2">
              <a href="/enroll/bootstrap.ps1" download="bootstrap.ps1"
                 class="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Download installer .ps1</a>
              <a href="/enroll/agent.zip" download
                 class="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Download ZIP</a>
            </div>
          </div>
        </div>

        <!-- Upgrade existing machine card (DVI-693) -->
        <div class="rounded-xl border border-blue-200 dark:border-blue-800 bg-blue-50/40 dark:bg-blue-900/10 p-4 mb-6">
          <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Upgrade existing machine</h3>
          <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
            Already-enrolled machines can be upgraded in-place — no enrollment token needed.
            The script stops the agent tasks, replaces the agent files with the latest version,
            generates a machine-level SSH key at <code class="text-xs">C:\ProgramData\Togen\id_togen_vision</code>
            (if not already present), uploads the public key to this server, and restarts the tasks.
          </p>
          <ol class="mt-2 text-xs text-gray-600 dark:text-gray-400 list-decimal ml-4 space-y-0.5">
            <li>Download <strong>upgrade.ps1</strong> below and run it as Administrator on the target machine.</li>
            <li>The public key is uploaded automatically. Approving the agent then writes it to the rsync server.</li>
          </ol>
          <div class="mt-3 flex flex-wrap items-center gap-2">
            <a href="/enroll/upgrade.ps1" download="upgrade.ps1"
               class="px-2 py-1 text-xs rounded-md border border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/30 cursor-pointer">Download upgrade.ps1</a>
            <a href="/enroll/agent.zip" download
               class="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Download ZIP</a>
            <button type="button" onclick="enrollShowUploadCmd('vision-ve')"
               class="px-2 py-1 text-xs rounded-md border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/30 cursor-pointer">Generate update command</button>
          </div>
          <div id="vision-ve-upload-cmd-wrap" class="hidden mt-2">
            <p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Run this on an already-enrolled machine missing its SSH key:</p>
            <div class="flex items-center gap-2">
              <code id="vision-ve-upload-cmd" class="flex-1 px-2 py-1 text-xs rounded-md bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 font-mono break-all"></code>
              <button type="button" onclick="visionCopyText('vision-ve-upload-cmd')"
                 class="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Copy</button>
            </div>
          </div>
        </div>

        <!-- Pending approvals -->
        <div class="mb-6">
          <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-2">Pending approvals</h3>
          <div id="vision-pending-empty" class="hidden text-xs text-gray-500 dark:text-gray-400">No machines awaiting approval.</div>
          <ul id="vision-pending-list" class="space-y-2"></ul>
        </div>

        <!-- Enrolled agents (migrated from Settings) -->
        <div>
          <div class="flex items-center justify-between mb-2">
            <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Enrolled agents</h3>
            <button onclick="visionEnrollLoad()"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer">Refresh</button>
          </div>
          <div id="vision-agents-loading" class="hidden text-xs text-gray-500 dark:text-gray-400">Loading agents...</div>
          <div id="vision-agents-empty" class="hidden text-xs text-gray-500 dark:text-gray-400">No agents enrolled yet.</div>
          <ul id="vision-agents-list" class="hidden space-y-2"></ul>
        </div>
      </div>
      {% endif %}

      <!-- Settings tab content (DVI-593): per-user Vision preferences -->
      <div id="vision-panel-settings" class="hidden">
        <div class="rounded-xl border border-gray-200 dark:border-gray-700 p-4 max-w-2xl">
          <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Assistant</h3>
          <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
            Choose the model Vision uses for assistant features. System Providers are configured
            in Togen Admin &rarr; Assistant; User Models come from your own User Settings &rarr; Assistant connection.
          </p>
          <div class="mt-4 flex items-center gap-3 flex-wrap">
            <label for="vision-settings-model" class="text-sm text-gray-600 dark:text-gray-300">Model</label>
            <select id="vision-settings-model"
                    class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </select>
          </div>
          <p id="vision-settings-empty" class="hidden mt-3 text-xs text-amber-600 dark:text-amber-400">
            No models available yet. Ask a Togen Admin to configure a System Provider key, or add your own
            key under User Settings &rarr; Assistant.
          </p>
          <div class="flex items-center gap-3 mt-5">
            <button id="vision-settings-save-btn" onclick="visionSaveAssistantModel()"
                    class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer">Save</button>
            <span id="vision-settings-save-status" class="text-sm text-gray-700 dark:text-gray-300"></span>
          </div>
        </div>

        <!-- Vision Agent pane (DVI-712): current agent version + out-of-date agents -->
        <div class="rounded-xl border border-gray-200 dark:border-gray-700 p-4 max-w-2xl mt-4">
          <div class="flex items-center justify-between">
            <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Vision Agent</h3>
            <div class="flex items-center gap-2">
              <button id="vision-staged-update-btn" onclick="visionStagedUpdate()"
                      class="px-3 py-1 text-xs font-medium rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed">Staged Update</button>
              <button onclick="visionAgentVersionLoad()"
                      class="px-3 py-1 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer">Refresh</button>
            </div>
          </div>
          <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
            The latest Togen Vision Agent version available for deployment. Enrolled agents
            running an older version are listed below with an option to update them in place.
          </p>
          <div class="mt-3 flex items-center gap-2 text-sm">
            <span class="text-gray-600 dark:text-gray-300">Current version:</span>
            <span id="vision-agent-latest-version" class="font-mono font-semibold text-gray-900 dark:text-gray-100">&mdash;</span>
          </div>
          <div class="mt-4">
            <h4 class="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-2">Agents not up to date</h4>
            <div id="vision-agent-version-loading" class="hidden text-xs text-gray-500 dark:text-gray-400">Loading agents&hellip;</div>
            <div id="vision-agent-version-empty" class="hidden text-xs text-gray-500 dark:text-gray-400">All enrolled agents are up to date.</div>
            <div id="vision-agent-version-error" class="hidden text-xs text-red-600 dark:text-red-400"></div>
            <ul id="vision-agent-version-list" class="hidden space-y-2"></ul>
            <p id="vision-staged-update-status" class="hidden mt-3 text-xs text-gray-600 dark:text-gray-300"></p>
          </div>
        </div>
      </div>
    </div><!-- /tool-vision -->

    <!-- Vision screenshot lightbox (DVI-427): full frame on demand -->
    <div id="vision-lightbox-overlay" class="hidden fixed inset-0 z-[110] flex items-center justify-center">
      <div onclick="visionLightboxClose()" class="absolute inset-0 bg-black/80"></div>
      <div class="relative max-w-[92vw] max-h-[92vh] flex flex-col">
        <div class="flex items-center justify-between mb-2">
          <span id="vision-lightbox-caption" class="text-sm text-gray-200"></span>
          <button onclick="visionLightboxClose()" class="p-1 rounded-lg text-gray-300 hover:text-white hover:bg-white/10 transition-colors">
            <svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
              <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
            </svg>
          </button>
        </div>
        <img id="vision-lightbox-img" src="" alt="Captured screenshot"
             class="rounded-lg shadow-2xl max-w-full max-h-[85vh] object-contain bg-gray-900">
      </div>
    </div>

    <!-- Vision agent detail popup (DVI-682): click a Collector tile to open -->
    <div id="vision-agent-popup-overlay" class="hidden fixed inset-0 z-[110] flex items-center justify-center p-4">
      <div onclick="visionAgentPopupClose()" class="absolute inset-0 bg-black/60"></div>
      <div class="relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl bg-white dark:bg-gray-900 shadow-2xl border border-gray-200 dark:border-gray-700">
        <div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700">
          <h3 id="vision-agent-popup-title" class="text-base font-semibold text-gray-800 dark:text-gray-100 truncate pr-2">Agent</h3>
          <div class="flex items-center gap-1 shrink-0">
            <button id="vision-agent-popup-gear" onclick="visionAgentPopupToggle()" title="Settings"
                    class="p-1.5 rounded-lg text-gray-500 dark:text-gray-400 hover:text-togen-teal hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer">
              <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
                <path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.108-1.204l-.526-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"/>
                <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
              </svg>
            </button>
            <button onclick="visionAgentPopupClose()" title="Close"
                    class="p-1.5 rounded-lg text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer">
              <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
              </svg>
            </button>
          </div>
        </div>
        <div id="vision-agent-popup-detail" class="px-5 py-4"></div>
        <div id="vision-agent-popup-settings" class="hidden px-5 py-4"></div>
      </div>
    </div>

    <!-- Auditor tool (DVI-558): Assistant tool-use audit review — admin only -->
    <div id="tool-auditor" class="tool-content hidden">
      <!-- Top-level tabs: Assistant / Policies (DVI-615) -->
      <div id="auditor-toptabbar" class="flex flex-nowrap overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden border-b border-gray-200 dark:border-gray-700 mb-4">
        <button id="auditor-toptab-assistant" onclick="auditorSwitchTopTab('assistant')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-togen-teal text-togen-teal cursor-pointer">
          Assistant
        </button>
        <button id="auditor-toptab-history" onclick="auditorSwitchTopTab('history')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          History
        </button>
        <button id="auditor-toptab-policies" onclick="auditorSwitchTopTab('policies')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Policies
        </button>
      </div>

      <!-- Assistant pane: tool-use audit log (DVI-615) -->
      <div id="auditor-pane-assistant">
      <!-- Tab bar -->
      <div id="auditor-tabbar" class="flex flex-nowrap overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden border-b border-gray-200 dark:border-gray-700 mb-4">
        <button id="auditor-tab-history" onclick="auditorSwitchTab('history')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-togen-teal text-togen-teal cursor-pointer">
          History
        </button>
        <button id="auditor-tab-failures" onclick="auditorSwitchTab('failures')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Failures
        </button>
        <div class="ml-auto flex items-center gap-2 px-2 pb-1">
          <input id="auditor-filter-user" type="text" placeholder="Filter user…"
                 oninput="auditorApplyFilter()"
                 class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-togen-teal w-32" />
          <select id="auditor-filter-tool" onchange="auditorApplyFilter()"
                  class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-togen-teal">
            <option value="">All tools</option>
            <optgroup label="Tier-0 (read-only)">
              <option value="prodrun_status">prodrun_status</option>
              <option value="reprint_list_projects">reprint_list_projects</option>
              <option value="wor_history">wor_history</option>
              <option value="manuals_lookup">manuals_lookup</option>
            </optgroup>
            <optgroup label="Tier-1 (mutating)">
              <option value="qr_submit">qr_submit</option>
              <option value="duplicate_folder">duplicate_folder</option>
              <option value="prodrun_submit">prodrun_submit</option>
              <option value="scs_save_to_project">scs_save_to_project</option>
              <option value="create_carrier">create_carrier</option>
            </optgroup>
          </select>
          <button onclick="auditorLoad(true)"
                  class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
            <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
            </svg>
            Refresh
          </button>
        </div>
      </div>

      <!-- Loading state -->
      <div id="auditor-loading" class="hidden text-center py-8">
        <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
          <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
          <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
        </svg>
        <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading audit log…</p>
      </div>

      <!-- Error state -->
      <div id="auditor-error" class="hidden text-center py-8">
        <svg class="h-10 w-10 mx-auto text-red-300 dark:text-red-600 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
          <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
        </svg>
        <p id="auditor-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
      </div>

      <!-- Empty state -->
      <div id="auditor-empty" class="hidden text-center py-10">
        <svg class="h-10 w-10 mx-auto text-gray-300 dark:text-gray-600 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
          <path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
        </svg>
        <p class="text-sm text-gray-500 dark:text-gray-400">No audit entries found.</p>
      </div>

      <!-- Table -->
      <div id="auditor-table-wrap" class="hidden overflow-x-auto rounded-xl border border-gray-200 dark:border-gray-700">
        <table class="min-w-full text-xs">
          <thead class="bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 uppercase tracking-wide">
            <tr>
              <th class="px-3 py-2 text-left font-medium">Time</th>
              <th class="px-3 py-2 text-left font-medium">User</th>
              <th class="px-3 py-2 text-left font-medium">Tool</th>
              <th class="px-3 py-2 text-left font-medium">Args</th>
              <th class="px-3 py-2 text-left font-medium">Access</th>
              <th class="px-3 py-2 text-left font-medium">Outcome</th>
            </tr>
          </thead>
          <tbody id="auditor-tbody" class="divide-y divide-gray-100 dark:divide-gray-800 bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300">
          </tbody>
        </table>
      </div>

      <!-- Count + Load More -->
      <div id="auditor-footer" class="hidden flex items-center justify-between mt-3">
        <span id="auditor-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
        <button id="auditor-loadmore-btn" onclick="auditorLoadMore()"
                class="hidden px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">
          Load more
        </button>
      </div>
      </div><!-- /auditor-pane-assistant -->

      <!-- History pane (DVI-785): mirrors history from other tools, one sub-tab per tool -->
      <div id="auditor-pane-history" class="hidden">
        <div id="auditor-histtabbar" class="flex flex-nowrap overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden border-b border-gray-200 dark:border-gray-700 mb-4">
          <button id="auditor-histtab-prodrun" onclick="auditorSwitchHistTab('prodrun')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-togen-teal text-togen-teal cursor-pointer">Production Run</button>
          <button id="auditor-histtab-print" onclick="auditorSwitchHistTab('print')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">Print</button>
          <button id="auditor-histtab-wor-admin" onclick="auditorSwitchHistTab('wor-admin')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">Work Order Admin</button>
          <button id="auditor-histtab-rpm" onclick="auditorSwitchHistTab('rpm')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">Repair Parts Manager</button>
          <button id="auditor-histtab-single-carrier-sheet" onclick="auditorSwitchHistTab('single-carrier-sheet')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">Carrier Sheet</button>
          <button id="auditor-histtab-mts-label" onclick="auditorSwitchHistTab('mts-label')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">MTS Labels</button>
          <button id="auditor-histtab-file-browser" onclick="auditorSwitchHistTab('file-browser')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">File Browser</button>
          <button id="auditor-histtab-ocr-tool" onclick="auditorSwitchHistTab('ocr-tool')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">OCR Tool</button>
          <button id="auditor-histtab-takeoff-bridge" onclick="auditorSwitchHistTab('takeoff-bridge')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">Take-off Bridge</button>
          <button id="auditor-histtab-project-builder" onclick="auditorSwitchHistTab('project-builder')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">Project Builder</button>
          <button id="auditor-histtab-sync-admin" onclick="auditorSwitchHistTab('sync-admin')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">Sync Admin</button>
          <button id="auditor-histtab-togen-admin" onclick="auditorSwitchHistTab('togen-admin')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">Togen Admin</button>
          <button id="auditor-histtab-vision-agents" onclick="auditorSwitchHistTab('vision-agents')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">Vision Agents</button>
          <button id="auditor-histtab-bots" onclick="auditorSwitchHistTab('bots')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">Bots</button>
        </div>
        <div id="auditor-hist-prodrun">
          <div id="auditor-hist-prodrun-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-prodrun-list" class="space-y-2"></div>
        </div>
        <div id="auditor-hist-print" class="hidden">
          <div class="flex items-center justify-end gap-2 mb-3">
            <input id="auditor-hist-print-filter-user" type="text" placeholder="Filter user…"
                   oninput="auditorHistLoadPrint(true)"
                   class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-togen-teal w-32" />
            <button onclick="auditorHistLoadPrint(true)"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
              </svg>
              Refresh
            </button>
          </div>
          <div id="auditor-hist-print-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-print-list" class="space-y-2"></div>
          <div class="mt-3 flex items-center justify-between">
            <span id="auditor-hist-print-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
            <button id="auditor-hist-print-loadmore" onclick="auditorHistPrintLoadMore()"
                    class="hidden px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">
              Load more
            </button>
          </div>
        </div>
        <div id="auditor-hist-wor-admin" class="hidden">
          <div class="flex items-center justify-end gap-2 mb-3">
            <input id="auditor-hist-wor-admin-filter-user" type="text" placeholder="Filter user…"
                   oninput="auditorHistLoadGeneric('wor-admin', true)"
                   class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-togen-teal w-32" />
            <button onclick="auditorHistLoadGeneric('wor-admin', true)"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg>
              Refresh
            </button>
          </div>
          <div id="auditor-hist-wor-admin-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-wor-admin-list" class="space-y-2"></div>
          <div class="mt-3 flex items-center justify-between">
            <span id="auditor-hist-wor-admin-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
            <button id="auditor-hist-wor-admin-loadmore" onclick="auditorHistLoadMoreGeneric('wor-admin')"
                    class="hidden px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">Load more</button>
          </div>
        </div>
        <div id="auditor-hist-rpm" class="hidden">
          <div class="flex items-center justify-end gap-2 mb-3">
            <input id="auditor-hist-rpm-filter-user" type="text" placeholder="Filter user…"
                   oninput="auditorHistLoadGeneric('rpm', true)"
                   class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-togen-teal w-32" />
            <button onclick="auditorHistLoadGeneric('rpm', true)"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg>
              Refresh
            </button>
          </div>
          <div id="auditor-hist-rpm-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-rpm-list" class="space-y-2"></div>
          <div class="mt-3 flex items-center justify-between">
            <span id="auditor-hist-rpm-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
            <button id="auditor-hist-rpm-loadmore" onclick="auditorHistLoadMoreGeneric('rpm')"
                    class="hidden px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">Load more</button>
          </div>
        </div>
        <div id="auditor-hist-single-carrier-sheet" class="hidden">
          <div class="flex items-center justify-end gap-2 mb-3">
            <input id="auditor-hist-single-carrier-sheet-filter-user" type="text" placeholder="Filter user…"
                   oninput="auditorHistLoadGeneric('single-carrier-sheet', true)"
                   class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-togen-teal w-32" />
            <button onclick="auditorHistLoadGeneric('single-carrier-sheet', true)"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg>
              Refresh
            </button>
          </div>
          <div id="auditor-hist-single-carrier-sheet-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-single-carrier-sheet-list" class="space-y-2"></div>
          <div class="mt-3 flex items-center justify-between">
            <span id="auditor-hist-single-carrier-sheet-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
            <button id="auditor-hist-single-carrier-sheet-loadmore" onclick="auditorHistLoadMoreGeneric('single-carrier-sheet')"
                    class="hidden px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">Load more</button>
          </div>
        </div>
        <div id="auditor-hist-mts-label" class="hidden">
          <div class="flex items-center justify-end gap-2 mb-3">
            <input id="auditor-hist-mts-label-filter-user" type="text" placeholder="Filter user…"
                   oninput="auditorHistLoadGeneric('mts-label', true)"
                   class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-togen-teal w-32" />
            <button onclick="auditorHistLoadGeneric('mts-label', true)"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg>
              Refresh
            </button>
          </div>
          <div id="auditor-hist-mts-label-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-mts-label-list" class="space-y-2"></div>
          <div class="mt-3 flex items-center justify-between">
            <span id="auditor-hist-mts-label-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
            <button id="auditor-hist-mts-label-loadmore" onclick="auditorHistLoadMoreGeneric('mts-label')"
                    class="hidden px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">Load more</button>
          </div>
        </div>
        <div id="auditor-hist-file-browser" class="hidden">
          <div class="flex items-center justify-end gap-2 mb-3">
            <input id="auditor-hist-file-browser-filter-user" type="text" placeholder="Filter user…"
                   oninput="auditorHistLoadGeneric('file-browser', true)"
                   class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-togen-teal w-32" />
            <button onclick="auditorHistLoadGeneric('file-browser', true)"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg>
              Refresh
            </button>
          </div>
          <div id="auditor-hist-file-browser-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-file-browser-list" class="space-y-2"></div>
          <div class="mt-3 flex items-center justify-between">
            <span id="auditor-hist-file-browser-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
            <button id="auditor-hist-file-browser-loadmore" onclick="auditorHistLoadMoreGeneric('file-browser')"
                    class="hidden px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">Load more</button>
          </div>
        </div>
        <div id="auditor-hist-ocr-tool" class="hidden">
          <div class="flex items-center justify-end gap-2 mb-3">
            <input id="auditor-hist-ocr-tool-filter-user" type="text" placeholder="Filter user…"
                   oninput="auditorHistLoadGeneric('ocr-tool', true)"
                   class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-togen-teal w-32" />
            <button onclick="auditorHistLoadGeneric('ocr-tool', true)"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg>
              Refresh
            </button>
          </div>
          <div id="auditor-hist-ocr-tool-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-ocr-tool-list" class="space-y-2"></div>
          <div class="mt-3 flex items-center justify-between">
            <span id="auditor-hist-ocr-tool-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
            <button id="auditor-hist-ocr-tool-loadmore" onclick="auditorHistLoadMoreGeneric('ocr-tool')"
                    class="hidden px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">Load more</button>
          </div>
        </div>
        <div id="auditor-hist-takeoff-bridge" class="hidden">
          <div class="flex items-center justify-end gap-2 mb-3">
            <input id="auditor-hist-takeoff-bridge-filter-user" type="text" placeholder="Filter user…"
                   oninput="auditorHistLoadGeneric('takeoff-bridge', true)"
                   class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-togen-teal w-32" />
            <button onclick="auditorHistLoadGeneric('takeoff-bridge', true)"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg>
              Refresh
            </button>
          </div>
          <div id="auditor-hist-takeoff-bridge-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-takeoff-bridge-list" class="space-y-2"></div>
          <div class="mt-3 flex items-center justify-between">
            <span id="auditor-hist-takeoff-bridge-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
            <button id="auditor-hist-takeoff-bridge-loadmore" onclick="auditorHistLoadMoreGeneric('takeoff-bridge')"
                    class="hidden px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">Load more</button>
          </div>
        </div>
        <div id="auditor-hist-project-builder" class="hidden">
          <div class="flex items-center justify-end gap-2 mb-3">
            <input id="auditor-hist-project-builder-filter-user" type="text" placeholder="Filter user…"
                   oninput="auditorHistLoadGeneric('project-builder', true)"
                   class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-togen-teal w-32" />
            <button onclick="auditorHistLoadGeneric('project-builder', true)"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg>
              Refresh
            </button>
          </div>
          <div id="auditor-hist-project-builder-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-project-builder-list" class="space-y-2"></div>
          <div class="mt-3 flex items-center justify-between">
            <span id="auditor-hist-project-builder-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
            <button id="auditor-hist-project-builder-loadmore" onclick="auditorHistLoadMoreGeneric('project-builder')"
                    class="hidden px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">Load more</button>
          </div>
        </div>
        <div id="auditor-hist-sync-admin" class="hidden">
          <div class="flex items-center justify-end gap-2 mb-3">
            <input id="auditor-hist-sync-admin-filter-user" type="text" placeholder="Filter user…"
                   oninput="auditorHistLoadGeneric('sync-admin', true)"
                   class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-togen-teal w-32" />
            <button onclick="auditorHistLoadGeneric('sync-admin', true)"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg>
              Refresh
            </button>
          </div>
          <div id="auditor-hist-sync-admin-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-sync-admin-list" class="space-y-2"></div>
          <div class="mt-3 flex items-center justify-between">
            <span id="auditor-hist-sync-admin-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
            <button id="auditor-hist-sync-admin-loadmore" onclick="auditorHistLoadMoreGeneric('sync-admin')"
                    class="hidden px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">Load more</button>
          </div>
        </div>
        <div id="auditor-hist-togen-admin" class="hidden">
          <div class="flex items-center justify-end gap-2 mb-3">
            <input id="auditor-hist-togen-admin-filter-user" type="text" placeholder="Filter user…"
                   oninput="auditorHistLoadGeneric('togen-admin', true)"
                   class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-togen-teal w-32" />
            <button onclick="auditorHistLoadGeneric('togen-admin', true)"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg>
              Refresh
            </button>
          </div>
          <div id="auditor-hist-togen-admin-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-togen-admin-list" class="space-y-2"></div>
          <div class="mt-3 flex items-center justify-between">
            <span id="auditor-hist-togen-admin-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
            <button id="auditor-hist-togen-admin-loadmore" onclick="auditorHistLoadMoreGeneric('togen-admin')"
                    class="hidden px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">Load more</button>
          </div>
        </div>
        <div id="auditor-hist-vision-agents" class="hidden">
          <div class="flex items-center justify-end gap-2 mb-3">
            <input id="auditor-hist-vision-agents-filter-user" type="text" placeholder="Filter user…"
                   oninput="auditorHistLoadGeneric('vision-agents', true)"
                   class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-togen-teal w-32" />
            <button onclick="auditorHistLoadGeneric('vision-agents', true)"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg>
              Refresh
            </button>
          </div>
          <div id="auditor-hist-vision-agents-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-vision-agents-list" class="space-y-2"></div>
          <div class="mt-3 flex items-center justify-between">
            <span id="auditor-hist-vision-agents-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
            <button id="auditor-hist-vision-agents-loadmore" onclick="auditorHistLoadMoreGeneric('vision-agents')"
                    class="hidden px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">Load more</button>
          </div>
        </div>
        <div id="auditor-hist-bots" class="hidden">
          <div class="flex items-center justify-between gap-2 mb-3">
            <label class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
              <span class="font-medium">Bot</span>
              <select id="auditor-hist-bots-filter" onchange="auditorHistLoadBots()"
                      class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-togen-teal">
                <option value="winston" selected>Winston</option>
              </select>
            </label>
            <button onclick="auditorHistLoadBots()"
                    class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
              <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/></svg>
              Refresh
            </button>
          </div>
          <div id="auditor-hist-bots-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-bots-table-wrap" class="hidden overflow-x-auto">
            <table class="w-full text-sm">
              <thead>
                <tr class="border-b border-gray-200 dark:border-gray-700 text-left">
                  <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300 whitespace-nowrap">Run at</th>
                  <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Date</th>
                  <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Status</th>
                  <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300 text-right">Found</th>
                  <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300 text-right">Drafts</th>
                  <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300 text-right">Routed</th>
                  <th class="pb-2 font-semibold text-gray-600 dark:text-gray-300">Error</th>
                </tr>
              </thead>
              <tbody id="auditor-hist-bots-tbody" class="divide-y divide-gray-100 dark:divide-gray-800"></tbody>
            </table>
          </div>
        </div>
      </div><!-- /auditor-pane-history -->

      <!-- Policies pane (DVI-615): aggregated read-only review of policy dialogs from other tools -->
      <div id="auditor-pane-policies" class="hidden">
        <div id="auditor-poltabbar" class="flex flex-nowrap overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden border-b border-gray-200 dark:border-gray-700 mb-4">
          <button id="auditor-poltab-all" onclick="auditorSwitchPolTab('all')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-togen-teal text-togen-teal cursor-pointer">All</button>
          <button id="auditor-poltab-woradmin" onclick="auditorSwitchPolTab('woradmin')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">Work Order Admin</button>
          <button id="auditor-poltab-rpm" onclick="auditorSwitchPolTab('rpm')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">Repair Parts Manager</button>
          <button id="auditor-poltab-vision" onclick="auditorSwitchPolTab('vision')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">Vision</button>
        </div>
        <div id="auditor-policies-body" class="space-y-6"></div>
      </div><!-- /auditor-pane-policies -->

    </div><!-- /tool-auditor -->

    <div id="tool-tutorials" class="tool-content hidden">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-4">Tutorials</h2>
      <div id="tutorials-fullpage-grid" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
      </div>
      <p id="tutorials-fullpage-empty" class="hidden text-sm text-gray-500 dark:text-gray-400 text-center py-6">No tutorials available yet. Check back soon!</p>
    </div>

    <!-- ===== REPAIR PARTS MANAGER (DVI-603) ===== -->
    <div id="tool-repair-parts-manager" class="tool-content hidden">
      <!-- Tab bar: All Parts (default) / Mobile Machine / Preventative Maintenance / Settings (right) -->
      <div id="rpm-tabbar" class="flex flex-nowrap overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden border-b border-gray-200 dark:border-gray-700 mb-4">
        <button id="rpm-tab-all" onclick="rpmSwitchTab('all')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-togen-teal text-togen-teal cursor-pointer">
          All Parts
        </button>
        <button id="rpm-tab-mobilemachine" onclick="rpmSwitchTab('mobilemachine')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Mobile Machine
        </button>
        <button id="rpm-tab-preventive" onclick="rpmSwitchTab('preventive')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Preventative Maintenance
        </button>
        <button id="rpm-tab-processing" onclick="rpmSwitchTab('processing')"
                class="ml-auto flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Processing
        </button>
        <button id="rpm-tab-settings" onclick="rpmSwitchTab('settings')"
                class="flex-shrink-0 whitespace-nowrap px-4 py-2.5 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 cursor-pointer">
          Settings
        </button>
      </div>

      <!-- Filters + refresh (hidden on Settings tab) -->
      <div id="rpm-filters" class="flex flex-wrap items-center gap-2 mb-4">
        <div id="rpm-filter-type-wrap" class="flex items-center gap-1.5">
          <label class="text-xs text-gray-500 dark:text-gray-400">Type</label>
          <select id="rpm-filter-type" onchange="rpmLoad()"
                  class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-togen-teal">
            <option value="all">All</option>
            <option value="mobilemachine">Mobile Machine</option>
            <option value="preventive">Preventative Maintenance</option>
          </select>
        </div>
        <div class="flex items-center gap-1.5">
          <label class="text-xs text-gray-500 dark:text-gray-400">Approved</label>
          <select id="rpm-filter-approved" onchange="rpmLoad()"
                  class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-togen-teal">
            <option value="all">All</option>
            <option value="yes">Approved</option>
            <option value="no">Not approved</option>
          </select>
        </div>
        <div class="flex items-center gap-1.5">
          <label class="text-xs text-gray-500 dark:text-gray-400">Ordered</label>
          <select id="rpm-filter-ordered" onchange="rpmLoad()"
                  class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-togen-teal">
            <option value="all">All</option>
            <option value="yes">Ordered</option>
            <option value="no">Not ordered</option>
          </select>
        </div>
        <label class="flex items-center gap-1.5 cursor-pointer" title="Show Work Orders marked Complete (hidden by default).">
          <input type="checkbox" id="rpm-filter-completed" onchange="rpmLoad()"
                 class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer" />
          <span class="text-xs text-gray-500 dark:text-gray-400">Complete</span>
        </label>
        <button onclick="rpmLoad()"
                class="ml-auto px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
          <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
          </svg>
          Refresh
        </button>
      </div>

      <!-- Loading state -->
      <div id="rpm-loading" class="hidden text-center py-8">
        <svg class="animate-spin h-8 w-8 mx-auto text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
          <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
          <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
        </svg>
        <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading parts…</p>
      </div>

      <!-- Error state -->
      <div id="rpm-error" class="hidden text-center py-8">
        <svg class="h-10 w-10 mx-auto text-red-300 dark:text-red-600 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
          <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
        </svg>
        <p id="rpm-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
      </div>

      <!-- Empty state -->
      <div id="rpm-empty" class="hidden text-center py-10">
        <svg class="h-10 w-10 mx-auto text-gray-300 dark:text-gray-600 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
          <path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
        </svg>
        <p class="text-sm text-gray-500 dark:text-gray-400">No work orders with matching parts.</p>
      </div>

      <!-- Grouped Work Order -> parts list -->
      <div id="rpm-groups" class="hidden space-y-4"></div>

      <!-- Processing tab: awaiting approval + ordered-not-stocked sections -->
      <div id="rpm-processing" class="hidden space-y-6">
        <div>
          <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-2">Awaiting Approval</h3>
          <div id="rpm-proc-approval" class="space-y-2"></div>
          <p id="rpm-proc-approval-empty" class="hidden text-sm text-gray-500 dark:text-gray-400">No parts awaiting approval.</p>
        </div>
        <div>
          <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-2">Ordered — Not Yet Stocked</h3>
          <div id="rpm-proc-ordered" class="space-y-2"></div>
          <p id="rpm-proc-ordered-empty" class="hidden text-sm text-gray-500 dark:text-gray-400">No ordered parts awaiting stocking.</p>
        </div>
      </div>

      <!-- Settings pane: Policy -->
      <div id="rpm-settings" class="hidden space-y-6">
        <div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
          <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">Policy</h3>
          <label class="flex items-start gap-3 cursor-pointer">
            <input type="checkbox" id="rpm-policy-assume-stocked" onchange="rpmSettingsSave()"
                   class="mt-0.5 h-5 w-5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer" />
            <span class="min-w-0">
              <span class="block text-sm font-medium text-gray-800 dark:text-gray-100">Assume parts stocked and replaced when ticket is marked as Complete.</span>
              <span class="mt-1 block text-xs text-gray-500 dark:text-gray-400">When enabled, all parts on a completed Work Order are treated as stocked, so they no longer appear as awaiting approval, ordering, or stocking.</span>
            </span>
          </label>
          <label class="flex items-start gap-3 cursor-pointer mt-4">
            <input type="checkbox" id="rpm-policy-mark-delivered" onchange="rpmSettingsSave()"
                   class="mt-0.5 h-5 w-5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer" />
            <span class="min-w-0">
              <span class="block text-sm font-medium text-gray-800 dark:text-gray-100">Mark items as Stocked upon shipping carrier status indicated as delivered.</span>
              <span class="mt-1 block text-xs text-gray-500 dark:text-gray-400">When enabled, an ordered part is automatically marked Stocked once its carrier tracking reports delivered. (Live carrier tracking is pending provider setup.)</span>
            </span>
          </label>
          <p id="rpm-settings-status" class="mt-3 text-xs text-gray-500 dark:text-gray-400"></p>

          <!-- Approval permission (DVI-614) -->
          <div class="mt-5 pt-4 border-t border-gray-200 dark:border-gray-700">
            <h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-1">Approval permission</h4>
            <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Choose who is allowed to mark parts as approved. Users who do not qualify see the Mark&nbsp;Approved button greyed out.</p>
            <div class="space-y-2">
              <label class="flex items-center gap-2 cursor-pointer">
                <input type="radio" name="rpm-approve-mode" value="all_admins" onchange="rpmApprovalSaveFromDom()"
                       class="h-4 w-4 text-togen-teal border-gray-300 dark:border-gray-600 focus:ring-togen-teal cursor-pointer" />
                <span class="text-sm text-gray-800 dark:text-gray-100">All Work Order Admins</span>
              </label>
              <label class="flex items-center gap-2 cursor-pointer">
                <input type="radio" name="rpm-approve-mode" value="primary" onchange="rpmApprovalSaveFromDom()"
                       class="h-4 w-4 text-togen-teal border-gray-300 dark:border-gray-600 focus:ring-togen-teal cursor-pointer" />
                <span class="text-sm text-gray-800 dark:text-gray-100">Only Admins designated as Primary</span>
              </label>
              <label class="flex items-center gap-2 cursor-pointer">
                <input type="radio" name="rpm-approve-mode" value="specific" onchange="rpmApprovalSaveFromDom()"
                       class="h-4 w-4 text-togen-teal border-gray-300 dark:border-gray-600 focus:ring-togen-teal cursor-pointer" />
                <span class="text-sm text-gray-800 dark:text-gray-100">Only the following users</span>
              </label>
              <div id="rpm-approve-specific-wrap" class="hidden ml-6 mt-1">
                <div class="flex items-center gap-2">
                  <input type="email" id="rpm-approve-users-input" placeholder="email@example.com" onkeydown="if(event.key==='Enter'){event.preventDefault();rpmApproveAddUser('approve');}"
                         class="flex-1 px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-togen-teal" />
                  <button type="button" onclick="rpmApproveAddUser('approve')"
                          class="px-2.5 py-1 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 cursor-pointer">+ Add</button>
                </div>
                <div id="rpm-approve-users-list" class="mt-2 flex flex-wrap gap-1.5"></div>
              </div>
            </div>
            <p id="rpm-approval-status" class="mt-3 text-xs text-gray-500 dark:text-gray-400"></p>
          </div>

          <!-- Policy Enforced Notifications (DVI-614 follow-up) -->
          <div class="mt-5 pt-4 border-t border-gray-200 dark:border-gray-700">
            <h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-1">Policy Enforced Notifications</h4>
            <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Users added here receive <span class="font-medium">all</span> Repair Parts notifications for the selected Work Order type, regardless of their own notification settings.</p>
            <div class="space-y-3">
              <div>
                <span class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Mobile Machine</span>
                <div class="flex items-center gap-2">
                  <input type="email" id="rpm-policy-mobilemachine-users-input" placeholder="email@example.com" onkeydown="if(event.key==='Enter'){event.preventDefault();rpmApproveAddUser('policy_mobilemachine');}"
                         class="flex-1 px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-togen-teal" />
                  <button type="button" onclick="rpmApproveAddUser('policy_mobilemachine')"
                          class="px-2.5 py-1 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 cursor-pointer">+ Add</button>
                </div>
                <div id="rpm-policy-mobilemachine-users-list" class="mt-2 flex flex-wrap gap-1.5"></div>
              </div>
              <div>
                <span class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Preventative Maintenance</span>
                <div class="flex items-center gap-2">
                  <input type="email" id="rpm-policy-preventive-users-input" placeholder="email@example.com" onkeydown="if(event.key==='Enter'){event.preventDefault();rpmApproveAddUser('policy_preventive');}"
                         class="flex-1 px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-togen-teal" />
                  <button type="button" onclick="rpmApproveAddUser('policy_preventive')"
                          class="px-2.5 py-1 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 cursor-pointer">+ Add</button>
                </div>
                <div id="rpm-policy-preventive-users-list" class="mt-2 flex flex-wrap gap-1.5"></div>
              </div>
            </div>
          </div>
        </div>

        <!-- Auto Approval pane (DVI-614) -->
        <div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
          <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-3">Auto Approval</h3>
          <label class="flex items-start gap-3 cursor-pointer">
            <input type="checkbox" id="rpm-auto-enabled" onchange="rpmApprovalSaveFromDom()"
                   class="mt-0.5 h-5 w-5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer" />
            <span class="min-w-0">
              <span class="block text-sm font-medium text-gray-800 dark:text-gray-100">Enable auto approval</span>
              <span class="mt-1 block text-xs text-gray-500 dark:text-gray-400">When enabled, parts added on a Work Order created by a qualifying user are approved automatically.</span>
            </span>
          </label>
          <div id="rpm-auto-body" class="hidden mt-4 space-y-5">
            <div>
              <h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-2">Mobile Machine</h4>
              <div class="space-y-2" data-rpm-auto-type="mobilemachine">
                <label class="flex items-center gap-2 cursor-pointer">
                  <input type="radio" name="rpm-auto-mode-mobilemachine" value="approvers" onchange="rpmApprovalSaveFromDom()"
                         class="h-4 w-4 text-togen-teal border-gray-300 dark:border-gray-600 focus:ring-togen-teal cursor-pointer" />
                  <span class="text-sm text-gray-800 dark:text-gray-100">WOs created by users with approval permissions</span>
                </label>
                <label class="flex items-center gap-2 cursor-pointer">
                  <input type="radio" name="rpm-auto-mode-mobilemachine" value="all_admins" onchange="rpmApprovalSaveFromDom()"
                         class="h-4 w-4 text-togen-teal border-gray-300 dark:border-gray-600 focus:ring-togen-teal cursor-pointer" />
                  <span class="text-sm text-gray-800 dark:text-gray-100">WOs created by any members of Work Order Admins</span>
                </label>
                <label class="flex items-center gap-2 cursor-pointer">
                  <input type="radio" name="rpm-auto-mode-mobilemachine" value="specific" onchange="rpmApprovalSaveFromDom()"
                         class="h-4 w-4 text-togen-teal border-gray-300 dark:border-gray-600 focus:ring-togen-teal cursor-pointer" />
                  <span class="text-sm text-gray-800 dark:text-gray-100">WOs created by the following users</span>
                </label>
                <div id="rpm-auto-mobilemachine-specific-wrap" class="hidden ml-6 mt-1">
                  <div class="flex items-center gap-2">
                    <input type="email" id="rpm-auto-mobilemachine-users-input" placeholder="email@example.com" onkeydown="if(event.key==='Enter'){event.preventDefault();rpmApproveAddUser('auto_mobilemachine');}"
                           class="flex-1 px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-togen-teal" />
                    <button type="button" onclick="rpmApproveAddUser('auto_mobilemachine')"
                            class="px-2.5 py-1 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 cursor-pointer">+ Add</button>
                  </div>
                  <div id="rpm-auto-mobilemachine-users-list" class="mt-2 flex flex-wrap gap-1.5"></div>
                </div>
              </div>
            </div>
            <div>
              <h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-2">Preventative Maintenance</h4>
              <div class="space-y-2" data-rpm-auto-type="preventive">
                <label class="flex items-center gap-2 cursor-pointer">
                  <input type="radio" name="rpm-auto-mode-preventive" value="approvers" onchange="rpmApprovalSaveFromDom()"
                         class="h-4 w-4 text-togen-teal border-gray-300 dark:border-gray-600 focus:ring-togen-teal cursor-pointer" />
                  <span class="text-sm text-gray-800 dark:text-gray-100">WOs created by users with approval permissions</span>
                </label>
                <label class="flex items-center gap-2 cursor-pointer">
                  <input type="radio" name="rpm-auto-mode-preventive" value="all_admins" onchange="rpmApprovalSaveFromDom()"
                         class="h-4 w-4 text-togen-teal border-gray-300 dark:border-gray-600 focus:ring-togen-teal cursor-pointer" />
                  <span class="text-sm text-gray-800 dark:text-gray-100">WOs created by any members of Work Order Admins</span>
                </label>
                <label class="flex items-center gap-2 cursor-pointer">
                  <input type="radio" name="rpm-auto-mode-preventive" value="specific" onchange="rpmApprovalSaveFromDom()"
                         class="h-4 w-4 text-togen-teal border-gray-300 dark:border-gray-600 focus:ring-togen-teal cursor-pointer" />
                  <span class="text-sm text-gray-800 dark:text-gray-100">WOs created by the following users</span>
                </label>
                <div id="rpm-auto-preventive-specific-wrap" class="hidden ml-6 mt-1">
                  <div class="flex items-center gap-2">
                    <input type="email" id="rpm-auto-preventive-users-input" placeholder="email@example.com" onkeydown="if(event.key==='Enter'){event.preventDefault();rpmApproveAddUser('auto_preventive');}"
                           class="flex-1 px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-togen-teal" />
                    <button type="button" onclick="rpmApproveAddUser('auto_preventive')"
                            class="px-2.5 py-1 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 cursor-pointer">+ Add</button>
                  </div>
                  <div id="rpm-auto-preventive-users-list" class="mt-2 flex flex-wrap gap-1.5"></div>
                </div>
              </div>
            </div>
          </div>
        </div>

        <!-- Notifications pane (DVI-614) -->
        <div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
          <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-1">Notifications</h3>
          <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Choose which repair-part email updates you receive, by Work Order type. These preferences are personal and also appear under User Settings &rarr; Notifications.</p>
          <div class="overflow-x-auto">
            <table class="w-full text-sm border-collapse">
              <thead>
                <tr class="text-gray-500 dark:text-gray-400">
                  <th class="text-left font-medium py-2 pr-4">Work Order Type</th>
                  <th class="text-center font-medium px-2 py-2">Awaiting Approval</th>
                  <th class="text-center font-medium px-2 py-2">Approved</th>
                  <th class="text-center font-medium px-2 py-2">Ordered</th>
                  <th class="text-center font-medium px-2 py-2">Delivered</th>
                  <th class="text-center font-medium px-2 py-2">Stocked</th>
                </tr>
              </thead>
              <tbody id="rpm-notify-grid"><!-- populated by JS --></tbody>
            </table>
          </div>
          <div class="mt-4 flex items-center gap-3">
            <button id="rpm-notify-save-btn" onclick="rpmNotifSave('rpm-notify-grid')"
                    class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer">
              <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
              </svg>
              Save
            </button>
            <span id="rpm-notify-save-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
        </div>
      </div>
    </div><!-- /tool-repair-parts-manager -->

    <!-- WO Admin per-WO Parts dialog (RPM Phase 3, DVI-604). Placed before the
         shared Add/Edit form below so that form paints on top when both open. -->
    <div id="woradmin-parts-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
      <div onclick="woradminPartsClose()" class="absolute inset-0 bg-black/50"></div>
      <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4 overflow-hidden max-h-[90vh] flex flex-col">
        <div class="flex items-start justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
          <div class="min-w-0">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Parts</h2>
            <p id="woradmin-parts-wo" class="text-xs text-gray-500 dark:text-gray-400 truncate"></p>
          </div>
          <button onclick="woradminPartsClose()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
              <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
            </svg>
          </button>
        </div>
        <div class="px-6 py-4 overflow-y-auto flex-1">
          <p id="woradmin-parts-loading" class="hidden text-sm text-gray-500 dark:text-gray-400">Loading parts…</p>
          <p id="woradmin-parts-error" class="hidden text-sm text-red-600 dark:text-red-400"></p>
          <p id="woradmin-parts-empty" class="hidden text-sm text-gray-500 dark:text-gray-400">No parts associated with this work order yet.</p>
          <ul id="woradmin-parts-list" class="hidden divide-y divide-gray-100 dark:divide-gray-800"></ul>
        </div>
        <div class="flex items-center justify-between gap-2 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
          <button onclick="woradminPartsAdd()"
                  class="px-3 py-2 text-sm font-medium rounded-xl bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">
            <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"/></svg>
            Add Part
          </button>
          <button onclick="woradminPartsClose()"
                  class="px-4 py-2 text-sm font-medium rounded-xl bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors cursor-pointer">
            Close
          </button>
        </div>
      </div>
    </div>

    <!-- Add / Edit Part dialog (DVI-603) -->
    <div id="rpm-part-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
      <div onclick="rpmPartClose()" class="absolute inset-0 bg-black/50"></div>
      <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 overflow-hidden">
        <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
          <h2 id="rpm-part-title" class="text-lg font-semibold text-gray-800 dark:text-gray-100">Add Part</h2>
          <button onclick="rpmPartClose()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
              <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
            </svg>
          </button>
        </div>
        <div class="px-6 py-4 space-y-3">
          <p id="rpm-part-wo" class="text-xs text-gray-500 dark:text-gray-400 truncate"></p>
          <div>
            <label for="rpm-part-number" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Part Number</label>
            <input type="text" id="rpm-part-number" onblur="rpmPartAutofillName()"
                   class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
          </div>
          <div>
            <label for="rpm-part-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Part Name</label>
            <input type="text" id="rpm-part-name"
                   class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
          </div>
          <div class="flex gap-3">
            <div class="flex-1">
              <label for="rpm-part-qty" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Quantity</label>
              <input type="text" id="rpm-part-qty"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
            <div class="flex-1">
              <label for="rpm-part-amount" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Amount</label>
              <input type="text" id="rpm-part-amount"
                     class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            </div>
          </div>
          <!-- Tracking Number (DVI-609): only meaningful once a part is Ordered,
               so it is shown by rpmEditPart/woradminPartsEdit when p.ordered. -->
          <div id="rpm-part-tracking-wrap" class="hidden">
            <label for="rpm-part-tracking" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tracking Number</label>
            <input type="text" id="rpm-part-tracking"
                   class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
          </div>
          <!-- Stocked Item (DVI-626): only shown when adding a part. When checked
               the part is marked Stocked on save and its Ordered button is
               disabled (a stocked item does not need ordering). -->
          <div id="rpm-part-stocked-wrap" class="hidden">
            <label class="flex items-start gap-2 cursor-pointer">
              <input type="checkbox" id="rpm-part-stocked"
                     class="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
              <span>
                <span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Stocked Item</span>
                <span class="block text-xs text-gray-500 dark:text-gray-400">Mark as already in stock. The Ordered button will be disabled for this part.</span>
              </span>
            </label>
          </div>
          <p id="rpm-part-error" class="hidden text-sm text-red-600 dark:text-red-400"></p>
        </div>
        <div class="flex items-center justify-end gap-2 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
          <button onclick="rpmPartClose()"
                  class="px-4 py-2 text-sm font-medium rounded-xl bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors cursor-pointer">
            Cancel
          </button>
          <button id="rpm-part-save-btn" onclick="rpmPartSave()"
                  class="px-5 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer">
            Save
          </button>
        </div>
      </div>
    </div>

    <!-- Mark-Ordered tracking-number popup (DVI-609). Prompts for an optional
         tracking number when a part is marked Ordered. "Save" marks ordered with
         the tracking number; "Skip" marks ordered with no tracking number; the X
         / backdrop dismisses without marking ordered. -->
    <div id="rpm-tracking-overlay" class="hidden fixed inset-0 z-[110] flex items-center justify-center">
      <div onclick="rpmTrackingClose()" class="absolute inset-0 bg-black/50"></div>
      <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 overflow-hidden">
        <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Mark as Ordered</h2>
          <button onclick="rpmTrackingClose()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
              <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
            </svg>
          </button>
        </div>
        <div class="px-6 py-4 space-y-3">
          <p id="rpm-tracking-part" class="text-xs text-gray-500 dark:text-gray-400 truncate"></p>
          <div>
            <label for="rpm-tracking-number" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tracking Number <span class="font-normal text-gray-400">(optional)</span></label>
            <input type="text" id="rpm-tracking-number"
                   class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
          </div>
        </div>
        <div class="flex items-center justify-end gap-2 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
          <button onclick="rpmTrackingConfirm(false)"
                  class="px-4 py-2 text-sm font-medium rounded-xl bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors cursor-pointer">
            Skip
          </button>
          <button id="rpm-tracking-save-btn" onclick="rpmTrackingConfirm(true)"
                  class="px-5 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer">
            Save
          </button>
        </div>
      </div>
    </div>

    <!-- Equipment Manuals search popup for a part (DVI-626). Opened from a part
         row in RPM or the WO Admin parts dialog. Searches the manual attached to
         the Work Order first; "Search All Manuals" widens to every manual. -->
    <div id="rpm-emsearch-overlay" class="hidden fixed inset-0 z-[120] flex items-center justify-center">
      <div onclick="rpmManualSearchClose()" class="absolute inset-0 bg-black/50"></div>
      <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4 overflow-hidden max-h-[90vh] flex flex-col">
        <div class="flex items-start justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
          <div class="min-w-0">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Search Equipment Manuals</h2>
            <p id="rpm-emsearch-scope" class="text-xs text-gray-500 dark:text-gray-400 truncate"></p>
          </div>
          <button onclick="rpmManualSearchClose()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
            <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
              <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
            </svg>
          </button>
        </div>
        <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
          <div class="flex gap-2">
            <input type="text" id="rpm-emsearch-input" placeholder="Search term"
                   onkeydown="if(event.key==='Enter'){event.preventDefault();rpmManualSearchRun('wo');}"
                   class="flex-1 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
            <button onclick="rpmManualSearchRun('wo')"
                    class="px-4 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-lg text-sm transition-colors cursor-pointer">Search</button>
          </div>
        </div>
        <div id="rpm-emsearch-results" class="px-6 py-4 overflow-y-auto flex-1">
          <p class="text-sm text-gray-500 dark:text-gray-400">Enter a search term to find it in the equipment manuals.</p>
        </div>
        <div class="flex items-center justify-between gap-2 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
          <button id="rpm-emsearch-all-btn" onclick="rpmManualSearchRun('all')"
                  class="hidden px-3 py-2 text-sm font-medium rounded-xl bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors cursor-pointer">
            Search All Manuals
          </button>
          <span class="flex-1"></span>
          <button onclick="rpmManualSearchClose()"
                  class="px-4 py-2 text-sm font-medium rounded-xl bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors cursor-pointer">
            Close
          </button>
        </div>
      </div>
    </div>

  </div><!-- /tool-view -->

</div><!-- /container -->

<!-- ===== SIMPLE QR EMAIL POPUP (DVI-270) ===== -->
<div id="sqr-email-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
  <div onclick="sqrEmailClose()" class="absolute inset-0 bg-black/50"></div>
  <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-sm mx-4 overflow-hidden">
    <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Email QR Code</h2>
      <button onclick="sqrEmailClose()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
        <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
        </svg>
      </button>
    </div>
    <div class="px-6 py-4">
      <label for="sqr-email-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Send to</label>
      <input type="email" id="sqr-email-input" value="{{ user_email }}"
             class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal mb-3">
      <div class="flex items-center gap-3">
        <button id="sqr-email-send-btn" onclick="sqrEmailSend()"
                class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
            <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
          </svg>
          Send Email
        </button>
        <span id="sqr-email-status" class="text-sm text-gray-700 dark:text-gray-300"></span>
      </div>
    </div>
  </div>
</div>

<!-- ===== VISION SETTINGS POPUP (DVI-399) ===== -->
<div id="vision-settings-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
  <div onclick="visionSettingsClose()" class="absolute inset-0 bg-black/50"></div>
  <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4 overflow-hidden max-h-[90vh] overflow-y-auto">
    <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Vision Settings</h2>
      <button onclick="visionSettingsClose()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
        <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
        </svg>
      </button>
    </div>
    <div class="px-6 py-4">
      <!-- Tool name + description overrides (DVI-588) -->
      <div class="mb-5 pb-5 border-b border-gray-200 dark:border-gray-700 space-y-3">
        <div>
          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tool name</label>
          <input type="text" id="vision-name-input" placeholder="Vision"
                 class="w-full rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-sm text-gray-800 dark:text-gray-100 px-3 py-2 focus:ring-togen-teal focus:border-togen-teal" />
          <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Shown wherever this tool appears in Togen. Leave blank to use the default.</p>
        </div>
        <div>
          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tool description</label>
          <textarea id="vision-desc-input" rows="2" placeholder="Default description"
                    class="w-full rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-sm text-gray-800 dark:text-gray-100 px-3 py-2 focus:ring-togen-teal focus:border-togen-teal"></textarea>
          <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Shown on the tile on the Tools/home page. Leave blank to use the default.</p>
        </div>
      </div>
      <label class="flex items-center gap-3 cursor-pointer">
        <input type="checkbox" id="vision-mock-checkbox"
               class="h-5 w-5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer" />
        <span class="text-sm font-medium text-gray-800 dark:text-gray-100">Enable mock data</span>
      </label>
      <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Shows sample collector tiles so the Collector tab is demonstrable before any agent exists. The first real agent check-in disables this automatically.</p>
      <div class="flex items-center gap-3 mt-4">
        <button id="vision-settings-save-btn" onclick="visionSettingsSave()"
                class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer">
          Save
        </button>
        <span id="vision-settings-status" class="text-sm text-gray-700 dark:text-gray-300"></span>
      </div>

      <!-- Agent enrollment & management moved to the Vision "Enroll" tab (DVI-437). -->
      <p class="mt-6 pt-5 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400">
        Agent enrollment and management have moved to the <span class="font-medium text-gray-700 dark:text-gray-200">Enroll</span> tab.
      </p>
    </div>
  </div>
</div>

<!-- ===== TOOL SETTINGS POPUP (DVI-588): generic name/description editor ===== -->
<div id="tool-edit-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
  <div onclick="toolEditClose()" class="absolute inset-0 bg-black/50"></div>
  <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4 overflow-hidden max-h-[90vh] overflow-y-auto">
    <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
      <h2 id="tool-edit-title" class="text-lg font-semibold text-gray-800 dark:text-gray-100">Tool Settings</h2>
      <button onclick="toolEditClose()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
        <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
        </svg>
      </button>
    </div>
    <div class="px-6 py-4">
      <input type="hidden" id="tool-edit-id" value="" />
      <div class="space-y-3">
        <div>
          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tool name</label>
          <input type="text" id="tool-edit-name"
                 class="w-full rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-sm text-gray-800 dark:text-gray-100 px-3 py-2 focus:ring-togen-teal focus:border-togen-teal" />
          <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Shown wherever this tool appears in Togen. Leave blank to use the default.</p>
        </div>
        <div>
          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tool description</label>
          <textarea id="tool-edit-desc" rows="3"
                    class="w-full rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-sm text-gray-800 dark:text-gray-100 px-3 py-2 focus:ring-togen-teal focus:border-togen-teal"></textarea>
          <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Shown on the tile on the Tools/home page. Leave blank to use the default.</p>
        </div>
      </div>
      <div class="flex items-center gap-3 mt-4">
        <button id="tool-edit-save-btn" onclick="toolEditSave()"
                class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer">
          Save
        </button>
        <span id="tool-edit-status" class="text-sm text-gray-700 dark:text-gray-300"></span>
      </div>
    </div>
  </div>
</div>

<!-- ===== ABOUT POPUP (DVI-267) ===== -->
<div id="about-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
  <div id="about-backdrop" onclick="closeAbout()" class="absolute inset-0 bg-black/50"></div>
  <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 overflow-hidden">
    <!-- Red X close button in top-left -->
    <button onclick="closeAbout()" class="absolute top-3 left-3 z-10 h-6 w-6 flex items-center justify-center rounded-full bg-red-500 hover:bg-red-600 text-white transition-colors" title="Close">
      <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
        <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
      </svg>
    </button>
    <div class="px-6 pt-10 pb-4 text-center">
      <!-- Togen Bridge Mark logo -->
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="20 25 360 135" height="52" class="mx-auto mb-4" role="img" aria-label="Togen">
        <defs>
          <linearGradient id="aboutGrad" x1="0%" y1="0%" x2="100%" y2="100%">
            <stop offset="0%" style="stop-color:#00B4D8;stop-opacity:1"/>
            <stop offset="100%" style="stop-color:#0077A8;stop-opacity:1"/>
          </linearGradient>
        </defs>
        <rect x="30" y="80" width="14" height="70" rx="2" fill="#4A5568"/>
        <rect x="94" y="80" width="14" height="70" rx="2" fill="#4A5568"/>
        <path d="M 30 85 Q 67 30 108 85" stroke="url(#aboutGrad)" stroke-width="8" fill="none" stroke-linecap="round"/>
        <rect x="28" y="114" width="90" height="8" rx="2" fill="url(#aboutGrad)"/>
        <polygon points="45,130 55,130 60,140 55,150 45,150 50,140" fill="#00B4D8" opacity="0.5"/>
        <polygon points="63,130 73,130 78,140 73,150 63,150 68,140" fill="#00B4D8" opacity="0.7"/>
        <polygon points="81,130 91,130 96,140 91,150 81,150 86,140" fill="#00B4D8" opacity="0.9"/>
        <text x="132" y="112" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="700" font-size="62" class="fill-gray-900 dark:fill-white" letter-spacing="-1">togen</text>
        <circle cx="154" cy="70" r="5" fill="#00B4D8"/>
      </svg>
      <h2 class="text-xl font-bold text-gray-800 dark:text-gray-100 mb-2">Togen Tools Platform</h2>
      <div id="about-content" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">{{ about_html|safe }}</div>
    </div>
    <div class="px-6 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 text-center">
      <p class="text-xs text-gray-400 dark:text-gray-500">Version {{ public_version }}</p>
    </div>
  </div>
</div>

<!-- ===== ABOUT EDIT POPUP (DVI-412) ===== -->
<div id="about-edit-overlay" class="hidden fixed inset-0 z-[110] flex items-center justify-center">
  <div onclick="closeAboutEdit()" class="absolute inset-0 bg-black/50"></div>
  <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-2xl mx-4 overflow-hidden">
    <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Edit About Text</h2>
      <button onclick="closeAboutEdit()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
        <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
        </svg>
      </button>
    </div>
    <div class="px-6 py-4 space-y-3 max-h-[70vh] overflow-y-auto">
      <textarea id="about-edit-body" rows="8" oninput="aboutEditUpdatePreview()" placeholder="About content... (Markdown supported: # heading, **bold**, *italic*, - list, [link](https://...))"
                class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal resize-y"></textarea>
      <p class="text-xs text-gray-400 dark:text-gray-500">Supports Markdown &mdash; headings, <strong>bold</strong>, <em>italic</em>, lists, and links. Same formatting as News posts.</p>
      <div>
        <p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1">Preview</p>
        <div id="about-edit-preview" class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2 bg-gray-50 dark:bg-gray-900/40"></div>
      </div>
      <div class="flex items-center gap-3">
        <button onclick="saveAboutEdit()"
                class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
          </svg>
          Save
        </button>
        <span id="about-edit-status" class="text-sm"></span>
      </div>
    </div>
  </div>
</div>

<!-- ===== TITLE EDIT POPUP (DVI-555) ===== -->
<div id="title-edit-overlay" class="hidden fixed inset-0 z-[110] flex items-center justify-center">
  <div onclick="closeTitleEdit()" class="absolute inset-0 bg-black/50"></div>
  <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4 overflow-hidden">
    <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Edit Title</h2>
      <button onclick="closeTitleEdit()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
        <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
        </svg>
      </button>
    </div>
    <div class="px-6 py-4 space-y-4">
      <div>
        <label for="title-edit-title" class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1">Title</label>
        <input type="text" id="title-edit-title" maxlength="60"
               class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
      </div>
      <div>
        <label for="title-edit-tagline" class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1">Tagline</label>
        <input type="text" id="title-edit-tagline" maxlength="80"
               class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
      </div>
      <div class="flex items-center gap-3">
        <button onclick="saveTitleEdit()"
                class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2">
          <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
          </svg>
          Save
        </button>
        <span id="title-edit-status" class="text-sm"></span>
      </div>
    </div>
  </div>
</div>

<!-- ===== SUBSCRIBE POPUP (DVI-267) ===== -->
<div id="subscribe-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
  <div onclick="closeSubscribe()" class="absolute inset-0 bg-black/50"></div>
  <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-sm mx-4 overflow-hidden">
    <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Subscribe to News</h2>
      <button onclick="closeSubscribe()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
        <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
        </svg>
      </button>
    </div>
    <div class="px-6 py-4">
      <p class="text-sm text-gray-600 dark:text-gray-400 mb-3">Receive email notifications when new posts are published.</p>
      <input type="email" id="subscribe-email" value="{{ user_email }}"
             class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal mb-3">
      <div class="flex items-center gap-3">
        <button onclick="newsSubmitSubscribe()"
                class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer">
          Submit
        </button>
        <span id="subscribe-status" class="text-sm"></span>
      </div>
    </div>
  </div>
</div>

<!-- ===== SCHEDULE PUBLISH POPUP (DVI-391) ===== -->
<div id="news-schedule-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
  <div onclick="newsCloseSchedule()" class="absolute inset-0 bg-black/50"></div>
  <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-sm mx-4 overflow-hidden">
    <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Schedule Publish</h2>
      <button onclick="newsCloseSchedule()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
        <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
        </svg>
      </button>
    </div>
    <div class="px-6 py-4 space-y-3">
      <p class="text-sm text-gray-600 dark:text-gray-400">Choose when this post should publish. Subscribers are emailed at the scheduled time.</p>
      <div>
        <label for="news-sched-date" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Date</label>
        <input type="date" id="news-sched-date" onchange="newsUpdateScheduleHint()"
               class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
      </div>
      <div>
        <label for="news-sched-time" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Time</label>
        <input type="time" id="news-sched-time" onchange="newsUpdateScheduleHint()"
               class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
      </div>
      <div>
        <label for="news-sched-tz" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Timezone</label>
        <select id="news-sched-tz" onchange="newsUpdateScheduleHint()"
                class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
          <option value="America/New_York">Eastern (ET)</option>
          <option value="America/Chicago">Central (CT)</option>
          <option value="America/Denver">Mountain (MT)</option>
          <option value="America/Phoenix">Arizona (MST)</option>
          <option value="America/Los_Angeles">Pacific (PT)</option>
          <option value="America/Anchorage">Alaska (AKT)</option>
          <option value="Pacific/Honolulu">Hawaii (HST)</option>
          <option value="UTC">UTC</option>
        </select>
      </div>
      <p id="news-sched-hint" class="text-xs text-gray-500 dark:text-gray-400"></p>
      <div class="flex items-center gap-3 pt-1">
        <button onclick="newsSubmitSchedule()"
                class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer">
          Schedule Post
        </button>
        <span id="news-sched-status" class="text-sm"></span>
      </div>
    </div>
  </div>
</div>

<!-- ===== EDIT SCHEDULED POST POPUP (DVI-579) ===== -->
<div id="news-edit-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
  <div onclick="newsCloseEdit()" class="absolute inset-0 bg-black/50"></div>
  <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4 overflow-hidden">
    <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Edit Scheduled Post</h2>
      <button onclick="newsCloseEdit()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
        <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
        </svg>
      </button>
    </div>
    <div class="px-6 py-4 space-y-3 max-h-[80vh] overflow-y-auto">
      <p class="text-sm text-gray-600 dark:text-gray-400">Update this post before it publishes. Subscribers are emailed at the scheduled time.</p>
      <div>
        <label for="news-edit-title" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Title</label>
        <input type="text" id="news-edit-title" placeholder="Post title..."
               class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
      </div>
      <div>
        <label for="news-edit-body" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Content</label>
        <textarea id="news-edit-body" rows="5" placeholder="Post content... (Markdown supported)"
                  class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal"></textarea>
      </div>
      <div class="grid grid-cols-2 gap-3">
        <div>
          <label for="news-edit-date" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Date</label>
          <input type="date" id="news-edit-date" onchange="newsUpdateEditHint()"
                 class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
        </div>
        <div>
          <label for="news-edit-time" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Time</label>
          <input type="time" id="news-edit-time" onchange="newsUpdateEditHint()"
                 class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
        </div>
      </div>
      <div>
        <label for="news-edit-tz" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Timezone</label>
        <select id="news-edit-tz" onchange="newsUpdateEditHint()"
                class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
          <option value="America/New_York">Eastern (ET)</option>
          <option value="America/Chicago">Central (CT)</option>
          <option value="America/Denver">Mountain (MT)</option>
          <option value="America/Phoenix">Arizona (MST)</option>
          <option value="America/Los_Angeles">Pacific (PT)</option>
          <option value="America/Anchorage">Alaska (AKT)</option>
          <option value="Pacific/Honolulu">Hawaii (HST)</option>
          <option value="UTC">UTC</option>
        </select>
      </div>
      <p id="news-edit-hint" class="text-xs text-gray-500 dark:text-gray-400"></p>
      <div class="flex items-center gap-3 pt-1">
        <button onclick="newsSubmitEdit()"
                class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer">
          Save Changes
        </button>
        <span id="news-edit-status" class="text-sm"></span>
      </div>
    </div>
  </div>
</div>

<!-- ===== ADD SUBSCRIBER POPUP (DVI-278) ===== -->
<div id="addsub-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
  <div onclick="togenadminCloseAddSub()" class="absolute inset-0 bg-black/50"></div>
  <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-sm mx-4 overflow-hidden">
    <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Add Subscriber</h2>
      <button onclick="togenadminCloseAddSub()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
        <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
        </svg>
      </button>
    </div>
    <div class="px-6 py-4">
      <p class="text-sm text-gray-600 dark:text-gray-400 mb-3">Add an email address to the news subscribers list.</p>
      <input type="email" id="addsub-email" placeholder="email@example.com"
             class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal mb-3">
      <div class="flex items-center gap-3">
        <button onclick="togenadminSubmitAddSub()"
                class="px-5 py-2.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors cursor-pointer">
          Add
        </button>
        <span id="addsub-status" class="text-sm"></span>
      </div>
    </div>
  </div>
</div>

<!-- ===== KEYBOARD SHORTCUTS OVERLAY ===== -->
<div id="kb-shortcuts-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
  <div id="kb-shortcuts-backdrop" class="absolute inset-0 bg-black/50"></div>
  <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 overflow-hidden">
    <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
      <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Keyboard Shortcuts</h2>
      <button onclick="closeKeyboardShortcuts()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
        <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
        </svg>
      </button>
    </div>
    <div class="px-6 py-4">
      <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">Available on the Tools / Home page</p>
      <div class="space-y-2">
        <div class="flex items-center justify-between py-1.5">
          <span class="text-sm text-gray-700 dark:text-gray-300">Open Keyboard Shortcuts</span>
          <kbd class="inline-flex items-center px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-xs font-mono text-gray-700 dark:text-gray-300">?</kbd>
        </div>
        {% if is_takeoff_workflow or is_togen_admin %}
        <div class="flex items-center justify-between py-1.5">
          <span class="text-sm text-gray-700 dark:text-gray-300">File Browser</span>
          <kbd class="inline-flex items-center px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-xs font-mono text-gray-700 dark:text-gray-300">B</kbd>
        </div>
        <div class="flex items-center justify-between py-1.5">
          <span class="text-sm text-gray-700 dark:text-gray-300">Project Builder</span>
          <kbd class="inline-flex items-center px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-xs font-mono text-gray-700 dark:text-gray-300">P</kbd>
        </div>
        <div class="flex items-center justify-between py-1.5">
          <span class="text-sm text-gray-700 dark:text-gray-300">Take-off Bridge</span>
          <kbd class="inline-flex items-center px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-xs font-mono text-gray-700 dark:text-gray-300">T</kbd>
        </div>
        <div class="flex items-center justify-between py-1.5">
          <span class="text-sm text-gray-700 dark:text-gray-300">Sync Admin</span>
          <kbd class="inline-flex items-center px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-xs font-mono text-gray-700 dark:text-gray-300">S</kbd>
        </div>
        {% endif %}
        {% if is_prod_run or is_togen_admin %}
        <div class="flex items-center justify-between py-1.5">
          <span class="text-sm text-gray-700 dark:text-gray-300">Production Run</span>
          <kbd class="inline-flex items-center px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-xs font-mono text-gray-700 dark:text-gray-300">R</kbd>
        </div>
        {% endif %}
        <div class="flex items-center justify-between py-1.5">
          <span class="text-sm text-gray-700 dark:text-gray-300">Work Order Request</span>
          <kbd class="inline-flex items-center px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-xs font-mono text-gray-700 dark:text-gray-300">W</kbd>
        </div>
        <div class="flex items-center justify-between py-1.5">
          <span class="text-sm text-gray-700 dark:text-gray-300">Simple QR Creator</span>
          <kbd class="inline-flex items-center px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-xs font-mono text-gray-700 dark:text-gray-300">Q</kbd>
        </div>
        <div class="flex items-center justify-between py-1.5 border-t border-gray-100 dark:border-gray-700 mt-2 pt-3">
          <span class="text-sm text-gray-700 dark:text-gray-300">Reload page</span>
          <kbd class="inline-flex items-center px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-xs font-mono text-gray-700 dark:text-gray-300">Z</kbd>
        </div>
      </div>
    </div>
    <div class="px-6 py-3 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
      <p class="text-xs text-gray-400 dark:text-gray-500 text-center">Press <kbd class="px-1 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 font-mono">Esc</kbd> to close</p>
    </div>
  </div>
</div>

<!-- ===== WORKFLOW PICKER OVERLAY (DVI-291) ===== -->
<div id="workflow-picker-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
  <div onclick="closeWorkflowPicker()" class="absolute inset-0 bg-black/50"></div>
  <div class="relative bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 overflow-hidden">
    <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
      <div>
        <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Open Project</h2>
        <p id="workflow-picker-project" class="text-sm text-gray-500 dark:text-gray-400 mt-0.5"></p>
      </div>
      <button onclick="closeWorkflowPicker()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
        <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
          <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
        </svg>
      </button>
    </div>
    <div class="px-6 py-5">
      <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">Choose a workflow to open this project in:</p>
      <div class="grid grid-cols-2 gap-4">
        <button onclick="workflowPickerOpen('project-builder')"
                class="group flex flex-col items-center gap-3 p-5 bg-gray-50 dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-md transition-all duration-200 cursor-pointer">
          <div class="text-gray-400 dark:text-gray-500 group-hover:text-togen-teal transition-colors">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-8 w-8">
              <path d="M15.91 11.672a.375.375 0 010 .656l-5.603 3.113a.375.375 0 01-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112z" fill="currentColor" stroke="none" />
              <circle cx="12" cy="12" r="10" />
            </svg>
          </div>
          <span class="text-sm font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">Project Builder</span>
        </button>
        <button onclick="workflowPickerOpen('takeoff-bridge')"
                class="group flex flex-col items-center gap-3 p-5 bg-gray-50 dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-md transition-all duration-200 cursor-pointer">
          <div class="text-gray-400 dark:text-gray-500 group-hover:text-togen-teal transition-colors">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-8 w-8">
              <rect x="2" y="1" width="10" height="13" rx="1" />
              <line x1="4" y1="4" x2="10" y2="4" />
              <line x1="4" y1="6.5" x2="10" y2="6.5" />
              <line x1="4" y1="9" x2="8" y2="9" />
              <rect x="4" y="16" width="3" height="7" rx="0.5" />
              <rect x="17" y="16" width="3" height="7" rx="0.5" />
              <path d="M5.5 16 Q12 8 18.5 16" stroke-width="2" />
              <line x1="3" y1="23" x2="21" y2="23" stroke-width="1.5" />
            </svg>
          </div>
          <span class="text-sm font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">Take-off Bridge</span>
        </button>
      </div>
    </div>
  </div>
</div>

<!-- Tailwind safelist: ensures dynamic JS-set class names are compiled by CDN -->
<div class="hidden bg-togen-teal/10 dark:bg-togen-teal/20 border-togen-teal/30 dark:border-togen-teal/40 text-togen-dark dark:text-togen-teal outline-togen-teal hover:bg-togen-teal/5 dark:hover:bg-togen-teal/10 border-yellow-500 text-yellow-700 dark:text-yellow-400 hover:bg-yellow-50 dark:hover:bg-yellow-900/30 bg-yellow-50 dark:bg-yellow-900/20 border-yellow-300 dark:border-yellow-700 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-900/20 border-green-300 dark:border-green-700 text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/40 hover:bg-yellow-100 dark:hover:bg-yellow-900/40 opacity-50 cursor-not-allowed max-w-7xl border-l-2 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 border-togen-teal text-togen-teal"></div>

<script>
var _isWorAdmin = {{ 'true' if is_wor_admin else 'false' }};
var _isToGenAdmin = {{ 'true' if is_togen_admin else 'false' }};
var _isTakeoffWorkflow = {{ 'true' if is_takeoff_workflow else 'false' }};
var _isProdRun = {{ 'true' if is_prod_run else 'false' }};
var _isMts = {{ 'true' if is_togen_mts else 'false' }};
var _isScada = {{ 'true' if is_togen_scada else 'false' }};
var _isTogenVision = {{ 'true' if is_togen_vision else 'false' }};
var _printCanUse = {{ 'true' if print_can_use else 'false' }};
// DVI-764: hide all Print buttons when the Print system is disabled for this
// user (a CSS body class also covers dynamically-rendered buttons).
if (document.body && !_printCanUse) document.body.classList.add('togen-no-print');
var _toolVisibility = {{ tool_visibility | tojson }};
var _toolbarVisibility = {{ toolbar_visibility | tojson }};
var _toolbarLabels = {'search': 'Search', 'assistant': 'Assistant', 'calendar': 'Calendar'};
var _toolbarOrder = ['search', 'assistant', 'calendar'];
var _toolOrder = {{ tool_order | tojson }};
var _toolOverrides = {{ tool_overrides | tojson }};
var _worTypeVisibility = {{ wor_type_visibility | tojson }};
var _worTypeOrder = {{ wor_type_order | tojson }};
var _worTypeLabels = {'maintenance': 'Maintenance', 'mobilemachine': 'Mobile Machine', 'safety': 'Safety', 'preventive': 'Preventative Maintenance', 'ithelpdesk': 'IT Help Desk'};
var _sharedUrlPrefix = '{{ shared_url_prefix }}';
var _userSettings = {{ user_settings | tojson }};
var _worPrimaryEnforced = {{ wor_primary_enforced | tojson }};
window._brandingSettings = {{ branding_settings | tojson }};

// ===== TOOLS REGISTRY — add new tools here; order field controls tile position =====
var TOOLS = [
  {
    id: 'work-order',
    title: 'Work Order Request',
    description: 'Submit maintenance, mobile machine, or safety work order requests and generate completed PDFs.',
    order: 3,
    icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
          '<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z" />' +
          '</svg>'
  },
  {
    id: 'simple-qr',
    title: 'Simple QR Creator',
    description: 'Generate a QR code from any URL with options to copy or email.',
    order: 8,
    icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
          '<rect x="3" y="3" width="7" height="7" rx="1" />' +
          '<rect x="14" y="3" width="7" height="7" rx="1" />' +
          '<rect x="3" y="14" width="7" height="7" rx="1" />' +
          '<rect x="15" y="15" width="2" height="2" fill="currentColor" stroke="none" />' +
          '<rect x="19" y="15" width="2" height="2" fill="currentColor" stroke="none" />' +
          '<rect x="15" y="19" width="2" height="2" fill="currentColor" stroke="none" />' +
          '<rect x="19" y="19" width="2" height="2" fill="currentColor" stroke="none" />' +
          '<rect x="17" y="17" width="2" height="2" fill="currentColor" stroke="none" />' +
          '</svg>'
  },
  {
    id: 'equipment-manuals',
    title: 'Equipment Manuals',
    description: 'Browse repair and operator manuals uploaded directly or attached to Preventative Maintenance work orders.',
    order: 4,
    icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
          '<path d="M4 4.5A2.5 2.5 0 016.5 2H20v15H6.5A2.5 2.5 0 004 19.5z" />' +
          '<path d="M4 19.5A2.5 2.5 0 016.5 17H20v5H6.5A2.5 2.5 0 014 19.5z" />' +
          '<line x1="8" y1="6" x2="16" y2="6" />' +
          '<line x1="8" y1="9" x2="14" y2="9" />' +
          '</svg>'
  },
  {
    id: 'calendar',
    title: 'Calendar',
    description: 'View your Microsoft 365 calendar by month, week, or day.',
    order: 6,
    icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
          '<rect x="3" y="4" width="18" height="18" rx="2" />' +
          '<line x1="16" y1="2" x2="16" y2="6" />' +
          '<line x1="8" y1="2" x2="8" y2="6" />' +
          '<line x1="3" y1="10" x2="21" y2="10" />' +
          '</svg>'
  },
  {
    id: 'ocr-tool',
    title: 'OCR',
    description: 'Upload a scanned PDF and get back a text-selectable, searchable PDF.',
    order: 10,
    icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
          '<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />' +
          '<polyline points="14 2 14 8 20 8" />' +
          '<line x1="8" y1="13" x2="16" y2="13" />' +
          '<line x1="8" y1="17" x2="13" y2="17" />' +
          '</svg>'
  },
  {
    id: 'email',
    title: 'Email',
    description: 'View your mailbox and work with attachments — save, share, or send them into a Togen workflow.',
    order: 13,
    icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
          '<rect x="3" y="5" width="18" height="14" rx="2" />' +
          '<path d="M3 7l9 6 9-6" />' +
          '</svg>'
  },
  {
    id: 'bots',
    title: 'Bots',
    description: 'Run automated assistants that handle recurring tasks for you, like the Email Bot.',
    order: 14,
    icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
          '<rect x="4" y="8" width="16" height="11" rx="2" />' +
          '<path d="M12 4v4" />' +
          '<circle cx="12" cy="3" r="1" />' +
          '<circle cx="9" cy="13" r="1" fill="currentColor" stroke="none" />' +
          '<circle cx="15" cy="13" r="1" fill="currentColor" stroke="none" />' +
          '<path d="M9 16.5h6" />' +
          '<path d="M2 12v3" />' +
          '<path d="M22 12v3" />' +
          '</svg>'
  }
];

// Take-off Workflow tools: require Togen Take-off Workflow or Togen Admin group
if (_isTakeoffWorkflow || _isToGenAdmin) {
  TOOLS.push(
    {
      id: 'project-builder',
      title: 'Project Builder',
      description: 'Create a new project or edit existing project, crafting CSV file and Togen metadata for use in other Workflows.',
      order: 0,
      icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
            '<path d="M15.91 11.672a.375.375 0 010 .656l-5.603 3.113a.375.375 0 01-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112z" fill="currentColor" stroke="none" />' +
            '<circle cx="12" cy="12" r="10" />' +
            '</svg>'
    },
    {
      id: 'takeoff-bridge',
      title: 'Take-off Bridge',
      description: 'Generate QR carrier sheets from shop drawings, submittals, and CSV take-off data.',
      order: 1,
      icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
            '<rect x="2" y="1" width="10" height="13" rx="1" />' +
            '<line x1="4" y1="4" x2="10" y2="4" />' +
            '<line x1="4" y1="6.5" x2="10" y2="6.5" />' +
            '<line x1="4" y1="9" x2="8" y2="9" />' +
            '<rect x="4" y="16" width="3" height="7" rx="0.5" />' +
            '<rect x="17" y="16" width="3" height="7" rx="0.5" />' +
            '<path d="M5.5 16 Q12 8 18.5 16" stroke-width="2" />' +
            '<line x1="3" y1="23" x2="21" y2="23" stroke-width="1.5" />' +
            '</svg>'
    },
    {
      id: 'sync-admin',
      title: 'Sync Admin',
      description: 'Monitor rclone sync status and run resync when needed.',
      order: 5,
      icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
            '<path d="M4 4v5h5" />' +
            '<path d="M20 20v-5h-5" />' +
            '<path d="M20.49 9A9 9 0 005.64 5.64L4 4" />' +
            '<path d="M3.51 15A9 9 0 0018.36 18.36L20 20" />' +
            '</svg>'
    },
    {
      id: 'single-carrier-sheet',
      title: 'Single Carrier Sheet',
      description: 'Generate or Reprint an existing Carrier Sheet from Shop and Submittal drawings, or from a Project.',
      order: 9,
      icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
            '<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />' +
            '<polyline points="14 2 14 8 20 8" />' +
            '</svg>'
    }
  );
}

// File Browser: available to everyone with Togen access. Tabs are gated by
// group membership (Projects = Take-off, Vision = Vision); users without
// either membership see only the Attachments tab.
TOOLS.push({
  id: 'file-browser',
  title: 'File Browser',
  description: 'Browse, upload, download, rename and delete files in the shared directory.',
  order: 7,
  icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
        '<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />' +
        '</svg>'
});

// Production Run tool: require Togen Production Run or Togen Admin group
if (_isProdRun || _isToGenAdmin) {
  TOOLS.push({
    id: 'prod-run-merge',
    title: 'Production Run',
    description: 'Merge drawing and label PDFs from a production run from Excel Spreadsheet or CSV into combined documents.',
    order: 2,
    icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
          '<rect x="2" y="2" width="8" height="10" rx="1" />' +
          '<line x1="4" y1="5" x2="8" y2="5" />' +
          '<line x1="4" y1="7" x2="8" y2="7" />' +
          '<line x1="4" y1="9" x2="7" y2="9" />' +
          '<rect x="14" y="2" width="8" height="10" rx="1" />' +
          '<line x1="16" y1="5" x2="20" y2="5" />' +
          '<line x1="16" y1="7" x2="20" y2="7" />' +
          '<line x1="16" y1="9" x2="19" y2="9" />' +
          '<path d="M6 12 L6 15 L12 18" />' +
          '<path d="M18 12 L18 15 L12 18" />' +
          '<rect x="8" y="16" width="8" height="6" rx="1" />' +
          '<line x1="10" y1="18.5" x2="14" y2="18.5" />' +
          '<line x1="10" y1="20" x2="13" y2="20" />' +
          '</svg>'
  });
}

// MTS Label Creator: require Togen MTS or Togen Admin group
if (_isMts || _isToGenAdmin) {
  TOOLS.push({
    id: 'mts-label',
    title: 'MTS Label Creator',
    description: 'Create Made To Stock product labels.',
    order: 10,
    icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
          '<rect x="3" y="3" width="18" height="18" rx="2" />' +
          '<line x1="7" y1="8" x2="17" y2="8" />' +
          '<line x1="7" y1="12" x2="17" y2="12" />' +
          '<line x1="7" y1="16" x2="13" y2="16" />' +
          '</svg>'
  });
}

// SCADA View: require Togen SCADA or Togen Admin group
if (_isScada || _isToGenAdmin) {
  TOOLS.push({
    id: 'scada-view',
    title: 'SCADA View',
    description: 'View SCADA temperature sensor logs and monitoring data.',
    order: 11,
    icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
          '<rect x="2" y="3" width="20" height="14" rx="2" />' +
          '<path d="M6 10l3-3 2 2 3-3 4 4" />' +
          '<line x1="8" y1="21" x2="16" y2="21" />' +
          '<line x1="12" y1="17" x2="12" y2="21" />' +
          '</svg>'
  });
}

// Vision: require Togen Vision or Togen Admin group
if (_isToGenAdmin || _isTogenVision) {
  TOOLS.push({
    id: 'vision',
    title: 'Vision',
    description: 'Monitor Vision capture agents: machine check-ins, online status, and agent versions.',
    order: 12,
    icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
          '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />' +
          '<circle cx="12" cy="12" r="3" />' +
          '</svg>'
  });
}

if (_isWorAdmin) {
  TOOLS.push({
    id: 'wor-admin',
    title: 'Work Order Admin',
    description: 'Manage and track the status of all work order requests.',
    order: 4,
    icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
          '<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z" />' +
          '<rect x="14" y="14" width="9" height="7" rx="1.5" fill="currentColor" stroke="none" opacity="0.85" />' +
          '<path d="M16.5 14v-1.5a2 2 0 014 0V14" fill="none" />' +
          '</svg>'
  });
}

if (_isToGenAdmin) {
  TOOLS.push({
    id: 'togen-admin',
    title: 'Togen Admin',
    description: 'View Togen status, manage updates, and administer global Togen Tools Platform settings.',
    order: 6,
    icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
          '<path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z" />' +
          '<circle cx="12" cy="12" r="3" />' +
          '</svg>'
  });
  TOOLS.push({
    id: 'auditor',
    title: 'Auditor',
    description: 'Review Assistant tool-use history, authorization decisions, and failure modes.',
    order: 15,
    icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
          '<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />' +
          '<rect x="9" y="3" width="6" height="4" rx="1" />' +
          '<path d="M9 12h6" />' +
          '<path d="M9 16h4" />' +
          '<circle cx="17" cy="17" r="3" />' +
          '<path d="M19.5 19.5l1.5 1.5" />' +
          '</svg>'
  });
}

// Repair Parts Manager (DVI-596). Togen Admins always see it; Work Order Admins
// see it only when an admin enables the tool (the tool-visibility filter in
// _visibleTools() hides it for non-admins while repair-parts-manager is off).
if (_isToGenAdmin || _isWorAdmin) {
  TOOLS.push({
    id: 'repair-parts-manager',
    title: 'Repair Parts Manager',
    description: 'Track, approve, and order repair parts attached to Mobile Machine and Preventative Maintenance work orders.',
    order: 16,
    icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
          '<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z" />' +
          '<path d="M7 17l-3 3" />' +
          '<circle cx="6" cy="18" r="0.6" fill="currentColor" />' +
          '</svg>'
  });
}

// ===== ADMIN TOOL NAME/DESCRIPTION OVERRIDES (DVI-588) =====
// Admins can rename a tool (everywhere it shows) and rewrite its tile
// description from Togen Admin > Tools. _toolOverrides holds {tool_id:{name,
// description}}; we snapshot the built-in defaults once so clearing an override
// restores the original rather than the last-applied value.
var _toolDefaultMeta = null;
function _applyToolOverrides() {
  if (!_toolDefaultMeta) {
    _toolDefaultMeta = { titles: {}, descriptions: {}, labels: {} };
    TOOLS.forEach(function(t){
      _toolDefaultMeta.titles[t.id] = t.title;
      _toolDefaultMeta.descriptions[t.id] = t.description;
    });
    if (typeof _toolVisibilityLabels !== 'undefined') {
      Object.keys(_toolVisibilityLabels).forEach(function(k){
        _toolDefaultMeta.labels[k] = _toolVisibilityLabels[k];
      });
    }
  }
  TOOLS.forEach(function(t){
    var ov = _toolOverrides[t.id] || {};
    t.title = (ov.name && ov.name.length) ? ov.name : _toolDefaultMeta.titles[t.id];
    t.description = (ov.description && ov.description.length) ? ov.description : _toolDefaultMeta.descriptions[t.id];
  });
  if (typeof _toolVisibilityLabels !== 'undefined') {
    Object.keys(_toolDefaultMeta.labels).forEach(function(tid){
      var ov = _toolOverrides[tid] || {};
      _toolVisibilityLabels[tid] = (ov.name && ov.name.length) ? ov.name : _toolDefaultMeta.labels[tid];
    });
  }
}

// ===== HOME / TOOL NAVIGATION =====
var _activeTool = null;

// ===== HOME TILE GRID LAYOUT (DVI-512) =====
// Effective columns: per-user override → company default → 3 (D1).
function _effectiveTileColumns() {
  var u = _userSettings && _userSettings.tile_columns;
  if (u === 'unlimited') return 'unlimited';
  if (typeof u === 'number' && u >= 3 && u <= 9) return u;
  var c = (window._brandingSettings || {}).tile_columns;
  if (c === 'unlimited') return 'unlimited';
  if (typeof c === 'number' && c >= 3 && c <= 9) return c;
  return 3;
}

function _effectiveTileView() {
  return (_userSettings && _userSettings.tile_view === 'list') ? 'list' : 'grid';
}

// Apply the configured column count only at lg+ (>=1024px) via inline style;
// mobile/tablet keep the Tailwind 1/2-column collapse (D2/step 4).
function _applyTileGridColumns() {
  var grid = document.getElementById('tile-grid');
  if (!grid) return;
  // With a separator the grid is a flex column of `.tile-grid-section` grids
  // (DVI-524); apply the column template to each section instead of the wrapper.
  var sections = grid.querySelectorAll('.tile-grid-section');
  var targets = sections.length ? sections : [grid];
  var narrow = window.innerWidth < 1024;
  var cols = _effectiveTileColumns();
  Array.prototype.forEach.call(targets, function(el) {
    if (narrow) { el.style.gridTemplateColumns = ''; return; }
    el.style.gridTemplateColumns = (cols === 'unlimited')
      ? 'repeat(auto-fill, minmax(260px, 1fr))'
      : 'repeat(' + cols + ', minmax(0, 1fr))';
  });
}

// Effective side padding (%): per-user override → company default → 0 (DVI-515).
function _effectiveTilePadding() {
  var u = _userSettings && _userSettings.tile_side_padding;
  if (typeof u === 'number' && u >= 0 && u <= 40) return u;
  var c = (window._brandingSettings || {}).tile_side_padding;
  if (typeof c === 'number' && c >= 0 && c <= 40) return c;
  return 0;
}

// Apply equal left/right padding (as % of available width) to the home grid in
// both grid and list modes. Only at lg+ (>=1024px); mobile/tablet stay full-width.
function _applyTileSidePadding() {
  var grid = document.getElementById('tile-grid');
  if (!grid) return;
  var pct = (window.innerWidth < 1024) ? 0 : _effectiveTilePadding();
  grid.style.paddingLeft = pct ? pct + '%' : '';
  grid.style.paddingRight = pct ? pct + '%' : '';
}

// Tools sorted (user order → admin order) and filtered by admin + user visibility.
function _visibleSortedTools() {
  var effectiveOrder = (_userSettings.tool_order && _userSettings.tool_order.length) ? _userSettings.tool_order : _toolOrder;
  return TOOLS.slice().sort(function(a,b){
    var ai = effectiveOrder.indexOf(a.id), bi = effectiveOrder.indexOf(b.id);
    if (ai === -1) ai = 999;
    if (bi === -1) bi = 999;
    return ai - bi;
  }).filter(function(tool) {
    if (!_isToGenAdmin && _toolVisibility.hasOwnProperty(tool.id) && !_toolVisibility[tool.id]) return false;
    var userVis = _userSettings.tool_visibility;
    if (userVis && userVis.hasOwnProperty(tool.id) && !userVis[tool.id]) return false;
    return true;
  });
}

// Toggle button reflects the mode it switches TO.
function _updateTileViewToggle() {
  var icon = document.getElementById('tile-view-toggle-icon');
  var label = document.getElementById('tile-view-toggle-label');
  if (!icon || !label) return;
  var gridIcon = '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>';
  var listIcon = '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>';
  if (_effectiveTileView() === 'list') {
    icon.innerHTML = gridIcon; label.textContent = 'Grid view';
  } else {
    icon.innerHTML = listIcon; label.textContent = 'List view';
  }
}

function toggleTileView() {
  var newView = (_effectiveTileView() === 'list') ? 'grid' : 'list';
  if (!_userSettings) _userSettings = {};
  _userSettings.tile_view = newView;
  renderTileGrid();
  fetch('/user-settings', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({tile_view: newView})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){ if (d && d.settings) _userSettings = d.settings; })
    .catch(function(){});
}

// Compact secondary line + optional badge + icon color for a list row, reusing
// each tool's live-tile indicators (DVI-512, step 5).
function _tileListSecondary(tool) {
  var iconColor = 'text-gray-400 dark:text-gray-500';
  if (tool.id === 'scada-view' && _scadaAllReadings.length) {
    var enabled = _scadaAllReadings.filter(function(r){ return _scadaIsEnabled(r.label) && !_scadaReportingDisabled(r.label); });
    var sensorCount = enabled.filter(function(r){ return _scadaIsSensorLabel(r.label); }).length;
    var oorCount = enabled.filter(function(r){ return _scadaIsSensorLabel(r.label) && _scadaIsOutOfRange(r.label, r.value); }).length;
    if (sensorCount === 0) return { iconColor: iconColor, line: esc(tool.description), badge: '' };
    var sLine = (oorCount > 0)
      ? oorCount + ' of ' + sensorCount + ' sensor' + (sensorCount !== 1 ? 's' : '') + ' out of range'
      : sensorCount + ' sensor' + (sensorCount !== 1 ? 's' : '') + ' in range';
    var sBadge = (oorCount > 0)
      ? '<span class="flex-shrink-0 inline-flex items-center rounded-full bg-red-100 dark:bg-red-900/40 px-2 py-0.5 text-xs font-semibold text-red-700 dark:text-red-300">' + oorCount + ' alert' + (oorCount !== 1 ? 's' : '') + '</span>'
      : '<span class="flex-shrink-0 inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/40 px-2 py-0.5 text-xs font-semibold text-green-700 dark:text-green-300">All OK</span>';
    return { iconColor: iconColor, line: sLine, badge: sBadge };
  }
  if (tool.id === 'sync-admin' && _syncTileStatus && _syncTileStatus.state) {
    var st = _syncTileStatus;
    var colorMap = { ok: 'text-green-500', issues: 'text-yellow-500', resync: 'text-red-500', unknown: 'text-gray-400 dark:text-gray-500' };
    var wordMap = { ok: 'OK', issues: 'Issues detected', resync: 'Resync required', unknown: 'Status unknown' };
    var ago = _syncFmtAgo(st.lastActivity);
    var line = (st.state === 'resync' || st.state === 'unknown')
      ? (wordMap[st.state] || wordMap.unknown)
      : (ago ? 'Last sync ' + ago + ' · ' : '') + (wordMap[st.state] || wordMap.unknown);
    return { iconColor: colorMap[st.state] || colorMap.unknown, line: esc(line), badge: '' };
  }
  if (tool.id === 'vision' && _visionTileData) {
    var vd = _visionTileData;
    return { iconColor: iconColor, line: vd.online + ' online · ' + vd.offline + ' offline', badge: vd.mock ? '<span class="flex-shrink-0 inline-flex items-center rounded-full bg-yellow-100 dark:bg-yellow-900/40 px-2 py-0.5 text-xs font-semibold text-yellow-700 dark:text-yellow-300">Mock</span>' : '' };
  }
  if (tool.id === 'wor-admin' && _woradminActivity) {
    var sc = (_woradminActivity.status_counts || {});
    var due = (_woradminActivity.recurring_due_soon || []).length;
    return { iconColor: iconColor, line: (sc.new || 0) + ' new · ' + (sc.in_progress || 0) + ' in progress', badge: due > 0 ? '<span class="flex-shrink-0 inline-flex items-center rounded-full bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:text-amber-300">' + due + ' due</span>' : '' };
  }
  if (tool.id === 'email') {
    var unread = (_emailTileData && _emailTileData.available) ? (_emailTileData.count || 0) : 0;
    var atch = (_emailTileData && _emailTileData.available) ? (_emailTileData.attachments || 0) : 0;
    var wf = (_emailTileData && _emailTileData.available) ? (_emailTileData.workflow || 0) : 0;
    var eLine = esc(tool.description);
    if (unread > 0) {
      eLine = unread + ' unread message' + (unread !== 1 ? 's' : '');
      if (atch > 0) eLine += ' · ' + atch + ' w/ attach';
      if (wf > 0) eLine += ' · ' + wf + ' workflow';
    }
    return { iconColor: iconColor, line: eLine, badge: (unread > 0) ? '<span class="flex-shrink-0 inline-flex items-center rounded-full bg-togen-teal/10 dark:bg-togen-teal/20 px-2 py-0.5 text-xs font-semibold text-togen-teal">' + unread + ' unread</span>' : '' };
  }
  return { iconColor: iconColor, line: esc(tool.description), badge: '' };
}

// Split the visible, ordered tools around a "separator" marker in the effective
// tool order (DVI-524). Only a real divider when at least one tool sits on each
// side; otherwise everything renders as a single group in normal order.
function _partitionToolsBySeparator() {
  var effectiveOrder = (_userSettings.tool_order && _userSettings.tool_order.length) ? _userSettings.tool_order : _toolOrder;
  var visible = _visibleSortedTools();
  var sepIdx = effectiveOrder.indexOf('separator');
  if (sepIdx === -1) return { hasSeparator: false, above: visible, below: [] };
  var above = [], below = [];
  visible.forEach(function(tool) {
    var oi = effectiveOrder.indexOf(tool.id);
    if (oi === -1) oi = 999;
    if (oi < sepIdx) above.push(tool); else below.push(tool);
  });
  if (!above.length || !below.length) return { hasSeparator: false, above: above.concat(below), below: [] };
  return { hasSeparator: true, above: above, below: below };
}

// Full-width dashed divider drawn across the home grid between the two groups.
function _makeTileDivider() {
  var d = document.createElement('div');
  d.className = 'tile-separator-line border-t-2 border-dashed border-gray-300 dark:border-gray-600 my-1';
  d.setAttribute('aria-hidden', 'true');
  return d;
}

// A grid section used on each side of the separator; _applyTileGridColumns sets
// its column template just like the single-grid case.
function _makeGridSection() {
  var s = document.createElement('div');
  s.className = 'tile-grid-section grid grid-cols-1 sm:grid-cols-2 gap-5';
  return s;
}

// Compact full-width list row (~half tile height) for a single tool.
function _buildToolListRow(tool) {
  var row = document.createElement('button');
  row.onclick = function(){ openTool(tool.id, tool.title); };
  row.className = 'togen-pane group flex items-center gap-4 w-full text-left px-5 py-3 bg-white dark:bg-gray-900 rounded-xl shadow-sm ' +
                  'border border-gray-200 dark:border-gray-700 hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-md ' +
                  'transition-all duration-200 cursor-pointer';
  var meta = _tileListSecondary(tool);
  row.innerHTML =
    '<div class="flex-shrink-0 ' + meta.iconColor + ' group-hover:text-togen-teal transition-colors">' + tool.icon + '</div>' +
    '<div class="min-w-0 flex-1">' +
      '<p class="text-sm font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors truncate">' + esc(tool.title) + '</p>' +
      '<p class="text-xs text-gray-500 dark:text-gray-400 truncate">' + meta.line + '</p>' +
    '</div>' +
    meta.badge;
  return row;
}

// Compact full-width list view (~half tile height), stacked vertically (step 5).
function renderTileList() {
  var grid = document.getElementById('tile-grid');
  grid.className = 'flex flex-col gap-3';
  grid.style.gridTemplateColumns = '';
  grid.innerHTML = '';
  var part = _partitionToolsBySeparator();
  part.above.forEach(function(tool) { grid.appendChild(_buildToolListRow(tool)); });
  if (part.hasSeparator) {
    grid.appendChild(_makeTileDivider());
    part.below.forEach(function(tool) { grid.appendChild(_buildToolListRow(tool)); });
  }
  _applyTileSidePadding();
}

// Build one home-grid tile button for a tool. Visibility/order filtering is
// handled upstream by _visibleSortedTools / _partitionToolsBySeparator.
function _buildToolTile(tool) {
    var tile = document.createElement('button');
    tile.onclick = function(){ openTool(tool.id, tool.title); };
    if (tool.id === 'scada-view' && _scadaAllReadings.length) {
      // Option C: history backdrop + alert badge + sensor summary
      var enabledReadings = _scadaAllReadings.filter(function(r) { return _scadaIsEnabled(r.label) && !_scadaReportingDisabled(r.label); });
      var oorSensors = enabledReadings.filter(function(r) { return _scadaIsSensorLabel(r.label) && _scadaIsOutOfRange(r.label, r.value); });
      var sensorCount = enabledReadings.filter(function(r) { return _scadaIsSensorLabel(r.label); }).length;
      var oorCount = oorSensors.length;
      // Pick representative sensor for backdrop: prefer one out-of-range, else first enabled sensor with history
      var backdropLabel = null;
      if (oorSensors.length && _scadaHistory[oorSensors[0].label] && _scadaHistory[oorSensors[0].label].length) {
        backdropLabel = oorSensors[0].label;
      } else {
        var firstWithHist = enabledReadings.find(function(r) { return _scadaIsSensorLabel(r.label) && _scadaHistory[r.label] && _scadaHistory[r.label].length; });
        if (firstWithHist) backdropLabel = firstWithHist.label;
      }
      var backdropSvg = '';
      if (backdropLabel) {
        var bgGraph = scadaBuildGraph(_scadaHistory[backdropLabel], _scadaConfig.ranges[backdropLabel] || null, { mode: 'background' });
        if (bgGraph) backdropSvg = '<div class="absolute inset-0 pointer-events-none" style="opacity:0.55;z-index:0">' + bgGraph + '</div>';
      }
      // Alert badge (top-right)
      var badgeHtml;
      if (oorCount > 0) {
        badgeHtml = '<span class="absolute top-3 right-3 inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/40 px-2 py-0.5 text-xs font-semibold text-red-700 dark:text-red-300" style="z-index:2">' +
          '<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>' +
          oorCount + ' alert' + (oorCount !== 1 ? 's' : '') + '</span>';
      } else if (sensorCount > 0) {
        badgeHtml = '<span class="absolute top-3 right-3 inline-flex items-center gap-1 rounded-full bg-green-100 dark:bg-green-900/40 px-2 py-0.5 text-xs font-semibold text-green-700 dark:text-green-300" style="z-index:2">' +
          '<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>' +
          'All OK</span>';
      } else {
        badgeHtml = '';
      }
      // Sensor summary line + last-updated
      var summaryHtml;
      if (sensorCount === 0) {
        summaryHtml = esc(tool.description);
      } else if (oorCount > 0) {
        summaryHtml = oorCount + ' of ' + sensorCount + ' sensor' + (sensorCount !== 1 ? 's' : '') + ' out of range';
      } else {
        summaryHtml = sensorCount + ' sensor' + (sensorCount !== 1 ? 's' : '') + ' in range';
      }
      var timeHtml = _scadaTileTime ? '<span class="block mt-0.5 text-gray-400 dark:text-gray-500">Updated ' + esc(_scadaTileTime) + '</span>' : '';
      tile.className = 'togen-pane group relative overflow-hidden flex flex-col items-center gap-4 p-8 bg-white dark:bg-gray-900 rounded-2xl shadow-sm ' +
                       'border border-gray-200 dark:border-gray-700 ' +
                       'hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-md ' +
                       'transition-all duration-200 text-center cursor-pointer';
      tile.innerHTML = backdropSvg + badgeHtml +
        '<div class="relative text-gray-400 dark:text-gray-500 group-hover:text-togen-teal transition-colors" style="z-index:1">' + tool.icon + '</div>' +
        '<div class="relative" style="z-index:1">' +
          '<p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">' + esc(tool.title) + '</p>' +
          '<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 leading-relaxed">' + summaryHtml + timeHtml + '</p>' +
        '</div>';
    } else if (tool.id === 'sync-admin' && _syncTileStatus && _syncTileStatus.state) {
      // DVI-377: recolor icon by sync state, pulse only on red (resync required),
      // show a cursory recent-activity tick strip + one status line.
      var st = _syncTileStatus;
      var iconColorMap = { ok: 'text-green-500', issues: 'text-yellow-500', resync: 'text-red-500', unknown: 'text-gray-400 dark:text-gray-500' };
      var iconColor = iconColorMap[st.state] || iconColorMap.unknown;
      var statusWordMap = { ok: 'OK', issues: 'Issues detected', resync: 'Resync required', unknown: 'Status unknown' };
      var statusWord = statusWordMap[st.state] || statusWordMap.unknown;
      var pulseCls = (st.state === 'resync') ? ' animate-pulse' : '';
      // Status line: "Last sync 4m ago · OK" while a status is known.
      var ago = _syncFmtAgo(st.lastActivity);
      var statusLine;
      if (st.state === 'resync') {
        statusLine = statusWord;
      } else if (st.state === 'unknown') {
        statusLine = esc(tool.description);
      } else {
        statusLine = (ago ? 'Last sync ' + ago + ' · ' : '') + statusWord;
      }
      // Recent-activity tick strip (~6-8 marks).
      var tickHtml = '';
      if (st.recent && st.recent.length) {
        tickHtml = '<div class="flex items-center justify-center gap-1 mt-2" aria-hidden="true">' +
          st.recent.map(function(m){
            var c = (m === 'err') ? 'bg-red-500' : 'bg-green-500';
            return '<span class="inline-block h-1.5 w-2 rounded-sm ' + c + '"></span>';
          }).join('') + '</div>';
      }
      var ariaLabel = esc(tool.title) + ': ' + statusWord;
      tile.className = 'togen-pane group flex flex-col items-center gap-4 p-8 bg-white dark:bg-gray-900 rounded-2xl shadow-sm ' +
                       'border border-gray-200 dark:border-gray-700 ' +
                       'hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-md ' +
                       'transition-all duration-200 text-center cursor-pointer';
      tile.setAttribute('aria-label', ariaLabel);
      tile.innerHTML =
        '<div class="' + iconColor + ' group-hover:text-togen-teal transition-colors' + pulseCls + '" title="' + ariaLabel + '">' + tool.icon + '</div>' +
        '<div>' +
          '<p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">' + esc(tool.title) + '</p>' +
          '<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 leading-relaxed">' + esc(statusLine) + '</p>' +
          tickHtml +
        '</div>';
    } else if (tool.id === 'vision' && _visionTileData) {
      // DVI-399: Online/Offline status chips modeled on the WO Admin Live Tile.
      // (A capture-data background graph from the Windows agent comes later.)
      var vd = _visionTileData;
      var visionMockBadge = vd.mock
        ? '<span class="absolute top-3 right-3 inline-flex items-center gap-1 rounded-full bg-yellow-100 dark:bg-yellow-900/40 px-2 py-0.5 text-xs font-semibold text-yellow-700 dark:text-yellow-300" style="z-index:2">Mock</span>'
        : '';
      var visionChips = '<div class="relative flex items-center justify-center gap-1.5 mt-2" style="z-index:1">' +
        '<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300">' +
          '<span class="inline-block h-1.5 w-1.5 rounded-full bg-green-500"></span>' + vd.online + ' Online</span>' +
        '<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">' +
          '<span class="inline-block h-1.5 w-1.5 rounded-full bg-gray-400"></span>' + vd.offline + ' Offline</span>' +
        '</div>';
      tile.className = 'togen-pane group relative overflow-hidden flex flex-col items-center gap-4 p-8 bg-white dark:bg-gray-900 rounded-2xl shadow-sm ' +
                       'border border-gray-200 dark:border-gray-700 ' +
                       'hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-md ' +
                       'transition-all duration-200 text-center cursor-pointer';
      tile.innerHTML = visionMockBadge +
        '<div class="relative text-gray-400 dark:text-gray-500 group-hover:text-togen-teal transition-colors" style="z-index:1">' + tool.icon + '</div>' +
        '<div class="relative" style="z-index:1">' +
          '<p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">' + esc(tool.title) + '</p>' +
          '<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 leading-relaxed">' + esc(tool.description) + '</p>' +
          visionChips +
        '</div>';
    } else if (tool.id === 'wor-admin' && _woradminActivity) {
      // DVI-381: 30-day submission backdrop + amber due-soon badge + status chips,
      // mirroring the SCADA View tile pattern above.
      var wa = _woradminActivity;
      var subPts = (wa.submissions_by_day || []).map(function(x){ return { time: x.date, value: x.count }; });
      var waBg = scadaBuildGraph(subPts, {}, { mode: 'background', stroke: '#0d9488' });
      var waBackdrop = waBg ? '<div class="absolute inset-0 pointer-events-none" style="opacity:0.55;z-index:0">' + waBg + '</div>' : '';
      var dueCount = (wa.recurring_due_soon || []).length;
      var waBadge = '';
      if (dueCount > 0) {
        waBadge = '<span class="absolute top-3 right-3 inline-flex items-center gap-1 rounded-full bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:text-amber-300" style="z-index:2">' +
          '<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>' +
          dueCount + ' due</span>';
      }
      var sc = wa.status_counts || {};
      var chipsHtml = '<div class="relative flex items-center justify-center gap-1.5 mt-2" style="z-index:1">' +
        '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">' + (sc.new || 0) + ' New</span>' +
        '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">' + (sc.in_progress || 0) + ' In Progress</span>' +
        '</div>';
      tile.className = 'togen-pane group relative overflow-hidden flex flex-col items-center gap-4 p-8 bg-white dark:bg-gray-900 rounded-2xl shadow-sm ' +
                       'border border-gray-200 dark:border-gray-700 ' +
                       'hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-md ' +
                       'transition-all duration-200 text-center cursor-pointer';
      tile.innerHTML = waBackdrop + waBadge +
        '<div class="relative text-gray-400 dark:text-gray-500 group-hover:text-togen-teal transition-colors" style="z-index:1">' + tool.icon + '</div>' +
        '<div class="relative" style="z-index:1">' +
          '<p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">' + esc(tool.title) + '</p>' +
          '<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 leading-relaxed">' + esc(tool.description) + '</p>' +
          chipsHtml +
        '</div>';
    } else if (tool.id === 'togen-admin' && _backupTileStatus) {
      // DVI-412: backup status graphic on the Togen Admin Live Tile.
      var bs = _backupTileStatus;
      var bStateMap = {
        ok:       { dot: 'bg-green-500', cls: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300', word: 'Backup OK' },
        running:  { dot: 'bg-blue-500',  cls: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',   word: 'Backing up' },
        failed:   { dot: 'bg-red-500',   cls: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300',       word: 'Backup failed' },
        inactive: { dot: 'bg-gray-400',  cls: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300',      word: 'Backup inactive' }
      };
      var bm = bStateMap[bs.state] || bStateMap.inactive;
      // DVI-449: update status graphic on the Togen Admin Live Tile.
      var us = _updateTileStatus || { state: 'unknown' };
      var uStateMap = {
        current:   { dot: 'bg-green-500', cls: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',   word: 'Up to date' },
        available: { dot: 'bg-amber-500', cls: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300',   word: 'Update available' },
        unknown:   { dot: 'bg-gray-400',  cls: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300',          word: 'Update status unknown' }
      };
      var um = uStateMap[us.state] || uStateMap.unknown;
      // The update chip only renders for Togen admins; non-admins get 403 on the
      // version endpoint, so a status chip would always read "unknown" for them.
      var updateChipHtml = _isToGenAdmin
        ? '<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ' + um.cls + '">' +
            '<span class="inline-block h-1.5 w-1.5 rounded-full ' + um.dot + '"></span>' + um.word + '</span>'
        : '';
      var statusChips = '<div class="relative flex flex-wrap items-center justify-center gap-1.5 mt-2" style="z-index:1">' +
        '<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ' + bm.cls + '">' +
          '<span class="inline-block h-1.5 w-1.5 rounded-full ' + bm.dot + '"></span>' + bm.word + '</span>' +
        updateChipHtml +
        '</div>';
      tile.className = 'togen-pane group relative overflow-hidden flex flex-col items-center gap-4 p-8 bg-white dark:bg-gray-900 rounded-2xl shadow-sm ' +
                       'border border-gray-200 dark:border-gray-700 ' +
                       'hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-md ' +
                       'transition-all duration-200 text-center cursor-pointer';
      tile.innerHTML =
        '<div class="relative text-gray-400 dark:text-gray-500 group-hover:text-togen-teal transition-colors" style="z-index:1">' + tool.icon + '</div>' +
        '<div class="relative" style="z-index:1">' +
          '<p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">' + esc(tool.title) + '</p>' +
          '<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 leading-relaxed">' + esc(tool.description) + '</p>' +
          statusChips +
        '</div>';
    } else if (tool.id === 'calendar') {
      // Tile graphic: a small current-month grid behind a large day-of-month
      // number, so the tile reads as "today" at a glance.
      var calNow = new Date();
      var calY = calNow.getFullYear(), calM = calNow.getMonth(), calD = calNow.getDate();
      var calFirst = new Date(calY, calM, 1).getDay();
      var calDays = new Date(calY, calM + 1, 0).getDate();
      var miniCells = '';
      var calDayNames = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
      for (var cn = 0; cn < 7; cn++) {
        miniCells += '<div class="text-[7px] font-semibold text-gray-400 dark:text-gray-500 leading-none">' + calDayNames[cn] + '</div>';
      }
      for (var cb = 0; cb < calFirst; cb++) miniCells += '<div></div>';
      for (var cd = 1; cd <= calDays; cd++) {
        var miniCls = (cd === calD)
          ? 'text-[7px] leading-none rounded-sm bg-togen-teal text-white font-bold'
          : 'text-[7px] leading-none text-gray-500 dark:text-gray-400';
        miniCells += '<div class="' + miniCls + '">' + cd + '</div>';
      }
      var calMonthLbl = calNow.toLocaleString(undefined, { month: 'long', year: 'numeric' });
      tile.className = 'togen-pane group relative overflow-hidden flex flex-col items-center gap-4 p-8 bg-white dark:bg-gray-900 rounded-2xl shadow-sm ' +
                       'border border-gray-200 dark:border-gray-700 ' +
                       'hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-md ' +
                       'transition-all duration-200 text-center cursor-pointer';
      tile.innerHTML =
        '<div class="relative flex items-center justify-center gap-4 group-hover:text-togen-teal transition-colors" style="z-index:1">' +
          '<div class="text-5xl font-bold tabular-nums text-gray-700 dark:text-gray-200 group-hover:text-togen-teal transition-colors leading-none">' + calD + '</div>' +
          '<div class="grid grid-cols-7 gap-x-0.5 gap-y-0.5 w-24" aria-hidden="true">' + miniCells + '</div>' +
        '</div>' +
        '<div class="relative" style="z-index:1">' +
          '<p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">' + esc(tool.title) + '</p>' +
          '<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 leading-relaxed">' + esc(calMonthLbl) + '</p>' +
        '</div>';
    } else if (tool.id === 'email') {
      // DVI-470: unread-mail badge mirrors the SCADA/WO-Admin live-tile badge pattern.
      // DVI-635: add unread-with-attachments and workflow-supported sub-indicators.
      var unread = (_emailTileData && _emailTileData.available) ? (_emailTileData.count || 0) : 0;
      var atch = (_emailTileData && _emailTileData.available) ? (_emailTileData.attachments || 0) : 0;
      var wf = (_emailTileData && _emailTileData.available) ? (_emailTileData.workflow || 0) : 0;
      var emailBadge = (unread > 0)
        ? '<span class="absolute top-3 right-3 inline-flex items-center gap-1 rounded-full bg-togen-teal/10 dark:bg-togen-teal/20 px-2 py-0.5 text-xs font-semibold text-togen-teal" style="z-index:2">' +
            '<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor"><path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/><path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/></svg>' +
            unread + ' unread</span>'
        : '';
      var emailLine = (unread > 0)
        ? unread + ' unread message' + (unread !== 1 ? 's' : '')
        : esc(tool.description);
      // Sub-indicator chips for attachment / workflow-supported unread counts.
      var emailChips = '';
      if (unread > 0 && (atch > 0 || wf > 0)) {
        var chipBase = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ';
        var clipSvg = '<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" clip-rule="evenodd"/></svg>';
        var wfSvg = '<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor"><path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z"/></svg>';
        emailChips = '<div class="relative flex flex-wrap items-center justify-center gap-1.5 mt-2" style="z-index:1">';
        if (atch > 0) {
          emailChips += '<span class="' + chipBase + 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300" title="Unread emails with attachments">' + clipSvg + atch + ' w/ attach</span>';
        }
        if (wf > 0) {
          emailChips += '<span class="' + chipBase + 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300" title="Unread emails with a workflow-supported attachment (e.g. xlsx, csv)">' + wfSvg + wf + ' workflow</span>';
        }
        emailChips += '</div>';
      }
      tile.className = 'togen-pane group relative overflow-hidden flex flex-col items-center gap-4 p-8 bg-white dark:bg-gray-900 rounded-2xl shadow-sm ' +
                       'border border-gray-200 dark:border-gray-700 ' +
                       'hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-md ' +
                       'transition-all duration-200 text-center cursor-pointer';
      tile.innerHTML = emailBadge +
        '<div class="relative text-gray-400 dark:text-gray-500 group-hover:text-togen-teal transition-colors" style="z-index:1">' + tool.icon + '</div>' +
        '<div class="relative" style="z-index:1">' +
          '<p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">' + esc(tool.title) + '</p>' +
          '<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 leading-relaxed">' + emailLine + '</p>' +
        '</div>' +
        emailChips;
    } else {
      tile.className = 'togen-pane group flex flex-col items-center gap-4 p-8 bg-white dark:bg-gray-900 rounded-2xl shadow-sm ' +
                       'border border-gray-200 dark:border-gray-700 ' +
                       'hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-md ' +
                       'transition-all duration-200 text-center cursor-pointer';
      tile.innerHTML =
        '<div class="text-gray-400 dark:text-gray-500 group-hover:text-togen-teal transition-colors">' + tool.icon + '</div>' +
        '<div>' +
          '<p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">' + esc(tool.title) + '</p>' +
          '<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 leading-relaxed">' + esc(tool.description) + '</p>' +
        '</div>';
    }
    return tile;
}

function renderTileGrid() {
  _updateTileViewToggle();
  if (_effectiveTileView() === 'list') { renderTileList(); return; }
  var grid = document.getElementById('tile-grid');
  grid.innerHTML = '';
  var part = _partitionToolsBySeparator();
  if (part.hasSeparator) {
    // Two grid sections split by a full-width divider (DVI-524).
    grid.className = 'flex flex-col gap-5';
    grid.style.gridTemplateColumns = '';
    var secA = _makeGridSection();
    part.above.forEach(function(tool) { secA.appendChild(_buildToolTile(tool)); });
    grid.appendChild(secA);
    grid.appendChild(_makeTileDivider());
    var secB = _makeGridSection();
    part.below.forEach(function(tool) { secB.appendChild(_buildToolTile(tool)); });
    grid.appendChild(secB);
  } else {
    // Restore grid-mode container classes (list mode swaps them out).
    grid.className = 'grid grid-cols-1 sm:grid-cols-2 gap-5';
    part.above.forEach(function(tool) { grid.appendChild(_buildToolTile(tool)); });
  }
  _applyTileGridColumns();
  _applyTileSidePadding();
}

// Re-evaluate the lg+ column count and side padding on viewport resize.
window.addEventListener('resize', function() {
  if (_activeTool !== null) return;
  if (_effectiveTileView() === 'grid') _applyTileGridColumns();
  _applyTileSidePadding();
});

function _showTool(toolId, toolTitle) {
  _activeTool = toolId;
  if (_tutorialsPaneOpen) closeTutorialsPane();
  document.getElementById('home-view').classList.add('hidden');
  document.getElementById('tool-view').classList.remove('hidden');
  document.getElementById('tool-active-title').textContent = toolTitle;
  var openBtn = document.getElementById('sp-open-btn');
  if (openBtn) { if (toolId === 'project-builder') openBtn.classList.remove('hidden'); else openBtn.classList.add('hidden'); }
  var uploadBtn = document.getElementById('sp-upload-btn');
  if (uploadBtn) { if (toolId === 'project-builder') uploadBtn.classList.remove('hidden'); else uploadBtn.classList.add('hidden'); }
  // Show only the matching tool content section
  var allTools = document.querySelectorAll('.tool-content');
  for (var i = 0; i < allTools.length; i++) allTools[i].classList.add('hidden');
  var target = document.getElementById('tool-' + toolId);
  if (target) target.classList.remove('hidden');
  // For Take-off Bridge, load shared folders on first open
  if (toolId === 'takeoff-bridge' && !_sharedFoldersLoaded) {
    loadSharedFolders();
    _sharedFoldersLoaded = true;
  }
  // Order/hide WO type tiles per admin settings each time the tool opens
  if (toolId === 'work-order') {
    worRenderTypeSelector();
  }
  // Bots: a single bot opens directly; multiple bots show the selector (DVI-803)
  if (toolId === 'bots') {
    botsRender();
  }
  // Load admin list on first open
  if (toolId === 'wor-admin') {
    woradminRenderTabs();
    if (!_woradminInitialized) {
      _woradminInitialized = true;
      woradminLoadList();
    }
    woradminLoadActivity();
  }
  // Load sync log on first open, start auto-refresh
  if (toolId === 'sync-admin' && !_syncadminInitialized) {
    _syncadminInitialized = true;
    syncadminLoadLog();
    syncadminStartAutoRefresh();
  }
  // Load version info + sync status on first open
  if (toolId === 'togen-admin' && !_togenadminInitialized) {
    _togenadminInitialized = true;
    togenadminSwitchTab('app');
    togenadminLoadVersion();
    togenadminLoadSyncStatus();
    togenadminRenderToolVisibility();
    togenadminRenderToolbarVisibility();
    togenadminLoadPublicVersion();
    togenadminLoadUrlPrefix();
  }
  // Load SCADA config, status, and logs on first open
  if (toolId === 'scada-view' && !_scadaInitialized) {
    _scadaInitialized = true;
    scadaLoadConfig(function() {
      scadaLoadStatus();
      scadaLoadHistory(function() { scadaRenderStatusGrid(); });
    });
    scadaLoadLogs();
  }
  // Load Vision Collector on first open
  if (toolId === 'vision' && !_visionInitialized) {
    _visionInitialized = true;
    visionSwitchTab('collector');
    visionLoadAlerts();
  }
  // Load news on first open
  if (toolId === 'news' && !_newsInitialized) {
    _newsInitialized = true;
    newsLoadPosts();
    newsCheckSubscription();
  }
  // Pre-fill Project Builder defaults on first open
  if (toolId === 'project-builder' && !_spInitialized) {
    _spInitialized = true;
    spPrefillDefaults();
    spFetchProjectNumber();
    document.getElementById('sp-project-name').addEventListener('input', spUpdateFolderName);
    document.getElementById('sp-vendor-project-code').addEventListener('input', spUpdateFolderName);
    document.getElementById('sp-field-tranId').addEventListener('input', function() {
      if (_spProducts.length === 0) spUpdateFolderName();
    });
    document.getElementById('sp-folder-name').addEventListener('input', function() {
      _spFolderEdited = true;
    });
    spUploadInit();
    spLoadTempFiles();
  }
  // Show type selector on first open of Work Order (unless a type was already selected)
  if (toolId === 'work-order' && !_worSelectedType) {
    document.getElementById('wor-type-selector').classList.remove('hidden');
    document.getElementById('wor-maintenance-wrapper').classList.add('hidden');
    document.getElementById('wor-mm-wrapper').classList.add('hidden');
  }
  // Initialize MTS Label Creator on first open
  if (toolId === 'mts-label' && !_mtsInitialized) {
    _mtsInitialized = true;
    mtsInit();
  }
  // Load tutorials full page on open
  if (toolId === 'tutorials') {
    loadTutorialsFullPage();
  }
  // Load File Browser on first open; always reset to the Projects tab
  if (toolId === 'file-browser') {
    var fbAttBtn = document.getElementById('fb-tab-btn-attachments');
    if (fbAttBtn) fbAttBtn.classList.toggle('hidden', !_emailToolEnabled());
    fbSwitchTab('projects');
    // Only load the Projects file tree for users who have the Projects tab
    // (Take-off / admin); /fb/browse is Take-off-gated server-side.
    if (!_fbInitialized && document.getElementById('fb-tab-btn-projects')) {
      _fbInitialized = true;
      fbNavigate('');
    }
  }
  // Production Run (DVI-528): reset to the Run tab on each open
  if (toolId === 'prod-run-merge') {
    prodRunSwitchTab('run');
  }
  // Email (DVI-470): load mailbox on each open so new mail appears
  if (toolId === 'email') {
    emailLoadMessages();
  }
  // Load Equipment Manuals list on every open so newly attached manuals appear
  if (toolId === 'equipment-manuals') {
    _emInitialized = true;
    emLoadList();
  }
  // Calendar: reset to current month/today on each open
  if (toolId === 'calendar') {
    calToolInit();
  }
  // Auditor: load on first open, always refresh on re-open
  if (toolId === 'auditor') {
    auditorSwitchTopTab('assistant');
  }
  // Repair Parts Manager (DVI-603): reset to All Parts and refresh on each open
  if (toolId === 'repair-parts-manager') {
    rpmSwitchTab('all');
  }
  // OCR Tool: clear any prior upload/result state on each open
  if (toolId === 'ocr-tool') {
    document.getElementById('ocr-file').value = '';
    _ocrReset();
    ocrSwitchTab('upload');
    ocrLoadBackends();
  }
  // Tool detail view width is unchanged (D3): narrow by default, wide for the
  // file browser. The wide home grid (max-w-screen-2xl) is cleared here.
  var mainEl = document.getElementById('main-content');
  mainEl.classList.remove('max-w-screen-2xl');
  if (toolId === 'file-browser') {
    mainEl.classList.remove('max-w-4xl');
    mainEl.classList.add('max-w-7xl');
  } else {
    mainEl.classList.remove('max-w-7xl');
    mainEl.classList.add('max-w-4xl');
  }
  window.scrollTo({top: 0, behavior: 'smooth'});
}

function openTool(toolId, toolTitle) {
  if (_activeTool) {
    history.replaceState({page:'tool',tool:toolId,title:toolTitle}, '');
  } else {
    history.pushState({page:'tool',tool:toolId,title:toolTitle}, '');
  }
  _showTool(toolId, toolTitle);
}

function _showHome() {
  _activeTool = null;
  var allTools = document.querySelectorAll('.tool-content');
  for (var i = 0; i < allTools.length; i++) allTools[i].classList.add('hidden');
  var mtsEmbed = document.getElementById('mts-preview-embed');
  if (mtsEmbed) mtsEmbed.removeAttribute('src');
  if (typeof mtsReset === 'function') mtsReset();
  document.getElementById('tool-view').classList.add('hidden');
  document.getElementById('home-view').classList.remove('hidden');
  var openBtn = document.getElementById('sp-open-btn');
  if (openBtn) openBtn.classList.add('hidden');
  // Widen the home grid only (D3, DVI-512); the tool view stays max-w-4xl.
  var mainEl = document.getElementById('main-content');
  mainEl.classList.remove('max-w-7xl', 'max-w-4xl');
  mainEl.classList.add('max-w-screen-2xl');
  window.scrollTo({top: 0, behavior: 'smooth'});
  // Refresh SCADA tile badge + summary each time Home is shown
  if (_isScada || _isToGenAdmin) {
    _scadaFetchTileData(function() { renderTileGrid(); });
  } else if ((_isTakeoffWorkflow || _isToGenAdmin) && _syncTileStatus) {
    // Re-render Sync Admin tile from cached poll result (DVI-377); 30s poll keeps it fresh.
    renderTileGrid();
  }
  // Refresh Vision tile online/offline counts each time Home is shown (DVI-399).
  if (_isTogenVision || _isToGenAdmin) {
    _visionFetchTileData(function() { renderTileGrid(); });
  }
  // Refresh the Email unread badge each time Home is shown (DVI-470).
  _emailFetchTileData(function() { renderTileGrid(); });
}

function backToHome() {
  if (_activeTool && history.state && history.state.page === 'tool') {
    history.back();
  } else {
    _showHome();
  }
}

window.addEventListener('popstate', function(e) {
  var st = e.state;
  if (st && st.page === 'tool') {
    _showTool(st.tool, st.title);
  } else {
    _showHome();
  }
});

var _sharedFoldersLoaded = false;
var _worInitialized = false;
var _mmInitialized = false;
var _safetyInitialized = false;
var _pmInitialized = false;
var _woradminInitialized = false;
var _syncadminInitialized = false;
var _syncadminTimer = null;
var _syncStatusTimer = null;
// Cached latest sync poll result for the Home-page Sync Admin tile (DVI-377).
// { state: 'ok'|'issues'|'resync'|'unknown', recent: ['ok'|'err',...], lastActivity: ms|null }
var _syncTileStatus = null;
// Backup status shown on the Togen Admin Live Tile (DVI-412). The backup service
// is not wired up yet, so this stays 'inactive' until the backend subtask lands.
var _backupTileStatus = { state: 'inactive', lastRun: null };
// Update status shown on the Togen Admin Live Tile (DVI-449). Populated from
// /togen-admin/version: 'current' (local == remote), 'available' (update pending),
// or 'unknown' (version check failed / not yet loaded).
var _updateTileStatus = { state: 'unknown' };
var _togenadminInitialized = false;
var _spInitialized = false;
var _fbInitialized = false;

// ===== THEME =====
function toggleTheme() {
  var isDark = document.documentElement.classList.toggle('dark');
  localStorage.setItem('theme', isDark ? 'dark' : 'light');
  document.getElementById('icon-sun').classList.toggle('hidden', !isDark);
  document.getElementById('icon-moon').classList.toggle('hidden', isDark);
  if (typeof applyBrandingBackground === 'function') applyBrandingBackground();
  if (typeof applyPaneAlpha === 'function') applyPaneAlpha();
}

function initThemeIcons() {
  var isDark = document.documentElement.classList.contains('dark');
  document.getElementById('icon-sun').classList.toggle('hidden', !isDark);
  document.getElementById('icon-moon').classList.toggle('hidden', isDark);
}

// ===== DATE/TIME WIDGET =====
var _calViewDate = new Date();

function _updateClock() {
  var el = document.getElementById('datetime-display');
  if (!el) return;
  var now = new Date();
  var opts = { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' };
  el.textContent = now.toLocaleString(undefined, opts);
}
_updateClock();
setInterval(_updateClock, 15000);

function toggleDatetimeDropdown(e) {
  e.stopPropagation();
  var dd = document.getElementById('datetime-dropdown');
  var wasHidden = dd.classList.contains('hidden');
  dd.classList.toggle('hidden');
  document.getElementById('profile-menu').classList.add('hidden');
  document.getElementById('help-menu').classList.add('hidden');
  document.getElementById('start-menu').classList.add('hidden');
  var sSub = document.getElementById('start-shared-submenu');
  if (sSub) sSub.classList.add('hidden');
  if (wasHidden) {
    _calViewDate = new Date();
    _renderCalendar();
  }
}

function openCalendarTool(e) {
  if (e) e.stopPropagation();
  document.getElementById('datetime-dropdown').classList.add('hidden');
  openTool('calendar', 'Calendar');
}

function calPrev(e) {
  e.stopPropagation();
  _calViewDate.setMonth(_calViewDate.getMonth() - 1);
  _renderCalendar();
}

function calNext(e) {
  e.stopPropagation();
  _calViewDate.setMonth(_calViewDate.getMonth() + 1);
  _renderCalendar();
}

function _renderCalendar() {
  var label = document.getElementById('cal-month-label');
  var grid = document.getElementById('cal-grid');
  var year = _calViewDate.getFullYear();
  var month = _calViewDate.getMonth();
  label.textContent = _calViewDate.toLocaleString(undefined, { month: 'long', year: 'numeric' });

  var today = new Date();
  var todayStr = today.getFullYear() + '-' + today.getMonth() + '-' + today.getDate();

  var firstDay = new Date(year, month, 1).getDay();
  var daysInMonth = new Date(year, month + 1, 0).getDate();

  var html = '';
  var dayNames = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
  for (var d = 0; d < 7; d++) {
    html += '<div class="py-1 text-[10px] font-semibold text-gray-400 dark:text-gray-500">' + dayNames[d] + '</div>';
  }
  for (var blank = 0; blank < firstDay; blank++) {
    html += '<div></div>';
  }
  for (var day = 1; day <= daysInMonth; day++) {
    var cellStr = year + '-' + month + '-' + day;
    var isToday = cellStr === todayStr;
    var cls = 'py-1 rounded-lg text-gray-700 dark:text-gray-300';
    if (isToday) {
      cls = 'py-1 rounded-lg bg-togen-teal text-white font-bold';
    }
    html += '<div class="' + cls + '">' + day + '</div>';
  }
  grid.innerHTML = html;
}

document.addEventListener('click', function(e) {
  var btn = document.getElementById('datetime-btn');
  var dd = document.getElementById('datetime-dropdown');
  if (btn && dd && !btn.contains(e.target) && !dd.contains(e.target)) {
    dd.classList.add('hidden');
  }
});

// ===== REUSABLE TOP-BAR INLINE FIELD (DVI-531) =====
// Generic expand/collapse + auto-grow prompt bar. Configure each use via
// topbarFieldOpen({placeholder, onSubmit, disabled, panelHTML}). Built so the
// future Search field (DVI-534) can reuse the identical look with a different
// onSubmit. Only the Assistant wires it today.
var _topbarFieldSubmit = null;
var _topbarFieldInput = null;
var _topbarFieldOverflow = null;
var _topbarFieldIconClick = null;
var _topbarFieldDefaultIcon = null;

// Swap the inline field's leading icon. Captures the default (Assistant) path the
// first time so callers that omit iconPath always get the original look back.
function topbarFieldSetIcon(d) {
  var path = document.querySelector('#topbar-field-icon path');
  if (!path) return;
  if (_topbarFieldDefaultIcon === null) _topbarFieldDefaultIcon = path.getAttribute('d');
  path.setAttribute('d', d || _topbarFieldDefaultIcon);
}

function topbarFieldOpen(cfg) {
  cfg = cfg || {};
  _topbarFieldSubmit = cfg.onSubmit || null;
  _topbarFieldInput = cfg.onInput || null;
  _topbarFieldOverflow = cfg.onOverflow || null;
  _topbarFieldIconClick = cfg.onIconClick || null;
  var wrap = document.getElementById('topbar-field');
  var input = document.getElementById('topbar-field-input');
  var sendBtn = document.getElementById('topbar-field-submit');
  topbarFieldSetIcon(cfg.iconPath);
  // The leading icon becomes a clickable button only when a handler is wired
  // (Assistant uses it to open the popup); Search etc. leave it inert.
  var iconBtn = document.getElementById('topbar-field-icon-btn');
  if (iconBtn) {
    var clickable = !!_topbarFieldIconClick;
    iconBtn.classList.toggle('pointer-events-none', !clickable);
    iconBtn.classList.toggle('cursor-pointer', clickable);
    iconBtn.classList.toggle('hover:bg-gray-100', clickable);
    iconBtn.classList.toggle('dark:hover:bg-gray-700', clickable);
    iconBtn.title = clickable ? (cfg.iconTitle || '') : '';
  }
  input.placeholder = cfg.placeholder || '';
  input.value = '';
  input.disabled = !!cfg.disabled;
  sendBtn.classList.toggle('hidden', !!cfg.disabled);
  topbarFieldSetPanel(cfg.panelHTML || '');
  wrap.classList.remove('hidden');
  topbarFieldAutoGrow();
  if (!cfg.disabled) setTimeout(function() { input.focus(); }, 0);
}

function topbarFieldClose() {
  document.getElementById('topbar-field').classList.add('hidden');
  _topbarFieldSubmit = null;
  _topbarFieldInput = null;
  _topbarFieldOverflow = null;
  _topbarFieldIconClick = null;
}

function topbarFieldIconClick() {
  if (_topbarFieldIconClick) _topbarFieldIconClick();
}

// Live-input wrapper: keep auto-grow, then notify any configured onInput handler.
// When an onOverflow handler is set, hand off once the field exceeds 4 lines so
// long prompts move to a popup instead of growing the inline bar indefinitely.
function topbarFieldInput() {
  topbarFieldAutoGrow();
  var input = document.getElementById('topbar-field-input');
  if (_topbarFieldOverflow) {
    var lineHeight = 20;  // leading-5
    var padY = 8;         // py-1 (top + bottom)
    var lines = Math.round((input.scrollHeight - padY) / lineHeight);
    if (lines > 4) { _topbarFieldOverflow(input.value || ''); return; }
  }
  if (_topbarFieldInput) {
    _topbarFieldInput((input.value || '').trim());
  }
}

// Auto-grow downward: reset to one line, then expand to scrollHeight (capped).
function topbarFieldAutoGrow() {
  var input = document.getElementById('topbar-field-input');
  input.style.height = 'auto';
  input.style.height = Math.min(input.scrollHeight, 192) + 'px';
}

function topbarFieldKeydown(e) {
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); topbarFieldSubmit(); }
  else if (e.key === 'Escape') { topbarFieldClose(); }
}

function topbarFieldSubmit() {
  var input = document.getElementById('topbar-field-input');
  var val = (input.value || '').trim();
  if (!val || !_topbarFieldSubmit) return;
  _topbarFieldSubmit(val);
}

function topbarFieldSetPanel(html) {
  var panel = document.getElementById('topbar-field-panel');
  panel.innerHTML = html || '';
  panel.classList.toggle('hidden', !html);
}

// Click-away closes the inline field (ignore clicks on the launcher itself).
document.addEventListener('click', function(e) {
  var wrap = document.getElementById('topbar-field');
  var assistantBtn = document.getElementById('assistant-btn');
  var searchBtn = document.getElementById('search-btn');
  if (!wrap || wrap.classList.contains('hidden')) return;
  if (wrap.contains(e.target)) return;
  if (assistantBtn && assistantBtn.contains(e.target)) return;
  if (searchBtn && searchBtn.contains(e.target)) return;
  topbarFieldClose();
});

// ===== ASSISTANT (DVI-531) =====
// Launcher routes to the desktop inline bar or the mobile overlay. Availability
// is computed server-side (plan §3.6) and carried on the button's data-available.
function assistantIconClick() {
  var btn = document.getElementById('assistant-btn');
  var available = !!btn && btn.dataset.available === 'true';
  if (window.matchMedia('(min-width: 768px)').matches) assistantOpenDesktop(available);
  else assistantOpenMobile(available);
}

var _assistantUnconfiguredHTML =
  '<div class="space-y-2">' +
    '<p class="text-gray-600 dark:text-gray-300">The Assistant isn\'t connected yet. Add your API key on the User Settings → Assistant tab to start using it.</p>' +
    '<button onclick="assistantOpenSettings()" class="px-3 py-1.5 rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white text-xs font-semibold">Open User Settings</button>' +
  '</div>';

function assistantOpenDesktop(available) {
  if (!available) {
    topbarFieldOpen({ placeholder: 'Assistant is not available', disabled: true, panelHTML: _assistantUnconfiguredHTML });
    return;
  }
  topbarFieldOpen({
    placeholder: 'Ask the Assistant…',
    onSubmit: assistantDesktopSend,
    onOverflow: assistantOverflowToPopup,
    onIconClick: assistantIconToPopup,
    iconTitle: 'Open in Assistant popup',
  });
}

// When the inline prompt grows past 4 lines, move it into the popup chat dialog
// (the same overlay used on mobile) carrying the text across so the user keeps
// typing where they left off; the popup stays open for the Assistant's reply.
function assistantOverflowToPopup(text) {
  topbarFieldClose();
  assistantOpenMobile(true, text);
}

// Leading-icon button: open the popup carrying whatever is typed so far.
function assistantIconToPopup() {
  var input = document.getElementById('topbar-field-input');
  assistantOverflowToPopup(input ? (input.value || '') : '');
}

// Wrap inline-panel content (Thinking… / a reply) with an "open in popup" button
// so the user can move that turn into the popup chat and continue there.
function _assistantPanelWithOpen(innerHTML) {
  return '<div class="flex items-start gap-2">' +
           '<div id="topbar-asst-content" class="flex-1 min-w-0">' + innerHTML + '</div>' +
           '<button onclick="assistantPanelToPopup()" title="Open in Assistant popup" ' +
             'class="flex-shrink-0 p-1 rounded-lg text-gray-400 hover:text-togen-teal hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors">' +
             '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">' +
               '<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>' +
             '</svg>' +
           '</button>' +
         '</div>';
}

// Move the current inline Thinking/response into the popup chat and continue.
function assistantPanelToPopup() {
  var content = document.getElementById('topbar-asst-content');
  var html = content ? content.innerHTML : '';
  topbarFieldClose();
  assistantOpenMobile(true);
  if (html) {
    var mp = document.getElementById('assistant-mobile-panel');
    if (mp) { mp.classList.remove('hidden'); mp.innerHTML = html; }
  }
}

function assistantDesktopSend(text) {
  var input = document.getElementById('topbar-field-input');
  input.disabled = true;
  topbarFieldSetPanel(_assistantPanelWithOpen('<p class="text-gray-400 dark:text-gray-500">Thinking…</p>'));
  var setPanel = function(html) { topbarFieldSetPanel(html); };
  _assistantChat(text, function(reply) {
    input.disabled = false;
    topbarFieldSetPanel(_assistantPanelWithOpen('<div class="whitespace-pre-wrap">' + esc(reply) + '</div>'));
    input.value = ''; topbarFieldAutoGrow(); input.focus();
  }, function(err) {
    input.disabled = false;
    topbarFieldSetPanel('<p class="text-red-500">' + esc(err) + '</p>');
  }, setPanel);
}

// --- Mobile overlay ---
function assistantOpenMobile(available, seedText) {
  document.getElementById('assistant-mobile-unconfigured').classList.toggle('hidden', available);
  document.getElementById('assistant-mobile-body').classList.toggle('hidden', !available);
  var panel = document.getElementById('assistant-mobile-panel');
  panel.classList.add('hidden'); panel.innerHTML = '';
  var input = document.getElementById('assistant-mobile-input');
  if (input) { input.value = seedText || ''; input.disabled = false; }
  document.getElementById('assistant-mobile-overlay').classList.remove('hidden');
  // Auto-grow must run after the overlay is visible — scrollHeight is 0 while the
  // dialog is display:none, which would otherwise collapse the field to nothing.
  if (available && input) setTimeout(function() {
    assistantMobileAutoGrow();
    input.focus();
    var len = input.value.length;
    try { input.setSelectionRange(len, len); } catch (e) {}
  }, 0);
}

// Auto-grow the popup textarea downward (capped via max-h + scroll in CSS).
function assistantMobileAutoGrow() {
  var input = document.getElementById('assistant-mobile-input');
  if (!input) return;
  input.style.height = 'auto';
  input.style.height = Math.min(Math.max(input.scrollHeight, 56), 240) + 'px';
}

function assistantCloseMobile() {
  document.getElementById('assistant-mobile-overlay').classList.add('hidden');
}

function assistantMobileClickAway(e) {
  if (e.target.id === 'assistant-mobile-overlay') assistantCloseMobile();
}

function assistantMobileSend() {
  var input = document.getElementById('assistant-mobile-input');
  var val = (input.value || '').trim();
  if (!val) return;
  var panel = document.getElementById('assistant-mobile-panel');
  panel.classList.remove('hidden');
  panel.innerHTML = '<p class="text-gray-400 dark:text-gray-500">Thinking…</p>';
  input.disabled = true;
  var setPanel = function(html) { panel.classList.remove('hidden'); panel.innerHTML = html; };
  _assistantChat(val, function(reply) {
    input.disabled = false;
    panel.innerHTML = '<div class="whitespace-pre-wrap">' + esc(reply) + '</div>';
    input.value = ''; assistantMobileAutoGrow(); input.focus();
  }, function(err) {
    input.disabled = false;
    panel.innerHTML = '<p class="text-red-500">' + esc(err) + '</p>';
  }, setPanel);
}

function assistantOpenSettings() {
  topbarFieldClose();
  assistantCloseMobile();
  openUserSettings();
  usSwitchTab('assistant');
}

// State for the pending Tier-1 confirmation (Phase B).
var _assistantConfirmState = null;

// Build the HTML for a Tier-1 action confirmation card.
function _assistantConfirmCardHTML(pa, partialReply) {
  var html = '';
  if (partialReply) {
    html += '<div class="whitespace-pre-wrap mb-3 text-sm text-gray-700 dark:text-gray-200">' + esc(partialReply) + '</div>';
  }
  html += '<div class="rounded-xl border border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/60 p-3">';
  html += '<p class="text-xs font-semibold text-amber-700 dark:text-amber-300 mb-2">⚠️ Confirm action: ' + esc(pa.display_name) + '</p>';
  html += '<table class="w-full text-xs mb-3 border-collapse">';
  for (var k in pa.display_args) {
    if (Object.prototype.hasOwnProperty.call(pa.display_args, k) && pa.display_args[k]) {
      html += '<tr><td class="pr-3 py-0.5 text-gray-500 dark:text-gray-400 font-medium align-top whitespace-nowrap">' + esc(k) + '</td>';
      html += '<td class="py-0.5 text-gray-700 dark:text-gray-200 break-all">' + esc(String(pa.display_args[k])) + '</td></tr>';
    }
  }
  html += '</table>';
  html += '<div class="flex gap-2">';
  html += '<button id="asst-confirm-btn" onclick="assistantHandleConfirm(true)"'
        + ' class="flex-1 py-1.5 rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white text-xs font-semibold cursor-pointer transition-colors">Confirm</button>';
  html += '<button id="asst-cancel-btn" onclick="assistantHandleConfirm(false)"'
        + ' class="flex-1 py-1.5 rounded-lg bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 text-xs font-semibold cursor-pointer transition-colors">Cancel</button>';
  html += '</div></div>';
  return html;
}

// Handle Confirm or Cancel click on a Tier-1 action card.
function assistantHandleConfirm(confirmed) {
  var state = _assistantConfirmState;
  if (!state) return;
  var token = state.token;
  var setPanel = state.setPanel;
  var onReply  = state.onReply;
  var onError  = state.onError;
  _assistantConfirmState = null;

  var btn1 = document.getElementById('asst-confirm-btn');
  var btn2 = document.getElementById('asst-cancel-btn');
  if (btn1) btn1.disabled = true;
  if (btn2) btn2.disabled = true;
  setPanel('<p class="text-gray-400 dark:text-gray-500 text-sm">' + (confirmed ? 'Executing…' : 'Cancelling…') + '</p>');

  fetch('/assistant/confirm', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ token: token, confirmed: confirmed })
  }).then(function(r) {
    return r.json().then(function(d) { return { ok: r.ok, d: d || {} }; });
  }).then(function(res) {
    if (!res.ok) { onError(res.d.error || 'Request failed.'); return; }
    if (res.d.pending_action) {
      _assistantConfirmState = { token: res.d.pending_action.token, setPanel: setPanel, onReply: onReply, onError: onError };
      setPanel(_assistantConfirmCardHTML(res.d.pending_action, res.d.partial_reply || ''));
    } else {
      onReply(res.d.reply || '');
    }
  }).catch(function() { onError('Network error.'); });
}

// Shared POST /assistant/chat caller (Phase A + B).
// setPanel(html): function to update the active response panel — passed by callers.
function _assistantChat(text, onReply, onError, setPanel) {
  fetch('/assistant/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ messages: [{ role: 'user', content: text }] })
  }).then(function(r) {
    return r.json().then(function(d) { return { ok: r.ok, d: d || {} }; });
  }).then(function(res) {
    if (!res.ok) { onError(res.d.error || 'Request failed.'); return; }
    if (res.d.pending_action) {
      _assistantConfirmState = { token: res.d.pending_action.token, setPanel: setPanel, onReply: onReply, onError: onError };
      setPanel(_assistantConfirmCardHTML(res.d.pending_action, res.d.partial_reply || ''));
    } else {
      onReply(res.d.reply || '');
    }
  }).catch(function() { onError('Network error.'); });
}

// ===== AUDITOR (DVI-558) =====
var _auditorTab = 'history';
var _auditorOffset = 0;
var _auditorTotal = 0;
var _auditorLimit = 100;

// ===== AUDITOR top-level tabs: Assistant / Policies (DVI-615) =====
var _auditorTopTab = 'assistant';

function auditorSwitchTopTab(tab) {
  _auditorTopTab = tab;
  ['assistant', 'history', 'policies'].forEach(function(t) {
    var btn = document.getElementById('auditor-toptab-' + t);
    if (!btn) return;
    var active = t === tab;
    btn.classList.toggle('border-togen-teal', active);
    btn.classList.toggle('text-togen-teal', active);
    btn.classList.toggle('border-transparent', !active);
    btn.classList.toggle('text-gray-500', !active);
    btn.classList.toggle('dark:text-gray-400', !active);
  });
  var ap = document.getElementById('auditor-pane-assistant');
  var hp = document.getElementById('auditor-pane-history');
  var pp = document.getElementById('auditor-pane-policies');
  if (ap) ap.classList.toggle('hidden', tab !== 'assistant');
  if (hp) hp.classList.toggle('hidden', tab !== 'history');
  if (pp) pp.classList.toggle('hidden', tab !== 'policies');
  if (tab === 'assistant') auditorLoad(true);
  else if (tab === 'history') auditorSwitchHistTab(_auditorHistTab);
  else auditorLoadPolicies();
}

// ----- History tab (DVI-785 / DVI-790): mirrors history from other tools, one sub-tab each -----
var _auditorHistTab = 'prodrun';
var _auditorHistGenericOffsets = {};
var _auditorHistGenericTotals = {};
var _auditorHistGenericLimit = 50;

function auditorSwitchHistTab(tab) {
  _auditorHistTab = tab;
  var _allHistTabs = ['prodrun', 'print', 'wor-admin', 'rpm', 'single-carrier-sheet', 'mts-label',
                      'file-browser', 'ocr-tool', 'takeoff-bridge', 'project-builder',
                      'sync-admin', 'togen-admin', 'vision-agents', 'bots'];
  _allHistTabs.forEach(function(t) {
    var btn = document.getElementById('auditor-histtab-' + t);
    if (!btn) return;
    var active = t === tab;
    btn.classList.toggle('border-togen-teal', active);
    btn.classList.toggle('text-togen-teal', active);
    btn.classList.toggle('border-transparent', !active);
    btn.classList.toggle('text-gray-500', !active);
    btn.classList.toggle('dark:text-gray-400', !active);
  });
  _allHistTabs.forEach(function(t) {
    var el = document.getElementById('auditor-hist-' + t);
    if (el) el.classList.toggle('hidden', t !== tab);
  });
  if (tab === 'prodrun') auditorHistLoadProdRun();
  else if (tab === 'print') auditorHistLoadPrint(true);
  else if (tab === 'bots') auditorHistLoadBots();
  else auditorHistLoadGeneric(tab, true);
}

// ----- History → Bots sub-tab: mirrors the Winston bot History tab -----
function auditorHistLoadBots() {
  var sel = document.getElementById('auditor-hist-bots-filter');
  var bot = sel ? (sel.value || 'winston') : 'winston';
  var status = document.getElementById('auditor-hist-bots-status');
  var wrap = document.getElementById('auditor-hist-bots-table-wrap');
  var tbody = document.getElementById('auditor-hist-bots-tbody');
  if (!tbody) return;
  if (wrap) wrap.classList.add('hidden');
  tbody.innerHTML = '';
  if (status) status.textContent = 'Loading…';
  // Only Winston is supported for now.
  fetch('/winston/run-history').then(function(r){ return r.json(); }).then(function(d){
    var records = d.records || [];
    if (!records.length) { if (status) status.textContent = 'No runs recorded yet.'; return; }
    if (status) status.textContent = '';
    if (wrap) wrap.classList.remove('hidden');
    tbody.innerHTML = records.map(function(r){
      var ranAt = r.ran_at || '';
      var dtLabel = '';
      if (ranAt) { try { dtLabel = new Date(ranAt).toLocaleString(); } catch(e){ dtLabel = ranAt; } }
      var statusBadge;
      if (r.status === 'running') {
        statusBadge = '<span class="inline-flex items-center gap-1 text-xs font-medium px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">' +
          '<svg class="animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>' +
          'Running</span>';
      } else if (r.status === 'error') {
        statusBadge = '<span class="text-xs font-medium px-1.5 py-0.5 rounded bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">Error</span>';
      } else {
        statusBadge = '<span class="text-xs font-medium px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">Done</span>';
      }
      var errMsg = r.error ? '<span class="text-red-600 dark:text-red-400" title="' + esc(r.error) + '">' + esc(r.error.substring(0,60)) + (r.error.length > 60 ? '…' : '') + '</span>' : '';
      return '<tr class="align-top">' +
        '<td class="py-2 pr-4 text-gray-600 dark:text-gray-300 whitespace-nowrap text-xs">' + esc(dtLabel) + '</td>' +
        '<td class="py-2 pr-4 font-mono text-gray-800 dark:text-gray-100">' + esc(r.date || '') + '</td>' +
        '<td class="py-2 pr-4">' + statusBadge + '</td>' +
        '<td class="py-2 pr-4 text-right text-gray-700 dark:text-gray-300">' + (r.quotes_found || 0) + '</td>' +
        '<td class="py-2 pr-4 text-right text-gray-700 dark:text-gray-300">' + (r.drafts_created || 0) + '</td>' +
        '<td class="py-2 pr-4 text-right text-gray-700 dark:text-gray-300">' + (r.pdf_routed || 0) + '</td>' +
        '<td class="py-2 text-gray-500 dark:text-gray-400 text-xs max-w-xs">' + errMsg + '</td>' +
        '</tr>';
    }).join('');
  }).catch(function(){
    if (status) status.textContent = 'Failed to load bot history.';
  });
}

// Generic loader for /history/<tool> tabs (DVI-790)
function auditorHistLoadGeneric(tool, reset) {
  if (reset) _auditorHistGenericOffsets[tool] = 0;
  var offset = _auditorHistGenericOffsets[tool] || 0;
  var idSfx = tool;
  var status = document.getElementById('auditor-hist-' + idSfx + '-status');
  var list   = document.getElementById('auditor-hist-' + idSfx + '-list');
  var count  = document.getElementById('auditor-hist-' + idSfx + '-count');
  var more   = document.getElementById('auditor-hist-' + idSfx + '-loadmore');
  if (!list) return;
  if (reset) { list.innerHTML = ''; if (more) more.classList.add('hidden'); }
  if (status) status.textContent = 'Loading…';

  var userInp = document.getElementById('auditor-hist-' + idSfx + '-filter-user');
  var user = userInp ? (userInp.value || '').trim() : '';
  var qs = 'limit=' + _auditorHistGenericLimit + '&offset=' + offset;
  if (user) qs += '&user=' + encodeURIComponent(user);

  fetch('/history/' + encodeURIComponent(tool) + '?' + qs)
    .then(function(r) { return r.json().then(function(d) { return {ok: r.ok, d: d}; }); })
    .then(function(res) {
      if (status) status.textContent = '';
      if (!res.ok) { if (status) status.textContent = 'Error: ' + ((res.d && res.d.error) || 'Failed to load.'); return; }
      var entries = (res.d && res.d.entries) || [];
      _auditorHistGenericTotals[tool] = (res.d && res.d.total) || 0;
      if (offset === 0 && !entries.length) {
        if (status) status.textContent = 'No events recorded yet.';
        return;
      }
      var html = '';
      entries.forEach(function(e) { html += auditorHistRowHtml(e); });
      list.insertAdjacentHTML('beforeend', html);
      var shown = list.children.length;
      if (count) count.textContent = shown + ' of ' + _auditorHistGenericTotals[tool] + ' event(s)';
      if (more) more.classList.toggle('hidden', shown >= _auditorHistGenericTotals[tool]);
    })
    .catch(function(err) { if (status) status.textContent = 'Failed: ' + err.message; });
}

function auditorHistLoadMoreGeneric(tool) {
  _auditorHistGenericOffsets[tool] = (_auditorHistGenericOffsets[tool] || 0) + _auditorHistGenericLimit;
  auditorHistLoadGeneric(tool, false);
}

// ----- History → Print sub-tab (DVI-789): adapter over /history/print -----
var _auditorHistPrintOffset = 0;
var _auditorHistPrintTotal = 0;
var _auditorHistPrintLimit = 50;

function auditorHistPrintLoadMore() {
  _auditorHistPrintOffset += _auditorHistPrintLimit;
  auditorHistLoadPrint(false);
}

function auditorHistLoadPrint(reset) {
  if (reset) _auditorHistPrintOffset = 0;
  var status = document.getElementById('auditor-hist-print-status');
  var list = document.getElementById('auditor-hist-print-list');
  var count = document.getElementById('auditor-hist-print-count');
  var more = document.getElementById('auditor-hist-print-loadmore');
  if (!list) return;
  if (reset) { list.innerHTML = ''; if (more) more.classList.add('hidden'); }
  status.textContent = 'Loading…';

  var user = (document.getElementById('auditor-hist-print-filter-user').value || '').trim();
  var qs = 'limit=' + _auditorHistPrintLimit + '&offset=' + _auditorHistPrintOffset;
  if (user) qs += '&user=' + encodeURIComponent(user);

  fetch('/history/print?' + qs)
    .then(function(r) { return r.json().then(function(d) { return {ok: r.ok, d: d}; }); })
    .then(function(res) {
      status.textContent = '';
      if (!res.ok) { status.textContent = 'Error: ' + ((res.d && res.d.error) || 'Failed to load.'); return; }
      var entries = (res.d && res.d.entries) || [];
      _auditorHistPrintTotal = (res.d && res.d.total) || 0;
      if (_auditorHistPrintOffset === 0 && !entries.length) {
        status.textContent = 'No print jobs recorded yet.';
        return;
      }
      var html = '';
      entries.forEach(function(e) { html += auditorHistRowHtml(e); });
      list.insertAdjacentHTML('beforeend', html);
      var shown = list.children.length;
      count.textContent = shown + ' of ' + _auditorHistPrintTotal + ' job(s)';
      if (more) more.classList.toggle('hidden', shown >= _auditorHistPrintTotal);
    })
    .catch(function(err) { status.textContent = 'Failed: ' + err.message; });
}

// Reuses /prodrun-history + prHistoryRowHtml; clicking a run opens it in the
// Production Run tool's History tab (same as the File Browser Prod Run tab).
function auditorHistLoadProdRun() {
  var status = document.getElementById('auditor-hist-prodrun-status');
  var list = document.getElementById('auditor-hist-prodrun-list');
  if (!list) return;
  status.textContent = 'Loading…'; list.innerHTML = '';
  fetch('/prodrun-history').then(function(r){ return r.json(); }).then(function(data) {
    if (data.error) { status.textContent = 'Error: ' + data.error; return; }
    var runs = data.runs || [];
    if (!runs.length) { status.textContent = 'No production runs yet.'; return; }
    status.textContent = '';
    var html = '';
    runs.forEach(function(run){ html += prHistoryRowHtml(run, 'fbProdRunOpenEntry'); });
    list.innerHTML = html;
  }).catch(function(e){ status.textContent = 'Failed: ' + e.message; });
}

// ----- Printing tab (DVI-764): print-job history from /auditor/print-history -----
var _auditorPrintOffset = 0;
var _auditorPrintTotal = 0;
var _auditorPrintLimit = 100;

function auditorPrintApplyFilter() { auditorLoadPrintHistory(true); }

function auditorPrintLoadMore() {
  _auditorPrintOffset += _auditorPrintLimit;
  auditorLoadPrintHistory(false);
}

function auditorLoadPrintHistory(reset) {
  if (reset) { _auditorPrintOffset = 0; }
  var loading = document.getElementById('auditor-print-loading');
  var err = document.getElementById('auditor-print-error');
  var empty = document.getElementById('auditor-print-empty');
  var wrap = document.getElementById('auditor-print-table-wrap');
  var footer = document.getElementById('auditor-print-footer');
  var tbody = document.getElementById('auditor-print-tbody');
  if (reset && tbody) tbody.innerHTML = '';
  err.classList.add('hidden');
  empty.classList.add('hidden');
  if (reset) { wrap.classList.add('hidden'); footer.classList.add('hidden'); }
  loading.classList.remove('hidden');

  var user = (document.getElementById('auditor-print-filter-user').value || '').trim();
  var source = document.getElementById('auditor-print-filter-source').value || '';
  var qs = 'limit=' + _auditorPrintLimit + '&offset=' + _auditorPrintOffset;
  if (user) qs += '&user=' + encodeURIComponent(user);
  if (source) qs += '&source=' + encodeURIComponent(source);

  fetch('/auditor/print-history?' + qs)
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      loading.classList.add('hidden');
      if (!res.ok) {
        document.getElementById('auditor-print-error-msg').textContent = (res.d && res.d.error) || 'Failed to load print history.';
        err.classList.remove('hidden');
        return;
      }
      var jobs = (res.d && res.d.jobs) || [];
      _auditorPrintTotal = (res.d && res.d.total) || 0;
      if (_auditorPrintOffset === 0 && !jobs.length) {
        empty.classList.remove('hidden');
        return;
      }
      jobs.forEach(function(j){ tbody.insertAdjacentHTML('beforeend', _auditorPrintRow(j)); });
      wrap.classList.remove('hidden');
      footer.classList.remove('hidden');
      var shown = tbody.children.length;
      document.getElementById('auditor-print-count').textContent = shown + ' of ' + _auditorPrintTotal + ' job(s)';
      var more = document.getElementById('auditor-print-loadmore-btn');
      more.classList.toggle('hidden', shown >= _auditorPrintTotal);
    })
    .catch(function(){
      loading.classList.add('hidden');
      document.getElementById('auditor-print-error-msg').textContent = 'Failed to load print history.';
      err.classList.remove('hidden');
    });
}

function _auditorPrintStatusBadge(status) {
  var map = {
    done: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
    printing: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
    queued: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
    error: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
  };
  var cls = map[status] || map.queued;
  return '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ' + cls + '">' + esc(status) + '</span>';
}

function _auditorPrintRow(j) {
  var when = j.created_at ? new Date(j.created_at * 1000).toLocaleString() : '—';
  var doc = j.file_ref || '—';
  var printer = j.printer || '—';
  if (j.machine_name) printer += ' (' + j.machine_name + ')';
  return '<tr>' +
    '<td class="px-3 py-2 whitespace-nowrap">' + esc(when) + '</td>' +
    '<td class="px-3 py-2">' + esc(j.user || '—') + '</td>' +
    '<td class="px-3 py-2">' + esc(j.source_label || '—') + '</td>' +
    '<td class="px-3 py-2 max-w-xs truncate" title="' + esc(doc) + '">' + esc(doc) + '</td>' +
    '<td class="px-3 py-2">' + esc(printer) + '</td>' +
    '<td class="px-3 py-2">' + esc(j.kind || '—') + '</td>' +
    '<td class="px-3 py-2">' + esc(String(j.copies != null ? j.copies : '—')) + '</td>' +
    '<td class="px-3 py-2">' + _auditorPrintStatusBadge(j.status) +
      (j.error_message ? '<div class="text-[10px] text-red-500 mt-0.5">' + esc(j.error_message) + '</div>' : '') +
    '</td>' +
  '</tr>';
}

// ----- Policies tab: read-only aggregation of policy dialogs from other tools -----
var _auditorPolTab = 'all';
var _auditorPolicyData = null;  // {wor, rpmApproval, rpmSettings}

function auditorSwitchPolTab(tab) {
  _auditorPolTab = tab;
  ['all', 'woradmin', 'rpm', 'vision'].forEach(function(t) {
    var btn = document.getElementById('auditor-poltab-' + t);
    if (!btn) return;
    var active = t === tab;
    btn.classList.toggle('border-togen-teal', active);
    btn.classList.toggle('text-togen-teal', active);
    btn.classList.toggle('border-transparent', !active);
    btn.classList.toggle('text-gray-500', !active);
    btn.classList.toggle('dark:text-gray-400', !active);
  });
  auditorRenderPolicies();
}

function auditorLoadPolicies() {
  var body = document.getElementById('auditor-policies-body');
  if (!body) return;
  body.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 py-6 text-center">Loading policies…</p>';
  Promise.all([
    fetch('/wor-policy').then(function(r){ return r.json(); }).catch(function(){ return {}; }),
    fetch('/rpm/approval-policy').then(function(r){ return r.json(); }).catch(function(){ return {}; }),
    fetch('/rpm/settings').then(function(r){ return r.json(); }).catch(function(){ return {}; }),
    fetch('/togen-admin/vision-policy').then(function(r){ return r.json(); }).catch(function(){ return {}; })
  ]).then(function(res) {
    _auditorPolicyData = {
      wor: (res[0] && res[0].policy) || {},
      rpmApproval: (res[1] && res[1].policy) || {},
      rpmSettings: (res[2] && res[2].settings) || {},
      vision: (res[3] && res[3].policy) || {},
      visionDefaults: (res[3] && res[3].defaults) || {}
    };
    auditorRenderPolicies();
  }).catch(function() {
    body.innerHTML = '<p class="text-sm text-red-600 dark:text-red-400 py-6 text-center">Failed to load policies.</p>';
  });
}

function auditorRenderPolicies() {
  var body = document.getElementById('auditor-policies-body');
  if (!body) return;
  if (!_auditorPolicyData) { auditorLoadPolicies(); return; }
  var html = '';
  if (_auditorPolTab === 'all' || _auditorPolTab === 'woradmin') html += _auditorWorPolicyCard();
  if (_auditorPolTab === 'all' || _auditorPolTab === 'rpm') html += _auditorRpmPolicyCard();
  if (_auditorPolTab === 'all' || _auditorPolTab === 'vision') html += _auditorVisionPolicyCard();
  body.innerHTML = html;
}

function _auditorPolYesNo(v) {
  return v
    ? '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">On</span>'
    : '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400">Off</span>';
}

function _auditorPolCheck(v) {
  return v
    ? '<span class="text-togen-teal font-semibold">✓</span>'
    : '<span class="text-gray-300 dark:text-gray-600">—</span>';
}

function _auditorPolEmails(arr) {
  arr = arr || [];
  if (!arr.length) return '<span class="text-xs text-gray-400 dark:text-gray-500 italic">None</span>';
  return arr.map(function(e) {
    return '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200">' + esc(e) + '</span>';
  }).join(' ');
}

function _auditorPolHeader(title, toolId, toolTitle) {
  return '<div class="flex items-center justify-between mb-3">' +
    '<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">' + esc(title) + '</h2>' +
    '<button onclick="openTool(\'' + toolId + '\',\'' + esc(toolTitle) + '\')" ' +
    'class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer">Manage in ' + esc(toolTitle) + '</button>' +
    '</div>';
}

function _auditorWorPolicyCard() {
  var policy = (_auditorPolicyData && _auditorPolicyData.wor) || {};
  var colLabels = {'new': 'New', status_change: 'Status Change', completed: 'Completed', due_soon: '1 Day Before Due', overdue: 'Overdue'};
  var cols = _usNotifyCols;
  var head = '<th class="text-left font-medium py-2 pr-4 text-gray-500 dark:text-gray-400">Work Order Type</th>';
  cols.forEach(function(c) { head += '<th class="text-center font-medium px-2 py-2 text-gray-500 dark:text-gray-400">' + (colLabels[c] || c) + '</th>'; });
  var rows = '';
  _effectiveNotifyTypes().forEach(function(t) {
    var prefs = policy[t.id] || {};
    var r = '<td class="text-left py-2 pr-4 font-medium text-gray-800 dark:text-gray-100">' + esc(t.label) + '</td>';
    cols.forEach(function(c) { r += '<td class="text-center px-2 py-2">' + _auditorPolCheck(prefs[c]) + '</td>'; });
    rows += '<tr class="border-t border-gray-100 dark:border-gray-800">' + r + '</tr>';
  });
  return '<section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 px-6 py-4">' +
    _auditorPolHeader('Work Order Admin', 'wor-admin', 'Work Order Admin') +
    '<h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Policy Enforced Notifications</h3>' +
    '<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">The requesting user of a work order is always emailed for the events checked below, regardless of opt-in.</p>' +
    '<div class="overflow-x-auto"><table class="w-full text-sm border-collapse"><thead><tr>' + head + '</tr></thead><tbody>' + rows + '</tbody></table></div>' +
    '</section>';
}

function _auditorRpmPolicyCard() {
  var p = (_auditorPolicyData && _auditorPolicyData.rpmApproval) || {};
  var s = (_auditorPolicyData && _auditorPolicyData.rpmSettings) || {};
  var auto = p.auto_approval || {};
  var notify = p.policy_notify || {};
  var approveModeLabels = {all_admins: 'All Work Order Admins', primary: 'Only Admins designated as Primary', specific: 'Only specific users'};
  var autoModeLabels = {approvers: 'Same as approval permission', all_admins: 'All Work Order Admins', specific: 'Only specific users'};
  var html = '<section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 px-6 py-4 space-y-5">';
  html += _auditorPolHeader('Repair Parts Manager', 'repair-parts-manager', 'Repair Parts Manager');

  // Policy checkboxes
  html += '<div><h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-2">Policy</h3>' +
    '<div class="space-y-1.5 text-sm text-gray-700 dark:text-gray-300">' +
    '<div class="flex items-center gap-2">' + _auditorPolYesNo(s.assume_stocked_on_complete) + '<span>Assume parts stocked and replaced when ticket is marked as Complete.</span></div>' +
    '<div class="flex items-center gap-2">' + _auditorPolYesNo(s.mark_stocked_on_delivered) + '<span>Mark items as Stocked upon carrier status delivered.</span></div>' +
    '</div></div>';

  // Approval permission
  html += '<div class="pt-3 border-t border-gray-200 dark:border-gray-700"><h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Approval permission</h3>' +
    '<p class="text-sm text-gray-700 dark:text-gray-300">' + esc(approveModeLabels[p.approve_mode] || p.approve_mode || '—') + '</p>';
  if (p.approve_mode === 'specific') html += '<div class="mt-1 flex flex-wrap gap-1.5">' + _auditorPolEmails(p.approve_users) + '</div>';
  html += '</div>';

  // Policy Enforced Notifications
  html += '<div class="pt-3 border-t border-gray-200 dark:border-gray-700"><h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Policy Enforced Notifications</h3>' +
    '<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">Listed users receive all Repair Parts notifications for the work order type.</p>' +
    '<div class="space-y-2 text-sm">' +
    '<div><span class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Mobile Machine</span><div class="flex flex-wrap gap-1.5">' + _auditorPolEmails(notify.mobilemachine) + '</div></div>' +
    '<div><span class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Preventative Maintenance</span><div class="flex flex-wrap gap-1.5">' + _auditorPolEmails(notify.preventive) + '</div></div>' +
    '</div></div>';

  // Auto Approval
  html += '<div class="pt-3 border-t border-gray-200 dark:border-gray-700"><h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-2">Auto Approval ' + _auditorPolYesNo(auto.enabled) + '</h3>';
  if (auto.enabled) {
    ['mobilemachine', 'preventive'].forEach(function(wt) {
      var cfg = auto[wt] || {};
      var label = wt === 'mobilemachine' ? 'Mobile Machine' : 'Preventative Maintenance';
      html += '<div class="text-sm text-gray-700 dark:text-gray-300"><span class="font-medium">' + label + ':</span> ' + esc(autoModeLabels[cfg.mode] || cfg.mode || 'approvers');
      if (cfg.mode === 'specific') html += '<div class="mt-1 flex flex-wrap gap-1.5">' + _auditorPolEmails(cfg.users) + '</div>';
      html += '</div>';
    });
  }
  html += '</div>';

  html += '</section>';
  return html;
}

var _AUDITOR_DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

function _auditorVisionFields(eff) {
  var mode = eff.capture_mode || 'screenshot';
  var sched = eff.schedule || {};
  var days = (sched.days || []).map(function(d){ return _AUDITOR_DOW[d] || d; }).join(', ');
  var rows = [
    ['Capture', _auditorPolYesNo(eff.capture_enabled)],
    ['Mode', '<span class="font-medium">' + esc(mode) + '</span>'],
    [mode === 'video' ? 'Frame rate' : 'Capture interval',
      mode === 'video' ? esc(String(eff.capture_fps)) + ' fps' : esc(String(eff.capture_rate_s)) + ' s'],
    ['Idle pause', esc(String(eff.idle_threshold_s)) + ' s'],
    ['Schedule', (days || '—') + (sched.start ? ' · ' + esc(sched.start) + '–' + esc(sched.end || '') : '')],
    ['Retention', esc(String(eff.retention_days)) + ' days'],
    ['Exclusions', (eff.exclusions && eff.exclusions.length) ? _auditorPolEmails(eff.exclusions) : '<span class="text-xs text-gray-400 dark:text-gray-500 italic">None</span>']
  ];
  var html = '<div class="space-y-1.5 text-sm text-gray-700 dark:text-gray-300">';
  rows.forEach(function(r){
    html += '<div class="flex items-start gap-2"><span class="w-32 flex-shrink-0 text-gray-500 dark:text-gray-400">' + esc(r[0]) + '</span><span>' + r[1] + '</span></div>';
  });
  return html + '</div>';
}

function _auditorVisionPolicyCard() {
  var data = (_auditorPolicyData && _auditorPolicyData.vision) || {};
  var defaults = (_auditorPolicyData && _auditorPolicyData.visionDefaults) || {};
  var def = {};
  Object.keys(defaults).forEach(function(k){ def[k] = defaults[k]; });
  Object.keys(data.default || {}).forEach(function(k){ def[k] = data.default[k]; });
  var machines = data.machines || {};
  var ids = Object.keys(machines);
  var html = '<section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 px-6 py-4 space-y-5">';
  html += _auditorPolHeader('Vision', 'vision', 'Vision');
  html += '<div><h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-2">Default capture policy</h3>' + _auditorVisionFields(def) + '</div>';
  html += '<div class="pt-3 border-t border-gray-200 dark:border-gray-700"><h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-2">Per-machine overrides</h3>';
  if (!ids.length) {
    html += '<p class="text-sm text-gray-400 dark:text-gray-500 italic">No machine-specific overrides.</p>';
  } else {
    ids.forEach(function(mid){
      var eff = {};
      Object.keys(def).forEach(function(k){ eff[k] = def[k]; });
      Object.keys(machines[mid] || {}).forEach(function(k){ eff[k] = machines[mid][k]; });
      html += '<div class="mt-3 first:mt-0 rounded-xl border border-gray-200 dark:border-gray-700 p-3">' +
        '<p class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2 break-all">' + esc(mid) + '</p>' +
        _auditorVisionFields(eff) + '</div>';
    });
  }
  html += '</div></section>';
  return html;
}

function auditorSwitchTab(tab) {
  _auditorTab = tab;
  var tabs = ['history', 'failures'];
  tabs.forEach(function(t) {
    var btn = document.getElementById('auditor-tab-' + t);
    if (!btn) return;
    var active = t === tab;
    btn.classList.toggle('border-togen-teal', active);
    btn.classList.toggle('text-togen-teal', active);
    btn.classList.toggle('border-transparent', !active);
    btn.classList.toggle('text-gray-500', !active);
    btn.classList.toggle('dark:text-gray-400', !active);
  });
  auditorLoad(true);
}

function auditorApplyFilter() {
  auditorLoad(true);
}

function auditorLoad(reset) {
  if (reset) _auditorOffset = 0;
  var params = new URLSearchParams();
  params.set('limit', _auditorLimit);
  params.set('offset', _auditorOffset);
  if (_auditorTab === 'failures') params.set('failures_only', '1');
  var filterUser = (document.getElementById('auditor-filter-user') || {}).value || '';
  var filterTool = (document.getElementById('auditor-filter-tool') || {}).value || '';
  if (filterUser.trim()) params.set('user', filterUser.trim());
  if (filterTool) params.set('tool', filterTool);

  document.getElementById('auditor-loading').classList.remove('hidden');
  document.getElementById('auditor-error').classList.add('hidden');
  document.getElementById('auditor-empty').classList.add('hidden');
  document.getElementById('auditor-table-wrap').classList.add('hidden');
  document.getElementById('auditor-footer').classList.add('hidden');

  fetch('/auditor/entries?' + params.toString())
    .then(function(r) { return r.json().then(function(d) { return {ok: r.ok, d: d}; }); })
    .then(function(res) {
      document.getElementById('auditor-loading').classList.add('hidden');
      if (!res.ok) {
        document.getElementById('auditor-error-msg').textContent = res.d.error || 'Failed to load audit log.';
        document.getElementById('auditor-error').classList.remove('hidden');
        return;
      }
      var entries = res.d.entries || [];
      _auditorTotal = res.d.total || 0;
      _auditorOffset = res.d.offset || 0;
      if (reset) {
        document.getElementById('auditor-tbody').innerHTML = '';
      }
      if (entries.length === 0 && _auditorOffset === 0) {
        document.getElementById('auditor-empty').classList.remove('hidden');
        return;
      }
      _auditorRenderRows(entries);
      _auditorOffset += entries.length;
      document.getElementById('auditor-table-wrap').classList.remove('hidden');
      document.getElementById('auditor-footer').classList.remove('hidden');
      document.getElementById('auditor-count').textContent = 'Showing ' + Math.min(_auditorOffset, _auditorTotal) + ' of ' + _auditorTotal + ' entries';
      var lmBtn = document.getElementById('auditor-loadmore-btn');
      lmBtn.classList.toggle('hidden', _auditorOffset >= _auditorTotal);
    })
    .catch(function() {
      document.getElementById('auditor-loading').classList.add('hidden');
      document.getElementById('auditor-error-msg').textContent = 'Network error.';
      document.getElementById('auditor-error').classList.remove('hidden');
    });
}

function auditorLoadMore() {
  auditorLoad(false);
}

function _auditorRenderRows(entries) {
  var tbody = document.getElementById('auditor-tbody');
  entries.forEach(function(e) {
    var tr = document.createElement('tr');
    tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-colors';

    // Time — show local time, full ISO as title
    var ts = e.ts || '';
    var timeStr = ts;
    try { timeStr = new Date(ts).toLocaleString(); } catch (_) {}

    // Outcome badge color
    var outcome = String(e.outcome || '');
    var badgeClass = 'inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ';
    if (outcome === 'success') {
      badgeClass += 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300';
    } else if (outcome === 'denied') {
      badgeClass += 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300';
    } else {
      badgeClass += 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300';
    }

    // Access badge
    var authz = e.authz;
    var accessBadge = authz
      ? '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">allowed</span>'
      : '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400">denied</span>';

    // Args — compact JSON, truncated to avoid overflow
    var argsStr = '';
    try {
      var argsObj = e.args || {};
      argsStr = JSON.stringify(argsObj);
      if (argsStr.length > 80) argsStr = argsStr.slice(0, 77) + '…';
    } catch (_) { argsStr = ''; }

    tr.innerHTML =
      '<td class="px-3 py-2 whitespace-nowrap" title="' + esc(ts) + '">' + esc(timeStr) + '</td>' +
      '<td class="px-3 py-2 whitespace-nowrap">' + esc(e.user || '') + '</td>' +
      '<td class="px-3 py-2 whitespace-nowrap font-mono">' + esc(e.tool || '') + '</td>' +
      '<td class="px-3 py-2 font-mono text-gray-500 dark:text-gray-400 max-w-[200px] truncate" title="' + esc(JSON.stringify(e.args || {})) + '">' + esc(argsStr) + '</td>' +
      '<td class="px-3 py-2 whitespace-nowrap">' + accessBadge + '</td>' +
      '<td class="px-3 py-2 whitespace-nowrap"><span class="' + badgeClass + '">' + esc(outcome) + '</span></td>';
    tbody.appendChild(tr);
  });
}

// ===== REPAIR PARTS MANAGER (DVI-603) =====
// Grouped Work Order -> parts view backed by the Phase 1 /rpm/* endpoints.
// Tabs All Parts / Mobile Machine / Preventative Maintenance map to the type
// filter (Settings is a placeholder pane). _rpmGroups backs index-based click
// dispatch so part actions never need to escape filenames into onclick strings.
var _rpmTab = 'all';
var _rpmGroups = [];
var _rpmDlgMode = 'add';
var _rpmDlgFilename = '';
var _rpmDlgType = '';
var _rpmDlgPartId = '';
// Refresh callback run after a successful save in the shared Add/Edit Part
// dialog. The RPM tool sets it to rpmLoad; the WO Admin Parts dialog (DVI-604)
// overrides it so saves refresh that dialog instead.
var _rpmPartOnSaved = null;

function rpmSwitchTab(tab) {
  _rpmTab = tab;
  ['all', 'mobilemachine', 'preventive', 'processing', 'settings'].forEach(function(t) {
    var btn = document.getElementById('rpm-tab-' + t);
    if (!btn) return;
    if (t === tab) {
      btn.classList.remove('text-gray-500', 'dark:text-gray-400', 'hover:text-gray-700', 'dark:hover:text-gray-300', 'border-transparent');
      btn.classList.add('border-togen-teal', 'text-togen-teal');
    } else {
      btn.classList.remove('border-togen-teal', 'text-togen-teal');
      btn.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400', 'hover:text-gray-700', 'dark:hover:text-gray-300');
    }
  });
  var isSettings = (tab === 'settings');
  var isProcessing = (tab === 'processing');
  var isList = !isSettings && !isProcessing;
  // Filters bar only applies to the grouped list tabs.
  document.getElementById('rpm-filters').classList.toggle('hidden', !isList);
  document.getElementById('rpm-settings').classList.toggle('hidden', !isSettings);
  document.getElementById('rpm-processing').classList.toggle('hidden', !isProcessing);
  // Type filter only applies on All Parts; the MM/PM tabs are pre-scoped.
  document.getElementById('rpm-filter-type-wrap').classList.toggle('hidden', tab !== 'all');
  if (!isList) {
    ['rpm-loading', 'rpm-error', 'rpm-empty', 'rpm-groups'].forEach(function(id) {
      document.getElementById(id).classList.add('hidden');
    });
    if (isSettings) rpmSettingsLoad();
    if (isProcessing) rpmLoadProcessing();
    return;
  }
  rpmLoad();
}

function rpmLoad() {
  if (_rpmTab === 'settings' || _rpmTab === 'processing') return;
  var typeVal = (_rpmTab === 'all')
    ? ((document.getElementById('rpm-filter-type') || {}).value || 'all')
    : _rpmTab;
  var params = new URLSearchParams();
  params.set('type', typeVal);
  params.set('approved', (document.getElementById('rpm-filter-approved') || {}).value || 'all');
  params.set('ordered', (document.getElementById('rpm-filter-ordered') || {}).value || 'all');
  params.set('completed', (document.getElementById('rpm-filter-completed') || {}).checked ? 'yes' : 'no');
  document.getElementById('rpm-loading').classList.remove('hidden');
  document.getElementById('rpm-error').classList.add('hidden');
  document.getElementById('rpm-empty').classList.add('hidden');
  document.getElementById('rpm-groups').classList.add('hidden');
  fetch('/rpm/parts?' + params.toString())
    .then(function(r) { return r.json().then(function(d) { return {ok: r.ok, d: d}; }); })
    .then(function(res) {
      document.getElementById('rpm-loading').classList.add('hidden');
      if (!res.ok || res.d.error) {
        document.getElementById('rpm-error-msg').textContent = res.d.error || 'Failed to load parts.';
        document.getElementById('rpm-error').classList.remove('hidden');
        return;
      }
      _rpmGroups = res.d.groups || [];
      if (_rpmGroups.length === 0) {
        document.getElementById('rpm-empty').classList.remove('hidden');
        return;
      }
      _rpmRenderGroups();
      document.getElementById('rpm-groups').classList.remove('hidden');
    })
    .catch(function() {
      document.getElementById('rpm-loading').classList.add('hidden');
      document.getElementById('rpm-error-msg').textContent = 'Network error.';
      document.getElementById('rpm-error').classList.remove('hidden');
    });
}

var _RPM_TYPE_LABEL = {mobilemachine: 'Mobile Machine', preventive: 'Preventative Maintenance'};

function _rpmRenderGroups() {
  var wrap = document.getElementById('rpm-groups');
  wrap.innerHTML = _rpmGroups.map(function(g, gIdx) {
    var partsHtml = (g.parts || []).map(function(p, pIdx) {
      return _rpmPartRow(g, gIdx, p, pIdx);
    }).join('');
    var typeLabel = _RPM_TYPE_LABEL[g.wor_type] || g.wor_type || '';
    return '' +
      '<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden">' +
        '<div class="flex flex-wrap items-center gap-2 px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">' +
          '<div class="min-w-0 flex-1">' +
            '<div class="flex items-center gap-2 min-w-0">' +
              '<div class="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">' + esc(g.title || g.name) + '</div>' +
              (g.status === 'completed' ? '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[11px] font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 flex-shrink-0">Complete</span>' : '') +
            '</div>' +
            '<div class="text-xs text-gray-500 dark:text-gray-400 truncate">' +
              (g.wo_label ? esc(g.wo_label) + ' · ' : '') + esc(typeLabel) +
              ' · ' + (g.part_count || 0) + ' part' + ((g.part_count === 1) ? '' : 's') +
            '</div>' +
          '</div>' +
          '<button onclick="rpmOpenWorkOrder(' + gIdx + ')" ' +
            'class="px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors cursor-pointer inline-flex items-center gap-1.5">' +
            '<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>' +
            'Open Work Order</button>' +
          '<button onclick="rpmAddPart(' + gIdx + ')" ' +
            'class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer inline-flex items-center gap-1.5">' +
            '<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"/></svg>' +
            'Add Parts</button>' +
        '</div>' +
        '<ul class="divide-y divide-gray-100 dark:divide-gray-800">' + partsHtml + '</ul>' +
      '</div>';
  }).join('');
}

function _rpmStatusBadge(label, active) {
  var cls = active
    ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
    : 'bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400';
  return '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[11px] font-medium ' + cls + '">' + label + '</span>';
}

// Brief tracking indicator for a part row (DVI-609): a green "Delivered" badge
// once carrier status is delivered, else the tracking number if one was entered.
function _rpmTrackingMeta(p) {
  if (p && String(p.tracking_status || '').toLowerCase() === 'delivered') {
    return '<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[11px] font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300">Delivered</span>';
  }
  if (p && p.tracking_number) {
    return '<span class="text-xs text-gray-500 dark:text-gray-400">Tracking: <span class="font-mono">' + esc(p.tracking_number) + '</span></span>';
  }
  return '';
}

function _rpmPartRow(g, gIdx, p, pIdx) {
  var meta = [];
  if (p.qty) meta.push('Qty ' + esc(p.qty));
  if (p.amount) meta.push('Amt ' + esc(p.amount));
  var nameLine = esc(p.name || '(unnamed part)');
  var numLine = p.number ? '<span class="font-mono text-xs text-gray-500 dark:text-gray-400">' + esc(p.number) + '</span>' : '';
  var btn = function(onclick, label, extra) {
    return '<button onclick="' + onclick + '" class="px-2 py-1 text-xs font-medium rounded-lg ' + extra + ' transition-colors cursor-pointer">' + label + '</button>';
  };
  var approveBtn;
  if (!p.approved && g && g.can_approve === false) {
    approveBtn = '<button type="button" disabled title="You do not have permission to approve parts." ' +
      'class="px-2 py-1 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 opacity-50 cursor-not-allowed">Mark Approved</button>';
  } else {
    approveBtn = btn('rpmFlagPart(' + gIdx + ',' + pIdx + ',\'approved\')',
      p.approved ? 'Approved ✓' : 'Mark Approved',
      p.approved ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/50'
                 : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600');
  }
  // Stocked items skip ordering: the Ordered button is disabled (DVI-626).
  var orderBtn;
  if (p.stocked && !p.ordered) {
    orderBtn = '<button type="button" disabled title="Stocked items do not need ordering." ' +
      'class="px-2 py-1 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 opacity-50 cursor-not-allowed">Mark Ordered</button>';
  } else {
    orderBtn = btn('rpmFlagPart(' + gIdx + ',' + pIdx + ',\'ordered\')',
      p.ordered ? 'Ordered ✓' : 'Mark Ordered',
      p.ordered ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/50'
                : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600');
  }
  var stockedBtn = btn('rpmFlagPart(' + gIdx + ',' + pIdx + ',\'stocked\')',
    p.stocked ? 'Stocked ✓' : 'Stocked',
    p.stocked ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/50'
              : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600');
  var searchBtn = btn('rpmSearchManuals(' + gIdx + ',' + pIdx + ')', 'Manuals',
    'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600');
  var editBtn = btn('rpmEditPart(' + gIdx + ',' + pIdx + ')', 'Edit',
    'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600');
  var removeBtn = '<button onclick="rpmRemovePart(' + gIdx + ',' + pIdx + ')" title="Remove part" ' +
    'class="px-2 py-1 text-xs font-medium rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors cursor-pointer">&minus;</button>';
  return '' +
    '<li class="flex flex-wrap items-center gap-2 px-4 py-2.5">' +
      '<div class="min-w-0 flex-1">' +
        '<div class="text-sm text-gray-800 dark:text-gray-100 truncate">' + nameLine + '</div>' +
        '<div class="flex items-center gap-2 flex-wrap">' + numLine +
          (meta.length ? '<span class="text-xs text-gray-500 dark:text-gray-400">' + meta.join(' · ') + '</span>' : '') +
          _rpmTrackingMeta(p) +
        '</div>' +
      '</div>' +
      '<div class="flex items-center gap-1.5 flex-shrink-0">' +
        removeBtn + searchBtn + editBtn + approveBtn + orderBtn + stockedBtn +
      '</div>' +
    '</li>';
}

function rpmOpenWorkOrder(gIdx) {
  var g = _rpmGroups[gIdx];
  if (!g) return;
  fetch('/wor-viewer-url?file=' + encodeURIComponent(g.name))
    .then(function(r) { return r.json(); })
    .then(function(d) { if (d.url) window.open(d.url, '_blank'); });
}

// Shared part-flag request. onDone runs only on success. Returns the fetch
// promise so callers can re-enable buttons afterward.
function _rpmFlagRequest(filename, type, id, body, onDone) {
  body.filename = filename; body.type = type; body.id = id;
  return fetch('/rpm/part/flag', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)})
    .then(function(r) { return r.json(); })
    .then(function(d) { if (d.error) { alert(d.error); return; } if (onDone) onDone(); });
}

function rpmFlagPart(gIdx, pIdx, field) {
  var g = _rpmGroups[gIdx]; if (!g) return;
  var p = g.parts[pIdx]; if (!p) return;
  if (field === 'approved' && !p.approved && g.can_approve === false) {
    alert('You do not have permission to approve parts.');
    return;
  }
  // Marking Ordered (off -> on) prompts for an optional tracking number first.
  if (field === 'ordered' && !p.ordered) {
    rpmTrackingOpen(g.name, g.wor_type, p.id, p, rpmLoad);
    return;
  }
  var body = {}; body[field] = !p[field];
  _rpmFlagRequest(g.name, g.wor_type, p.id, body, rpmLoad);
}

// ----- Mark-Ordered tracking popup (DVI-609) -----
var _rpmTrackingCtx = null;

function rpmTrackingOpen(filename, type, id, part, onDone) {
  _rpmTrackingCtx = {filename: filename, type: type, id: id, onDone: onDone};
  document.getElementById('rpm-tracking-part').textContent = (part && (part.name || part.number)) || '';
  document.getElementById('rpm-tracking-number').value = (part && part.tracking_number) || '';
  document.getElementById('rpm-tracking-overlay').classList.remove('hidden');
  setTimeout(function() { document.getElementById('rpm-tracking-number').focus(); }, 0);
}

function rpmTrackingClose() {
  document.getElementById('rpm-tracking-overlay').classList.add('hidden');
  _rpmTrackingCtx = null;
}

// withTracking=true -> mark ordered with the entered tracking number.
// withTracking=false ("Skip") -> mark ordered with no tracking number.
function rpmTrackingConfirm(withTracking) {
  var ctx = _rpmTrackingCtx; if (!ctx) return;
  var body = {ordered: true};
  if (withTracking) body.tracking_number = (document.getElementById('rpm-tracking-number').value || '').trim();
  var btn = document.getElementById('rpm-tracking-save-btn');
  btn.disabled = true;
  var onDone = ctx.onDone;
  _rpmFlagRequest(ctx.filename, ctx.type, ctx.id, body, function() { rpmTrackingClose(); if (onDone) onDone(); })
    .then(function() { btn.disabled = false; });
}

function rpmRemovePart(gIdx, pIdx) {
  var g = _rpmGroups[gIdx]; if (!g) return;
  var p = g.parts[pIdx]; if (!p) return;
  if (!confirm('Remove ' + (p.name || p.number || 'this part') + ' from this work order?')) return;
  fetch('/rpm/part/remove', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({filename: g.name, type: g.wor_type, id: p.id})})
    .then(function(r) { return r.json(); })
    .then(function(d) { if (d.error) { alert(d.error); return; } rpmLoad(); });
}

function rpmAddPart(gIdx) {
  var g = _rpmGroups[gIdx]; if (!g) return;
  _rpmDlgMode = 'add';
  _rpmDlgFilename = g.name;
  _rpmDlgType = g.wor_type;
  _rpmDlgPartId = '';
  _rpmPartOnSaved = rpmLoad;
  document.getElementById('rpm-part-title').textContent = 'Add Part';
  document.getElementById('rpm-part-wo').textContent = g.title || g.name;
  ['number', 'name', 'qty', 'amount'].forEach(function(k) { document.getElementById('rpm-part-' + k).value = ''; });
  _rpmSetTrackingField(false, '');
  _rpmSetStockedField(true);
  _rpmPartShowError('');
  document.getElementById('rpm-part-overlay').classList.remove('hidden');
  setTimeout(function() { document.getElementById('rpm-part-number').focus(); }, 0);
}

// Show/hide + populate the Edit dialog's Tracking Number field. Only ordered
// parts get the field (tracking is meaningless before a part is ordered).
function _rpmSetTrackingField(show, value) {
  document.getElementById('rpm-part-tracking-wrap').classList.toggle('hidden', !show);
  document.getElementById('rpm-part-tracking').value = value || '';
}

// Show/hide + reset the "Stocked Item" checkbox (DVI-626). Only shown when
// adding a part; editing existing parts uses the row's Stocked button instead.
function _rpmSetStockedField(show) {
  document.getElementById('rpm-part-stocked-wrap').classList.toggle('hidden', !show);
  document.getElementById('rpm-part-stocked').checked = false;
}

// ===== Equipment Manuals search for a part (DVI-626) =====
// Shared popup used by both the RPM parts lists and the WO Admin parts dialog.
// Searches the manual attached to the Work Order first; "Search All Manuals"
// widens the search to every manual.
var _rpmManualSearchCtx = null; // {filename, type, label}

function rpmSearchManuals(gIdx, pIdx) {
  var g = _rpmGroups[gIdx]; if (!g) return;
  var p = g.parts[pIdx]; if (!p) return;
  rpmManualSearchOpen(g.name, g.wor_type, p);
}

function woradminSearchManuals(i) {
  if (!_woradminPartsWO) return;
  var p = _woradminPartsWO.parts[i]; if (!p) return;
  rpmManualSearchOpen(_woradminPartsWO.name, _woradminPartsWO.type, p);
}

function rpmManualSearchOpen(filename, type, part) {
  _rpmManualSearchCtx = {filename: filename, type: type};
  var input = document.getElementById('rpm-emsearch-input');
  // Default the query to the part number (most specific), else the name.
  input.value = (part && (part.number || part.name)) || '';
  document.getElementById('rpm-emsearch-results').innerHTML =
    '<p class="text-sm text-gray-500 dark:text-gray-400">Searching…</p>';
  document.getElementById('rpm-emsearch-all-btn').classList.add('hidden');
  document.getElementById('rpm-emsearch-scope').textContent = '';
  document.getElementById('rpm-emsearch-overlay').classList.remove('hidden');
  rpmManualSearchRun('wo');
}

function rpmManualSearchClose() {
  document.getElementById('rpm-emsearch-overlay').classList.add('hidden');
  _rpmManualSearchCtx = null;
}

function rpmManualSearchRun(scope) {
  var ctx = _rpmManualSearchCtx; if (!ctx) return;
  var q = (document.getElementById('rpm-emsearch-input').value || '').trim();
  var results = document.getElementById('rpm-emsearch-results');
  if (q.length < 2) {
    results.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Enter at least 2 characters to search.</p>';
    return;
  }
  results.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Searching…</p>';
  fetch('/rpm/manual-search?scope=' + encodeURIComponent(scope) +
        '&q=' + encodeURIComponent(q) +
        '&filename=' + encodeURIComponent(ctx.filename) +
        '&type=' + encodeURIComponent(ctx.type))
    .then(function(r) { return r.json(); })
    .then(function(d) {
      // Ignore stale responses if the query changed since this request fired.
      var cur = (document.getElementById('rpm-emsearch-input').value || '').trim();
      if (cur !== q) return;
      _rpmManualSearchRender(d, scope);
    })
    .catch(function(err) {
      results.innerHTML = '<p class="text-sm text-red-600 dark:text-red-400">Search error: ' + esc(err.message) + '</p>';
    });
}

function _rpmManualSearchRender(d, scope) {
  var results = document.getElementById('rpm-emsearch-results');
  var scopeEl = document.getElementById('rpm-emsearch-scope');
  var allBtn = document.getElementById('rpm-emsearch-all-btn');
  var hasWoManual = !!(d.wo_manual && d.wo_manual.filename);
  var searchedWo = (scope === 'wo' && hasWoManual);
  // Scope line + "Search All Manuals" button only when a WO manual exists.
  if (searchedWo) {
    scopeEl.textContent = 'Work Order manual: ' + (d.wo_manual.original_name || d.wo_manual.filename);
    allBtn.classList.remove('hidden');
  } else if (hasWoManual) {
    scopeEl.textContent = 'Searching all equipment manuals.';
    allBtn.classList.add('hidden');
  } else {
    scopeEl.textContent = 'No manual is attached to this Work Order — searching all manuals.';
    allBtn.classList.add('hidden');
  }
  var list = d.results || [];
  if (!list.length) {
    var widen = searchedWo
      ? ' Try "Search All Manuals" to look across every manual.'
      : '';
    results.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No matches for “' + esc(d.query) + '”.' + widen + '</p>';
    return;
  }
  var html = '<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">' + list.length +
    ' manual' + (list.length === 1 ? '' : 's') + ' match “' + esc(d.query) + '”</p>';
  list.forEach(function(m) {
    var hits = m.match_count + ' hit' + (m.match_count === 1 ? '' : 's');
    html += '<div class="py-3 px-3 rounded-lg border border-gray-100 dark:border-gray-700 mb-2">' +
      '<div class="flex items-start justify-between gap-3">' +
        '<div class="flex-1 min-w-0">' +
          '<p class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate" title="' + esc(m.equipment) + '">' + esc(m.equipment || '(no equipment name)') + '</p>' +
          '<p class="text-xs text-gray-500 dark:text-gray-400 truncate" title="' + esc(m.original_name) + '">' + esc(m.original_name) + '</p>' +
        '</div>' +
        '<button onclick="window.open(\'' + encodeURI(m.url) + '\',\'_blank\')" class="px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shrink-0">Open</button>' +
      '</div>' +
      '<p class="text-xs text-gray-600 dark:text-gray-300 mt-2 italic">' + esc(m.snippet) + '</p>' +
      '<p class="text-[11px] text-gray-400 dark:text-gray-500 mt-1">First match on page ' + m.page + ' · ' + hits + '</p>' +
    '</div>';
  });
  results.innerHTML = html;
}

function rpmEditPart(gIdx, pIdx) {
  var g = _rpmGroups[gIdx]; if (!g) return;
  var p = g.parts[pIdx]; if (!p) return;
  _rpmDlgMode = 'edit';
  _rpmDlgFilename = g.name;
  _rpmDlgType = g.wor_type;
  _rpmDlgPartId = p.id;
  _rpmPartOnSaved = rpmLoad;
  document.getElementById('rpm-part-title').textContent = 'Edit Part';
  document.getElementById('rpm-part-wo').textContent = g.title || g.name;
  document.getElementById('rpm-part-number').value = p.number || '';
  document.getElementById('rpm-part-name').value = p.name || '';
  document.getElementById('rpm-part-qty').value = p.qty || '';
  document.getElementById('rpm-part-amount').value = p.amount || '';
  _rpmSetTrackingField(!!p.ordered, p.tracking_number || '');
  _rpmSetStockedField(false);
  _rpmPartShowError('');
  document.getElementById('rpm-part-overlay').classList.remove('hidden');
}

function rpmPartClose() {
  document.getElementById('rpm-part-overlay').classList.add('hidden');
}

function _rpmPartShowError(msg) {
  var el = document.getElementById('rpm-part-error');
  el.textContent = msg || '';
  el.classList.toggle('hidden', !msg);
}

// Autofill Part Name from the known-parts catalog when a number is entered and
// the name field is still empty (never clobber a name the user already typed).
function rpmPartAutofillName() {
  var number = (document.getElementById('rpm-part-number').value || '').trim();
  var nameEl = document.getElementById('rpm-part-name');
  if (!number || (nameEl.value || '').trim()) return;
  fetch('/rpm/known-part?number=' + encodeURIComponent(number))
    .then(function(r) { return r.json(); })
    .then(function(d) { if (d.name && !(nameEl.value || '').trim()) nameEl.value = d.name; });
}

function rpmPartSave() {
  var body = {
    filename: _rpmDlgFilename,
    type: _rpmDlgType,
    number: (document.getElementById('rpm-part-number').value || '').trim(),
    name: (document.getElementById('rpm-part-name').value || '').trim(),
    qty: (document.getElementById('rpm-part-qty').value || '').trim(),
    amount: (document.getElementById('rpm-part-amount').value || '').trim()
  };
  if (!(body.number || body.name || body.qty || body.amount)) {
    _rpmPartShowError('Enter at least one part detail.');
    return;
  }
  var url = '/rpm/wo-parts/add';
  if (_rpmDlgMode === 'add') {
    var stockedEl = document.getElementById('rpm-part-stocked');
    if (stockedEl && stockedEl.checked) body.stocked = true;
  }
  if (_rpmDlgMode === 'edit') {
    url = '/rpm/part/update';
    body.id = _rpmDlgPartId;
    var tw = document.getElementById('rpm-part-tracking-wrap');
    if (tw && !tw.classList.contains('hidden')) {
      body.tracking_number = (document.getElementById('rpm-part-tracking').value || '').trim();
    }
  }
  var btn = document.getElementById('rpm-part-save-btn');
  btn.disabled = true;
  fetch(url, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)})
    .then(function(r) { return r.json(); })
    .then(function(d) {
      btn.disabled = false;
      if (d.error) { _rpmPartShowError(d.error); return; }
      rpmPartClose();
      (_rpmPartOnSaved || rpmLoad)();
    })
    .catch(function() { btn.disabled = false; _rpmPartShowError('Network error.'); });
}

// ----- Processing tab (DVI-607) -----
// Flat stores back index-based click dispatch for the two sections.
var _rpmProc = {approval: [], ordered: []};

function rpmLoadProcessing() {
  fetch('/rpm/processing')
    .then(function(r) { return r.json().then(function(d) { return {ok: r.ok, d: d}; }); })
    .then(function(res) {
      if (!res.ok || res.d.error) { return; }
      _rpmProc.approval = res.d.awaiting_approval || [];
      _rpmProc.ordered = res.d.ordered_not_stocked || [];
      _rpmRenderProcessing('rpm-proc-approval', 'rpm-proc-approval-empty', _rpmProc.approval, 'approval');
      _rpmRenderProcessing('rpm-proc-ordered', 'rpm-proc-ordered-empty', _rpmProc.ordered, 'ordered');
    })
    .catch(function() {});
}

function _rpmRenderProcessing(listId, emptyId, items, section) {
  var wrap = document.getElementById(listId);
  document.getElementById(emptyId).classList.toggle('hidden', items.length > 0);
  wrap.innerHTML = items.map(function(it, idx) {
    var p = it.part || {};
    var meta = [];
    if (p.qty) meta.push('Qty ' + esc(p.qty));
    if (p.amount) meta.push('Amt ' + esc(p.amount));
    var numLine = p.number ? '<span class="font-mono text-xs text-gray-500 dark:text-gray-400">' + esc(p.number) + '</span>' : '';
    var typeLabel = _RPM_TYPE_LABEL[it.wor_type] || it.wor_type || '';
    var actionField = (section === 'approval') ? 'approved' : 'stocked';
    var actionLabel = (section === 'approval') ? 'Mark Approved' : 'Mark Stocked';
    return '' +
      '<div class="flex flex-wrap items-center gap-2 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-2.5">' +
        '<div class="min-w-0 flex-1">' +
          '<div class="text-sm text-gray-800 dark:text-gray-100 truncate">' + esc(p.name || '(unnamed part)') + '</div>' +
          '<div class="flex items-center gap-2 flex-wrap">' + numLine +
            (meta.length ? '<span class="text-xs text-gray-500 dark:text-gray-400">' + meta.join(' · ') + '</span>' : '') +
            _rpmTrackingMeta(p) +
          '</div>' +
          '<div class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">' +
            (it.wo_label ? esc(it.wo_label) + ' · ' : '') + esc(it.title || it.name) + ' · ' + esc(typeLabel) +
          '</div>' +
        '</div>' +
        '<div class="flex items-center gap-1.5 flex-shrink-0">' +
          '<button onclick="rpmProcOpenWO(\'' + section + '\',' + idx + ')" ' +
            'class="px-2 py-1 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors cursor-pointer">Open WO</button>' +
          '<button onclick="rpmProcFlag(\'' + section + '\',' + idx + ',\'' + actionField + '\')" ' +
            'class="px-2 py-1 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer">' + actionLabel + '</button>' +
        '</div>' +
      '</div>';
  }).join('');
}

function rpmProcOpenWO(section, idx) {
  var it = _rpmProc[section] && _rpmProc[section][idx];
  if (!it) return;
  fetch('/wor-viewer-url?file=' + encodeURIComponent(it.name))
    .then(function(r) { return r.json(); })
    .then(function(d) { if (d.url) window.open(d.url, '_blank'); });
}

function rpmProcFlag(section, idx, field) {
  var it = _rpmProc[section] && _rpmProc[section][idx];
  if (!it) return;
  var body = {filename: it.name, type: it.wor_type, id: (it.part || {}).id};
  body[field] = true;
  fetch('/rpm/part/flag', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)})
    .then(function(r) { return r.json(); })
    .then(function(d) { if (d.error) { alert(d.error); return; } rpmLoadProcessing(); });
}

// ----- Settings tab: Policy pane (DVI-607) -----
function rpmSettingsLoad() {
  fetch('/rpm/settings')
    .then(function(r) { return r.json(); })
    .then(function(d) {
      var s = d.settings || {};
      var cb = document.getElementById('rpm-policy-assume-stocked');
      if (cb) cb.checked = !!s.assume_stocked_on_complete;
      var cb2 = document.getElementById('rpm-policy-mark-delivered');
      if (cb2) cb2.checked = !!s.mark_stocked_on_delivered;
    })
    .catch(function() {});
  rpmApprovalLoad();
  rpmNotifLoad();
}

function rpmSettingsSave() {
  var cb = document.getElementById('rpm-policy-assume-stocked');
  var cb2 = document.getElementById('rpm-policy-mark-delivered');
  var status = document.getElementById('rpm-settings-status');
  var body = {
    assume_stocked_on_complete: !!(cb && cb.checked),
    mark_stocked_on_delivered: !!(cb2 && cb2.checked),
  };
  if (status) status.textContent = 'Saving…';
  fetch('/rpm/settings', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)})
    .then(function(r) { return r.json(); })
    .then(function(d) {
      if (d.error) { if (status) status.textContent = d.error; return; }
      if (status) { status.textContent = 'Saved.'; setTimeout(function() { status.textContent = ''; }, 2000); }
    })
    .catch(function() { if (status) status.textContent = 'Network error.'; });
}

// ----- Settings tab: Approval permission + Auto Approval (DVI-614) -----
var _rpmApprovalPolicy = null;

// Maps an allowlist key to the policy array it edits.
function _rpmApprovalUserList(key) {
  if (!_rpmApprovalPolicy) return null;
  if (key === 'approve') return _rpmApprovalPolicy.approve_users;
  if (key === 'auto_mobilemachine') return _rpmApprovalPolicy.auto_approval.mobilemachine.users;
  if (key === 'auto_preventive') return _rpmApprovalPolicy.auto_approval.preventive.users;
  if (key === 'policy_mobilemachine') return (_rpmApprovalPolicy.policy_notify || {}).mobilemachine;
  if (key === 'policy_preventive') return (_rpmApprovalPolicy.policy_notify || {}).preventive;
  return null;
}

// Maps an allowlist key to its input + chip-list element ids.
function _rpmAllowlistIds(key) {
  if (key === 'approve') return {input: 'rpm-approve-users-input', list: 'rpm-approve-users-list'};
  if (key === 'auto_mobilemachine') return {input: 'rpm-auto-mobilemachine-users-input', list: 'rpm-auto-mobilemachine-users-list'};
  if (key === 'auto_preventive') return {input: 'rpm-auto-preventive-users-input', list: 'rpm-auto-preventive-users-list'};
  if (key === 'policy_mobilemachine') return {input: 'rpm-policy-mobilemachine-users-input', list: 'rpm-policy-mobilemachine-users-list'};
  if (key === 'policy_preventive') return {input: 'rpm-policy-preventive-users-input', list: 'rpm-policy-preventive-users-list'};
  return {input: '', list: ''};
}

function rpmApprovalLoad() {
  fetch('/rpm/approval-policy')
    .then(function(r) { return r.json(); })
    .then(function(d) { _rpmApprovalPolicy = d.policy || null; rpmApprovalRender(); })
    .catch(function() {});
}

// Render all controls + allowlists from _rpmApprovalPolicy.
function rpmApprovalRender() {
  var p = _rpmApprovalPolicy;
  if (!p) return;
  // Approve mode radios.
  var modeRadios = document.getElementsByName('rpm-approve-mode');
  for (var i = 0; i < modeRadios.length; i++) modeRadios[i].checked = (modeRadios[i].value === p.approve_mode);
  // Auto approval master + per-type radios.
  var en = document.getElementById('rpm-auto-enabled');
  if (en) en.checked = !!(p.auto_approval && p.auto_approval.enabled);
  ['mobilemachine', 'preventive'].forEach(function(wt) {
    var cfg = (p.auto_approval && p.auto_approval[wt]) || {};
    var rs = document.getElementsByName('rpm-auto-mode-' + wt);
    for (var j = 0; j < rs.length; j++) rs[j].checked = (rs[j].value === (cfg.mode || 'approvers'));
  });
  rpmApprovalApplyVisibility();
  rpmRenderUserChips('approve', 'rpm-approve-users-list');
  rpmRenderUserChips('auto_mobilemachine', 'rpm-auto-mobilemachine-users-list');
  rpmRenderUserChips('auto_preventive', 'rpm-auto-preventive-users-list');
  rpmRenderUserChips('policy_mobilemachine', 'rpm-policy-mobilemachine-users-list');
  rpmRenderUserChips('policy_preventive', 'rpm-policy-preventive-users-list');
}

function rpmApprovalApplyVisibility() {
  var p = _rpmApprovalPolicy; if (!p) return;
  document.getElementById('rpm-approve-specific-wrap').classList.toggle('hidden', p.approve_mode !== 'specific');
  document.getElementById('rpm-auto-body').classList.toggle('hidden', !(p.auto_approval && p.auto_approval.enabled));
  ['mobilemachine', 'preventive'].forEach(function(wt) {
    var cfg = (p.auto_approval && p.auto_approval[wt]) || {};
    document.getElementById('rpm-auto-' + wt + '-specific-wrap').classList.toggle('hidden', cfg.mode !== 'specific');
  });
}

function rpmRenderUserChips(key, listId) {
  var list = document.getElementById(listId);
  var arr = _rpmApprovalUserList(key) || [];
  if (!list) return;
  if (!arr.length) { list.innerHTML = '<span class="text-xs text-gray-400 dark:text-gray-500 italic">No users added.</span>'; return; }
  list.innerHTML = arr.map(function(email) {
    return '<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200">' +
      esc(email) +
      '<button type="button" onclick="rpmApproveRemoveUser(\'' + key + '\',\'' + esc(email) + '\')" title="Remove" class="text-gray-400 hover:text-red-500 cursor-pointer">&times;</button>' +
      '</span>';
  }).join('');
}

// Read radios/checkbox into the policy, then save.
function rpmApprovalSaveFromDom() {
  if (!_rpmApprovalPolicy) return;
  var p = _rpmApprovalPolicy;
  var modeRadios = document.getElementsByName('rpm-approve-mode');
  for (var i = 0; i < modeRadios.length; i++) if (modeRadios[i].checked) p.approve_mode = modeRadios[i].value;
  var en = document.getElementById('rpm-auto-enabled');
  p.auto_approval.enabled = !!(en && en.checked);
  ['mobilemachine', 'preventive'].forEach(function(wt) {
    var rs = document.getElementsByName('rpm-auto-mode-' + wt);
    for (var j = 0; j < rs.length; j++) if (rs[j].checked) p.auto_approval[wt].mode = rs[j].value;
  });
  rpmApprovalApplyVisibility();
  rpmApprovalSave();
}

function rpmApproveAddUser(key) {
  var ids = _rpmAllowlistIds(key);
  var input = document.getElementById(ids.input);
  if (!input) return;
  var email = (input.value || '').trim().toLowerCase();
  if (!email || email.indexOf('@') === -1) { alert('Enter a valid email address.'); return; }
  var arr = _rpmApprovalUserList(key);
  if (!arr) return;
  if (arr.indexOf(email) === -1) arr.push(email);
  input.value = '';
  rpmRenderUserChips(key, ids.list);
  rpmApprovalSave();
}

function rpmApproveRemoveUser(key, email) {
  var arr = _rpmApprovalUserList(key);
  if (!arr) return;
  var idx = arr.indexOf(email);
  if (idx !== -1) arr.splice(idx, 1);
  rpmRenderUserChips(key, _rpmAllowlistIds(key).list);
  rpmApprovalSave();
}

function rpmApprovalSave() {
  if (!_rpmApprovalPolicy) return;
  var status = document.getElementById('rpm-approval-status');
  if (status) status.textContent = 'Saving…';
  fetch('/rpm/approval-policy', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({policy: _rpmApprovalPolicy})})
    .then(function(r) { return r.json(); })
    .then(function(d) {
      if (d.error) { if (status) status.textContent = d.error; return; }
      _rpmApprovalPolicy = d.policy || _rpmApprovalPolicy;
      if (status) { status.textContent = 'Saved.'; setTimeout(function() { status.textContent = ''; }, 2000); }
    })
    .catch(function() { if (status) status.textContent = 'Network error.'; });
}

// ----- Settings tab: Notifications grid (DVI-614). Shared with User Settings. -----
var _rpmNotifTypes = [
  {id: 'mobilemachine', label: 'Mobile Machine'},
  {id: 'preventive', label: 'Preventative Maintenance'}
];
var _rpmNotifCols = ['awaiting', 'approved', 'ordered', 'delivered', 'stocked'];
var _rpmNotifGrid = {};

function rpmNotifLoad() {
  fetch('/rpm/notifications')
    .then(function(r) { return r.json(); })
    .then(function(d) {
      _rpmNotifGrid = d.notifications || {};
      rpmNotifBuildRows('rpm-notify-grid');
      rpmNotifBuildRows('us-rpm-notify-grid');
    })
    .catch(function() {});
}

function rpmNotifBuildRows(tbodyId) {
  var tbody = document.getElementById(tbodyId);
  if (!tbody) return;
  tbody.innerHTML = '';
  _rpmNotifTypes.forEach(function(t) {
    var prefs = _rpmNotifGrid[t.id] || {};
    var tr = document.createElement('tr');
    tr.className = 'border-t border-gray-100 dark:border-gray-800';
    var cells = '<td class="text-left py-3 pr-4 font-medium text-gray-800 dark:text-gray-100">' + t.label + '</td>';
    _rpmNotifCols.forEach(function(col) {
      cells += '<td class="text-center px-2 py-3">' +
        '<input type="checkbox" data-rpm-notify-type="' + t.id + '" data-rpm-notify-col="' + col + '"' +
        (prefs[col] ? ' checked' : '') +
        ' class="h-5 w-5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer" /></td>';
    });
    tr.innerHTML = cells;
    tbody.appendChild(tr);
  });
}

function rpmNotifSave(tbodyId) {
  var grid = {};
  _rpmNotifTypes.forEach(function(t) { grid[t.id] = {}; });
  var checks = document.querySelectorAll('#' + tbodyId + ' input[type="checkbox"]');
  for (var i = 0; i < checks.length; i++) {
    var type = checks[i].getAttribute('data-rpm-notify-type');
    var col = checks[i].getAttribute('data-rpm-notify-col');
    if (grid[type]) grid[type][col] = checks[i].checked;
  }
  var isUs = (tbodyId === 'us-rpm-notify-grid');
  var status = document.getElementById(isUs ? 'us-rpm-notify-save-status' : 'rpm-notify-save-status');
  if (status) status.textContent = 'Saving…';
  fetch('/rpm/notifications', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({notifications: grid})})
    .then(function(r) { return r.json(); })
    .then(function(d) {
      if (d.error) { if (status) status.textContent = d.error; return; }
      _rpmNotifGrid = d.notifications || grid;
      rpmNotifBuildRows('rpm-notify-grid');
      rpmNotifBuildRows('us-rpm-notify-grid');
      if (status) { status.textContent = 'Saved.'; setTimeout(function() { status.textContent = ''; }, 2000); }
    })
    .catch(function() { if (status) status.textContent = 'Network error.'; });
}

// ===== SEARCH (DVI-534) =====
// Reuses the reusable top-bar inline field (desktop) / a mobile overlay (mobile).
// Tool/navigation matches are computed client-side from the visible TOOLS list;
// project matches come from the backend /search endpoint. Results render as a
// clickable list; the flat _searchResults store backs the click handlers.
var _SEARCH_ICON = 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z';
var _searchResults = [];
var _searchDebounce = null;
var _searchRenderPanel = null; // function(html) — desktop or mobile sink

function searchIconClick() {
  if (window.matchMedia('(min-width: 768px)').matches) searchOpenDesktop();
  else searchOpenMobile();
}

function searchOpenDesktop() {
  _searchRenderPanel = topbarFieldSetPanel;
  topbarFieldOpen({
    placeholder: 'Search projects & tools…',
    iconPath: _SEARCH_ICON,
    onSubmit: searchQuery,
    onInput: searchQueryDebounced
  });
}

// --- Mobile overlay ---
function searchOpenMobile() {
  _searchRenderPanel = searchMobileSetPanel;
  var input = document.getElementById('search-mobile-input');
  if (input) { input.value = ''; }
  searchMobileSetPanel('');
  document.getElementById('search-mobile-overlay').classList.remove('hidden');
  if (input) setTimeout(function() { input.focus(); }, 0);
}

function searchCloseMobile() {
  document.getElementById('search-mobile-overlay').classList.add('hidden');
}

function searchMobileClickAway(e) {
  if (e.target.id === 'search-mobile-overlay') searchCloseMobile();
}

function searchMobileInput() {
  var input = document.getElementById('search-mobile-input');
  searchQueryDebounced((input.value || '').trim());
}

function searchMobileSetPanel(html) {
  var panel = document.getElementById('search-mobile-panel');
  if (!panel) return;
  panel.innerHTML = html || '';
  panel.classList.toggle('hidden', !html);
}

// --- Query + render ---
function searchQueryDebounced(q) {
  if (_searchDebounce) clearTimeout(_searchDebounce);
  _searchDebounce = setTimeout(function() { searchQuery(q); }, 200);
}

function searchQuery(q) {
  q = (q || '').trim();
  var render = _searchRenderPanel || topbarFieldSetPanel;
  if (!q) { render(''); return; }
  var needle = q.toLowerCase();

  // Tool/navigation matches (client-side, respect visibility/membership).
  var tools = (typeof _visibleSortedTools === 'function') ? _visibleSortedTools() : [];
  var toolMatches = tools.filter(function(t) {
    return (t.title && t.title.toLowerCase().indexOf(needle) !== -1) ||
           (t.description && t.description.toLowerCase().indexOf(needle) !== -1);
  });

  // Project matches (backend). Render tools immediately, then merge projects in.
  searchRenderResults(toolMatches, [], render, true);
  fetch('/search?q=' + encodeURIComponent(q)).then(function(r) {
    return r.ok ? r.json() : { projects: [] };
  }).then(function(d) {
    searchRenderResults(toolMatches, (d && d.projects) || [], render, false);
  }).catch(function() {
    searchRenderResults(toolMatches, [], render, false);
  });
}

function searchRenderResults(toolMatches, projects, render, loading) {
  _searchResults = [];
  var html = '';
  if (toolMatches.length) {
    html += '<p class="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Tools</p>';
    toolMatches.forEach(function(t) {
      var idx = _searchResults.push({ kind: 'tool', id: t.id, title: t.title }) - 1;
      html += searchRow(idx, t.title, 'Open tool');
    });
  }
  if (projects.length) {
    html += '<p class="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Projects</p>';
    projects.forEach(function(p) {
      var idx = _searchResults.push({ kind: 'project', name: p.name, has_meta: p.has_meta }) - 1;
      html += searchRow(idx, p.name, p.has_meta ? 'Open project…' : 'External — import only');
    });
  }
  if (!html) {
    html = loading
      ? '<p class="px-3 py-3 text-gray-400 dark:text-gray-500">Searching…</p>'
      : '<p class="px-3 py-3 text-gray-400 dark:text-gray-500">No matches.</p>';
  }
  render(html);
}

function searchRow(idx, label, sub) {
  return '<button type="button" onclick="searchGoResult(' + idx + ')" ' +
    'class="w-full text-left px-3 py-2 hover:bg-togen-teal/10 dark:hover:bg-togen-teal/20 transition-colors cursor-pointer flex flex-col">' +
      '<span class="truncate text-gray-800 dark:text-gray-100">' + esc(label) + '</span>' +
      '<span class="truncate text-xs text-gray-400 dark:text-gray-500">' + esc(sub) + '</span>' +
    '</button>';
}

function searchGoResult(idx) {
  var r = _searchResults[idx];
  if (!r) return;
  topbarFieldClose();
  searchCloseMobile();
  if (r.kind === 'tool') {
    openTool(r.id, r.title);
  } else if (r.kind === 'project') {
    // Present the same Open Project workflow chooser used by the Start menu's
    // Projects submenu (DVI-291 picker), rather than jumping straight in.
    _workflowPickerFolder = r.name;
    _workflowPickerImportOnly = !r.has_meta;
    document.getElementById('workflow-picker-project').textContent = r.name;
    document.getElementById('workflow-picker-overlay').classList.remove('hidden');
  }
}

// ===== CALENDAR TOOL (DVI-469, events DVI-556) =====
// Client-side month/week/day calendar. Events for the visible range are fetched
// from /calendar/events (the signed-in user's Microsoft 365 calendar) and bucketed
// by local date. Source filters at the top of the view scope which events render;
// Microsoft is the only source for now, more arrive as Togen tools add entries.
var _calToolView = 'month';
var _calToolDate = new Date();
var _calToolEvents = [];          // raw events for the loaded window
var _calToolSourceColors = { microsoft: '#0078d4', workorder: '#0d9488' };
var _calToolLastVisible = [];      // visible events for the current render (day popup source)
var _calToolEventIndex = {};       // id -> parsed event, for click lookups
var _calToolCurrentWO = null;      // { file, wor_type, kind } for the open WO popup

function calToolInit() {
  _calToolDate = new Date();
  // Work Order events are admin-only (WO Admins / Togen Admins); reveal the
  // filter chip only for them (DVI-630).
  var woWrap = document.getElementById('cal-filter-workorder-wrap');
  if (woWrap) woWrap.classList.toggle('hidden', !(_isWorAdmin || _isToGenAdmin));
  calToolSetView('month');
}

function calToolSetView(view) {
  _calToolView = view;
  document.getElementById('cal-month-view').classList.toggle('hidden', view !== 'month');
  document.getElementById('cal-week-view').classList.toggle('hidden', view !== 'week');
  document.getElementById('cal-day-view').classList.toggle('hidden', view !== 'day');
  ['month', 'week', 'day'].forEach(function(v) {
    var b = document.getElementById('cal-view-' + v);
    if (!b) return;
    if (v === view) {
      b.classList.add('bg-white', 'text-togen-teal');
      b.classList.remove('text-white');
    } else {
      b.classList.remove('bg-white', 'text-togen-teal');
      b.classList.add('text-white');
    }
  });
  calToolReload();
}

function calToolToday() {
  _calToolDate = new Date();
  calToolReload();
}

function calToolPrev() { _calToolShift(-1); }
function calToolNext() { _calToolShift(1); }

function _calToolShift(dir) {
  if (_calToolView === 'month') {
    _calToolDate.setMonth(_calToolDate.getMonth() + dir);
  } else if (_calToolView === 'week') {
    _calToolDate.setDate(_calToolDate.getDate() + 7 * dir);
  } else {
    _calToolDate.setDate(_calToolDate.getDate() + dir);
  }
  calToolReload();
}

function _calToolSameDay(a, b) {
  return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}

// Sources currently enabled via the filter checkboxes.
function _calToolEnabledSources() {
  var s = [];
  var ms = document.getElementById('cal-filter-microsoft');
  if (!ms || ms.checked) s.push('microsoft');
  var wo = document.getElementById('cal-filter-workorder');
  var woWrap = document.getElementById('cal-filter-workorder-wrap');
  if (wo && woWrap && !woWrap.classList.contains('hidden') && wo.checked) s.push('workorder');
  return s;
}

// Local-day window [start, end) covering the current view, padded one day on each
// side so events near edges (after timezone conversion) are not dropped.
function _calToolWindow() {
  var start, end;
  if (_calToolView === 'month') {
    start = new Date(_calToolDate.getFullYear(), _calToolDate.getMonth(), 1);
    end = new Date(_calToolDate.getFullYear(), _calToolDate.getMonth() + 1, 1);
  } else if (_calToolView === 'week') {
    start = _calToolWeekStart(_calToolDate);
    end = new Date(start); end.setDate(end.getDate() + 7);
  } else {
    start = new Date(_calToolDate.getFullYear(), _calToolDate.getMonth(), _calToolDate.getDate());
    end = new Date(start); end.setDate(end.getDate() + 1);
  }
  var padStart = new Date(start); padStart.setDate(padStart.getDate() - 1);
  var padEnd = new Date(end); padEnd.setDate(padEnd.getDate() + 1);
  return { start: padStart, end: padEnd };
}

function calToolReload() {
  var status = document.getElementById('cal-tool-status');
  var sources = _calToolEnabledSources();
  if (sources.length === 0) {
    _calToolEvents = [];
    if (status) status.textContent = '';
    _calToolRender();
    return;
  }
  var win = _calToolWindow();
  var qs = 'start=' + encodeURIComponent(win.start.toISOString()) +
           '&end=' + encodeURIComponent(win.end.toISOString());
  if (status) status.textContent = 'Loading…';

  var jobs = [];
  // Microsoft 365 calendar.
  if (sources.indexOf('microsoft') !== -1) {
    jobs.push(fetch('calendar/events?' + qs)
      .then(function(r) { return r.json(); })
      .then(function(data) {
        if (data && data.available && Array.isArray(data.events)) {
          return { events: data.events, status: '' };
        }
        var msg = (data && data.reason === 'permission')
          ? 'Calendar access not granted in Azure AD.'
          : (data && data.reason === 'config')
            ? 'Calendar service not configured.'
            : (data && data.error) ? data.error : '';
        return { events: [], status: msg };
      })
      .catch(function() { return { events: [], status: 'Could not load calendar events.' }; }));
  }
  // Work Order date events (admin-only, DVI-630).
  if (sources.indexOf('workorder') !== -1) {
    jobs.push(fetch('calendar/wo-events?' + qs)
      .then(function(r) { return r.json(); })
      .then(function(data) {
        if (data && data.available && Array.isArray(data.events)) {
          return { events: data.events, status: '' };
        }
        return { events: [], status: '' };
      })
      .catch(function() { return { events: [], status: '' }; }));
  }

  Promise.all(jobs).then(function(results) {
    var merged = [];
    var msgs = [];
    results.forEach(function(res) {
      if (res.events && res.events.length) merged = merged.concat(res.events);
      if (res.status) msgs.push(res.status);
    });
    _calToolEvents = merged;
    if (status) status.textContent = msgs.join(' ');
    _calToolRender();
  });
}

// Events visible under the current source filters, parsed to local Date objects.
function _calToolVisibleEvents() {
  var sources = _calToolEnabledSources();
  return _calToolEvents
    .filter(function(ev) { return sources.indexOf(ev.source) !== -1 && ev.start; })
    .map(function(ev) {
      var s = _calToolParse(ev.start);
      var e = ev.end ? _calToolParse(ev.end) : null;
      return { raw: ev, start: s, end: e };
    })
    .filter(function(ev) { return ev.start && !isNaN(ev.start.getTime()); });
}

// Graph returns UTC datetimes (Prefer: UTC) without a trailing 'Z'; normalize so
// the browser parses them as UTC and converts to local for display.
function _calToolParse(s) {
  if (!s) return null;
  var str = s;
  // Date-only (all-day) values, e.g. Work Order due dates: parse as local
  // midnight so they land on the intended calendar day regardless of timezone.
  var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(str);
  if (m) return new Date(+m[1], (+m[2]) - 1, +m[3]);
  if (!/[zZ]$/.test(str) && !/[+\-]\d\d:?\d\d$/.test(str)) str += 'Z';
  return new Date(str);
}

function _calToolFmtTime(d) {
  return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}

// Events occurring on a given local day, sorted by start time (all-day first).
function _calToolEventsForDay(events, day) {
  return events
    .filter(function(ev) { return _calToolSameDay(ev.start, day); })
    .sort(function(a, b) {
      if (a.raw.all_day !== b.raw.all_day) return a.raw.all_day ? -1 : 1;
      return a.start - b.start;
    });
}

function _calToolRender() {
  var label = document.getElementById('cal-tool-label');
  if (!label) return;
  var events = _calToolVisibleEvents();
  _calToolLastVisible = events;
  _calToolEventIndex = {};
  events.forEach(function(ev) { if (ev.raw && ev.raw.id != null) _calToolEventIndex[String(ev.raw.id)] = ev; });
  if (_calToolView === 'month') {
    label.textContent = _calToolDate.toLocaleString(undefined, { month: 'long', year: 'numeric' });
    _calToolRenderMonth(events);
  } else if (_calToolView === 'week') {
    var ws = _calToolWeekStart(_calToolDate);
    var we = new Date(ws); we.setDate(we.getDate() + 6);
    var startLbl = ws.toLocaleString(undefined, { month: 'short', day: 'numeric' });
    var endLbl = we.toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
    label.textContent = startLbl + ' – ' + endLbl;
    _calToolRenderWeek(ws, events);
  } else {
    label.textContent = _calToolDate.toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
    _calToolRenderDay(events);
  }
}

function _calToolWeekStart(d) {
  var s = new Date(d.getFullYear(), d.getMonth(), d.getDate());
  s.setDate(s.getDate() - s.getDay());
  return s;
}

function _calEsc(s) {
  return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
    return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
  });
}

// Escape an event id for safe inlining inside a single-quoted JS string in an
// onclick attribute.
function _calJsId(s) {
  return String(s == null ? '' : s)
    .replace(/\\/g, '\\\\').replace(/'/g, "\\'")
    .replace(/"/g, '&quot;').replace(/</g, '&lt;');
}

// Compact chip shown inside a month-grid day cell. Work Order chips are
// clickable (open the WO popup); the click is stopped from bubbling to the day
// cell so the day popup does not also open (DVI-630 interaction).
function _calToolEventChip(ev) {
  var color = _calToolSourceColors[ev.raw.source] || '#0078d4';
  var time = ev.raw.all_day ? '' : (_calToolFmtTime(ev.start) + ' ');
  var title = (time + (ev.raw.subject || '')) + (ev.raw.location ? ' — ' + ev.raw.location : '');
  var isWO = ev.raw.source === 'workorder';
  var click = isWO ? ' onclick="event.stopPropagation();calToolOpenWO(\'' + _calJsId(ev.raw.id) + '\')"' : '';
  var extra = isWO ? ' cursor-pointer hover:brightness-95' : '';
  return '<div' + click + ' class="truncate text-[11px] leading-tight px-1 py-0.5 rounded text-gray-700 dark:text-gray-200' + extra + '" ' +
    'style="background:' + color + '1a;border-left:3px solid ' + color + '" title="' + _calEsc(title) + '">' +
    (time ? '<span class="font-semibold">' + _calEsc(time) + '</span>' : '') + _calEsc(ev.raw.subject || '(no title)') + '</div>';
}

// Detailed event row for week/day list views and the day popup. Work Order
// rows are clickable to open the WO popup (DVI-630 interaction).
function _calToolEventRow(ev) {
  var color = _calToolSourceColors[ev.raw.source] || '#0078d4';
  var isWO = ev.raw.source === 'workorder';
  var when;
  if (isWO) {
    when = (ev.raw.wo_date_type || 'Work Order') +
      (ev.raw.wor_type_label ? ' · ' + ev.raw.wor_type_label : '');
  } else {
    when = ev.raw.all_day ? 'All day'
      : (_calToolFmtTime(ev.start) + (ev.end ? ' – ' + _calToolFmtTime(ev.end) : ''));
  }
  var click = isWO ? ' onclick="event.stopPropagation();calToolOpenWO(\'' + _calJsId(ev.raw.id) + '\')"' : '';
  var extra = isWO ? ' cursor-pointer hover:brightness-95' : '';
  return '<div' + click + ' class="flex items-start gap-2 rounded-lg px-2 py-1.5' + extra + '" style="background:' + color + '14">' +
      '<span class="mt-1 inline-block h-2.5 w-2.5 rounded-sm flex-shrink-0" style="background:' + color + '"></span>' +
      '<div class="min-w-0">' +
        '<div class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + _calEsc(ev.raw.subject || '(no title)') + '</div>' +
        '<div class="text-xs text-gray-500 dark:text-gray-400">' + _calEsc(when) +
          (ev.raw.location ? ' · ' + _calEsc(ev.raw.location) : '') + '</div>' +
      '</div>' +
    '</div>';
}

function _calToolRenderMonth(events) {
  var grid = document.getElementById('cal-month-grid');
  var year = _calToolDate.getFullYear();
  var month = _calToolDate.getMonth();
  var today = new Date();
  var firstDay = new Date(year, month, 1).getDay();
  var daysInMonth = new Date(year, month + 1, 0).getDate();
  var html = '';
  for (var b = 0; b < firstDay; b++) {
    html += '<div class="min-h-[5rem] rounded-lg border border-transparent"></div>';
  }
  for (var day = 1; day <= daysInMonth; day++) {
    var cellDate = new Date(year, month, day);
    var isToday = (year === today.getFullYear() && month === today.getMonth() && day === today.getDate());
    var cellCls = 'min-h-[5rem] rounded-lg border p-1.5 text-left transition-colors cursor-pointer ' +
      (isToday
        ? 'border-togen-teal bg-togen-teal/5'
        : 'border-gray-200 dark:border-gray-700 hover:border-togen-teal');
    var numCls = isToday
      ? 'inline-flex items-center justify-center h-6 w-6 rounded-full bg-togen-teal text-white text-sm font-bold'
      : 'text-sm font-medium text-gray-700 dark:text-gray-300';
    var dayEvents = _calToolEventsForDay(events, cellDate);
    var evHtml = '';
    for (var k = 0; k < Math.min(dayEvents.length, 3); k++) {
      evHtml += _calToolEventChip(dayEvents[k]);
    }
    if (dayEvents.length > 3) {
      evHtml += '<div class="text-[11px] text-gray-400 dark:text-gray-500 px-1">+' + (dayEvents.length - 3) + ' more</div>';
    }
    html += '<div onclick="calToolOpenDay(' + year + ',' + month + ',' + day + ')" class="' + cellCls + '"><span class="' + numCls + '">' + day + '</span>' +
      '<div class="mt-1 space-y-0.5">' + evHtml + '</div></div>';
  }
  grid.innerHTML = html;
}

function _calToolRenderWeek(weekStart, events) {
  var container = document.getElementById('cal-week-view');
  var today = new Date();
  var html = '';
  for (var i = 0; i < 7; i++) {
    var d = new Date(weekStart); d.setDate(d.getDate() + i);
    var isToday = _calToolSameDay(d, today);
    var rowCls = 'flex items-start gap-4 rounded-xl border p-3 ' +
      (isToday ? 'border-togen-teal bg-togen-teal/5' : 'border-gray-200 dark:border-gray-700');
    var dayEvents = _calToolEventsForDay(events, d);
    var body = dayEvents.length
      ? dayEvents.map(_calToolEventRow).join('')
      : '<div class="text-sm text-gray-400 dark:text-gray-500">No events</div>';
    html += '<div class="' + rowCls + '">' +
        '<div onclick="calToolOpenDay(' + d.getFullYear() + ',' + d.getMonth() + ',' + d.getDate() + ')" class="flex flex-col items-center justify-center w-14 flex-shrink-0 cursor-pointer rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" title="Open day">' +
          '<span class="text-[11px] uppercase font-semibold text-gray-400 dark:text-gray-500">' + d.toLocaleString(undefined, { weekday: 'short' }) + '</span>' +
          '<span class="text-2xl font-bold ' + (isToday ? 'text-togen-teal' : 'text-gray-700 dark:text-gray-200') + '">' + d.getDate() + '</span>' +
        '</div>' +
        '<div class="flex-1 min-w-0 space-y-1">' + body + '</div>' +
      '</div>';
  }
  container.innerHTML = html;
}

function _calToolRenderDay(events) {
  var container = document.getElementById('cal-day-view');
  var d = _calToolDate;
  var today = new Date();
  var isToday = _calToolSameDay(d, today);
  var headCls = 'flex items-start gap-4 rounded-xl border p-4 ' +
    (isToday ? 'border-togen-teal bg-togen-teal/5' : 'border-gray-200 dark:border-gray-700');
  var dayEvents = _calToolEventsForDay(events, d);
  var body = dayEvents.length
    ? dayEvents.map(_calToolEventRow).join('')
    : '<div class="text-sm text-gray-400 dark:text-gray-500">No events scheduled</div>';
  container.innerHTML =
    '<div class="' + headCls + '">' +
      '<div class="flex flex-col items-center justify-center w-16 flex-shrink-0">' +
        '<span class="text-xs uppercase font-semibold text-gray-400 dark:text-gray-500">' + d.toLocaleString(undefined, { weekday: 'short' }) + '</span>' +
        '<span class="text-3xl font-bold ' + (isToday ? 'text-togen-teal' : 'text-gray-700 dark:text-gray-200') + '">' + d.getDate() + '</span>' +
      '</div>' +
      '<div class="flex-1 min-w-0 space-y-1.5">' + body + '</div>' +
    '</div>';
}

// ----- Day popup: expand a clicked calendar day into a focused daily list -----
function calToolOpenDay(y, m, d) {
  var day = new Date(y, m, d);
  var evs = _calToolEventsForDay(_calToolLastVisible || [], day);
  var titleEl = document.getElementById('cal-day-modal-title');
  if (titleEl) titleEl.textContent = day.toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
  var body = document.getElementById('cal-day-modal-body');
  if (body) {
    body.innerHTML = evs.length
      ? evs.map(_calToolEventRow).join('')
      : '<div class="text-sm text-gray-400 dark:text-gray-500">No events</div>';
  }
  var modal = document.getElementById('cal-day-modal');
  if (modal) modal.classList.remove('hidden');
}

function calToolCloseDay() {
  var modal = document.getElementById('cal-day-modal');
  if (modal) modal.classList.add('hidden');
}

// ----- Work Order popup: details + Open in WOA -----
function _calWoField(label, val) {
  if (!val) return '';
  return '<div><div class="text-[11px] uppercase tracking-wide font-semibold text-gray-400 dark:text-gray-500">' + _calEsc(label) + '</div>' +
    '<div class="text-sm text-gray-800 dark:text-gray-100">' + _calEsc(val) + '</div></div>';
}

function calToolOpenWO(id) {
  var ev = _calToolEventIndex[String(id)];
  if (!ev) return;
  var r = ev.raw;
  _calToolCurrentWO = { file: r.wo_file || '', wor_type: r.wor_type || 'maintenance', kind: r.wo_kind };
  var dateStr = (ev.start && !isNaN(ev.start.getTime()))
    ? ev.start.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
    : '';
  var rows = '';
  rows += _calWoField('Work Order', r.subject || '(no title)');
  rows += _calWoField('Type', r.wor_type_label || '');
  rows += _calWoField(r.wo_date_type || 'Date', dateStr);
  rows += _calWoField('Location', r.location || '');
  if (r.wo_kind === 'recurring') {
    rows += '<p class="text-xs text-gray-400 dark:text-gray-500">This is a recurring schedule occurrence; "Open in WOA" focuses the source work order.</p>';
  }
  var body = document.getElementById('cal-wo-modal-body');
  if (body) body.innerHTML = rows;
  var btn = document.getElementById('cal-wo-open-woa-btn');
  if (btn) btn.classList.toggle('hidden', !_calToolCurrentWO.file);
  var modal = document.getElementById('cal-wo-modal');
  if (modal) modal.classList.remove('hidden');
}

function calToolCloseWO() {
  var modal = document.getElementById('cal-wo-modal');
  if (modal) modal.classList.add('hidden');
}

// Open the Work Order Admin tool and focus the selected work order, as if it
// were clicked in WOA. The list loads asynchronously on first open, so poll
// until _woradminData is populated before selecting (DVI-630 interaction).
function calToolWoOpenInWOA() {
  var wo = _calToolCurrentWO;
  if (!wo || !wo.file) return;
  calToolCloseWO();
  calToolCloseDay();
  openTool('wor-admin', 'Work Order Admin');
  _calToolFocusWOWhenReady(wo.file, wo.wor_type, 0);
}

function _calToolFocusWOWhenReady(name, worType, tries) {
  if (typeof _woradminData !== 'undefined' && _woradminData && _woradminData.length) {
    if (typeof woradminSelectByName === 'function') woradminSelectByName(name);
    return;
  }
  if (tries > 40) {
    // Give up waiting for the list; load the preview directly as a fallback.
    if (typeof woradminPreview === 'function') woradminPreview(name, worType || 'maintenance');
    return;
  }
  setTimeout(function() { _calToolFocusWOWhenReady(name, worType, tries + 1); }, 150);
}

// ===== WORK ORDER ADMIN — EMBEDDED CALENDAR (DVI-746) =====
// A compact, Work-Order-only calendar shown inside the Work Order Admin tool.
// Defaults to the Day view, offers a Month/Week/Day selector, and reuses the
// Calendar tool's generic date/render helpers (_calToolParse, _calToolSameDay,
// _calToolEventsForDay, _calToolEventChip, _calToolEventRow, _calToolWeekStart).
// Only the /calendar/wo-events source is loaded so Microsoft and other entries
// are excluded. Clicking a Work Order opens it in the WOA list/preview.
var _woracalView = 'day';
var _woracalDate = new Date();
var _woracalEvents = [];     // parsed {raw,start,end} list for the loaded window

function woracalInit() {
  _woracalDate = new Date();
  woracalSetView('day');
}

function woracalSetView(view) {
  _woracalView = view;
  var mv = document.getElementById('woracal-month-view');
  var wv = document.getElementById('woracal-week-view');
  var dv = document.getElementById('woracal-day-view');
  if (mv) mv.classList.toggle('hidden', view !== 'month');
  if (wv) wv.classList.toggle('hidden', view !== 'week');
  if (dv) dv.classList.toggle('hidden', view !== 'day');
  ['month', 'week', 'day'].forEach(function(v) {
    var b = document.getElementById('woracal-view-' + v);
    if (!b) return;
    if (v === view) { b.classList.add('bg-white', 'text-togen-teal'); b.classList.remove('text-white'); }
    else { b.classList.remove('bg-white', 'text-togen-teal'); b.classList.add('text-white'); }
  });
  woracalReload();
}

function woracalToday() { _woracalDate = new Date(); woracalReload(); }
function woracalPrev() { _woracalShift(-1); }
function woracalNext() { _woracalShift(1); }

function _woracalShift(dir) {
  if (_woracalView === 'month') _woracalDate.setMonth(_woracalDate.getMonth() + dir);
  else if (_woracalView === 'week') _woracalDate.setDate(_woracalDate.getDate() + 7 * dir);
  else _woracalDate.setDate(_woracalDate.getDate() + dir);
  woracalReload();
}

function _woracalWindow() {
  var start, end;
  if (_woracalView === 'month') {
    start = new Date(_woracalDate.getFullYear(), _woracalDate.getMonth(), 1);
    end = new Date(_woracalDate.getFullYear(), _woracalDate.getMonth() + 1, 1);
  } else if (_woracalView === 'week') {
    start = _calToolWeekStart(_woracalDate);
    end = new Date(start); end.setDate(end.getDate() + 7);
  } else {
    start = new Date(_woracalDate.getFullYear(), _woracalDate.getMonth(), _woracalDate.getDate());
    end = new Date(start); end.setDate(end.getDate() + 1);
  }
  var padStart = new Date(start); padStart.setDate(padStart.getDate() - 1);
  var padEnd = new Date(end); padEnd.setDate(padEnd.getDate() + 1);
  return { start: padStart, end: padEnd };
}

function woracalReload() {
  var status = document.getElementById('woracal-status');
  var win = _woracalWindow();
  var qs = 'start=' + encodeURIComponent(win.start.toISOString()) +
           '&end=' + encodeURIComponent(win.end.toISOString());
  if (status) status.textContent = 'Loading…';
  fetch('calendar/wo-events?' + qs)
    .then(function(r) { return r.json(); })
    .then(function(data) {
      var raw = (data && data.available && Array.isArray(data.events)) ? data.events : [];
      _woracalEvents = raw
        .filter(function(ev) { return ev.start; })
        .map(function(ev) {
          return { raw: ev, start: _calToolParse(ev.start), end: ev.end ? _calToolParse(ev.end) : null };
        })
        .filter(function(ev) { return ev.start && !isNaN(ev.start.getTime()); });
      if (status) status.textContent = '';
      _woracalRender();
    })
    .catch(function() {
      _woracalEvents = [];
      if (status) status.textContent = 'Could not load work order events.';
      _woracalRender();
    });
}

function _woracalRender() {
  var label = document.getElementById('woracal-label');
  if (!label) return;
  var events = _woracalEvents;
  // Make events clickable through the shared calendar popup helpers.
  _calToolLastVisible = events;
  _calToolEventIndex = {};
  events.forEach(function(ev) { if (ev.raw && ev.raw.id != null) _calToolEventIndex[String(ev.raw.id)] = ev; });
  if (_woracalView === 'month') {
    label.textContent = _woracalDate.toLocaleString(undefined, { month: 'long', year: 'numeric' });
    _woracalRenderMonth(events);
  } else if (_woracalView === 'week') {
    var ws = _calToolWeekStart(_woracalDate);
    var we = new Date(ws); we.setDate(we.getDate() + 6);
    label.textContent = ws.toLocaleString(undefined, { month: 'short', day: 'numeric' }) + ' – ' +
      we.toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
    _woracalRenderWeek(ws, events);
  } else {
    label.textContent = _woracalDate.toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
    _woracalRenderDay(events);
  }
}

function _woracalRenderMonth(events) {
  var grid = document.getElementById('woracal-month-grid');
  if (!grid) return;
  var year = _woracalDate.getFullYear(), month = _woracalDate.getMonth();
  var today = new Date();
  var firstDay = new Date(year, month, 1).getDay();
  var daysInMonth = new Date(year, month + 1, 0).getDate();
  var html = '';
  for (var b = 0; b < firstDay; b++) html += '<div class="min-h-[5rem] rounded-lg border border-transparent"></div>';
  for (var day = 1; day <= daysInMonth; day++) {
    var cellDate = new Date(year, month, day);
    var isToday = (year === today.getFullYear() && month === today.getMonth() && day === today.getDate());
    var cellCls = 'min-h-[5rem] rounded-lg border p-1.5 text-left transition-colors ' +
      (isToday ? 'border-togen-teal bg-togen-teal/5' : 'border-gray-200 dark:border-gray-700');
    var numCls = isToday
      ? 'inline-flex items-center justify-center h-6 w-6 rounded-full bg-togen-teal text-white text-sm font-bold'
      : 'text-sm font-medium text-gray-700 dark:text-gray-300';
    var dayEvents = _calToolEventsForDay(events, cellDate);
    var evHtml = '';
    for (var k = 0; k < Math.min(dayEvents.length, 3); k++) evHtml += _calToolEventChip(dayEvents[k]);
    if (dayEvents.length > 3) evHtml += '<div class="text-[11px] text-gray-400 dark:text-gray-500 px-1">+' + (dayEvents.length - 3) + ' more</div>';
    html += '<div class="' + cellCls + '"><span class="' + numCls + '">' + day + '</span>' +
      '<div class="mt-1 space-y-0.5">' + evHtml + '</div></div>';
  }
  grid.innerHTML = html;
}

function _woracalRenderWeek(weekStart, events) {
  var container = document.getElementById('woracal-week-view');
  if (!container) return;
  var today = new Date();
  var html = '';
  for (var i = 0; i < 7; i++) {
    var d = new Date(weekStart); d.setDate(d.getDate() + i);
    var isToday = _calToolSameDay(d, today);
    var rowCls = 'flex items-start gap-4 rounded-xl border p-3 ' +
      (isToday ? 'border-togen-teal bg-togen-teal/5' : 'border-gray-200 dark:border-gray-700');
    var dayEvents = _calToolEventsForDay(events, d);
    var body = dayEvents.length ? dayEvents.map(_calToolEventRow).join('')
      : '<div class="text-sm text-gray-400 dark:text-gray-500">No work orders</div>';
    html += '<div class="' + rowCls + '">' +
        '<div class="flex flex-col items-center justify-center w-14 flex-shrink-0">' +
          '<span class="text-[11px] uppercase font-semibold text-gray-400 dark:text-gray-500">' + d.toLocaleString(undefined, { weekday: 'short' }) + '</span>' +
          '<span class="text-2xl font-bold ' + (isToday ? 'text-togen-teal' : 'text-gray-700 dark:text-gray-200') + '">' + d.getDate() + '</span>' +
        '</div>' +
        '<div class="flex-1 min-w-0 space-y-1">' + body + '</div>' +
      '</div>';
  }
  container.innerHTML = html;
}

function _woracalRenderDay(events) {
  var container = document.getElementById('woracal-day-view');
  if (!container) return;
  var d = _woracalDate;
  var isToday = _calToolSameDay(d, new Date());
  var headCls = 'flex items-start gap-4 rounded-xl border p-4 ' +
    (isToday ? 'border-togen-teal bg-togen-teal/5' : 'border-gray-200 dark:border-gray-700');
  var dayEvents = _calToolEventsForDay(events, d);
  var body = dayEvents.length ? dayEvents.map(_calToolEventRow).join('')
    : '<div class="text-sm text-gray-400 dark:text-gray-500">No work orders scheduled</div>';
  container.innerHTML =
    '<div class="' + headCls + '">' +
      '<div class="flex flex-col items-center justify-center w-16 flex-shrink-0">' +
        '<span class="text-xs uppercase font-semibold text-gray-400 dark:text-gray-500">' + d.toLocaleString(undefined, { weekday: 'short' }) + '</span>' +
        '<span class="text-3xl font-bold ' + (isToday ? 'text-togen-teal' : 'text-gray-700 dark:text-gray-200') + '">' + d.getDate() + '</span>' +
      '</div>' +
      '<div class="flex-1 min-w-0 space-y-1.5">' + body + '</div>' +
    '</div>';
}

// ===== PROFILE MENU =====
function toggleProfileMenu() {
  document.getElementById('profile-menu').classList.toggle('hidden');
  document.getElementById('start-menu').classList.add('hidden');
  document.getElementById('datetime-dropdown').classList.add('hidden');
  var sSub = document.getElementById('start-shared-submenu');
  if (sSub) sSub.classList.add('hidden');
}

function handleLogout() {
  window.location.href = '/logout';
}

document.addEventListener('click', function(e) {
  var btn  = document.getElementById('profile-btn');
  var menu = document.getElementById('profile-menu');
  if (!btn.contains(e.target) && !menu.contains(e.target)) {
    menu.classList.add('hidden');
  }
});

initThemeIcons();
applyBrandingBackground();
applyPaneAlpha();

// Re-evaluate background + pane transparency when crossing the 768px breakpoint
// (DVI-364: corporate bg and transparency are desktop-only).
(function() {
  var _wasMobile = window.innerWidth < 768;
  window.addEventListener('resize', function() {
    var isMobile = window.innerWidth < 768;
    if (isMobile !== _wasMobile) {
      _wasMobile = isMobile;
      applyBrandingBackground();
      applyPaneAlpha();
    }
  });
})();

// ===== START MENU =====
var _startSharedFolders = [];
var _startSharedLoaded = false;

function toggleStartMenu(e) {
  e.stopPropagation();
  var menu = document.getElementById('start-menu');
  var wasHidden = menu.classList.contains('hidden');
  menu.classList.toggle('hidden');
  document.getElementById('help-menu').classList.add('hidden');
  document.getElementById('profile-menu').classList.add('hidden');
  document.getElementById('datetime-dropdown').classList.add('hidden');
  if (wasHidden && !_startSharedLoaded) {
    _startSharedLoaded = true;
    loadStartSharedFolders();
  }
}

function loadStartSharedFolders() {
  fetch('list-shared-folders')
    .then(function(r){ return r.json(); })
    .then(function(data) {
      _startSharedFolders = data.folders || [];
      renderStartSharedFolders(_startSharedFolders);
    })
    .catch(function() {
      document.getElementById('start-shared-list').innerHTML = '<p class="text-xs text-red-500 dark:text-red-400 px-4 py-3">Failed to load.</p>';
    });
}

function renderStartSharedFolders(folders) {
  var container = document.getElementById('start-shared-list');
  if (folders.length === 0) {
    container.innerHTML = '<p class="text-xs text-gray-400 dark:text-gray-500 px-4 py-3">No folders found.</p>';
    return;
  }
  container.innerHTML = '';
  folders.forEach(function(name) {
    var btn = document.createElement('button');
    btn.className = 'w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors truncate';
    btn.textContent = name;
    btn.title = name;
    btn.onclick = function() { startMenuOpenSharedFolder(name); };
    container.appendChild(btn);
  });
}

function filterStartSharedFolders(q) {
  var lower = q.trim().toLowerCase();
  var filtered = lower ? _startSharedFolders.filter(function(n){ return n.toLowerCase().includes(lower); }) : _startSharedFolders;
  renderStartSharedFolders(filtered);
}

function toggleStartSharedSubmenu(e) {
  e.stopPropagation();
  document.getElementById('start-shared-submenu').classList.toggle('hidden');
}

function startMenuNew() {
  document.getElementById('start-menu').classList.add('hidden');
  openTool('project-builder', 'Project Builder');
}

function startMenuWorkOrder() {
  document.getElementById('start-menu').classList.add('hidden');
  openTool('work-order', 'Work Order Request');
}

function startMenuProductionRun() {
  document.getElementById('start-menu').classList.add('hidden');
  openTool('prod-run-merge', 'Production Run');
}

function startMenuSingleCarrierSheet() {
  document.getElementById('start-menu').classList.add('hidden');
  openTool('single-carrier-sheet', 'Single Carrier Sheet');
}

function startMenuMtsLabel() {
  document.getElementById('start-menu').classList.add('hidden');
  openTool('mts-label', 'Made To Stock Label Creator');
}

var _workflowPickerFolder = '';
var _workflowPickerImportOnly = false;

function startMenuOpenSharedFolder(folderName) {
  document.getElementById('start-menu').classList.add('hidden');
  document.getElementById('start-shared-submenu').classList.add('hidden');
  _workflowPickerFolder = folderName;
  _workflowPickerImportOnly = false;
  document.getElementById('workflow-picker-project').textContent = folderName;
  document.getElementById('workflow-picker-overlay').classList.remove('hidden');
}

function closeWorkflowPicker() {
  document.getElementById('workflow-picker-overlay').classList.add('hidden');
  _workflowPickerFolder = '';
  _workflowPickerImportOnly = false;
}

function workflowPickerOpen(workflow) {
  var folderName = _workflowPickerFolder;
  var importOnly = _workflowPickerImportOnly;
  closeWorkflowPicker();
  if (workflow === 'project-builder') {
    openTool('project-builder', 'Project Builder');
    // Folders surfaced by search that lack project metadata can only be imported.
    if (importOnly) { if (typeof spStartImport === 'function') spStartImport(folderName); }
    else { spLoadProject(folderName); }
  } else if (workflow === 'takeoff-bridge') {
    openTool('takeoff-bridge', 'Take-off Bridge');
    fetch('list-shared-folders')
      .then(function(r){ return r.json(); })
      .then(function(data) {
        _allSharedFolders = data.folders || [];
        renderSharedFolders(_allSharedFolders);
        _sharedFoldersLoaded = true;
        var btns = document.querySelectorAll('#shared-folders-list button');
        for (var i = 0; i < btns.length; i++) {
          if (btns[i].textContent === folderName) {
            selectSharedFolder(folderName, btns[i]);
            break;
          }
        }
      });
  }
}

document.addEventListener('click', function(e) {
  var sBtn = document.getElementById('start-menu-btn');
  var sMenu = document.getElementById('start-menu');
  var sSub = document.getElementById('start-shared-submenu');
  if (sBtn && sMenu && !sBtn.contains(e.target) && !sMenu.contains(e.target) && (!sSub || !sSub.contains(e.target))) {
    sMenu.classList.add('hidden');
    if (sSub) sSub.classList.add('hidden');
  }
});

// ===== HELP MENU & TUTORIALS PANE =====
var _tutorialsPaneOpen = false;

function toggleHelpMenu(e) {
  e.stopPropagation();
  document.getElementById('help-menu').classList.toggle('hidden');
  document.getElementById('profile-menu').classList.add('hidden');
  document.getElementById('start-menu').classList.add('hidden');
  document.getElementById('datetime-dropdown').classList.add('hidden');
  var sSub = document.getElementById('start-shared-submenu');
  if (sSub) sSub.classList.add('hidden');
}

document.addEventListener('click', function(e) {
  var hBtn  = document.getElementById('help-btn');
  var hMenu = document.getElementById('help-menu');
  if (hBtn && hMenu && !hBtn.contains(e.target) && !hMenu.contains(e.target)) {
    hMenu.classList.add('hidden');
  }
});

function openTutorialsPane() {
  document.getElementById('help-menu').classList.add('hidden');
  var pane = document.getElementById('tutorials-pane');
  var main = document.getElementById('main-content');
  pane.classList.remove('hidden');
  main.style.paddingTop = '1.5rem';
  loadTutorials();
  _tutorialsPaneOpen = true;
}

function closeTutorialsPane() {
  var pane = document.getElementById('tutorials-pane');
  var main = document.getElementById('main-content');
  pane.classList.add('hidden');
  main.style.paddingTop = '';
  _tutorialsPaneOpen = false;
}

function openTutorialsPage() {
  closeTutorialsPane();
  openTool('tutorials', 'Tutorials');
}

function _renderTutorialGrid(gridId, emptyId) {
  var grid = document.getElementById(gridId);
  var empty = document.getElementById(emptyId);
  fetch('/togen/tutorials/manifest.json').then(function(r) {
    if (!r.ok) throw new Error('no manifest');
    return r.json();
  }).then(function(tutorials) {
    if (!tutorials || !tutorials.length) {
      grid.classList.add('hidden');
      empty.classList.remove('hidden');
      return;
    }
    grid.classList.remove('hidden');
    empty.classList.add('hidden');
    grid.innerHTML = '';
    tutorials.forEach(function(t) {
      var card = document.createElement('a');
      card.href = t.url || '#';
      card.target = '_blank';
      card.className = 'flex flex-col items-center gap-2 p-4 bg-gray-50 dark:bg-gray-800 rounded-xl ' +
                       'border border-gray-200 dark:border-gray-700 hover:border-togen-teal dark:hover:border-togen-teal ' +
                       'hover:shadow-md transition-all duration-200 text-center cursor-pointer';
      var iconHtml = t.thumbnail
        ? '<img src="' + esc(t.thumbnail) + '" alt="" class="h-12 w-12 rounded-lg object-cover">'
        : '<svg class="h-12 w-12 text-gray-400 dark:text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">' +
          '<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>' +
          '<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>' +
          '</svg>';
      card.innerHTML = iconHtml +
        '<p class="text-xs font-medium text-gray-700 dark:text-gray-300 leading-tight">' + esc(t.title || 'Tutorial') + '</p>';
      grid.appendChild(card);
    });
  }).catch(function() {
    grid.classList.add('hidden');
    empty.classList.remove('hidden');
  });
}

function loadTutorials() {
  _renderTutorialGrid('tutorials-grid', 'tutorials-empty');
}

function loadTutorialsFullPage() {
  _renderTutorialGrid('tutorials-fullpage-grid', 'tutorials-fullpage-empty');
}

// ===== SUPPORT REQUEST =====
var _srInitialized = false;

function openSupportRequest() {
  document.getElementById('help-menu').classList.add('hidden');
  closeTutorialsPane();
  var toolTitle = 'Support Request';
  var prevTool = _activeTool;
  openTool('support-request', toolTitle);
  if (!_srInitialized) {
    _srInitialized = true;
  }
  // Populate captured context
  var f = document.getElementById('sr-form');
  if (f) {
    var ssoName = '{{ user_name }}';
    var ssoEmail = '{{ user_email }}';
    if (ssoName && ssoName !== 'User') f.elements['sr_name'].value = ssoName;
    if (ssoEmail) f.elements['sr_email'].value = ssoEmail;
    f.elements['sr_datetime'].value = new Date().toLocaleString();
    f.elements['sr_active_tool'].value = prevTool
      ? TOOLS.filter(function(t){ return t.id === prevTool; }).map(function(t){ return t.title; })[0] || prevTool
      : 'Home / None';
    // Capture app state JSON
    var state = {
      activeTool: prevTool || null,
      availableTools: TOOLS.map(function(t){ return {id: t.id, title: t.title}; }),
      tutorialsPaneOpen: _tutorialsPaneOpen,
      url: window.location.href,
      userAgent: navigator.userAgent,
      screenSize: window.innerWidth + 'x' + window.innerHeight,
      timestamp: new Date().toISOString()
    };
    f.elements['sr_app_state'].value = JSON.stringify(state, null, 2);
    f.elements['sr_send_to'].value = 'icast.voyagetech.com@email.autotask.net';
  }
  document.getElementById('sr-submit-status').textContent = '';
}

// ===== PROJECT BUILDER =====
var _spProducts = [];
var _spSubmittedFolder = '';
var _spProjectNumber = '';
var _spFolderEdited = false;
var _spEditingFolder = '';
var _spImportSourceFolder = '';

var _SP_FIELDS = [
  {id: 'tranId', csv: 'tranId'},
  {id: 'structure', csv: 'itemLine_custom:Structure'},
  {id: 'item', csv: 'itemLine_item'},
  {id: 'quantity', csv: 'itemLine_quantity'},
  {id: 'salesPrice', csv: 'itemLine_salesPrice'},
  {id: 'weight', csv: 'itemLine_weight'},
  {id: 'itemSize', csv: 'Item_Size'},
  {id: 'partRef', csv: 'Part Reference'},
  {id: 'description', csv: 'itemLine_description'}
];

var _SP_DEFAULTS = {quantity: '1', salesPrice: '0'};

function spFetchProjectNumber() {
  fetch('project-builder-next-number')
    .then(function(r){ return r.json(); })
    .then(function(data) {
      if (data.number) {
        _spProjectNumber = data.number;
        spUpdateFolderName();
      }
    });
}

function spUpdateFolderName() {
  if (_spFolderEdited) return;
  var tranId = '';
  if (_spProducts.length > 0) {
    tranId = _spProducts[0].tranId || '';
  } else {
    tranId = document.getElementById('sp-field-tranId').value.trim();
  }
  var vendorCode = document.getElementById('sp-vendor-project-code').value.trim();
  var projName = document.getElementById('sp-project-name').value.trim();
  var parts = [];
  if (tranId) parts.push(tranId);
  if (vendorCode) parts.push(vendorCode);
  if (projName) parts.push(projName);
  if (_spProjectNumber) parts.push(_spProjectNumber);
  document.getElementById('sp-folder-name').value = parts.join(' - ');
}

function spPrefillDefaults() {
  document.getElementById('sp-field-quantity').value = _SP_DEFAULTS.quantity;
  document.getElementById('sp-field-salesPrice').value = _SP_DEFAULTS.salesPrice;
  if (_spProducts.length > 0) {
    var first = _spProducts[0];
    document.getElementById('sp-field-tranId').value = first.tranId || '';
    document.getElementById('sp-field-structure').value = first.structure || '';
  }
}

function spAddProduct() {
  var name = document.getElementById('sp-project-name').value.trim();
  if (!name) {
    document.getElementById('sp-add-status').textContent = 'Please enter a project name first.';
    document.getElementById('sp-add-status').className = 'text-sm text-red-600 dark:text-red-400';
    return;
  }
  var tranIdVal = document.getElementById('sp-field-tranId').value.trim();
  if (!tranIdVal) {
    document.getElementById('sp-add-status').textContent = 'Please enter a Transaction ID.';
    document.getElementById('sp-add-status').className = 'text-sm text-red-600 dark:text-red-400';
    return;
  }
  var row = {};
  var hasValue = false;
  _SP_FIELDS.forEach(function(f) {
    var v = document.getElementById('sp-field-' + f.id).value.trim();
    row[f.id] = v;
    if (v && v !== _SP_DEFAULTS[f.id]) hasValue = true;
  });
  if (!hasValue) {
    document.getElementById('sp-add-status').textContent = 'Please fill in at least one field.';
    document.getElementById('sp-add-status').className = 'text-sm text-red-600 dark:text-red-400';
    return;
  }
  _spProducts.push(row);
  _SP_FIELDS.forEach(function(f) { document.getElementById('sp-field-' + f.id).value = ''; });
  document.getElementById('sp-add-status').textContent = 'Product #' + _spProducts.length + ' added.';
  document.getElementById('sp-add-status').className = 'text-sm text-green-600 dark:text-green-400';
  spRenderProducts();
  spPrefillDefaults();
  spUpdateFolderName();
}

function spRemoveProduct(idx) {
  _spProducts.splice(idx, 1);
  spRenderProducts();
  spUpdateFolderName();
}

var _spEditIndex = -1;

function spEditProduct(idx) {
  _spEditIndex = idx;
  var row = _spProducts[idx];
  _SP_FIELDS.forEach(function(f) {
    document.getElementById('sp-edit-' + f.id).value = row[f.id] || '';
  });
  document.getElementById('sp-edit-status').textContent = '';
  spRenderEditDrawings();
  document.getElementById('sp-edit-modal').classList.remove('hidden');
}

// Drawing attachments (DVI-658) require an existing project folder: an opened
// project (_spEditingFolder) or one already submitted this session
// (_spSubmittedFolder). Brand-new / not-yet-imported projects have no folder yet.
function spDrawingFolder() {
  return _spEditingFolder || _spSubmittedFolder || '';
}

function _spDrawingKeyFields() {
  var row = _spProducts[_spEditIndex] || {};
  return {
    row_index: _spEditIndex,
    tranId: row.tranId || '',
    structure: row.structure || '',
    item: row.item || '',
    partRef: row.partRef || ''
  };
}

// Reflect the current row's shop/submittal state in the Edit dialog. When no
// project folder exists yet the zones are disabled with an explanatory note.
function spRenderEditDrawings() {
  var folder = spDrawingFolder();
  var hasFolder = !!folder;
  document.getElementById('sp-edit-draw-note').classList.toggle('hidden', hasFolder);
  document.getElementById('sp-edit-draw-zones').classList.toggle('opacity-50', !hasFolder);
  document.getElementById('sp-edit-draw-zones').classList.toggle('pointer-events-none', !hasFolder);
  var row = _spProducts[_spEditIndex] || {};
  ['shop', 'submittal'].forEach(function(ft) {
    var url = ft === 'shop' ? row.shopUrl : row.submittalUrl;
    var has = !!(url && String(url).trim());
    var status = document.getElementById('sp-edit-' + ft + '-status');
    var detach = document.getElementById('sp-edit-' + ft + '-detach');
    if (has) {
      var name = decodeURIComponent(String(url).split('/').pop() || '');
      status.textContent = '✓ ' + name;
      status.className = 'text-xs text-green-700 dark:text-green-400 font-medium mb-2 break-all';
      detach.classList.remove('hidden');
    } else {
      status.textContent = 'No file attached';
      status.className = 'text-xs text-gray-400 dark:text-gray-500 mb-2 break-all';
      detach.classList.add('hidden');
    }
  });
}

function spDrawingDragOver(ev, ft) {
  ev.preventDefault();
  if (!spDrawingFolder()) return;
  document.getElementById('sp-edit-' + ft + '-zone').classList.add('border-togen-teal');
}
function spDrawingDragLeave(ft) {
  document.getElementById('sp-edit-' + ft + '-zone').classList.remove('border-togen-teal');
}
function spDrawingDrop(ev, ft) {
  ev.preventDefault();
  document.getElementById('sp-edit-' + ft + '-zone').classList.remove('border-togen-teal');
  if (!spDrawingFolder()) return;
  if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0]) {
    spDrawingUploadFile(ft, ev.dataTransfer.files[0]);
  }
}

function spDrawingUpload(ft, input) {
  var file = input.files[0];
  input.value = '';
  if (file) spDrawingUploadFile(ft, file);
}

function spDrawingUploadFile(ft, file) {
  var folder = spDrawingFolder();
  if (!folder || _spEditIndex < 0) return;
  var status = document.getElementById('sp-edit-' + ft + '-status');
  status.textContent = 'Uploading…';
  status.className = 'text-xs text-gray-500 dark:text-gray-400 mb-2 break-all';
  var k = _spDrawingKeyFields();
  var fd = new FormData();
  fd.append('file', file);
  fd.append('folder', folder);
  fd.append('file_type', ft);
  fd.append('row_index', k.row_index);
  fd.append('tranId', k.tranId);
  fd.append('structure', k.structure);
  fd.append('item', k.item);
  fd.append('partRef', k.partRef);
  fetch('project-builder-upload-drawing', {method: 'POST', body: fd})
    .then(function(r){ return r.json(); })
    .then(function(data) {
      if (data.error) {
        status.textContent = 'Error: ' + data.error;
        status.className = 'text-xs text-red-600 dark:text-red-400 mb-2 break-all';
        return;
      }
      var row = _spProducts[_spEditIndex];
      if (row) row[ft === 'shop' ? 'shopUrl' : 'submittalUrl'] = data.url;
      spRenderEditDrawings();
      spRenderProducts();
    })
    .catch(function() {
      status.textContent = 'Upload failed.';
      status.className = 'text-xs text-red-600 dark:text-red-400 mb-2 break-all';
    });
}

function spDrawingDetach(ft) {
  var folder = spDrawingFolder();
  if (!folder || _spEditIndex < 0) return;
  var status = document.getElementById('sp-edit-' + ft + '-status');
  var k = _spDrawingKeyFields();
  fetch('project-builder-detach-drawing', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      folder: folder, file_type: ft, row_index: k.row_index,
      tranId: k.tranId, structure: k.structure, item: k.item, partRef: k.partRef
    })
  }).then(function(r){ return r.json(); }).then(function(data) {
    if (data.error) {
      status.textContent = 'Error: ' + data.error;
      status.className = 'text-xs text-red-600 dark:text-red-400 mb-2 break-all';
      return;
    }
    var row = _spProducts[_spEditIndex];
    if (row) row[ft === 'shop' ? 'shopUrl' : 'submittalUrl'] = '';
    spRenderEditDrawings();
    spRenderProducts();
  }).catch(function() {
    status.textContent = 'Detach failed.';
    status.className = 'text-xs text-red-600 dark:text-red-400 mb-2 break-all';
  });
}

function spSaveEditProduct() {
  if (_spEditIndex < 0 || _spEditIndex >= _spProducts.length) return;
  var editTranId = document.getElementById('sp-edit-tranId').value.trim();
  if (!editTranId) {
    document.getElementById('sp-edit-status').textContent = 'Please enter a Transaction ID.';
    return;
  }
  document.getElementById('sp-edit-status').textContent = '';
  _SP_FIELDS.forEach(function(f) {
    _spProducts[_spEditIndex][f.id] = document.getElementById('sp-edit-' + f.id).value.trim();
  });
  spCloseEditDialog();
  spRenderProducts();
  spUpdateFolderName();
}

function spCloseEditDialog() {
  _spEditIndex = -1;
  document.getElementById('sp-edit-modal').classList.add('hidden');
}

function spRenderProducts() {
  var tbody = document.getElementById('sp-products-tbody');
  var section = document.getElementById('sp-products-section');
  var countEl = document.getElementById('sp-product-count');
  countEl.textContent = _spProducts.length;
  if (_spProducts.length === 0) {
    section.classList.add('hidden');
    return;
  }
  section.classList.remove('hidden');
  tbody.innerHTML = '';
  _spProducts.forEach(function(row, i) {
    var tr = document.createElement('tr');
    tr.className = 'border-b border-gray-100 dark:border-gray-800';
    // Drawing-reference indicator (DVI-658): show a small icon left of Edit/Remove
    // when this row has a shop (J) or submittal (K) drawing link. Clicking opens
    // the Edit dialog where the attachments are managed.
    var hasShop = !!(row.shopUrl && String(row.shopUrl).trim());
    var hasSub  = !!(row.submittalUrl && String(row.submittalUrl).trim());
    var drawIcon = '';
    if (hasShop || hasSub) {
      var parts = [];
      if (hasShop) parts.push('Shop drawing');
      if (hasSub) parts.push('Submittal');
      drawIcon =
        '<button onclick="spEditProduct('+i+')" title="' + esc(parts.join(' + ') + ' attached') + '"' +
        ' class="align-middle text-togen-teal hover:text-togen-teal-dark dark:text-teal-400 dark:hover:text-teal-300 mr-2">' +
        '<svg class="h-4 w-4 inline-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">' +
        '<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/>' +
        '</svg></button>';
    }
    tr.innerHTML =
      '<td class="py-2 pr-3 text-gray-400">' + (i+1) + '</td>' +
      '<td class="py-2 pr-3">' + esc(row.item || '—') + '</td>' +
      '<td class="py-2 pr-3">' + esc(row.partRef || '—') + '</td>' +
      '<td class="py-2 pr-3">' + esc(row.quantity || '—') + '</td>' +
      '<td class="py-2 pr-3">' + esc(row.description || '—') + '</td>' +
      '<td class="py-2 pr-3 whitespace-nowrap">' + drawIcon + '<button onclick="spEditProduct('+i+')" class="text-togen-teal hover:text-togen-teal-dark dark:text-teal-400 dark:hover:text-teal-300 text-xs font-medium mr-2">Edit</button><button onclick="spRemoveProduct('+i+')" class="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-xs font-medium">Remove</button></td>';
    tbody.appendChild(tr);
  });
}

function spSubmit() {
  var name = document.getElementById('sp-project-name').value.trim();
  var customerName = document.getElementById('sp-customer-name').value.trim();
  var vendorProjectCode = document.getElementById('sp-vendor-project-code').value.trim();
  var folderName = document.getElementById('sp-folder-name').value.trim();
  if (!name) {
    document.getElementById('sp-submit-status').textContent = 'Please enter a project name.';
    document.getElementById('sp-submit-status').className = 'text-sm text-red-600 dark:text-red-400';
    return;
  }
  if (!customerName) {
    document.getElementById('sp-submit-status').textContent = 'Please enter a customer name.';
    document.getElementById('sp-submit-status').className = 'text-sm text-red-600 dark:text-red-400';
    return;
  }
  if (!folderName) {
    document.getElementById('sp-submit-status').textContent = 'Please enter a folder name.';
    document.getElementById('sp-submit-status').className = 'text-sm text-red-600 dark:text-red-400';
    return;
  }
  if (_spProducts.length === 0) {
    document.getElementById('sp-submit-status').textContent = 'Please add at least one product line.';
    document.getElementById('sp-submit-status').className = 'text-sm text-red-600 dark:text-red-400';
    return;
  }
  var btn = document.getElementById('sp-submit-btn');
  btn.disabled = true; btn.classList.add('opacity-50', 'cursor-not-allowed');
  document.getElementById('sp-submit-status').textContent = 'Checking folder…';
  document.getElementById('sp-submit-status').className = 'text-sm text-gray-500 dark:text-gray-400';

  if (_spEditingFolder) {
    spDoSubmit(name, customerName, vendorProjectCode, folderName);
    return;
  }
  fetch('project-builder-check', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({folder_name: folderName})
  }).then(function(r){ return r.json(); }).then(function(data) {
    if (data.exists) {
      if (_spImportSourceFolder) {
        btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed');
        document.getElementById('sp-submit-status').textContent = 'A folder named "' + folderName + '" already exists. Choose a unique name.';
        document.getElementById('sp-submit-status').className = 'text-sm text-red-600 dark:text-red-400';
        return;
      }
      if (!confirm('A folder named "' + folderName + '" already exists in shared. Overwrite?')) {
        btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed');
        document.getElementById('sp-submit-status').textContent = 'Cancelled.';
        return;
      }
    }
    spDoSubmit(name, customerName, vendorProjectCode, folderName);
  }).catch(function() {
    btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed');
    document.getElementById('sp-submit-status').textContent = 'Error checking folder.';
    document.getElementById('sp-submit-status').className = 'text-sm text-red-600 dark:text-red-400';
  });
}

function spDoSubmit(name, customerName, vendorProjectCode, folderName) {
  var rows = _spProducts.map(function(row) {
    var cols = _SP_FIELDS.map(function(f){ return row[f.id] || ''; });
    cols.push('', '', '');
    return cols;
  });
  document.getElementById('sp-submit-status').textContent = _spEditingFolder ? 'Updating project…' : 'Creating project…';
  fetch('project-builder-submit', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      project_name: name,
      customer_name: customerName,
      vendor_project_code: vendorProjectCode,
      folder_name: folderName,
      project_number: _spProjectNumber,
      products: _spProducts,
      rows: rows,
      editing_folder: _spEditingFolder || '',
      import_source_folder: _spImportSourceFolder || ''
    })
  }).then(function(r){ return r.json(); }).then(function(data) {
    var btn = document.getElementById('sp-submit-btn');
    if (data.error) {
      btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed');
      document.getElementById('sp-submit-status').textContent = 'Error: ' + data.error;
      document.getElementById('sp-submit-status').className = 'text-sm text-red-600 dark:text-red-400';
      return;
    }
    _spSubmittedFolder = data.folder;
    _spTempFiles = [];
    spRenderTempFiles();
    spMaybeAddWatcher(data.folder);
    document.getElementById('sp-submit-status').textContent = (_spEditingFolder ? 'Project updated: ' : 'Project created: ') + data.folder;
    document.getElementById('sp-submit-status').className = 'text-sm text-green-700 dark:text-green-400 font-medium';
    btn.classList.add('hidden');
    document.getElementById('sp-bridge-btn').classList.remove('hidden');
    document.getElementById('sp-filebrowser-btn').classList.remove('hidden');
    document.getElementById('sp-customer-name').readOnly = true;
    document.getElementById('sp-customer-name').classList.add('bg-gray-50', 'dark:bg-gray-700');
    document.getElementById('sp-project-name').readOnly = true;
    document.getElementById('sp-project-name').classList.add('bg-gray-50', 'dark:bg-gray-700');
    document.getElementById('sp-vendor-project-code').readOnly = true;
    document.getElementById('sp-vendor-project-code').classList.add('bg-gray-50', 'dark:bg-gray-700');
    document.getElementById('sp-folder-name').readOnly = true;
    document.getElementById('sp-folder-name').classList.add('bg-gray-50', 'dark:bg-gray-700');
    document.getElementById('sp-form-section').classList.add('hidden');
  }).catch(function() {
    var btn = document.getElementById('sp-submit-btn');
    btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed');
    document.getElementById('sp-submit-status').textContent = 'Submission failed.';
    document.getElementById('sp-submit-status').className = 'text-sm text-red-600 dark:text-red-400';
  });
}

function spMaybeAddWatcher(folder) {
  var cb = document.getElementById('sp-notify');
  if (!cb || !cb.checked || !folder) return;
  fetch('/fb/watchers', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ path: folder })
  })
    .then(function(r){ return r.json().then(function(d){ return { ok: r.ok, status: r.status, d: d }; }); })
    .then(function(res){
      // 409 = already watched; treat as success and refresh the cached list.
      if (res.ok || res.status === 409) {
        if (res.d && Array.isArray(res.d.watchers)) {
          if (_userSettings) _userSettings.fb_watchers = res.d.watchers;
        }
      }
    })
    .catch(function(){ /* watcher registration is best-effort; project already saved */ });
}

function spGoToBridge() {
  openTool('takeoff-bridge', 'Take-off Bridge');
  if (!_sharedFoldersLoaded) {
    _sharedFoldersLoaded = true;
  }
  fetch('list-shared-folders')
    .then(function(r){ return r.json(); })
    .then(function(data) {
      _allSharedFolders = data.folders || [];
      renderSharedFolders(_allSharedFolders);
      var btns = document.querySelectorAll('#shared-folders-list button');
      for (var i = 0; i < btns.length; i++) {
        if (btns[i].textContent === _spSubmittedFolder) {
          selectSharedFolder(_spSubmittedFolder, btns[i]);
          break;
        }
      }
    });
}

function spOpenInFileBrowser() {
  _fbInitialized = true;
  openTool('file-browser', 'File Browser');
  fbNavigate(_spSubmittedFolder);
}

function spReset() {
  _spProducts = [];
  _spSubmittedFolder = '';
  _spFolderEdited = false;
  _spEditingFolder = '';
  _spImportSourceFolder = '';
  document.getElementById('sp-import-disclaimer').classList.add('hidden');
  var spSubmitLabel = document.getElementById('sp-submit-label');
  if (spSubmitLabel) spSubmitLabel.textContent = 'Submit';
  document.getElementById('sp-customer-name').value = '';
  document.getElementById('sp-customer-name').readOnly = false;
  document.getElementById('sp-customer-name').classList.remove('bg-gray-50', 'dark:bg-gray-700');
  document.getElementById('sp-project-name').value = '';
  document.getElementById('sp-project-name').readOnly = false;
  document.getElementById('sp-project-name').classList.remove('bg-gray-50', 'dark:bg-gray-700');
  document.getElementById('sp-vendor-project-code').value = '';
  document.getElementById('sp-vendor-project-code').readOnly = false;
  document.getElementById('sp-vendor-project-code').classList.remove('bg-gray-50', 'dark:bg-gray-700');
  document.getElementById('sp-folder-name').value = '';
  document.getElementById('sp-folder-name').readOnly = false;
  document.getElementById('sp-folder-name').classList.remove('bg-gray-50', 'dark:bg-gray-700');
  document.getElementById('sp-name-warning').classList.add('hidden');
  var spNotify = document.getElementById('sp-notify');
  if (spNotify) spNotify.checked = false;
  _SP_FIELDS.forEach(function(f) { document.getElementById('sp-field-' + f.id).value = ''; });
  document.getElementById('sp-add-status').textContent = '';
  document.getElementById('sp-submit-status').textContent = '';
  document.getElementById('sp-products-section').classList.add('hidden');
  document.getElementById('sp-form-section').classList.remove('hidden');
  document.getElementById('sp-customer-name').removeAttribute('required');
  document.getElementById('sp-project-name').removeAttribute('required');
  var btn = document.getElementById('sp-submit-btn');
  btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed', 'hidden');
  document.getElementById('sp-bridge-btn').classList.add('hidden');
  document.getElementById('sp-filebrowser-btn').classList.add('hidden');
  spRenderProducts();
  spPrefillDefaults();
  spFetchProjectNumber();
  spUpdateOcrBtn();
}

// ===== Project Builder file upload + OCR discovery (DVI-661) =====
var _spTempFiles = [];
var _spUploadWired = false;

function spToggleUploadPane() {
  var pane = document.getElementById('sp-upload-pane');
  if (!pane) return;
  pane.classList.toggle('hidden');
  if (!pane.classList.contains('hidden')) spLoadTempFiles();
}

function spUploadInit() {
  if (_spUploadWired) return;
  _spUploadWired = true;
  var dz = document.getElementById('sp-dropzone');
  var input = document.getElementById('sp-upload-input');
  if (input) {
    input.addEventListener('change', function() {
      if (input.files && input.files.length) spUploadFiles(input.files);
      input.value = '';
    });
  }
  if (dz) {
    ['dragenter', 'dragover'].forEach(function(ev) {
      dz.addEventListener(ev, function(e) {
        e.preventDefault(); e.stopPropagation();
        dz.classList.add('border-togen-teal', 'bg-togen-teal/5');
      });
    });
    ['dragleave', 'drop'].forEach(function(ev) {
      dz.addEventListener(ev, function(e) {
        e.preventDefault(); e.stopPropagation();
        dz.classList.remove('border-togen-teal', 'bg-togen-teal/5');
      });
    });
    dz.addEventListener('drop', function(e) {
      if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
        spUploadFiles(e.dataTransfer.files);
      }
    });
  }
}

function spUploadFiles(fileList) {
  var fd = new FormData();
  for (var i = 0; i < fileList.length; i++) fd.append('files', fileList[i]);
  var status = document.getElementById('sp-upload-status');
  if (status) { status.textContent = 'Uploading…'; status.className = 'text-xs text-gray-500 dark:text-gray-400'; }
  fetch('project-builder/upload-temp', { method: 'POST', body: fd })
    .then(function(r){ return r.json(); })
    .then(function(data) {
      if (data.error) {
        if (status) { status.textContent = 'Error: ' + data.error; status.className = 'text-xs text-red-600 dark:text-red-400'; }
        return;
      }
      _spTempFiles = data.files || [];
      spRenderTempFiles();
      if (status) { status.textContent = ''; }
    })
    .catch(function() {
      if (status) { status.textContent = 'Upload failed.'; status.className = 'text-xs text-red-600 dark:text-red-400'; }
    });
}

function spRenderTempFiles() {
  var list = document.getElementById('sp-upload-list');
  if (!list) return;
  list.innerHTML = '';
  _spTempFiles.forEach(function(f) {
    var row = document.createElement('div');
    row.className = 'flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gray-50 dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-300';
    var ocrBadge = f.ocr ? '<span class="ml-1 px-1.5 py-0.5 rounded text-[10px] font-semibold bg-togen-teal/15 text-togen-teal">OCR</span>' : '';
    row.innerHTML = '<svg class="h-4 w-4 text-gray-400 dark:text-gray-500 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg>' +
      '<span class="truncate flex-1">' + esc(f.name) + '</span>' + ocrBadge;
    var rm = document.createElement('button');
    rm.type = 'button';
    rm.className = 'ml-auto text-red-500 hover:text-red-700 dark:text-red-400 text-xs font-medium flex-shrink-0';
    rm.textContent = 'Remove';
    rm.onclick = function(){ spRemoveTempFile(f.name); };
    row.appendChild(rm);
    list.appendChild(row);
  });
  spUpdateOcrBtn();
}

function spRemoveTempFile(name) {
  fetch('project-builder/remove-temp', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ filename: name })
  }).then(function(r){ return r.json(); }).then(function(data) {
    _spTempFiles = data.files || [];
    spRenderTempFiles();
  }).catch(function(){});
}

function spLoadTempFiles() {
  fetch('project-builder/temp-files')
    .then(function(r){ return r.json(); })
    .then(function(data) { _spTempFiles = data.files || []; spRenderTempFiles(); })
    .catch(function(){});
}

function spUpdateOcrBtn() {
  var btn = document.getElementById('sp-ocr-discover-btn');
  if (!btn) return;
  var hasOcrTemp = _spTempFiles.some(function(f){ return f.ocr; });
  var inFolder = !!(_spEditingFolder || _spImportSourceFolder);
  if (hasOcrTemp || inFolder) {
    btn.classList.remove('hidden');
    // Feature B (DVI-661): the OCR Discovery button lives inside the Project
    // Files pane, which is collapsed by default. When a project is opened or
    // imported, auto-expand the pane so the button is actually visible.
    if (inFolder) {
      var pane = document.getElementById('sp-upload-pane');
      if (pane) pane.classList.remove('hidden');
    }
  } else {
    btn.classList.add('hidden');
  }
  spUpdateAskAssistantBtn();
}

// DVI-662 D1: show "Ask Assistant about these documents" when there are docs in
// scope (temp uploads or an opened/imported folder) AND the Assistant is
// connected (entitled + usable key — same gate as the topbar Assistant icon).
function spUpdateAskAssistantBtn() {
  var btn = document.getElementById('sp-ask-assistant-btn');
  if (!btn) return;
  var hasDocs = _spTempFiles.length > 0 || !!(_spEditingFolder || _spImportSourceFolder);
  var asstBtn = document.getElementById('assistant-btn');
  var connected = !!asstBtn && asstBtn.dataset.available === 'true';
  btn.classList.toggle('hidden', !(hasDocs && connected));
}

function spAskAssistantAboutDocs() {
  var asstBtn = document.getElementById('assistant-btn');
  if (!asstBtn || asstBtn.dataset.available !== 'true') {
    // Not connected — route to the standard Assistant launcher, which shows the
    // "configure an API key" prompt.
    if (typeof assistantIconClick === 'function') assistantIconClick();
    return;
  }
  var status = document.getElementById('sp-upload-status');
  if (status) { status.textContent = 'Gathering documents for the Assistant…'; status.className = 'text-xs text-gray-500 dark:text-gray-400'; }
  var folder = _spEditingFolder || _spImportSourceFolder || '';
  fetch('project-builder/correlate-docs', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ folder_name: folder })
  }).then(function(r){ return r.json().then(function(d){ return { ok: r.ok, d: d || {} }; }); })
    .then(function(res) {
      if (!res.ok || !res.d.prompt) {
        if (status) { status.textContent = res.d.error || 'Could not gather documents.'; status.className = 'text-xs text-red-600 dark:text-red-400'; }
        return;
      }
      if (status) { status.textContent = 'Asked the Assistant about ' + res.d.doc_count + ' document' + (res.d.doc_count === 1 ? '' : 's') + '.'; status.className = 'text-xs text-green-700 dark:text-green-400 font-medium'; }
      _spSeedAssistant(res.d.prompt);
    }).catch(function() {
      if (status) { status.textContent = 'Could not reach the Assistant.'; status.className = 'text-xs text-red-600 dark:text-red-400'; }
    });
}

// Open the existing Assistant surface (desktop topbar field or mobile overlay)
// pre-seeded with a prompt and send it immediately.
function _spSeedAssistant(prompt) {
  if (window.matchMedia('(min-width: 768px)').matches) {
    assistantOpenDesktop(true);
    var input = document.getElementById('topbar-field-input');
    if (input) { input.value = prompt; if (typeof topbarFieldAutoGrow === 'function') topbarFieldAutoGrow(); }
    assistantDesktopSend(prompt);
  } else {
    assistantOpenMobile(true);
    var minput = document.getElementById('assistant-mobile-input');
    if (minput) minput.value = prompt;
    assistantMobileSend();
  }
}

function spOcrDiscover() {
  var status = document.getElementById('sp-upload-status');
  if (status) { status.textContent = 'Reading documents…'; status.className = 'text-xs text-gray-500 dark:text-gray-400'; }
  var folder = _spEditingFolder || _spImportSourceFolder || '';
  var req;
  if (folder) {
    req = fetch('project-builder/discover-folder', {
      method: 'POST', headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({ folder_name: folder })
    });
  } else {
    req = fetch('project-builder/discover-temp', { method: 'POST' });
  }
  req.then(function(r){ return r.json(); }).then(function(data) {
    if (data.error) {
      if (status) { status.textContent = 'Error: ' + data.error; status.className = 'text-xs text-red-600 dark:text-red-400'; }
      return;
    }
    var n = spApplyDiscovered(data.fields || {});
    if (status) {
      status.textContent = n ? ('Discovered ' + n + ' field' + (n === 1 ? '' : 's') + ' — review and edit as needed.') : 'No project info found in the documents.';
      status.className = n ? 'text-xs text-green-700 dark:text-green-400 font-medium' : 'text-xs text-gray-500 dark:text-gray-400';
    }
  }).catch(function() {
    if (status) { status.textContent = 'OCR failed.'; status.className = 'text-xs text-red-600 dark:text-red-400'; }
  });
}

function spApplyDiscovered(fields) {
  var n = 0;
  function setVal(id, v) {
    var el = document.getElementById(id);
    if (el && !el.readOnly && v) { el.value = v; n++; }
  }
  setVal('sp-customer-name', fields.customer_name);
  setVal('sp-project-name', fields.project_name);
  setVal('sp-vendor-project-code', fields.vendor_project_code);
  if (fields.tranId) {
    var tran = document.getElementById('sp-field-tranId');
    if (tran) { tran.value = fields.tranId; n++; }
  }
  if (n) spUpdateFolderName();
  return n;
}

var _spOpenFolders = [];

function spOpenDialog() {
  var modal = document.getElementById('sp-open-modal');
  modal.classList.remove('hidden');
  document.getElementById('sp-open-search').value = '';
  document.getElementById('sp-open-list').innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 py-3">Loading projects…</p>';
  fetch('project-builder-list-projects')
    .then(function(r){ return r.json(); })
    .then(function(data) {
      _spOpenFolders = data.folders || [];
      spRenderOpenList(_spOpenFolders);
    })
    .catch(function() {
      document.getElementById('sp-open-list').innerHTML = '<p class="text-sm text-red-500 py-3">Error loading projects.</p>';
    });
}

function spCloseDialog() {
  document.getElementById('sp-open-modal').classList.add('hidden');
}

function spFilterOpenList(q) {
  q = q.toLowerCase();
  var filtered = _spOpenFolders.filter(function(f){ return f.name.toLowerCase().indexOf(q) !== -1; });
  spRenderOpenList(filtered);
}

function spRenderOpenList(folders) {
  var list = document.getElementById('sp-open-list');
  if (folders.length === 0) {
    list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 py-3">No projects found.</p>';
    return;
  }
  list.innerHTML = '';
  folders.forEach(function(folder) {
    var name = folder.name;
    var hasMeta = folder.has_meta;
    var btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'w-full text-left px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-togen-teal/10 dark:hover:bg-togen-teal/20 transition-colors cursor-pointer flex items-center gap-2';
    var badge = hasMeta ? '' : '<span class="ml-auto flex-shrink-0 inline-block px-2 py-0.5 rounded-full text-[10px] font-semibold bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">External — import only</span>';
    btn.innerHTML = '<svg class="h-4 w-4 text-gray-400 dark:text-gray-500 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd"/><path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z"/></svg><span class="truncate">' + esc(name) + '</span>' + badge;
    btn.onclick = hasMeta ? function(){ spLoadProject(name); } : function(){ spStartImport(name); };
    list.appendChild(btn);
  });
}

function spLoadProject(folderName) {
  spCloseDialog();
  document.getElementById('sp-submit-status').textContent = 'Loading project…';
  document.getElementById('sp-submit-status').className = 'text-sm text-gray-500 dark:text-gray-400';
  fetch('project-builder-load-project', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({folder_name: folderName})
  }).then(function(r){ return r.json(); }).then(function(data) {
    if (data.error) {
      document.getElementById('sp-submit-status').textContent = 'Error: ' + data.error;
      document.getElementById('sp-submit-status').className = 'text-sm text-red-600 dark:text-red-400';
      return;
    }
    spReset();
    _spEditingFolder = folderName;
    if (data.meta) {
      document.getElementById('sp-customer-name').value = data.meta.customer_name || '';
      document.getElementById('sp-project-name').value = data.meta.project_name || '';
      document.getElementById('sp-vendor-project-code').value = data.meta.vendor_project_code || '';
      _spProjectNumber = data.meta.project_number || '';
      if (data.meta.products && data.meta.products.length > 0) {
        _spProducts = data.meta.products;
      } else if (data.csv_rows && data.csv_rows.length > 0) {
        _spProducts = data.csv_rows;
      }
    }
    _spFolderEdited = true;
    document.getElementById('sp-folder-name').value = folderName;
    spRenderProducts();
    spUpdateOcrBtn();
    document.getElementById('sp-submit-status').textContent = 'Project loaded: ' + folderName;
    document.getElementById('sp-submit-status').className = 'text-sm text-green-700 dark:text-green-400 font-medium';
  }).catch(function() {
    document.getElementById('sp-submit-status').textContent = 'Error loading project.';
    document.getElementById('sp-submit-status').className = 'text-sm text-red-600 dark:text-red-400';
  });
}

function spStartImport(sourceFolder) {
  spCloseDialog();
  spReset();
  _spEditingFolder = '';
  _spImportSourceFolder = sourceFolder;
  document.getElementById('sp-submit-status').textContent = 'Loading source project…';
  document.getElementById('sp-submit-status').className = 'text-sm text-gray-500 dark:text-gray-400';
  fetch('project-builder-load-project', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({folder_name: sourceFolder})
  }).then(function(r){ return r.json(); }).then(function(data) {
    if (data.error) {
      document.getElementById('sp-submit-status').textContent = 'Error: ' + data.error;
      document.getElementById('sp-submit-status').className = 'text-sm text-red-600 dark:text-red-400';
      return;
    }
    var parsed = parseFolderNameForProject(sourceFolder);
    document.getElementById('sp-project-name').value = parsed.projectName;
    document.getElementById('sp-vendor-project-code').value = parsed.vendorCode;
    if (data.csv_rows && data.csv_rows.length > 0) {
      _spProducts = data.csv_rows;
      document.getElementById('sp-customer-name').setAttribute('required', 'required');
      document.getElementById('sp-project-name').setAttribute('required', 'required');
    }
    spRenderProducts();
    spUpdateOcrBtn();
    document.getElementById('sp-import-disclaimer').classList.remove('hidden');
    var label = document.getElementById('sp-submit-label');
    if (label) label.textContent = 'Import/Duplicate';
    fetch('project-builder-next-number')
      .then(function(r){ return r.json(); })
      .then(function(numData) {
        _spProjectNumber = numData.number || '';
        _spFolderEdited = false;
        spUpdateFolderName();
        document.getElementById('sp-submit-status').textContent = 'Importing from: ' + sourceFolder;
        document.getElementById('sp-submit-status').className = 'text-sm text-gray-600 dark:text-gray-400';
      });
  }).catch(function() {
    document.getElementById('sp-submit-status').textContent = 'Error loading source project.';
    document.getElementById('sp-submit-status').className = 'text-sm text-red-600 dark:text-red-400';
  });
}

function srSubmit(e) {
  e.preventDefault();
  var f = document.getElementById('sr-form');
  var btn = document.getElementById('sr-submit-btn');
  var status = document.getElementById('sr-submit-status');
  btn.disabled = true;
  status.textContent = 'Submitting...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';

  var payload = {
    name: f.elements['sr_name'].value,
    email: f.elements['sr_email'].value,
    description: f.elements['sr_description'].value,
    datetime: f.elements['sr_datetime'].value,
    active_tool: f.elements['sr_active_tool'].value,
    app_state: f.elements['sr_app_state'].value,
    send_to: f.elements['sr_send_to'].value
  };

  fetch('/support-request-submit', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(payload)
  })
    .then(function(r){
      var ct = (r.headers.get('content-type') || '');
      if (!ct.includes('application/json')) {
        if (r.status === 401 || r.redirected) throw new Error('Session expired — please reload the page and log in again.');
        throw new Error('Server returned an unexpected response (HTTP ' + r.status + '). Please try again.');
      }
      return r.json();
    })
    .then(function(d){
      btn.disabled = false;
      if (d.error) {
        status.textContent = 'Error: ' + d.error;
        status.className = 'text-sm text-red-500';
        return;
      }
      status.textContent = 'Support request submitted successfully! Reference: ' + d.filename;
      status.className = 'text-sm text-green-600 dark:text-green-400';
      f.elements['sr_description'].value = '';
    })
    .catch(function(err){
      btn.disabled = false;
      status.textContent = 'Error: ' + err.message;
      status.className = 'text-sm text-red-500';
    });
}

function srCancel() {
  backToHome();
}

// ===== FEATURE REQUEST (DVI-416) =====
function openFeatureRequest() {
  document.getElementById('help-menu').classList.add('hidden');
  closeTutorialsPane();
  openTool('feature-request', 'Feature Request');
  var f = document.getElementById('fr-form');
  if (f) {
    var ssoName = '{{ user_name }}';
    var ssoEmail = '{{ user_email }}';
    if (ssoName && ssoName !== 'User') f.elements['fr_name'].value = ssoName;
    if (ssoEmail) f.elements['fr_email'].value = ssoEmail;
    f.elements['fr_send_to'].value = 'icast.voyagetech.com@email.autotask.net';
  }
  document.getElementById('fr-submit-status').textContent = '';
}

function frSubmit(e) {
  e.preventDefault();
  var f = document.getElementById('fr-form');
  var btn = document.getElementById('fr-submit-btn');
  var status = document.getElementById('fr-submit-status');
  btn.disabled = true;
  status.textContent = 'Submitting...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';

  var payload = {
    name: f.elements['fr_name'].value,
    email: f.elements['fr_email'].value,
    description: f.elements['fr_description'].value,
    beneficiaries: f.elements['fr_beneficiaries'].value,
    send_to: f.elements['fr_send_to'].value
  };

  fetch('/feature-request-submit', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(payload)
  })
    .then(function(r){
      var ct = (r.headers.get('content-type') || '');
      if (!ct.includes('application/json')) {
        if (r.status === 401 || r.redirected) throw new Error('Session expired — please reload the page and log in again.');
        throw new Error('Server returned an unexpected response (HTTP ' + r.status + '). Please try again.');
      }
      return r.json();
    })
    .then(function(d){
      btn.disabled = false;
      if (d.error) {
        status.textContent = 'Error: ' + d.error;
        status.className = 'text-sm text-red-500';
        return;
      }
      status.textContent = 'Feature request submitted successfully! Reference: ' + d.filename;
      status.className = 'text-sm text-green-600 dark:text-green-400';
      f.elements['fr_description'].value = '';
      f.elements['fr_beneficiaries'].value = '';
    })
    .catch(function(err){
      btn.disabled = false;
      status.textContent = 'Error: ' + err.message;
      status.className = 'text-sm text-red-500';
    });
}

function frCancel() {
  backToHome();
}

// ===== IT HELP DESK (DVI-413) =====
function itOpenHelpDesk() {
  openTool('it-help-desk', 'IT Help Desk');
  var f = document.getElementById('ithd-form');
  if (f) {
    var ssoName = '{{ user_name }}';
    var ssoEmail = '{{ user_email }}';
    if (ssoName && ssoName !== 'User') f.elements['ithd_name'].value = ssoName;
    if (ssoEmail) f.elements['ithd_email'].value = ssoEmail;
  }
  document.getElementById('ithd-submit-status').textContent = '';
}

function itSubmit(e) {
  e.preventDefault();
  var f = document.getElementById('ithd-form');
  var btn = document.getElementById('ithd-submit-btn');
  var status = document.getElementById('ithd-submit-status');
  btn.disabled = true;
  status.textContent = 'Submitting...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';

  var related = [];
  var boxes = f.querySelectorAll('input[name="ithd_related"]:checked');
  for (var i = 0; i < boxes.length; i++) related.push(boxes[i].value);

  var payload = {
    name: f.elements['ithd_name'].value,
    email: f.elements['ithd_email'].value,
    description: f.elements['ithd_description'].value,
    related: related
  };

  fetch('/it-help-desk-submit', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(payload)
  })
    .then(function(r){
      var ct = (r.headers.get('content-type') || '');
      if (!ct.includes('application/json')) {
        if (r.status === 401 || r.redirected) throw new Error('Session expired — please reload the page and log in again.');
        throw new Error('Server returned an unexpected response (HTTP ' + r.status + '). Please try again.');
      }
      return r.json();
    })
    .then(function(d){
      btn.disabled = false;
      if (d.error) {
        status.textContent = 'Error: ' + d.error;
        status.className = 'text-sm text-red-500';
        return;
      }
      status.textContent = 'IT Help Desk ticket submitted successfully! Reference: ' + d.filename;
      status.className = 'text-sm text-green-600 dark:text-green-400';
      f.elements['ithd_description'].value = '';
      var boxes = f.querySelectorAll('input[name="ithd_related"]:checked');
      for (var i = 0; i < boxes.length; i++) boxes[i].checked = false;
    })
    .catch(function(err){
      btn.disabled = false;
      status.textContent = 'Error: ' + err.message;
      status.className = 'text-sm text-red-500';
    });
}

function itCancel() {
  backToHome();
}

// ===== COLLAPSIBLE SECTIONS =====
function toggleSection(id) {
  var body    = document.getElementById('body-'+id);
  var chevron = document.getElementById('chevron-'+id);
  var collapsed = body.classList.toggle('hidden');
  chevron.style.transform = collapsed ? '' : 'rotate(180deg)';
}

// ===== ZIP UPLOAD =====
function onZipChosen(input) {
  var file = input.files[0]; if (file) uploadZipFile(file);
}

function uploadZipFile(file) {
  var lbl = document.getElementById('zip-lbl');
  lbl.textContent = 'Uploading and extracting…';
  lbl.className = 'mt-3 block text-sm text-gray-500 dark:text-gray-400';
  var fd = new FormData(); fd.append('zip', file);
  fetch('upload-zip', {method:'POST', body:fd})
    .then(function(r){ return r.json(); }).then(function(data) {
      if (data.error) {
        lbl.textContent = 'Error: '+data.error;
        lbl.className = 'mt-3 block text-sm text-red-600 dark:text-red-400';
        return;
      }
      csvFolder = data.folder; csvItems = data.items; csvItemRefs = data.item_refs || [];
      csvAutoFiles = data.auto_files || [];
      csvMetadata = data.metadata || null;
      lbl.textContent = '✓ '+file.name+' — '+csvItems.length+' items extracted';
      lbl.className = 'mt-3 block text-sm text-green-700 dark:text-green-400 font-medium';
      renderItems();
      document.getElementById('items-wrap').classList.remove('hidden');
      document.getElementById('items-wrap').scrollIntoView({behavior:'smooth', block:'start'});
    }).catch(function() {
      lbl.textContent = 'Upload failed';
      lbl.className = 'mt-3 block text-sm text-red-600 dark:text-red-400';
    });
}

// ===== MULTI-ITEM STATE =====
var csvFolder    = null;
var _submitHasRun = false;  // set once Submit All completes; flips the button to "Resubmit" on later edits
var csvItems     = [];
var csvItemRefs  = [];  // column H — Part Reference Number
var csvAutoFiles = [];  // auto-matched filenames from zip [{shop, submittal, carrier}, ...]
// uploaded[i] = {shop: bool, submittal: bool, carrier: bool}
var uploaded     = {};
// createChecked[i] = true when the item's "Create" checkbox is set (no carrier sheet yet)
var createChecked = {};
// project_metadata.json contents for the loaded folder (null when absent/unknown)
var csvMetadata  = null;

function esc(s) {
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function fmtLocalDate(isoStr) {
  var d = new Date(isoStr);
  if (isNaN(d)) return esc(isoStr);
  return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
}

// ===== Shared Print popup (DVI-670 / DVI-657 P4) =====
// Each tool's Print button calls togenOpenPrintPopup({source, fileRef, kind,
// title, allowLabelToggle}). The popup shows the effective default printer,
// a dropdown to change it, a copies field, and a Print button -> POST /print/jobs.
var _printPopup = {opts: null, allOptions: [], data: null};

function _printEnsureModal() {
  var m = document.getElementById('togen-print-modal');
  if (m) return m;
  m = document.createElement('div');
  m.id = 'togen-print-modal';
  m.className = 'hidden fixed inset-0 z-[120] flex items-center justify-center bg-black/40';
  m.innerHTML =
    '<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-sm mx-4 p-5">' +
      '<div class="flex items-center justify-between mb-3">' +
        '<h3 id="togen-print-title" class="text-base font-semibold text-gray-800 dark:text-gray-100">Print</h3>' +
        '<button onclick="togenClosePrintPopup()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none">&times;</button>' +
      '</div>' +
      '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Printer</label>' +
      '<select id="togen-print-select" class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-sm text-gray-800 dark:text-gray-200 mb-2"></select>' +
      '<div class="flex items-center gap-3 mb-3">' +
        '<label class="text-sm text-gray-700 dark:text-gray-300">Copies</label>' +
        '<input id="togen-print-copies" type="number" min="1" max="50" value="1" class="w-20 px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-sm text-gray-800 dark:text-gray-200">' +
        '<button id="togen-print-labeltoggle" onclick="_printAddLabels()" class="hidden ml-auto text-xs text-togen-teal hover:underline">Show label printers</button>' +
      '</div>' +
      '<p id="togen-print-msg" class="text-xs mb-3 hidden"></p>' +
      '<div class="flex justify-end gap-2">' +
        '<button onclick="togenClosePrintPopup()" class="px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Cancel</button>' +
        '<button id="togen-print-go" onclick="_printSubmit()" class="px-4 py-2 text-sm rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white font-medium">Print</button>' +
      '</div>' +
    '</div>';
  document.body.appendChild(m);
  m.addEventListener('click', function(e) { if (e.target === m) togenClosePrintPopup(); });
  return m;
}

function _printSetMsg(text, kind) {
  var el = document.getElementById('togen-print-msg');
  if (!el) return;
  if (!text) { el.classList.add('hidden'); el.textContent = ''; return; }
  el.textContent = text;
  el.className = 'text-xs mb-3 ' + (kind === 'error'
    ? 'text-red-600 dark:text-red-400'
    : (kind === 'success' ? 'text-green-600 dark:text-green-400' : 'text-gray-500 dark:text-gray-400'));
}

function _printRenderSelect() {
  var sel = document.getElementById('togen-print-select');
  sel.innerHTML = '';
  _printPopup.allOptions.forEach(function(o, i) {
    var opt = document.createElement('option');
    opt.value = String(i);
    opt.textContent = o.label;
    sel.appendChild(opt);
  });
  if (_printPopup.allOptions.length) sel.value = '0';
}

function _printAddLabels() {
  var d = _printPopup.data || {};
  var avail = (d.available_printers || {});
  var labels = avail.labels || [];
  labels.forEach(function(n) {
    var dup = _printPopup.allOptions.some(function(o){ return o.kind === 'label' && o.value === n; });
    if (!dup) _printPopup.allOptions.push({label: n + ' (label)', value: n, kind: 'label'});
  });
  var btn = document.getElementById('togen-print-labeltoggle');
  if (btn) btn.classList.add('hidden');
  _printRenderSelect();
}

function togenOpenPrintPopup(opts) {
  if (!_printCanUse) { alert('The Print system is currently disabled.'); return; }
  if (!opts || !opts.fileRef) { alert('Nothing to print yet.'); return; }
  _printPopup.opts = opts;
  _printPopup.allOptions = [];
  _printPopup.data = null;
  var m = _printEnsureModal();
  document.getElementById('togen-print-title').textContent = opts.title || 'Print';
  document.getElementById('togen-print-copies').value = '1';
  document.getElementById('togen-print-go').disabled = true;
  var lblBtn = document.getElementById('togen-print-labeltoggle');
  lblBtn.classList.toggle('hidden', !opts.allowLabelToggle);
  _printSetMsg('Loading printers…');
  var sel = document.getElementById('togen-print-select');
  sel.innerHTML = '<option>Loading…</option>';
  m.classList.remove('hidden');

  fetch('/print/options').then(function(r){ return r.json(); }).then(function(d) {
    _printPopup.data = d;
    var primary = opts.kind === 'label' ? 'labels' : 'documents';
    var avail = (d.available_printers || {});
    var list = avail[primary] || [];
    var eff = (d.effective_defaults || {});
    var effName = opts.kind === 'label' ? eff.label_printer : eff.document_printer;
    _printPopup.allOptions = [{
      label: 'Default' + (effName ? ' — ' + effName : ' (none configured)'),
      value: '__default__', kind: opts.kind
    }];
    list.forEach(function(n) { _printPopup.allOptions.push({label: n, value: n, kind: opts.kind}); });
    _printRenderSelect();
    if (!list.length && !effName) {
      _printSetMsg('No ' + opts.kind + ' printer is configured. Ask an admin to enrol a Print agent.', 'error');
      document.getElementById('togen-print-go').disabled = false; // server returns a clear 400
    } else {
      _printSetMsg('');
      document.getElementById('togen-print-go').disabled = false;
    }
  }).catch(function() {
    _printSetMsg('Could not load printers.', 'error');
    document.getElementById('togen-print-select').innerHTML = '';
  });
}

function togenClosePrintPopup() {
  var m = document.getElementById('togen-print-modal');
  if (m) m.classList.add('hidden');
}

function _printSubmit() {
  var opts = _printPopup.opts;
  if (!opts) return;
  var sel = document.getElementById('togen-print-select');
  var chosen = _printPopup.allOptions[parseInt(sel.value, 10)] || _printPopup.allOptions[0];
  if (!chosen) { _printSetMsg('No printer selected.', 'error'); return; }
  var copies = parseInt(document.getElementById('togen-print-copies').value, 10) || 1;
  var goBtn = document.getElementById('togen-print-go');
  goBtn.disabled = true;
  _printSetMsg('Sending…');
  fetch('/print/jobs', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({source: opts.source, file_ref: opts.fileRef,
                          printer: chosen.value, kind: chosen.kind, copies: copies})
  }).then(function(r) {
    return r.json().then(function(d) { return {ok: r.ok, d: d}; });
  }).then(function(res) {
    goBtn.disabled = false;
    if (!res.ok) { _printSetMsg(res.d.error || 'Print failed.', 'error'); return; }
    _printSetMsg('Sent to ' + (res.d.printer || 'printer') + '.', 'success');
    setTimeout(togenClosePrintPopup, 1200);
  }).catch(function() {
    goBtn.disabled = false;
    _printSetMsg('Network error.', 'error');
  });
}

// Fire one job per entry to each kind's effective default, no popup.
// jobs: [{source, fileRef, kind}]; statusCb(text, kind) optional for feedback.
function togenPrintToDefaults(jobs, statusCb) {
  if (!_printCanUse) { if (statusCb) statusCb('The Print system is currently disabled.', 'error'); return; }
  if (!jobs || !jobs.length) return;
  var done = 0, failed = 0, total = jobs.length;
  function report() {
    if (!statusCb) return;
    if (done + failed < total) { statusCb('Printing…', 'info'); return; }
    if (failed) statusCb(failed + ' of ' + total + ' print job(s) failed.', 'error');
    else statusCb('Sent ' + total + ' job(s) to default printers.', 'success');
  }
  report();
  jobs.forEach(function(j) {
    fetch('/print/jobs', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({source: j.source, file_ref: j.fileRef,
                            printer: '__default__', kind: j.kind, copies: 1})
    }).then(function(r) { if (r.ok) done++; else failed++; report(); })
      .catch(function() { failed++; report(); });
  });
}

function onCsvChosen(input) {
  var file = input.files[0]; if (file) uploadCsvFile(file);
}

function uploadCsvFile(file) {
  var lbl = document.getElementById('csv-lbl');
  lbl.textContent = 'Uploading…'; lbl.className = 'ml-3 text-sm text-gray-500 dark:text-gray-400';
  var fd = new FormData(); fd.append('csv', file);
  fetch('upload-csv', {method:'POST', body:fd})
    .then(function(r){ return r.json(); }).then(function(data) {
      if (data.error) { lbl.textContent = 'Error: '+data.error; lbl.className = 'ml-3 text-sm text-red-600 dark:text-red-400'; return; }
      csvFolder = data.folder; csvItems = data.items; csvItemRefs = data.item_refs || [];
      csvAutoFiles = [];  // no auto-matching for plain CSV upload
      csvMetadata = data.metadata || null;
      lbl.textContent = '✓ '+file.name+' — '+csvItems.length+' items';
      lbl.className = 'ml-3 text-sm text-green-700 dark:text-green-400 font-medium';
      renderItems();
      document.getElementById('items-wrap').classList.remove('hidden');
    }).catch(function(){ lbl.textContent = 'Upload failed'; lbl.className = 'ml-3 text-sm text-red-600 dark:text-red-400'; });
}

function setupDragDrop(btn, onFile, accept) {
  btn.addEventListener('dragover', function(e) {
    e.preventDefault(); e.dataTransfer.dropEffect = 'copy';
    btn.classList.add('outline', 'outline-2', 'outline-togen-teal', 'outline-offset-2');
  });
  btn.addEventListener('dragleave', function(e) {
    if (!btn.contains(e.relatedTarget))
      btn.classList.remove('outline', 'outline-2', 'outline-togen-teal', 'outline-offset-2');
  });
  btn.addEventListener('drop', function(e) {
    e.preventDefault();
    btn.classList.remove('outline', 'outline-2', 'outline-togen-teal', 'outline-offset-2');
    var file = e.dataTransfer.files[0];
    if (!file) return;
    if (accept && !accept.split(',').some(function(ext){ return file.name.toLowerCase().endsWith(ext.trim()); })) return;
    onFile(file);
  });
}

function renderItems() {
  var c = document.getElementById('items-container');
  c.innerHTML = ''; uploaded = {}; previewUrls = {}; createChecked = {};
  // Show folder-level action buttons — disabled until Submit All completes
  var zipBtn = document.getElementById('dl-zip-btn');
  zipBtn.classList.remove('hidden');
  zipBtn.disabled = true;
  zipBtn.className = 'px-5 py-3 bg-gray-300 dark:bg-gray-700 text-gray-400 dark:text-gray-500 font-semibold rounded-xl text-sm cursor-not-allowed';
  var pubBtn = document.getElementById('publish-btn');
  pubBtn.classList.remove('hidden');
  pubBtn.disabled = true;
  pubBtn.textContent = 'Publish';
  pubBtn.className = 'px-5 py-3 bg-gray-300 dark:bg-gray-700 text-gray-400 dark:text-gray-500 font-semibold rounded-xl text-sm cursor-not-allowed';
  var pubDupBtn = document.getElementById('publish-dup-btn');
  pubDupBtn.classList.remove('hidden');
  pubDupBtn.disabled = true;
  pubDupBtn.className = 'px-5 py-3 bg-gray-300 dark:bg-gray-700 text-gray-400 dark:text-gray-500 font-semibold rounded-xl text-sm cursor-not-allowed';
  _submitHasRun = false;
  document.getElementById('publish-msg').classList.add('hidden');
  csvItems.forEach(function(name, i) {
    uploaded[i] = {shop: false, submittal: false, carrier: false};
    // Preview panel — sits above the item card, hidden until toggled
    var pw = document.createElement('div');
    pw.id = 'preview-wrap-'+i;
    pw.className = 'hidden mb-2 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden bg-white dark:bg-gray-900';
    pw.innerHTML = '<embed id="preview-embed-'+i+'" type="application/pdf" class="w-full" style="height:500px;">';
    c.appendChild(pw);
    var d = document.createElement('div');
    d.className = 'border border-gray-200 dark:border-gray-700 rounded-xl p-4 bg-gray-50 dark:bg-gray-800';
    d.id = 'card-'+i;
    var ref = csvItemRefs[i] || '';
    d.innerHTML =
      '<div class="flex items-center justify-between mb-2">' +
        '<p class="font-medium text-gray-800 dark:text-gray-200">' + esc(name) + (ref ? ' <span class="font-normal text-gray-500 dark:text-gray-400 text-xs ml-2">Ref: '+esc(ref)+'</span>' : '') + '</p>' +
        '<div id="result-'+i+'" class="flex gap-2"></div>' +
      '</div>' +
      '<div class="flex flex-wrap gap-3 items-center">' +
        '<input type="file" id="fi-shop-'+i+'" accept=".pdf" class="hidden" onchange="doUpload('+i+',\'shop\',this)">' +
        '<button id="btn-shop-'+i+'" onclick="document.getElementById(\'fi-shop-'+i+'\').click()"' +
                ' class="px-3 py-1.5 text-xs font-medium border-2 border-gray-400 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">' +
          'Shop Drawing' +
        '</button>' +
        '<span id="lbl-shop-'+i+'" class="text-xs text-gray-500 dark:text-gray-400"></span>' +
        _detachBtnHtml(i, 'shop') +

        '<input type="file" id="fi-sub-'+i+'" accept=".pdf" class="hidden" onchange="doUpload('+i+',\'submittal\',this)">' +
        '<button id="btn-sub-'+i+'" onclick="document.getElementById(\'fi-sub-'+i+'\').click()"' +
                ' class="px-3 py-1.5 text-xs font-medium border-2 border-gray-400 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">' +
          'Submittal' +
        '</button>' +
        '<span id="lbl-sub-'+i+'" class="text-xs text-gray-500 dark:text-gray-400"></span>' +
        _detachBtnHtml(i, 'submittal') +

        '<input type="file" id="fi-car-'+i+'" accept=".pdf" class="hidden" onchange="doUpload('+i+',\'carrier\',this)">' +
        '<button id="btn-car-'+i+'" onclick="document.getElementById(\'fi-car-'+i+'\').click()"' +
                ' class="px-3 py-1.5 text-xs font-medium border-2 border-gray-400 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">' +
          'QR Carrier Sheet' +
        '</button>' +
        '<span id="lbl-car-'+i+'" class="text-xs text-gray-500 dark:text-gray-400"></span>' +
        _detachBtnHtml(i, 'carrier') +

        '<label id="create-wrap-'+i+'" class="hidden flex items-center gap-1.5 cursor-pointer select-none">' +
          '<input type="checkbox" id="create-cb-'+i+'"' +
                 ' class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal dark:bg-gray-700"' +
                 ' onchange="onCreateToggle('+i+')">' +
          '<span class="text-xs font-medium text-togen-teal">Create</span>' +
        '</label>' +
      '</div>';
    c.appendChild(d);
    // Wire drag-and-drop on item upload buttons
    setupDragDrop(document.getElementById('btn-shop-'+i), function(f){ doUploadFile(i,'shop',f); }, '.pdf');
    setupDragDrop(document.getElementById('btn-sub-'+i),  function(f){ doUploadFile(i,'submittal',f); }, '.pdf');
    setupDragDrop(document.getElementById('btn-car-'+i),  function(f){ doUploadFile(i,'carrier',f); }, '.pdf');
    // Apply auto-matched files (zip workflow only — csvAutoFiles is empty for plain CSV upload)
    var auto = csvAutoFiles[i] || {};
    ['shop', 'submittal', 'carrier'].forEach(function(ftype) {
      if (!auto[ftype]) return;
      var k   = _ftKey[ftype];
      var lbl = document.getElementById('lbl-'+k+'-'+i);
      var btn = document.getElementById('btn-'+k+'-'+i);
      // Show just the filename even if the path includes a subdirectory
      lbl.textContent = '✓ '+auto[ftype].split('/').pop();
      lbl.className = 'text-xs text-green-700 dark:text-green-400 font-medium';
      btn.className = _btnOk;
      uploaded[i][ftype] = true;
      _setDetachVisible(i, ftype, true);
    });
    // The Create checkbox only exists for items that have a document (shop
    // drawing or submittal) but no carrier sheet — checked by default on
    // detection. Items with no documents (e.g. stock items) need no carrier
    // sheet, so the checkbox stays hidden entirely until a document arrives.
    createChecked[i] = false;
    if (!uploaded[i].carrier && (uploaded[i].shop || uploaded[i].submittal)) {
      createChecked[i] = true;
      document.getElementById('create-cb-'+i).checked = true;
      document.getElementById('create-wrap-'+i).classList.remove('hidden');
    }
    _refreshCarrierBtnState(i);
  });
  updateMasterToggle();
  checkSubmitReady();
}

// Carrier button is red (required) only when a shop drawing exists but no
// carrier sheet was uploaded and Create is unchecked for the item.
function _refreshCarrierBtnState(idx) {
  if (uploaded[idx].carrier) return;
  document.getElementById('btn-car-'+idx).className =
    (uploaded[idx].shop && !createChecked[idx]) ? _btnErr : _btnNorm;
}

// Deselection ✕ beside a detected/matched file — same look as the Production Run
// remove buttons. Hidden until the slot has a file.
function _detachBtnHtml(idx, ftype) {
  return '<button id="rm-'+_ftKey[ftype]+'-'+idx+'" onclick="detachFile('+idx+',\''+ftype+'\')"' +
         ' title="Disconnect file"' +
         ' class="hidden w-5 h-5 flex items-center justify-center rounded-full text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors text-xs font-bold">✕</button>';
}

function _setDetachVisible(idx, ftype, visible) {
  var x = document.getElementById('rm-'+_ftKey[ftype]+'-'+idx);
  if (x) x.classList.toggle('hidden', !visible);
}

// Disconnect a detected/uploaded file from the item (state association only —
// the file itself stays in the folder). Detaching the carrier restores the
// no-carrier-detected behavior: Create checkbox shown and checked.
function detachFile(idx, ftype) {
  fetch('detach-file', {
    method: 'POST',
    headers: {'Content-Type':'application/json'},
    body: JSON.stringify({folder: csvFolder, item_index: String(idx), file_type: ftype})
  }).then(function(r){ return r.json(); }).then(function(data) {
    if (data.error) return;
    uploaded[idx][ftype] = false;
    var k = _ftKey[ftype];
    var lbl = document.getElementById('lbl-'+k+'-'+idx);
    lbl.textContent = ''; lbl.className = 'text-xs text-gray-500 dark:text-gray-400';
    document.getElementById('btn-'+k+'-'+idx).className = _btnNorm;
    _setDetachVisible(idx, ftype, false);
    // Previous merge result (if any) no longer reflects the item's files
    document.getElementById('result-'+idx).innerHTML = '';
    var pwrap = document.getElementById('preview-wrap-'+idx);
    if (pwrap) pwrap.classList.add('hidden');
    // Re-apply the Create rules: the checkbox exists only while a document
    // (shop or submittal) remains attached and no carrier is present — and is
    // checked by default in that case. Detaching the last document hides it
    // (no document → no carrier sheet needed).
    if (!uploaded[idx].carrier) {
      var hasDoc = !!(uploaded[idx].shop || uploaded[idx].submittal);
      createChecked[idx] = hasDoc;
      var cwrap = document.getElementById('create-wrap-'+idx);
      if (cwrap) cwrap.classList.toggle('hidden', !hasDoc);
      var ccb = document.getElementById('create-cb-'+idx);
      if (ccb) ccb.checked = createChecked[idx];
      updateMasterToggle();
    }
    _refreshCarrierBtnState(idx);
    checkSubmitReady();
  });
}

function onCreateToggle(idx) {
  createChecked[idx] = document.getElementById('create-cb-'+idx).checked;
  _refreshCarrierBtnState(idx);
  updateMasterToggle();
  checkSubmitReady();
}

// The master toggle only governs lines that have at least a Shop Drawing and
// no carrier sheet — lines without documents need no carrier sheet at all.
function toggleCreateAll(checked) {
  csvItems.forEach(function(_, i) {
    if (uploaded[i].carrier || !uploaded[i].shop) return;
    createChecked[i] = checked;
    var cb = document.getElementById('create-cb-'+i);
    if (cb) cb.checked = checked;
    _refreshCarrierBtnState(i);
  });
  updateMasterToggle();
  checkSubmitReady();
}

function updateMasterToggle() {
  var wrap = document.getElementById('create-all-wrap');
  var cb   = document.getElementById('create-all-cb');
  var missing = 0, checkedCount = 0;
  csvItems.forEach(function(_, i) {
    if (!uploaded[i].carrier && uploaded[i].shop) {
      missing++;
      if (createChecked[i]) checkedCount++;
    }
  });
  if (missing === 0) { wrap.classList.add('hidden'); return; }
  wrap.classList.remove('hidden');
  cb.checked = checkedCount === missing;
  cb.indeterminate = checkedCount > 0 && checkedCount < missing;
}

var _ftKey = {shop:'shop', submittal:'sub', carrier:'car'};
var _btnBase  = 'px-3 py-1.5 text-xs font-medium border-2 rounded-lg transition-colors ';
var _btnNorm  = _btnBase + 'border-gray-400 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700';
var _btnOk    = _btnBase + 'border-green-500 text-green-700 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/30';
var _btnErr   = _btnBase + 'border-red-400 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30';

function doUpload(idx, ftype, input) {
  var file = input.files[0]; if (file) doUploadFile(idx, ftype, file);
}

function doUploadFile(idx, ftype, file) {
  var k   = _ftKey[ftype];
  var lbl = document.getElementById('lbl-'+k+'-'+idx);
  var btn = document.getElementById('btn-'+k+'-'+idx);
  lbl.textContent = 'Uploading…'; lbl.className = 'text-xs text-gray-500 dark:text-gray-400';
  var fd = new FormData();
  fd.append('file', file); fd.append('folder', csvFolder);
  fd.append('item_index', idx); fd.append('file_type', ftype);
  fetch('upload-file', {method:'POST', body:fd})
    .then(function(r){ return r.json(); }).then(function(data) {
      if (data.error) { lbl.textContent = 'Error: '+data.error; lbl.className = 'text-xs text-red-600 dark:text-red-400'; return; }
      lbl.textContent = '✓ '+file.name; lbl.className = 'text-xs text-green-700 dark:text-green-400 font-medium';
      btn.className = _btnOk;
      uploaded[idx][ftype] = true;
      _setDetachVisible(idx, ftype, true);
      if (ftype === 'carrier') {
        // A real carrier sheet replaces the Create option for this item
        createChecked[idx] = false;
        var cwrap = document.getElementById('create-wrap-'+idx);
        if (cwrap) cwrap.classList.add('hidden');
        var ccb = document.getElementById('create-cb-'+idx);
        if (ccb) ccb.checked = false;
        updateMasterToggle();
      } else {
        // First document on the line makes a carrier sheet relevant — reveal
        // the Create checkbox (left unchecked: a manual upload means the user
        // decides between providing a carrier or checking Create themselves).
        if (!uploaded[idx].carrier) {
          var cwrap2 = document.getElementById('create-wrap-'+idx);
          if (cwrap2) cwrap2.classList.remove('hidden');
        }
        _refreshCarrierBtnState(idx);
        updateMasterToggle();
      }
      checkSubmitReady();
    }).catch(function(){ lbl.textContent = 'Failed'; lbl.className = 'text-xs text-red-600 dark:text-red-400'; });
}

function checkSubmitReady() {
  // Invalid only if a shop drawing was uploaded without a carrier sheet and Create is unchecked
  var valid = csvItems.every(function(_, i){ return !uploaded[i].shop || uploaded[i].carrier || createChecked[i]; });
  var btn = document.getElementById('submit-btn');
  btn.disabled = !valid;
  // After an initial submit, any line-item change reactivates this button to
  // process the edits — relabel it so the action reads as "Resubmit".
  if (_submitHasRun) btn.textContent = 'Resubmit';
}

function submitAll() {
  // Create-flagged items need confirmed project metadata before submitting
  var anyCreate = csvItems.some(function(_, i){ return !uploaded[i].carrier && createChecked[i]; });
  if (anyCreate && !_metaRequiredPresent()) {
    showMetaModal(csvFolder, 'submit');
    return;
  }
  var btn = document.getElementById('submit-btn');
  btn.disabled = true; btn.textContent = 'Processing…';
  csvItems.forEach(function(_, i) {
    document.getElementById('result-'+i).innerHTML =
      '<span class="text-xs text-orange-600 dark:text-orange-400 italic">Processing…</span>';
  });
  var createFlags = {};
  csvItems.forEach(function(_, i) {
    if (!uploaded[i].carrier && createChecked[i]) createFlags[i] = true;
  });
  fetch('submit', {
    method: 'POST',
    headers: {'Content-Type':'application/json'},
    body: JSON.stringify({
      folder: csvFolder, item_count: csvItems.length,
      create_flags: createFlags, metadata: csvMetadata
    })
  }).then(function(r){ return r.json(); }).then(function(data) {
    if (data.error) { btn.textContent = 'Error: '+data.error; btn.disabled = false; return; }
    var anyProcessed = false, anyError = false;
    data.results.forEach(function(r, i) {
      var el = document.getElementById('result-'+i);
      if (r.skipped) {
        el.innerHTML = '';  // line had no files — nothing to show
      } else if (r.error) {
        el.innerHTML = '<span class="text-xs text-red-600 dark:text-red-400">Error: '+esc(r.error)+'</span>';
        anyError = true;
      } else {
        if (r.created && r.carrier) {
          // Carrier sheet was generated server-side — mark it like an uploaded one
          uploaded[i].carrier = true;
          createChecked[i] = false;
          var cwrap = document.getElementById('create-wrap-'+i);
          if (cwrap) cwrap.classList.add('hidden');
          var clbl = document.getElementById('lbl-car-'+i);
          clbl.textContent = '✓ '+r.carrier.split('/').pop();
          clbl.className = 'text-xs text-green-700 dark:text-green-400 font-medium';
          document.getElementById('btn-car-'+i).className = _btnOk;
          _setDetachVisible(i, 'carrier', true);
        }
        previewUrls[i] = r.preview_url;
        el.innerHTML =
          '<button onclick="toggleItemPreview('+i+')" class="px-3 py-1.5 bg-gray-600 hover:bg-gray-700 text-white text-xs font-medium rounded-lg transition-colors">Preview QR Carrier Sheet</button>' +
          '<a href="'+r.download_url+'" download class="px-3 py-1.5 bg-togen-teal hover:bg-togen-teal-dark text-white text-xs font-medium rounded-lg transition-colors">Download QR Carrier Sheet</a>';
        anyProcessed = true;
      }
    });
    updateMasterToggle();
    // Per-item errors are recoverable — let the user fix inputs and resubmit
    if (anyError) { btn.textContent = 'Submit All'; btn.disabled = false; checkSubmitReady(); }
    else { _submitHasRun = true; btn.textContent = 'Done'; }
    // Enable Download ZIP and Publish now that submit has run
    var zipBtn = document.getElementById('dl-zip-btn');
    zipBtn.disabled = false;
    zipBtn.className = 'px-5 py-3 bg-green-600 text-white font-semibold rounded-xl text-sm hover:bg-green-700 transition-colors';
    var pubBtn2 = document.getElementById('publish-btn');
    pubBtn2.disabled = false;
    pubBtn2.className = 'px-5 py-3 bg-orange-500 text-white font-semibold rounded-xl text-sm hover:bg-orange-600 transition-colors';
    var pubDupBtn2 = document.getElementById('publish-dup-btn');
    pubDupBtn2.disabled = false;
    pubDupBtn2.className = 'px-5 py-3 bg-orange-500 text-white font-semibold rounded-xl text-sm hover:bg-orange-600 transition-colors';
    if (anyProcessed) {
      var a = document.getElementById('dl-csv');
      a.href = 'download-csv?folder='+encodeURIComponent(csvFolder);
      a.classList.remove('hidden');
    }
  }).catch(function(){ btn.textContent = 'Submit failed'; btn.disabled = false; });
}

// ===== SHARED FOLDER SELECTION =====
var _allSharedFolders = [];
var _selectedSharedFolderName = '';
var _selectedFolderBtn = null;
var _folderItemNorm = 'w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-togen-teal/10 dark:hover:bg-togen-teal/10 transition-colors';
var _folderItemSel  = 'w-full text-left px-4 py-2.5 text-sm font-medium text-togen-teal dark:text-togen-teal bg-togen-teal/10 dark:bg-togen-teal/10 transition-colors';

function loadSharedFolders() {
  var container = document.getElementById('shared-folders-list');
  fetch('list-shared-folders')
    .then(function(r){ return r.json(); })
    .then(function(data) {
      _allSharedFolders = data.folders || [];
      renderSharedFolders(_allSharedFolders);
    })
    .catch(function() {
      container.innerHTML = '<p class="text-sm text-red-500 dark:text-red-400 px-4 py-3">Failed to load folders.</p>';
    });
}

function renderSharedFolders(folders) {
  var container = document.getElementById('shared-folders-list');
  if (folders.length === 0) {
    container.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 px-4 py-3">No folders found.</p>';
    return;
  }
  container.innerHTML = '';
  folders.forEach(function(name) {
    var btn = document.createElement('button');
    btn.className = _folderItemNorm;
    btn.textContent = name;
    btn.onclick = function() { selectSharedFolder(name, btn); };
    container.appendChild(btn);
  });
}

function filterSharedFolders(q) {
  var lower = q.trim().toLowerCase();
  var filtered = lower ? _allSharedFolders.filter(function(n){ return n.toLowerCase().includes(lower); }) : _allSharedFolders;
  renderSharedFolders(filtered);
}

function openSharedInFileBrowser() {
  if (!_selectedSharedFolderName) return;
  openTool('file-browser', 'File Browser');
  fbNavigate(_selectedSharedFolderName);
}

function selectSharedFolder(name, btn) {
  if (_selectedFolderBtn) _selectedFolderBtn.className = _folderItemNorm;
  _selectedFolderBtn = btn;
  btn.className = _folderItemSel;

  var lbl = document.getElementById('folder-select-lbl');
  lbl.textContent = 'Loading…'; lbl.className = 'mt-3 block text-sm text-gray-500 dark:text-gray-400';

  fetch('select-shared-folder', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({folder: name})
  }).then(function(r){ return r.json(); }).then(function(data) {
    if (data.error) {
      lbl.textContent = 'Error: '+data.error;
      lbl.className = 'mt-3 block text-sm text-red-600 dark:text-red-400';
      return;
    }
    csvFolder = data.folder; csvItems = data.items; csvItemRefs = data.item_refs || [];
    csvAutoFiles = data.auto_files || [];
    csvMetadata = data.metadata || null;
    _selectedSharedFolderName = name;
    lbl.textContent = '✓ '+name+' — '+csvItems.length+' items';
    lbl.className = 'block text-sm text-green-700 dark:text-green-400 font-medium';
    document.getElementById('shared-fb-btn').classList.remove('hidden');
    renderItems();
    document.getElementById('items-wrap').classList.remove('hidden');
    document.getElementById('items-wrap').scrollIntoView({behavior:'smooth', block:'start'});
    // Metadata popup is deliberately NOT shown here — it only appears on
    // Submit All, and only when a carrier sheet needs to be created.
  }).catch(function() {
    lbl.textContent = 'Failed to load folder.';
    lbl.className = 'mt-3 block text-sm text-red-600 dark:text-red-400';
  });
}

// ===== PROJECT METADATA MODAL =====
var _metaModalFolder = '';

function _isProjectNumber(s) {
  return /^T\d{8,}/.test(s);
}

function _isNumericToken(s) {
  return /^\d[\d\-\.\/]*$/.test(s);
}

// Shared folder-name parser (Project Builder import + Take-off Bridge metadata popup).
// Folder format: "Job Number - Vendor Project Code (optional) - Project Name - Project Code".
// The " - " separators are not always present; Job Number, Vendor Project Code and
// Project Code are mostly numeric (job numbers may contain a dash, e.g. 26-2087;
// project codes may be T-prefixed, e.g. T2026061101), while the Project Name is the
// alphanumeric text span in the middle.
function parseFolderNameForProject(folderName) {
  var result = { customerName: '', projectName: '', vendorCode: '', jobNumber: '', projectNumber: '' };
  var tokens = folderName.trim().split(/\s+/).filter(function(t){ return t.length > 0; });
  function isCode(t) { return _isProjectNumber(t) || _isNumericToken(t); }
  function isDash(t) { return /^-+$/.test(t); }
  var first = -1, last = -1;
  for (var i = 0; i < tokens.length; i++) {
    if (!isCode(tokens[i]) && !isDash(tokens[i])) {
      if (first === -1) first = i;
      last = i;
    }
  }
  if (first === -1) {
    result.projectName = folderName;
    return result;
  }
  var leading = [];
  for (var j = 0; j < first; j++) {
    if (isCode(tokens[j])) leading.push(tokens[j]);
  }
  if (leading.length > 0) result.jobNumber = leading[0];
  if (leading.length > 1) result.vendorCode = leading[1];
  var trailing = [];
  for (var k = last + 1; k < tokens.length; k++) {
    if (isCode(tokens[k])) trailing.push(tokens[k]);
  }
  if (trailing.length > 0) result.projectNumber = trailing[trailing.length - 1];
  // Everything between the first and last text token (including dashes and numbers
  // embedded in the name, e.g. "Building 2 Phase") is the Project Name.
  result.projectName = tokens.slice(first, last + 1).join(' ');
  return result;
}

// Modal modes: 'folder' = no-metadata prompt on folder select; 'submit' = project-level
// confirmation triggered by Submit All; 'item' = per-item override cycling when
// "Apply to all items" is unchecked.
var _metaModalMode = 'folder';
var _metaCycleQueue = [];        // create-flagged item indexes left to cycle
var _metaCycleItem = -1;         // item index currently shown in 'item' mode
var _metaProjectValues = null;   // project-level values captured on the submit-mode screen
var _metaItemOverrides = {};     // item index -> {field: value} differing from project-level

var _metaFieldIds = {
  customer_name: 'meta-customer-name',
  project_name: 'meta-project-name',
  vendor_project_code: 'meta-vendor-code',
  job_number: 'meta-job-number',
  production_date: 'meta-production-date',
  spec_type: 'meta-spec-type',
  station: 'meta-station'
};

function _metaSetFields(values) {
  Object.keys(_metaFieldIds).forEach(function(k) {
    document.getElementById(_metaFieldIds[k]).value = values[k] || '';
  });
}

function _metaReadFields() {
  var out = {};
  Object.keys(_metaFieldIds).forEach(function(k) {
    out[k] = document.getElementById(_metaFieldIds[k]).value.trim();
  });
  return out;
}

function _metaRequiredPresent() {
  return !!(csvMetadata && String(csvMetadata.customer_name || '').trim()
                        && String(csvMetadata.project_name || '').trim());
}

function showMetaModal(folderName, mode) {
  _metaModalFolder = folderName;
  _metaModalMode = mode || 'folder';
  _metaCycleQueue = []; _metaCycleItem = -1;
  _metaProjectValues = null; _metaItemOverrides = {};
  var parsed = parseFolderNameForProject(folderName);
  var meta = csvMetadata || {};
  _metaSetFields({
    customer_name: meta.customer_name || parsed.customerName,
    project_name: meta.project_name || parsed.projectName,
    vendor_project_code: meta.vendor_project_code || parsed.vendorCode,
    job_number: meta.job_number || parsed.jobNumber,
    production_date: meta.production_date || '',
    spec_type: meta.spec_type || '',
    station: meta.station || ''
  });
  document.getElementById('meta-modal-title').textContent = 'Project Information';
  document.getElementById('meta-modal-desc').textContent = _metaModalMode === 'submit'
    ? 'Carrier sheets will be created for the flagged items. Confirm the project details below.'
    : 'No project metadata found for this folder. Fill in the details below or close to skip.';
  var applyWrap = document.getElementById('meta-apply-wrap');
  if (_metaModalMode === 'submit') {
    applyWrap.classList.remove('hidden');
    document.getElementById('meta-apply-all').checked = true;
  } else {
    applyWrap.classList.add('hidden');
  }
  document.getElementById('meta-save-btn').textContent = 'Save';
  var status = document.getElementById('meta-modal-status');
  status.textContent = ''; status.classList.add('hidden');
  var ocrHint = document.getElementById('meta-ocr-hint');
  if (ocrHint) ocrHint.textContent = "Read the project's documents to auto-fill empty fields.";
  _metaResetFilesUI();
  document.getElementById('meta-modal-overlay').classList.remove('hidden');
  if (!_metaResizeBound) {
    window.addEventListener('resize', function() {
      if (!document.getElementById('meta-modal-overlay').classList.contains('hidden')) _metaLayout();
    });
    _metaResizeBound = true;
  }
  _metaLayout();
}

// Apply an OCR extract result to the empty meta fields (non-destructive) and
// report the outcome into the given status element. Shared by the folder-level
// "OCR Discovery" button and the per-document "OCR this Document" button.
function _metaApplyOcrResult(ok, d, statusEl, noun) {
  noun = noun || 'documents';
  statusEl.classList.remove('text-togen-teal', 'text-yellow-600', 'dark:text-yellow-400',
                            'text-red-600', 'dark:text-red-400', 'text-gray-500', 'dark:text-gray-400');
  if (!ok || !d || !d.fields) {
    statusEl.textContent = (d && d.error) ? d.error
      : 'No project details could be read from the ' + noun + '.';
    statusEl.classList.add('text-yellow-600', 'dark:text-yellow-400');
    statusEl.classList.remove('hidden');
    return;
  }
  var filled = 0, found = 0;
  Object.keys(_metaFieldIds).forEach(function(k) {
    if (!(k in d.fields) || !d.fields[k]) return;
    found++;
    var el = document.getElementById(_metaFieldIds[k]);
    if (el && !el.value.trim()) { el.value = d.fields[k]; filled++; }
  });
  if (found === 0) {
    statusEl.textContent = 'No project details could be read from the ' + noun + '.';
    statusEl.classList.add('text-yellow-600', 'dark:text-yellow-400');
  } else if (filled > 0) {
    statusEl.textContent = 'OCR pre-filled ' + filled + ' field' + (filled === 1 ? '' : 's') +
                           ' — please review.';
    statusEl.classList.add('text-togen-teal');
  } else {
    statusEl.textContent = 'OCR read complete — matching fields were already filled.';
    statusEl.classList.add('text-togen-teal');
  }
  statusEl.classList.remove('hidden');
}

// DVI-502: explicit "OCR Discovery" button — read a representative drawing in the
// folder and fill any meta fields still empty (non-destructive; suggestions only).
async function metaModalOcrDiscovery() {
  var folderName = _metaModalFolder;
  if (!folderName) return;
  var btn = document.getElementById('meta-ocr-btn');
  var status = document.getElementById('meta-modal-status');
  if (btn) btn.disabled = true;
  status.textContent = 'Reading project documents…';
  status.className = 'text-xs font-medium text-gray-500 dark:text-gray-400';
  status.classList.remove('hidden');
  try {
    var r = await fetch('takeoff/extract-metadata', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({folder: folderName})
    });
    if (folderName !== _metaModalFolder) return;  // modal moved on / closed
    var d = await r.json();
    _metaApplyOcrResult(r.ok, d, status, 'documents');
  } catch (err) {
    if (folderName !== _metaModalFolder) return;
    status.textContent = 'Could not read the project documents. Please try again.';
    status.className = 'text-xs font-medium text-red-600 dark:text-red-400';
  } finally {
    if (btn) btn.disabled = false;
  }
}

// DVI-641: run OCR discovery on the SINGLE document currently in preview to fill
// the empty Project Information fields. PDF-only (image/text docs have no title
// block to read); the button is only shown for PDFs.
async function metaPreviewOcr() {
  var folderName = _metaModalFolder;
  if (!folderName || !_metaPreviewRel) return;
  var btn = document.getElementById('meta-preview-ocr');
  var status = document.getElementById('meta-preview-status');
  if (btn) btn.disabled = true;
  status.classList.remove('text-togen-teal', 'text-yellow-600', 'dark:text-yellow-400',
                          'text-red-600', 'dark:text-red-400');
  status.classList.add('text-gray-500', 'dark:text-gray-400');
  status.textContent = 'Reading this document…';
  status.classList.remove('hidden');
  var rel = _metaPreviewRel;
  try {
    var r = await fetch('takeoff/extract-metadata-file', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({folder: folderName, file: rel})
    });
    if (folderName !== _metaModalFolder || rel !== _metaPreviewRel) return;
    var d = await r.json();
    _metaApplyOcrResult(r.ok, d, status, 'document');
  } catch (err) {
    if (folderName !== _metaModalFolder || rel !== _metaPreviewRel) return;
    status.classList.remove('text-gray-500', 'dark:text-gray-400');
    status.classList.add('text-red-600', 'dark:text-red-400');
    status.textContent = 'Could not read this document. Please try again.';
  } finally {
    if (btn) btn.disabled = false;
  }
}

function closeMetaModal() {
  document.getElementById('meta-modal-overlay').classList.add('hidden');
  _metaModalFolder = '';
  _metaModalMode = 'folder';
  _metaCycleQueue = []; _metaCycleItem = -1;
  _metaProjectValues = null; _metaItemOverrides = {};
  _metaResetFilesUI();
}

// ---- DVI-641: View Project Files / Preview side panels ------------------
// The Project Information popup can expand rightward into a file-list panel and
// then a preview panel. On screens too narrow to show all open panels side by
// side, only the focused panel is shown and back buttons navigate leftward.
var _metaStage = 0;            // 0 = form only, 1 = files open, 2 = preview open
var _metaFocus = 0;            // panel shown in narrow (single-panel) mode
var _metaFilesLoadedFolder = '';
var _metaPreviewDocUrl = '';
var _metaPreviewRel = '';
var _metaPreviewExt = '';        // ext of the file in preview (region OCR is PDF-only)
var _metaRegionMode = null;      // null | 'all' | <field key> while armed for a draw
var _metaResizeBound = false;
var _metaRegionKeyBound = false;

// Minimum widths each panel needs to stay readable when shown side by side.
var _META_MIN = {form: 340, files: 260, preview: 380};

function _metaResetFilesUI() {
  _metaStage = 0; _metaFocus = 0;
  _metaFilesLoadedFolder = ''; _metaPreviewDocUrl = ''; _metaPreviewRel = '';
  var list = document.getElementById('meta-files-list');
  if (list) list.innerHTML = '';
  var body = document.getElementById('meta-preview-body');
  if (body) body.innerHTML = '';
  var pname = document.getElementById('meta-preview-name');
  if (pname) pname.textContent = 'Preview';
  var openBtn = document.getElementById('meta-preview-open');
  if (openBtn) openBtn.classList.add('hidden');
  var ocrBtn = document.getElementById('meta-preview-ocr');
  if (ocrBtn) ocrBtn.classList.add('hidden');
  var regionBtn = document.getElementById('meta-preview-region');
  if (regionBtn) regionBtn.classList.add('hidden');
  var pstatus = document.getElementById('meta-preview-status');
  if (pstatus) { pstatus.classList.add('hidden'); pstatus.textContent = ''; }
  _metaPreviewExt = '';
  _metaDisarmRegion();
  _metaEnsureFieldOcrIcons();
  _metaSetFieldIconsVisible(false);
}

function _metaSetBacks(filesVisible, prevVisible) {
  var fb = document.getElementById('meta-files-back');
  var pb = document.getElementById('meta-preview-back');
  if (fb) fb.classList.toggle('hidden', !filesVisible);
  if (pb) pb.classList.toggle('hidden', !prevVisible);
}

// Lay out the popup. Stage 0 (project info only) stays a compact centered card.
// Once a panel is opened the dialog uses most of the viewport width so the
// preview is large. When the viewport is too narrow to show the open panels
// side by side (mobile / narrow aspect) the panels become a horizontal
// carousel: only one shows at a time and the off-screen panels slide left,
// with back buttons to step back toward the project fields.
function _metaLayout() {
  var box = document.getElementById('meta-modal-box');
  var track = document.getElementById('meta-modal-track');
  var form = document.getElementById('meta-panel-form');
  var files = document.getElementById('meta-panel-files');
  var prev = document.getElementById('meta-panel-preview');
  if (!box || !track || !form) return;

  // Stage-based presence (independent of focus): a panel exists once reached.
  files.classList.toggle('hidden', _metaStage < 1);
  files.classList.toggle('flex', _metaStage >= 1);
  prev.classList.toggle('hidden', _metaStage < 2);
  prev.classList.toggle('flex', _metaStage >= 2);

  if (_metaStage === 0) {
    form.style.width = '100%';
    track.style.width = '100%';
    track.style.transform = 'none';
    box.style.width = '100%';
    box.style.maxWidth = '28rem';
    _metaSetBacks(false, false);
    return;
  }

  var boxW = Math.min(window.innerWidth - 24, 1600);
  box.style.width = boxW + 'px';
  box.style.maxWidth = boxW + 'px';

  var present = [{el: form, focus: 0}];
  if (_metaStage >= 1) present.push({el: files, focus: 1});
  if (_metaStage >= 2) present.push({el: prev, focus: 2});

  var needed = _META_MIN.form
             + (_metaStage >= 1 ? _META_MIN.files : 0)
             + (_metaStage >= 2 ? _META_MIN.preview : 0);

  if (boxW >= needed) {
    // Side-by-side: give the preview (or the file list when no preview yet)
    // all the leftover width so documents render large.
    var formW = _META_MIN.form;
    form.style.width = formW + 'px';
    if (_metaStage >= 2) {
      files.style.width = _META_MIN.files + 'px';
      prev.style.width = (boxW - formW - _META_MIN.files) + 'px';
    } else if (_metaStage >= 1) {
      files.style.width = (boxW - formW) + 'px';
    }
    track.style.width = boxW + 'px';
    track.style.transform = 'none';
    _metaSetBacks(false, false);
  } else {
    // Carousel: each panel is full box width; slide to the focused one.
    if (_metaFocus > _metaStage) _metaFocus = _metaStage;
    present.forEach(function(p) { p.el.style.width = boxW + 'px'; });
    track.style.width = (present.length * boxW) + 'px';
    var pos = 0;
    for (var i = 0; i < present.length; i++) {
      if (present[i].focus === _metaFocus) { pos = i; break; }
    }
    track.style.transform = 'translateX(-' + (pos * boxW) + 'px)';
    _metaSetBacks(_metaStage >= 1, _metaStage >= 2);
  }
}

function metaOpenFiles() {
  if (!_metaModalFolder) return;
  if (_metaStage < 1) _metaStage = 1;
  _metaFocus = 1;
  _metaLayout();
  _metaLoadFiles();
}

function metaFilesBack() { _metaFocus = 0; _metaLayout(); }
function metaPreviewBack() { _metaFocus = 1; _metaLayout(); }

function metaPreviewOpen() {
  if (_metaPreviewDocUrl) window.open(_metaPreviewDocUrl, '_blank');
}

async function _metaLoadFiles() {
  var folder = _metaModalFolder;
  var list = document.getElementById('meta-files-list');
  if (!list) return;
  if (_metaFilesLoadedFolder === folder && list.children.length) return;
  list.innerHTML = '<p class="px-3 py-2 text-xs text-gray-400 dark:text-gray-500">Loading…</p>';
  try {
    var r = await fetch('takeoff/project-files?folder=' + encodeURIComponent(folder));
    var d = await r.json();
    if (folder !== _metaModalFolder) return;
    if (!r.ok || !d.files || !d.files.length) {
      list.innerHTML = '<p class="px-3 py-2 text-xs text-gray-400 dark:text-gray-500">No files found in this project folder.</p>';
      _metaFilesLoadedFolder = folder;
      return;
    }
    list.innerHTML = '';
    d.files.forEach(function(f) {
      var btn = document.createElement('button');
      btn.type = 'button';
      btn.dataset.rel = f.rel;
      btn.className = 'w-full text-left flex items-start gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer';
      btn.onclick = function() { _metaSelectFile(f.rel, f.name, f.ext); };
      var icon = document.createElement('span');
      icon.className = 'flex-shrink-0';
      icon.innerHTML = '<svg class="h-4 w-4 mt-0.5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>';
      var col = document.createElement('span');
      col.className = 'min-w-0 flex-1';
      var nm = document.createElement('span');
      nm.className = 'block truncate text-gray-700 dark:text-gray-200';
      nm.textContent = f.name;
      col.appendChild(nm);
      if (f.dir) {
        var sub = document.createElement('span');
        sub.className = 'block truncate text-xs text-gray-400 dark:text-gray-500';
        sub.textContent = f.dir;
        col.appendChild(sub);
      }
      btn.appendChild(icon);
      btn.appendChild(col);
      list.appendChild(btn);
    });
    _metaFilesLoadedFolder = folder;
  } catch (err) {
    if (folder !== _metaModalFolder) return;
    list.innerHTML = '<p class="px-3 py-2 text-xs text-red-500">Could not load files.</p>';
  }
}

function _metaSelectFile(rel, name, ext) {
  _metaStage = 2; _metaFocus = 2;
  _metaPreviewRel = rel;
  var list = document.getElementById('meta-files-list');
  if (list) {
    Array.prototype.forEach.call(list.children, function(c) {
      if (!c.dataset) return;
      c.classList.toggle('bg-togen-teal/10', c.dataset.rel === rel);
    });
  }
  document.getElementById('meta-preview-name').textContent = name;
  var base = (_sharedUrlPrefix || '').replace(/\/+$/, '');
  var segs = (_metaModalFolder + '/' + rel).split('/').map(encodeURIComponent).join('/');
  _metaPreviewDocUrl = base + '/shared/' + segs;
  document.getElementById('meta-preview-open').classList.remove('hidden');

  var e = (ext || '').toLowerCase();
  _metaPreviewExt = e;
  _metaDisarmRegion();
  // OCR (whole-doc + region select + per-field icons) reads a PDF — PDF-only.
  var ocrBtn = document.getElementById('meta-preview-ocr');
  if (ocrBtn) ocrBtn.classList.toggle('hidden', e !== 'pdf');
  var regionBtn = document.getElementById('meta-preview-region');
  if (regionBtn) regionBtn.classList.toggle('hidden', e !== 'pdf');
  _metaSetFieldIconsVisible(e === 'pdf');
  var pstatus = document.getElementById('meta-preview-status');
  if (pstatus) { pstatus.classList.add('hidden'); pstatus.textContent = ''; }

  var body = document.getElementById('meta-preview-body');
  body.innerHTML = '';
  var prevUrl = 'takeoff/file-preview?folder=' + encodeURIComponent(_metaModalFolder) +
                '&file=' + encodeURIComponent(rel);
  if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].indexOf(e) >= 0) {
    var wrap = document.createElement('div');
    wrap.className = 'p-3';
    var img = document.createElement('img');
    img.src = prevUrl;
    img.className = 'block max-w-full h-auto mx-auto';
    wrap.appendChild(img);
    body.appendChild(wrap);
  } else if (e === 'pdf') {
    // Render each page as a fit-to-width image so the first page sits fully
    // inside the pane and users can scroll through pages — native PDF viewers
    // zoom too far in on mobile and provide no controls (DVI-641 feedback).
    _metaRenderPdf(body, _metaModalFolder, rel);
  } else if (['txt', 'csv', 'log', 'json', 'md', 'html', 'htm'].indexOf(e) >= 0) {
    var frame = document.createElement('iframe');
    frame.src = prevUrl;
    frame.style.width = '100%';
    frame.style.height = '100%';
    frame.style.minHeight = '60vh';
    frame.style.border = '0';
    body.appendChild(frame);
  } else {
    body.innerHTML = '<div class="flex flex-col items-center justify-center h-full text-center text-sm text-gray-500 dark:text-gray-400 p-6 gap-2">' +
      '<p>No inline preview available for this file type.</p>' +
      '<p>Use <span class="font-semibold">Open</span> to view it in the Document Viewer.</p></div>';
  }
  _metaLayout();
}

// Render a project PDF as a vertical stack of fit-to-width page images. Falls
// back to the browser's native PDF viewer if server-side rendering is missing.
async function _metaRenderPdf(body, folder, rel) {
  body.innerHTML = '<p class="px-4 py-4 text-xs text-gray-400 dark:text-gray-500">Loading document…</p>';
  var q = 'folder=' + encodeURIComponent(folder) + '&file=' + encodeURIComponent(rel);
  try {
    var r = await fetch('takeoff/pdf-pages?' + q);
    var d = await r.json();
    if (folder !== _metaModalFolder || rel !== _metaPreviewRel) return;
    if (!r.ok || !d.pages) throw new Error(d && d.error ? d.error : 'no pages');
    body.innerHTML = '';
    var wrap = document.createElement('div');
    wrap.className = 'flex flex-col items-center gap-3 p-3';
    for (var i = 0; i < d.pages; i++) {
      // Each page sits in a relative wrapper so a transparent overlay can
      // capture rectangle-select drags for region OCR (DVI-645). The overlay
      // only receives pointer events while a draw is armed; otherwise the
      // page scrolls normally.
      var pw = document.createElement('div');
      pw.className = 'relative';
      pw.dataset.page = i;
      var img = document.createElement('img');
      img.src = 'takeoff/pdf-page?' + q + '&page=' + i;
      img.loading = 'lazy';
      img.className = 'block max-w-full h-auto shadow-sm border border-gray-200 dark:border-gray-700 bg-white';
      pw.appendChild(img);
      var ov = document.createElement('div');
      ov.className = 'meta-region-ov absolute inset-0';
      ov.style.pointerEvents = 'none';
      ov.addEventListener('pointerdown', _metaRegionPointerDown);
      pw.appendChild(ov);
      wrap.appendChild(pw);
    }
    body.appendChild(wrap);
    if (_metaRegionMode) _metaArmRegion(_metaRegionMode);  // re-arm overlays
  } catch (err) {
    if (folder !== _metaModalFolder || rel !== _metaPreviewRel) return;
    body.innerHTML = '';
    var frame = document.createElement('iframe');
    frame.src = 'takeoff/file-preview?' + q + '#zoom=page-fit';
    frame.style.width = '100%';
    frame.style.height = '100%';
    frame.style.minHeight = '60vh';
    frame.style.border = '0';
    body.appendChild(frame);
  }
}

// ---- DVI-645: user-selected region OCR ---------------------------------
// Friendly labels for status messages / button titles, keyed like _metaFieldIds.
var _metaFieldLabels = {
  customer_name: 'Customer/Contractor Name', project_name: 'Project Name',
  vendor_project_code: 'Vendor Project Code', job_number: 'Job Number',
  production_date: 'Production Date', spec_type: 'Spec Type', station: 'Station'
};
function _metaFieldLabel(k) { return _metaFieldLabels[k] || k; }

// Inject a small "OCR this region into this field" icon button next to every
// project-info input (idempotent — runs each time the modal opens).
function _metaEnsureFieldOcrIcons() {
  Object.keys(_metaFieldIds).forEach(function(k) {
    var input = document.getElementById(_metaFieldIds[k]);
    if (!input) return;
    var cell = input.parentElement;
    if (!cell || cell.querySelector('.meta-field-ocr')) return;
    cell.classList.add('relative');
    var btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'meta-field-ocr hidden absolute right-0 top-0 p-0.5 rounded text-togen-teal hover:text-white hover:bg-togen-teal transition-colors cursor-pointer';
    btn.title = 'Draw a box on the previewed document to OCR into this field';
    btn.setAttribute('aria-label', 'OCR a region into ' + _metaFieldLabel(k));
    btn.innerHTML = '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/></svg>';
    btn.onclick = function() { _metaArmRegion(k); };
    cell.appendChild(btn);
  });
}

function _metaSetFieldIconsVisible(show) {
  var icons = document.querySelectorAll('.meta-field-ocr');
  Array.prototype.forEach.call(icons, function(b) { b.classList.toggle('hidden', !show); });
}

// Arm a rectangle-select. mode is 'all' (best-guess, fills any empty fields)
// or a field key (raw text into that one input). Activates the page overlays.
function _metaArmRegion(mode) {
  if (_metaPreviewExt !== 'pdf' || !_metaPreviewRel) return;
  _metaRegionMode = mode;
  // On a narrow/carousel layout the form may be on-screen instead of the
  // preview — slide to the preview so the user can draw.
  if (_metaStage < 2) { _metaStage = 2; }
  _metaFocus = 2; _metaLayout();
  var ovs = document.querySelectorAll('.meta-region-ov');
  Array.prototype.forEach.call(ovs, function(o) {
    o.style.pointerEvents = 'auto'; o.style.touchAction = 'none'; o.style.cursor = 'crosshair';
  });
  var st = document.getElementById('meta-preview-status');
  if (st) {
    st.classList.remove('hidden', 'text-togen-teal', 'text-yellow-600', 'dark:text-yellow-400',
                        'text-red-600', 'dark:text-red-400');
    st.classList.add('text-gray-500', 'dark:text-gray-400');
    st.textContent = (mode === 'all')
      ? 'Draw a box around the area to OCR (Esc to cancel).'
      : 'Draw a box around the value for “' + _metaFieldLabel(mode) + '” (Esc to cancel).';
  }
  if (!_metaRegionKeyBound) {
    document.addEventListener('keydown', function(e) {
      if (e.key === 'Escape' && _metaRegionMode) { _metaDisarmRegion(true); }
    });
    _metaRegionKeyBound = true;
  }
}

function _metaDisarmRegion(announce) {
  _metaRegionMode = null;
  var ovs = document.querySelectorAll('.meta-region-ov');
  Array.prototype.forEach.call(ovs, function(o) {
    o.style.pointerEvents = 'none'; o.style.touchAction = ''; o.style.cursor = '';
    while (o.firstChild) o.removeChild(o.firstChild);  // drop any in-progress box
  });
  if (announce) {
    var st = document.getElementById('meta-preview-status');
    if (st) { st.classList.add('hidden'); st.textContent = ''; }
  }
}

function _metaRegionPointerDown(e) {
  if (!_metaRegionMode) return;
  e.preventDefault();
  var ov = e.currentTarget;
  var rect = ov.getBoundingClientRect();
  var startX = e.clientX, startY = e.clientY;
  var box = document.createElement('div');
  box.className = 'absolute border-2 border-dashed border-togen-teal bg-togen-teal/10 pointer-events-none';
  ov.appendChild(box);
  function clampX(v) { return Math.min(Math.max(v, rect.left), rect.right); }
  function clampY(v) { return Math.min(Math.max(v, rect.top), rect.bottom); }
  function draw(cx, cy) {
    box.style.left = (Math.min(startX, cx) - rect.left) + 'px';
    box.style.top = (Math.min(startY, cy) - rect.top) + 'px';
    box.style.width = Math.abs(cx - startX) + 'px';
    box.style.height = Math.abs(cy - startY) + 'px';
  }
  function mv(ev) { draw(clampX(ev.clientX), clampY(ev.clientY)); }
  function up(ev) {
    document.removeEventListener('pointermove', mv);
    document.removeEventListener('pointerup', up);
    var cx = clampX(ev.clientX), cy = clampY(ev.clientY);
    if (box.parentElement) box.parentElement.removeChild(box);
    if (rect.width <= 0 || rect.height <= 0) return;
    var fr = {
      x0: (Math.min(startX, cx) - rect.left) / rect.width,
      y0: (Math.min(startY, cy) - rect.top) / rect.height,
      x1: (Math.max(startX, cx) - rect.left) / rect.width,
      y1: (Math.max(startY, cy) - rect.top) / rect.height
    };
    if ((fr.x1 - fr.x0) < 0.01 || (fr.y1 - fr.y0) < 0.01) return;  // ignore taps
    var page = parseInt((ov.parentElement && ov.parentElement.dataset.page) || '0', 10);
    _metaRunRegion(page, fr);
  }
  document.addEventListener('pointermove', mv);
  document.addEventListener('pointerup', up);
}

async function _metaRunRegion(page, fr) {
  var folder = _metaModalFolder, rel = _metaPreviewRel, mode = _metaRegionMode;
  var st = document.getElementById('meta-preview-status');
  _metaDisarmRegion();
  if (st) {
    st.classList.remove('hidden', 'text-togen-teal', 'text-yellow-600', 'dark:text-yellow-400',
                        'text-red-600', 'dark:text-red-400');
    st.classList.add('text-gray-500', 'dark:text-gray-400');
    st.textContent = 'Reading the selected region…';
  }
  try {
    var payload = {folder: folder, file: rel, page: page, rect: fr};
    if (mode && mode !== 'all') payload.field = mode;
    var r = await fetch('takeoff/extract-region', {
      method: 'POST', headers: {'Content-Type': 'application/json'},
      body: JSON.stringify(payload)
    });
    if (folder !== _metaModalFolder || rel !== _metaPreviewRel) return;
    var d = await r.json();
    if (mode === 'all' || !mode) { _metaApplyOcrResult(r.ok, d, st, 'selected region'); }
    else { _metaApplyRegionField(r.ok, d, st, mode); }
  } catch (err) {
    if (folder !== _metaModalFolder || rel !== _metaPreviewRel) return;
    if (st) {
      st.classList.remove('text-gray-500', 'dark:text-gray-400');
      st.classList.add('text-red-600', 'dark:text-red-400');
      st.textContent = 'Could not read the selected region. Please try again.';
    }
  }
}

function _metaApplyRegionField(ok, d, st, key) {
  if (!st) return;
  st.classList.remove('text-togen-teal', 'text-yellow-600', 'dark:text-yellow-400',
                      'text-red-600', 'dark:text-red-400', 'text-gray-500', 'dark:text-gray-400');
  var raw = (d && typeof d.text === 'string') ? d.text.trim() : '';
  if (!ok || !raw) {
    st.textContent = (d && d.error) ? d.error : 'No text was read from that region.';
    st.classList.add('text-yellow-600', 'dark:text-yellow-400');
    st.classList.remove('hidden');
    return;
  }
  var el = document.getElementById(_metaFieldIds[key]);
  if (!el) { st.classList.add('text-yellow-600', 'dark:text-yellow-400'); return; }
  if (el.type === 'date') {
    var iso = _metaToISODate(raw);
    if (iso) { el.value = iso; }
    else {
      st.textContent = 'Read “' + raw + '” but could not parse it as a date — please enter it manually.';
      st.classList.add('text-yellow-600', 'dark:text-yellow-400');
      st.classList.remove('hidden');
      return;
    }
  } else {
    el.value = raw;
  }
  el.classList.add('ring-2', 'ring-togen-teal');
  setTimeout(function() { el.classList.remove('ring-2', 'ring-togen-teal'); }, 1200);
  st.textContent = 'Filled “' + _metaFieldLabel(key) + '” from the selected region — please review.';
  st.classList.add('text-togen-teal');
  st.classList.remove('hidden');
}

// Best-effort parse of OCR'd text into an ISO (YYYY-MM-DD) value for the date
// input. Uses local date parts so the value is not shifted by the timezone.
function _metaToISODate(s) {
  function fmt(y, m, dd) { return y + '-' + ('0' + m).slice(-2) + '-' + ('0' + dd).slice(-2); }
  var m = s.match(/(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{2,4})/);
  if (m) {
    var mo = +m[1], da = +m[2], yr = +m[3];
    if (yr < 100) yr += 2000;
    var dd = new Date(yr, mo - 1, da);
    if (!isNaN(dd.getTime())) return fmt(dd.getFullYear(), dd.getMonth() + 1, dd.getDate());
  }
  var d = new Date(s);
  if (!isNaN(d.getTime())) return fmt(d.getFullYear(), d.getMonth() + 1, d.getDate());
  return '';
}

function metaModalClickAway(e) {
  if (e.target === document.getElementById('meta-modal-overlay')) {
    closeMetaModal();
  }
}

function submitMetaModal() {
  var values = _metaReadFields();
  var status = document.getElementById('meta-modal-status');
  if (!values.customer_name || !values.project_name) {
    status.textContent = 'Customer name and project name are required.';
    status.className = 'text-xs font-medium text-red-600 dark:text-red-400';
    status.classList.remove('hidden');
    return;
  }
  if (_metaModalMode === 'item') {
    // Keep only the fields that differ from the project-level values
    var override = {};
    Object.keys(values).forEach(function(k) {
      if (values[k] !== _metaProjectValues[k]) override[k] = values[k];
    });
    if (Object.keys(override).length > 0) _metaItemOverrides[_metaCycleItem] = override;
    _metaAdvanceCycle();
    return;
  }
  if (_metaModalMode === 'submit') {
    _metaProjectValues = values;
    if (!document.getElementById('meta-apply-all').checked) {
      csvItems.forEach(function(_, i) {
        if (!uploaded[i].carrier && createChecked[i]) _metaCycleQueue.push(i);
      });
      if (_metaCycleQueue.length > 0) {
        _metaModalMode = 'item';
        _metaAdvanceCycle();
        return;
      }
    }
    // Apply to all items — clear any stale per-item overrides
    _metaSave(values, {});
    return;
  }
  _metaSave(values, null);
}

function _metaAdvanceCycle() {
  if (_metaCycleQueue.length === 0) {
    _metaSave(_metaProjectValues, _metaItemOverrides);
    return;
  }
  _metaCycleItem = _metaCycleQueue.shift();
  document.getElementById('meta-modal-title').textContent =
    'Project Information — ' + csvItems[_metaCycleItem];
  document.getElementById('meta-modal-desc').textContent =
    'Adjust details for this item if needed. Values differing from the project apply to this item only.';
  document.getElementById('meta-apply-wrap').classList.add('hidden');
  var prefill = {};
  Object.keys(_metaProjectValues).forEach(function(k){ prefill[k] = _metaProjectValues[k]; });
  var existing = _metaItemOverrides[_metaCycleItem] || {};
  Object.keys(existing).forEach(function(k){ prefill[k] = existing[k]; });
  _metaSetFields(prefill);
  document.getElementById('meta-save-btn').textContent = _metaCycleQueue.length > 0 ? 'Next' : 'Save';
  var status = document.getElementById('meta-modal-status');
  status.textContent = ''; status.classList.add('hidden');
}

// itemOverrides: null = leave existing overrides untouched; {} = clear; {idx:{...}} = replace
function _metaSave(values, itemOverrides) {
  var wasSubmitFlow = _metaModalMode !== 'folder';
  var status = document.getElementById('meta-modal-status');
  status.textContent = 'Saving…';
  status.className = 'text-xs font-medium text-gray-500 dark:text-gray-400';
  status.classList.remove('hidden');
  var body = {
    folder: _metaModalFolder,
    customer_name: values.customer_name,
    project_name: values.project_name,
    vendor_project_code: values.vendor_project_code,
    job_number: values.job_number,
    production_date: values.production_date,
    spec_type: values.spec_type,
    station: values.station
  };
  if (itemOverrides) body.item_overrides = itemOverrides;
  fetch('save-project-metadata', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body)
  }).then(function(r){ return r.json(); }).then(function(data) {
    if (data.error) {
      status.textContent = 'Error: ' + data.error;
      status.className = 'text-xs font-medium text-red-600 dark:text-red-400';
    } else {
      csvMetadata = data.metadata || csvMetadata;
      closeMetaModal();
      if (wasSubmitFlow) submitAll();  // metadata confirmed — resume Submit All
    }
  }).catch(function() {
    status.textContent = 'Failed to save metadata.';
    status.className = 'text-xs font-medium text-red-600 dark:text-red-400';
  });
}

// ===== ITEM PREVIEW =====
var previewUrls = {};

function toggleItemPreview(idx) {
  var wrap  = document.getElementById('preview-wrap-'+idx);
  var embed = document.getElementById('preview-embed-'+idx);
  if (wrap.classList.contains('hidden')) {
    embed.src = previewUrls[idx];
    wrap.classList.remove('hidden');
    wrap.scrollIntoView({behavior:'smooth', block:'nearest'});
  } else {
    wrap.classList.add('hidden');
    embed.removeAttribute('src');
  }
}

// ===== DOWNLOAD ZIP =====
function downloadJobZip() {
  if (!csvFolder) return;
  window.location = 'download-job-zip?folder='+encodeURIComponent(csvFolder);
}

// ===== PUBLISH =====
function publishJob(overwrite) {
  if (!csvFolder) return;
  var btn = document.getElementById('publish-btn');
  var msg = document.getElementById('publish-msg');
  btn.disabled = true; btn.textContent = 'Publishing…';
  msg.classList.add('hidden');
  fetch('publish', {
    method:'POST',
    headers:{'Content-Type':'application/json'},
    body: JSON.stringify({folder: csvFolder, overwrite: !!overwrite})
  }).then(function(r){ return r.json().then(function(data){ return {status: r.status, data: data}; }); }).then(function(res) {
    var data = res.data;
    if (res.status === 409 && data.exists) {
      btn.disabled = false; btn.textContent = 'Publish';
      if (confirm('A folder named "' + csvFolder + '" already exists in the shared directory.\n\nDo you want to overwrite it?')) {
        publishJob(true);
      }
    } else if (data.error) {
      msg.textContent = 'Publish failed: '+data.error;
      msg.className = 'mt-2 text-sm font-medium text-red-600 dark:text-red-400';
      msg.classList.remove('hidden');
      btn.disabled = false; btn.textContent = 'Publish';
    } else {
      btn.textContent = '✓ Published';
      btn.className = 'px-5 py-3 bg-gray-400 text-white font-semibold rounded-xl text-sm cursor-default';
      msg.textContent = overwrite ? 'Folder overwritten and published.' : 'Folder published to shared directory.';
      msg.className = 'mt-2 text-sm font-medium text-green-700 dark:text-green-400';
      msg.classList.remove('hidden');
    }
  }).catch(function() {
    msg.textContent = 'Publish request failed.';
    msg.className = 'mt-2 text-sm font-medium text-red-600 dark:text-red-400';
    msg.classList.remove('hidden');
    btn.disabled = false; btn.textContent = 'Publish';
  });
}

// ===== PUBLISH DUPLICATE =====
function openDuplicateModal() {
  if (!csvFolder) return;
  var input = document.getElementById('dup-folder-name');
  input.value = csvFolder + '-2';
  var status = document.getElementById('dup-modal-status');
  status.classList.add('hidden');
  var btn = document.getElementById('dup-confirm-btn');
  btn.disabled = false; btn.textContent = 'Duplicate to New Folder';
  document.getElementById('dup-modal-overlay').classList.remove('hidden');
  input.focus(); input.select();
}

function closeDuplicateModal() {
  document.getElementById('dup-modal-overlay').classList.add('hidden');
}

function dupModalClickAway(e) {
  if (e.target === document.getElementById('dup-modal-overlay')) closeDuplicateModal();
}

function submitDuplicate() {
  var name = document.getElementById('dup-folder-name').value.trim();
  var status = document.getElementById('dup-modal-status');
  if (!name) {
    status.textContent = 'Folder name is required.';
    status.className = 'text-xs font-medium text-red-600 dark:text-red-400';
    status.classList.remove('hidden');
    return;
  }
  var btn = document.getElementById('dup-confirm-btn');
  btn.disabled = true; btn.textContent = 'Duplicating…';
  status.classList.add('hidden');
  fetch('duplicate-folder', {
    method:'POST',
    headers:{'Content-Type':'application/json'},
    body: JSON.stringify({folder: csvFolder, new_folder: name})
  }).then(function(r){ return r.json().then(function(data){ return {status: r.status, data: data}; }); }).then(function(res) {
    var data = res.data;
    if (data.error) {
      status.textContent = data.error;
      status.className = 'text-xs font-medium text-red-600 dark:text-red-400';
      status.classList.remove('hidden');
      btn.disabled = false; btn.textContent = 'Duplicate to New Folder';
      return;
    }
    closeDuplicateModal();
    var msg = document.getElementById('publish-msg');
    msg.textContent = 'Duplicated to new folder "' + data.folder + '".';
    msg.className = 'mt-2 text-sm font-medium text-green-700 dark:text-green-400';
    msg.classList.remove('hidden');
  }).catch(function() {
    status.textContent = 'Duplicate request failed.';
    status.className = 'text-xs font-medium text-red-600 dark:text-red-400';
    status.classList.remove('hidden');
    btn.disabled = false; btn.textContent = 'Duplicate to New Folder';
  });
}

// ===== SINGLE-ITEM LEGACY =====
var singlePollTimer = null;
var currentJob = null;

function setSingleStatus(msg, type) {
  var bar = document.getElementById('single-status');
  bar.className = 'mt-4 flex items-center gap-3 p-4 rounded-xl border ' + (
    type === 'success' ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800' :
    type === 'error'   ? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800' :
                         'bg-togen-teal/10 dark:bg-togen-teal/20 border-togen-teal/30 dark:border-togen-teal/40'
  );
  bar.classList.remove('hidden');
  document.getElementById('s-spinner').classList.toggle('hidden', type !== 'info');
  document.getElementById('s-check').classList.toggle('hidden', type !== 'success');
  document.getElementById('s-x').classList.toggle('hidden', type !== 'error');
  var msgEl = document.getElementById('s-msg');
  msgEl.className = 'text-sm font-medium ' + (
    type === 'success' ? 'text-green-700 dark:text-green-400' :
    type === 'error'   ? 'text-red-700 dark:text-red-400' :
                         'text-togen-dark dark:text-togen-teal'
  );
  msgEl.textContent = msg;
}

function resetSingleForm() {
  if (singlePollTimer) { clearInterval(singlePollTimer); singlePollTimer = null; }
  currentJob = null;
  _scsViewerUrl = null;
  _scsViewerRel = null;
  _scsGeneratedName = null;
  _scsJobFiles = null;
  _scsCreateFields = null;
  document.getElementById('single-form').reset();
  document.getElementById('single-form').classList.remove('hidden');
  document.getElementById('single-submit-btn').disabled = false;
  document.getElementById('preview-placeholder').classList.remove('hidden');
  document.getElementById('preview-container').classList.add('hidden');
  document.getElementById('preview-embed').removeAttribute('src');
  document.getElementById('single-status').classList.add('hidden');
  document.getElementById('reprint-container').classList.add('hidden');
  document.getElementById('reprint-embed').removeAttribute('src');
  _reprintMode = false;
  // Reset carrier create checkbox to default (checked)
  var cb = document.getElementById('carrier-create-cb');
  if (cb) { cb.checked = true; onCarrierCreateChange(cb); }
}

// ===== CARRIER CREATE CHECKBOX LOGIC (DVI-305) =====
function onCarrierCreateChange(cb) {
  var fileInput = document.getElementById('carrier-file');
  var hint = document.getElementById('carrier-create-hint');
  var star = document.getElementById('carrier-required-star');
  var submittal = document.getElementById('submittal-file');
  var submittalStar = document.getElementById('submittal-required-star');
  var submittalOpt = document.getElementById('submittal-optional-hint');
  if (cb.checked) {
    fileInput.removeAttribute('required');
    fileInput.value = '';
    hint.classList.remove('hidden');
    star.classList.add('hidden');
    // Create mode: a submittal is optional — its QR box is left blank when absent.
    if (submittal) submittal.removeAttribute('required');
    if (submittalStar) submittalStar.classList.add('hidden');
    if (submittalOpt) submittalOpt.classList.remove('hidden');
  } else {
    fileInput.setAttribute('required', 'required');
    hint.classList.add('hidden');
    star.classList.remove('hidden');
    // Merge-existing mode still needs the submittal drawing to build both QR codes.
    if (submittal) submittal.setAttribute('required', 'required');
    if (submittalStar) submittalStar.classList.remove('hidden');
    if (submittalOpt) submittalOpt.classList.add('hidden');
  }
}

function onCarrierFileChange(input) {
  if (input.files && input.files.length > 0) {
    var cb = document.getElementById('carrier-create-cb');
    cb.checked = false;
    onCarrierCreateChange(cb);
  }
}

// Init carrier create state on page load
(function() {
  var cb = document.getElementById('carrier-create-cb');
  if (cb) onCarrierCreateChange(cb);
})();

// Raw project name read from OCR (before job/vendor tokens are prepended) and
// the last value we auto-filled into the Project Name field. Together they let
// us recompose the tokenized name when the Job Number / Vendor Project Code
// change, while never clobbering a value the user has edited by hand.
var _carrierOcrRawName = '';
var _carrierAutoProjectName = '';

function openCreateCarrierDialog() {
  document.getElementById('create-carrier-error').classList.add('hidden');
  document.getElementById('create-carrier-ocr-hint').classList.add('hidden');
  document.getElementById('create-carrier-submit-btn').disabled = false;
  document.getElementById('create-carrier-overlay').classList.remove('hidden');
  document.getElementById('create-carrier-form').querySelector('[name="job_number"]').focus();
  _carrierOcrRawName = '';
  _carrierAutoProjectName = '';
  createCarrierOcrDiscovery(false);
}

// Build the conventional Project Name used across Project Builder and the other
// tools: "Job Number - Vendor Project Code (optional) - Project Name". If the
// raw name already leads with the job number it is treated as already-composed
// and returned unchanged.
function composeCarrierProjectName(jobNumber, vendorCode, rawName) {
  jobNumber = (jobNumber || '').trim();
  vendorCode = (vendorCode || '').trim();
  rawName = (rawName || '').trim();
  if (jobNumber && rawName.indexOf(jobNumber) === 0) return rawName;
  var parts = [];
  if (jobNumber) parts.push(jobNumber);
  if (vendorCode) parts.push(vendorCode);
  if (rawName) parts.push(rawName);
  return parts.join(' - ');
}

// Recompose the Project Name field from the current Job Number + Vendor Project
// Code + the OCR-discovered raw name. Only touches the field while it is empty
// or still holds the last value we auto-filled (so manual edits are preserved).
function carrierComposeProjectName() {
  if (!_carrierOcrRawName) return;
  var ccForm = document.getElementById('create-carrier-form');
  var pn = ccForm.querySelector('[name="project_name"]');
  if (!pn) return;
  var cur = pn.value.trim();
  if (cur && cur !== _carrierAutoProjectName) return;
  var jobNumber = ccForm.querySelector('[name="job_number"]').value.trim();
  var vendorCode = ccForm.querySelector('[name="vendor_project_code"]').value.trim();
  var composed = composeCarrierProjectName(jobNumber, vendorCode, _carrierOcrRawName);
  pn.value = composed;
  _carrierAutoProjectName = composed;
}

// DVI-546: OCR Discovery — read the title block(s) of the provided documents
// (Shop and, when present, Submittal drawings) and pre-fill empty, user-editable
// Create fields. Mirrors the Take-off Bridge metadata discovery. `triggered` is
// true for an explicit button click (shows status feedback) and false for the
// silent best-effort run on dialog open.
async function createCarrierOcrDiscovery(triggered) {
  var hint = document.getElementById('create-carrier-ocr-hint');
  var btn = document.getElementById('create-carrier-ocr-btn');
  var singleForm = document.getElementById('single-form');
  function setHint(msg, cls) {
    hint.textContent = msg;
    hint.className = 'text-xs ' + cls;
    hint.classList.remove('hidden');
  }
  var docs = [];
  var shopFile = singleForm.querySelector('[name="shop"]').files[0];
  var submittalFile = singleForm.querySelector('[name="submittal"]').files[0];
  if (shopFile && /\.pdf$/i.test(shopFile.name)) docs.push(shopFile);
  if (submittalFile && /\.pdf$/i.test(submittalFile.name)) docs.push(submittalFile);
  if (docs.length === 0) {
    if (triggered) setHint('Upload a Shop or Submittal Drawing first, then run OCR Discovery.',
                           'text-yellow-600 dark:text-yellow-400');
    else hint.classList.add('hidden');
    return;
  }
  if (btn) btn.disabled = true;
  setHint('Reading document' + (docs.length > 1 ? 's' : '') + '…', 'text-gray-500 dark:text-gray-400');
  try {
    // OCR each provided document; the first non-empty value wins (Shop first).
    var merged = {};
    for (var i = 0; i < docs.length; i++) {
      var fd = new FormData();
      fd.append('file', docs[i]);
      var r = await fetch('create-carrier/extract', {method:'POST', body:fd});
      var d = await r.json();
      if (r.ok && d.fields) {
        Object.keys(d.fields).forEach(function(k) {
          if (d.fields[k] && !merged[k]) merged[k] = d.fields[k];
        });
      }
    }
    var ccForm = document.getElementById('create-carrier-form');
    var found = Object.keys(merged).length;
    if (merged.project_name) {
      _carrierOcrRawName = merged.project_name;
      var jobNumber = merged.job_number || ccForm.querySelector('[name="job_number"]').value.trim();
      var vendorCode = ccForm.querySelector('[name="vendor_project_code"]').value.trim();
      merged.project_name = composeCarrierProjectName(jobNumber, vendorCode, merged.project_name);
    }
    var filled = 0;
    Object.keys(merged).forEach(function(k) {
      var input = ccForm.querySelector('[name="'+k+'"]');
      if (input && !input.value.trim()) {
        input.value = merged[k];
        filled++;
        if (k === 'project_name') _carrierAutoProjectName = merged[k];
      }
    });
    if (found === 0) {
      if (triggered) setHint('No project details could be read from the document' +
                             (docs.length > 1 ? 's' : '') + '.', 'text-yellow-600 dark:text-yellow-400');
      else hint.classList.add('hidden');
    } else if (filled > 0) {
      setHint('OCR pre-filled ' + filled + ' field' + (filled === 1 ? '' : 's') +
              ' — please review before generating.', 'text-togen-teal');
    } else {
      setHint('OCR read complete — matching fields were already filled.', 'text-togen-teal');
    }
  } catch (err) {
    if (triggered) setHint('Could not read the documents. Please try again.',
                           'text-red-600 dark:text-red-400');
    else hint.classList.add('hidden');
  } finally {
    if (btn) btn.disabled = false;
  }
}

function closeCreateCarrierDialog() {
  document.getElementById('create-carrier-overlay').classList.add('hidden');
  document.getElementById('create-carrier-submit-btn').disabled = false;
}

async function submitCreateCarrier(e) {
  e.preventDefault();
  var errEl = document.getElementById('create-carrier-error');
  var submitBtn = document.getElementById('create-carrier-submit-btn');
  errEl.classList.add('hidden');
  submitBtn.disabled = true;

  var singleForm = document.getElementById('single-form');
  var shopFile = singleForm.querySelector('[name="shop"]').files[0];
  var submittalFile = singleForm.querySelector('[name="submittal"]').files[0];
  if (!shopFile) {
    errEl.textContent = 'Shop Drawing is required.';
    errEl.classList.remove('hidden');
    submitBtn.disabled = false;
    return;
  }

  var fd = new FormData();
  fd.append('shop', shopFile);
  // Submittal is optional (DVI-546): when omitted, the left QR box is left blank.
  if (submittalFile) fd.append('submittal', submittalFile);

  var ccForm = document.getElementById('create-carrier-form');
  ['job_number','part_ref','product_code','weight','production_date','structure_id',
   'spec_type','structure_size','station','customer_name','project_name'].forEach(function(f) {
    fd.append(f, ccForm.querySelector('[name="'+f+'"]').value.trim());
  });
  if (singlePollTimer) { clearInterval(singlePollTimer); singlePollTimer = null; }
  setSingleStatus('Generating carrier sheet…', 'info');

  try {
    var r = await fetch('create-carrier', {method:'POST', body:fd});
    var d = await r.json();
    if (!r.ok) {
      errEl.textContent = d.error || 'Generation failed.';
      errEl.classList.remove('hidden');
      submitBtn.disabled = false;
      setSingleStatus(d.error || 'Generation failed.', 'error');
      return;
    }
    closeCreateCarrierDialog();
    currentJob = d.job_id;
    _scsJobFiles = {shop: shopFile.name, submittal: submittalFile ? submittalFile.name : ''};
    _scsCreateFields = {};
    ['product_code','structure_id','weight','structure_size','part_ref'].forEach(function(f) {
      _scsCreateFields[f] = ccForm.querySelector('[name="'+f+'"]').value.trim();
    });
    setSingleStatus('Processing — checking every 2 seconds…', 'info');
    await pollSingleStatus();
    if (singlePollTimer === null && currentJob) singlePollTimer = setInterval(pollSingleStatus, 2000);
  } catch (err) {
    errEl.textContent = 'Network error: ' + err.message;
    errEl.classList.remove('hidden');
    submitBtn.disabled = false;
    setSingleStatus('Network error: ' + err.message, 'error');
  }
}

function showSinglePreview(jobId) {
  document.getElementById('preview-placeholder').classList.add('hidden');
  document.getElementById('preview-embed').src = 'preview/'+encodeURIComponent(jobId);
  document.getElementById('download-btn').href = 'download/'+encodeURIComponent(jobId);
  document.getElementById('preview-container').classList.remove('hidden');
}

async function pollSingleStatus() {
  if (!currentJob) return;
  try {
    var r = await fetch('status/'+encodeURIComponent(currentJob));
    if (!r.ok) return;
    var d = await r.json();
    if (d.status === 'done') {
      clearInterval(singlePollTimer); singlePollTimer = null;
      _scsGeneratedName = d.filename || null;
      setSingleStatus('Done! Your carrier PDF is ready.', 'success');
      showSinglePreview(currentJob);
      _scsFetchViewerUrl(currentJob);
      document.getElementById('single-submit-btn').disabled = false;
    }
  } catch (_) {}
}

document.getElementById('single-form').addEventListener('submit', async function(e) {
  e.preventDefault();
  // If Create checkbox is checked, show the carrier info dialog instead
  var createCb = document.getElementById('carrier-create-cb');
  if (createCb && createCb.checked) {
    openCreateCarrierDialog();
    return;
  }
  if (singlePollTimer) { clearInterval(singlePollTimer); singlePollTimer = null; }
  document.getElementById('single-submit-btn').disabled = true;
  setSingleStatus('Uploading…', 'info');
  var fd = new FormData(document.getElementById('single-form'));
  try {
    var r = await fetch('upload', {method:'POST', body:fd});
    var d = await r.json();
    if (!r.ok) { setSingleStatus(d.error || 'Upload failed.', 'error'); document.getElementById('single-submit-btn').disabled = false; return; }
    currentJob = d.job_id;
    var _sf = document.getElementById('single-form');
    _scsJobFiles = {
      shop: (_sf.querySelector('[name="shop"]').files[0] || {}).name || '',
      submittal: (_sf.querySelector('[name="submittal"]').files[0] || {}).name || ''
    };
    _scsCreateFields = null;
    setSingleStatus('Processing — checking every 2 seconds…', 'info');
    await pollSingleStatus();
    if (singlePollTimer === null && currentJob) singlePollTimer = setInterval(pollSingleStatus, 2000);
  } catch (err) {
    setSingleStatus('Network error: '+err.message, 'error');
    document.getElementById('single-submit-btn').disabled = false;
  }
});

// Wire drag-and-drop on static upload elements
setupDragDrop(document.getElementById('zip-drop'), uploadZipFile, '.zip');
setupDragDrop(document.getElementById('csv-btn'),  uploadCsvFile, '.csv');

// ===== REPRINT CARRIER SHEET (DVI-296) =====
var _reprintProjects = [];
var _reprintCarriers = [];
var _reprintStep = 'projects'; // 'projects' or 'carriers'
var _reprintSelectedFolder = null;
var _reprintJobName = '';
var _reprintMode = false;

function reprintOpenDialog() {
  _reprintStep = 'projects';
  _reprintSelectedFolder = null;
  var modal = document.getElementById('reprint-modal');
  modal.classList.remove('hidden');
  document.getElementById('reprint-search').value = '';
  document.getElementById('reprint-search').placeholder = 'Filter projects…';
  document.getElementById('reprint-modal-title').textContent = 'Reprint Carrier Sheet';
  document.getElementById('reprint-back-btn').classList.add('hidden');
  document.getElementById('reprint-list').innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 py-3">Loading projects…</p>';
  fetch('reprint-list-projects')
    .then(function(r){ return r.json(); })
    .then(function(data) {
      _reprintProjects = data.folders || [];
      reprintRenderProjects(_reprintProjects);
    })
    .catch(function() {
      document.getElementById('reprint-list').innerHTML = '<p class="text-sm text-red-500 py-3">Error loading projects.</p>';
    });
}

function reprintCloseDialog() {
  document.getElementById('reprint-modal').classList.add('hidden');
}

function reprintFilterList(q) {
  q = q.toLowerCase();
  if (_reprintStep === 'projects') {
    var filtered = _reprintProjects.filter(function(f){ return f.toLowerCase().indexOf(q) !== -1; });
    reprintRenderProjects(filtered);
  } else {
    var filtered = _reprintCarriers.filter(function(c){ return c.name.toLowerCase().indexOf(q) !== -1; });
    reprintRenderCarriers(filtered);
  }
}

function reprintRenderProjects(folders) {
  var list = document.getElementById('reprint-list');
  if (folders.length === 0) {
    list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 py-3">No projects found.</p>';
    return;
  }
  list.innerHTML = '';
  folders.forEach(function(name) {
    var btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'w-full text-left px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-togen-teal/10 dark:hover:bg-togen-teal/20 transition-colors cursor-pointer flex items-center gap-2';
    btn.innerHTML = '<svg class="h-4 w-4 text-gray-400 dark:text-gray-500 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd"/><path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z"/></svg>' + esc(name);
    btn.onclick = function(){ reprintSelectProject(name); };
    list.appendChild(btn);
  });
}

function reprintSelectProject(folderName) {
  _reprintStep = 'carriers';
  _reprintSelectedFolder = folderName;
  document.getElementById('reprint-search').value = '';
  document.getElementById('reprint-search').placeholder = 'Filter carrier sheets…';
  document.getElementById('reprint-modal-title').textContent = esc(folderName);
  document.getElementById('reprint-back-btn').classList.remove('hidden');
  document.getElementById('reprint-list').innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 py-3">Loading carrier sheets…</p>';
  fetch('reprint-list-carriers', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({folder_name: folderName})
  }).then(function(r){ return r.json(); }).then(function(data) {
    if (data.error) {
      document.getElementById('reprint-list').innerHTML = '<p class="text-sm text-red-500 py-3">' + esc(data.error) + '</p>';
      return;
    }
    _reprintCarriers = data.carriers || [];
    _reprintJobName = data.job_name || folderName;
    if (_reprintCarriers.length === 0) {
      document.getElementById('reprint-list').innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 py-3">No carrier sheets found in this project.</p>';
      return;
    }
    reprintRenderCarriers(_reprintCarriers);
  }).catch(function() {
    document.getElementById('reprint-list').innerHTML = '<p class="text-sm text-red-500 py-3">Error loading carrier sheets.</p>';
  });
}

function reprintRenderCarriers(carriers) {
  var list = document.getElementById('reprint-list');
  if (carriers.length === 0) {
    list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 py-3">No matching carrier sheets.</p>';
    return;
  }
  list.innerHTML = '';
  carriers.forEach(function(c) {
    var btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'w-full text-left px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-togen-teal/10 dark:hover:bg-togen-teal/20 transition-colors cursor-pointer flex items-center gap-2';
    btn.innerHTML = '<svg class="h-4 w-4 text-togen-teal flex-shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8" stroke-linecap="round" stroke-linejoin="round"/></svg>' + esc(c.name);
    btn.onclick = function(){ reprintLoadCarrier(c); };
    list.appendChild(btn);
  });
}

function reprintBackToProjects() {
  _reprintStep = 'projects';
  _reprintSelectedFolder = null;
  document.getElementById('reprint-search').value = '';
  document.getElementById('reprint-search').placeholder = 'Filter projects…';
  document.getElementById('reprint-modal-title').textContent = 'Reprint Carrier Sheet';
  document.getElementById('reprint-back-btn').classList.add('hidden');
  reprintRenderProjects(_reprintProjects);
}

function reprintLoadCarrier(carrier) {
  reprintCloseDialog();
  _reprintMode = true;
  _reprintCarrierObj = carrier;
  _scsViewerUrl = _sharedUrlPrefix + '/shared/' + encodeURI(carrier.carrier_path);

  // Hide normal form elements, show reprint container
  document.getElementById('single-form').classList.add('hidden');
  document.getElementById('preview-placeholder').classList.add('hidden');
  document.getElementById('preview-container').classList.add('hidden');
  document.getElementById('single-status').classList.add('hidden');
  document.getElementById('reprint-container').classList.remove('hidden');

  // Set preview
  document.getElementById('reprint-embed').src = 'fb/preview?path=' + encodeURIComponent(carrier.carrier_path);
  document.getElementById('reprint-download-btn').href = 'fb/download?path=' + encodeURIComponent(carrier.carrier_path);
  document.getElementById('reprint-job-name').textContent = _reprintJobName || _reprintSelectedFolder || '';

  // Shop drawing button
  var shopBtn = document.getElementById('reprint-shop-btn');
  if (carrier.shop_path) {
    shopBtn.href = 'fb/preview?path=' + encodeURIComponent(carrier.shop_path);
    shopBtn.classList.remove('hidden');
  } else {
    shopBtn.classList.add('hidden');
  }

  // Submittal drawing button
  var subBtn = document.getElementById('reprint-submittal-btn');
  if (carrier.submittal_path) {
    subBtn.href = 'fb/preview?path=' + encodeURIComponent(carrier.submittal_path);
    subBtn.classList.remove('hidden');
  } else {
    subBtn.classList.add('hidden');
  }
}

function reprintExit() {
  _reprintMode = false;
  _reprintCarrierObj = null;
  _scsViewerUrl = null;
  _scsViewerRel = null;
  document.getElementById('reprint-container').classList.add('hidden');
  document.getElementById('reprint-embed').removeAttribute('src');
  document.getElementById('single-form').classList.remove('hidden');
  document.getElementById('preview-placeholder').classList.remove('hidden');
}

// ===== SINGLE CARRIER SHEET WORKFLOW MENU (DVI-308) =====
var _scsViewerUrl = null;
var _scsViewerRel = null;   // generated carrier path relative to SHARED_DIR (print file_ref, DVI-670)

function _scsFetchViewerUrl(jobId) {
  fetch('/single-carrier-viewer-url?job_id=' + encodeURIComponent(jobId))
    .then(function(r) { return r.json(); })
    .then(function(d) { if (d.url) _scsViewerUrl = d.url; if (d.rel) _scsViewerRel = d.rel; });
}

function scsPrint() {
  var rel = _reprintMode
    ? (_reprintCarrierObj && _reprintCarrierObj.carrier_path)
    : _scsViewerRel;
  if (!rel) { alert('Generate or select a carrier sheet first.'); return; }
  togenOpenPrintPopup({source: 'shared', fileRef: rel, kind: 'label', title: 'Print Carrier Sheet'});
}

function scsToggleWorkflowMenu(e) {
  e.stopPropagation();
  var menu = _reprintMode
    ? document.getElementById('scs-reprint-workflow-menu')
    : document.getElementById('scs-workflow-menu');
  menu.classList.toggle('hidden');
  if (!menu.classList.contains('hidden')) {
    setTimeout(function() {
      document.addEventListener('click', _scsCloseWorkflowMenu, {once: true});
    }, 0);
  }
}

function _scsCloseWorkflowMenu() {
  document.getElementById('scs-workflow-menu').classList.add('hidden');
  document.getElementById('scs-reprint-workflow-menu').classList.add('hidden');
}

function scsOpenViewer() {
  if (_scsViewerUrl) window.open(_scsViewerUrl, '_blank');
}

function scsOpenInQrCreator() {
  _scsCloseWorkflowMenu();
  if (_scsViewerUrl) sqrOpenWithUrl(_scsViewerUrl);
}

// ===== SCS OPEN IN FILE BROWSER (DVI-309) =====
var _scsGeneratedName = null;   // generated carrier PDF filename (upload/create modes)
var _scsJobFiles = null;        // {shop, submittal} original filenames from the form
var _scsCreateFields = null;    // create-mode form values for CSV append
var _reprintCarrierObj = null;  // selected carrier entry in reprint mode
var _fbProjects = [];
var _fbSelectedFolder = null;

function scsFbParseProductCode(name) {
  // Carrier filenames end with "<product code> CarrierQR[ generated].pdf"
  if (!name) return '';
  var stem = String(name).replace(/\.pdf$/i, '').replace(/\s*carrier\s*qr.*$/i, '').trim();
  if (!stem) return '';
  var parts = stem.split(/\s+/);
  return parts[parts.length - 1];
}

function scsOpenFileBrowser() {
  _scsCloseWorkflowMenu();
  if (!_scsViewerUrl) return;
  _fbSelectedFolder = null;
  document.getElementById('scs-fb-search').value = '';
  var code = '';
  if (_reprintMode && _reprintCarrierObj) {
    var cp = _reprintCarrierObj.carrier_path || '';
    code = scsFbParseProductCode(cp.split('/').pop());
  } else if (_scsCreateFields && _scsCreateFields.product_code) {
    code = _scsCreateFields.product_code;
  } else {
    code = scsFbParseProductCode(_scsGeneratedName);
  }
  document.getElementById('scs-fb-product-code').value = code;
  scsFbUpdateSaveState();
  document.getElementById('scs-fb-modal').classList.remove('hidden');
  document.getElementById('scs-fb-list').innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 py-3">Loading projects…</p>';
  fetch('reprint-list-projects')
    .then(function(r){ return r.json(); })
    .then(function(data) {
      _fbProjects = data.folders || [];
      scsFbRenderProjects(_fbProjects);
    })
    .catch(function() {
      document.getElementById('scs-fb-list').innerHTML = '<p class="text-sm text-red-500 py-3">Error loading projects.</p>';
    });
}

function scsFbClose() {
  document.getElementById('scs-fb-modal').classList.add('hidden');
}

function scsFbFilter(q) {
  q = q.toLowerCase();
  scsFbRenderProjects(_fbProjects.filter(function(f){ return f.toLowerCase().indexOf(q) !== -1; }));
}

function scsFbRenderProjects(folders) {
  var list = document.getElementById('scs-fb-list');
  if (folders.length === 0) {
    list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 py-3">No projects found.</p>';
    return;
  }
  list.innerHTML = '';
  folders.forEach(function(name) {
    var btn = document.createElement('button');
    btn.type = 'button';
    var sel = (name === _fbSelectedFolder);
    btn.className = 'w-full text-left px-3 py-2.5 text-sm transition-colors cursor-pointer flex items-center gap-2 ' +
      (sel ? 'bg-togen-teal/15 dark:bg-togen-teal/25 text-togen-teal font-medium'
           : 'text-gray-700 dark:text-gray-300 hover:bg-togen-teal/10 dark:hover:bg-togen-teal/20');
    btn.innerHTML = '<svg class="h-4 w-4 flex-shrink-0 ' + (sel ? 'text-togen-teal' : 'text-gray-400 dark:text-gray-500') + '" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/></svg>' + esc(name);
    btn.onclick = function(){ scsFbSelect(name); };
    list.appendChild(btn);
  });
}

function scsFbSelect(name) {
  _fbSelectedFolder = name;
  scsFbFilter(document.getElementById('scs-fb-search').value);
  scsFbUpdateSaveState();
}

function scsFbUpdateSaveState() {
  var code = document.getElementById('scs-fb-product-code').value.trim();
  document.getElementById('scs-fb-save-btn').disabled = !(_fbSelectedFolder && code);
}

function scsFbPayload(appendRow) {
  var payload = {
    folder_name: _fbSelectedFolder,
    product_code: document.getElementById('scs-fb-product-code').value.trim(),
    append_row: !!appendRow
  };
  if (_reprintMode && _reprintCarrierObj) {
    payload.carrier_path = _reprintCarrierObj.carrier_path;
    if (_reprintCarrierObj.shop_path) payload.shop_path = _reprintCarrierObj.shop_path;
    if (_reprintCarrierObj.submittal_path) payload.submittal_path = _reprintCarrierObj.submittal_path;
  } else {
    payload.job_id = currentJob;
    if (_scsJobFiles) {
      if (_scsJobFiles.shop) payload.shop_filename = _scsJobFiles.shop;
      if (_scsJobFiles.submittal) payload.submittal_filename = _scsJobFiles.submittal;
    }
    if (_scsCreateFields) {
      payload.structure_id = _scsCreateFields.structure_id || '';
      payload.weight = _scsCreateFields.weight || '';
      payload.structure_size = _scsCreateFields.structure_size || '';
      payload.part_ref = _scsCreateFields.part_ref || '';
    }
  }
  return payload;
}

function scsFbSave() { _scsFbDoSave(false, document.getElementById('scs-fb-save-btn'), 'Save', 'Saving…'); }
function scsFbWrite() { _scsFbDoSave(true, document.getElementById('scs-fb-write-btn'), 'Write', 'Writing…'); }

function _scsFbDoSave(appendRow, btn, idleLabel, busyLabel) {
  btn.disabled = true;
  btn.textContent = busyLabel;
  var folder = _fbSelectedFolder;
  fetch('scs-save-to-project', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(scsFbPayload(appendRow))
  }).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, data: d}; }); })
    .then(function(res) {
      btn.textContent = idleLabel;
      btn.disabled = false;
      var d = res.data || {};
      if (!res.ok || d.error) {
        scsFbClose();
        scsFbNomatchHide();
        setSingleStatus(d.error || 'Save failed.', 'error');
        return;
      }
      if (d.csv_match === false && !appendRow) {
        scsFbClose();
        document.getElementById('scs-fb-nomatch-modal').classList.remove('hidden');
        return;
      }
      scsFbClose();
      scsFbNomatchHide();
      var msg = 'Saved to ' + folder + '.';
      if (d.csv_warning) {
        setSingleStatus(msg + ' ' + d.csv_warning + '.', 'error');
      } else if (d.appended) {
        setSingleStatus(msg + ' Added new line for ' + (d.product_code || '') + ' to ' + d.csv_name + '.', 'success');
      } else {
        setSingleStatus(msg + ' Updated ' + d.updated_rows + ' row(s) in ' + d.csv_name + '.', 'success');
      }
    })
    .catch(function(err) {
      btn.textContent = idleLabel;
      btn.disabled = false;
      setSingleStatus('Network error: ' + err.message, 'error');
    });
}

function scsFbNomatchHide() {
  document.getElementById('scs-fb-nomatch-modal').classList.add('hidden');
}

function scsFbNomatchCancel() {
  scsFbNomatchHide();
  setSingleStatus('Saved to ' + _fbSelectedFolder + '. Project records not updated.', 'success');
}

// ===== PRODUCE PRODUCTION RUN TOOL =====
var _prFolder = null;     // job folder id
var _prItems  = [];       // [{doc_num, drawing_url, label_url}, ...]
var _prOverrides = {};    // {idx: {drawing: url_or_filename, label: url_or_filename}}
var _prodRunPollTimer = null;

// ===== Production Run tabs (DVI-528): Run / History / Settings =====
function prodRunSwitchTab(tab) {
  var runBtn = document.getElementById('prodrun-tab-btn-run');
  var histBtn = document.getElementById('prodrun-tab-btn-history');
  var setBtn = document.getElementById('prodrun-tab-btn-settings');
  var runPane = document.getElementById('prodrun-tab-run');
  var histPane = document.getElementById('prodrun-tab-history');
  var setPane = document.getElementById('prodrun-tab-settings');
  if (!runPane) return;
  var activeCls = ['border-togen-teal', 'text-togen-teal'];
  var idleCls = ['border-transparent', 'text-gray-500', 'dark:text-gray-400', 'hover:text-gray-700', 'dark:hover:text-gray-300'];
  function setActive(btn, on) {
    if (!btn) return;
    if (on) { btn.classList.add.apply(btn.classList, activeCls); btn.classList.remove.apply(btn.classList, idleCls); }
    else { btn.classList.remove.apply(btn.classList, activeCls); btn.classList.add.apply(btn.classList, idleCls); }
  }
  runPane.classList.add('hidden');
  histPane.classList.add('hidden');
  setPane.classList.add('hidden');
  setActive(runBtn, false); setActive(histBtn, false); setActive(setBtn, false);
  if (tab === 'history') {
    setActive(histBtn, true);
    histPane.classList.remove('hidden');
    prodRunHistoryBack();
    prodRunLoadHistory();
  } else if (tab === 'settings') {
    setActive(setBtn, true);
    setPane.classList.remove('hidden');
  } else {
    setActive(runBtn, true);
    runPane.classList.remove('hidden');
  }
}

// Reusable history-row markup. handlerFn is the global function name invoked with
// the run's job_id on click (Production Run History and File Browser both use it).
function prHistoryRowHtml(run, handlerFn) {
  var when = run.timestamp_ms ? new Date(run.timestamp_ms).toLocaleString() : '';
  var sub = run.csv_filename || '';
  if (run.customer) sub += (sub ? ' · ' : '') + run.customer;
  if (run.so_num) sub += (sub ? ' · SO ' : 'SO ') + run.so_num;
  var meta = [];
  meta.push(run.item_count + ' item' + (run.item_count === 1 ? '' : 's'));
  if (run.drawing_pages) meta.push(run.drawing_pages + 'p drawings');
  if (run.label_pages) meta.push(run.label_pages + 'p labels');
  if (run.failed_downloads) meta.push(run.failed_downloads + ' failed');
  return '<button onclick="' + handlerFn + '(\'' + esc(run.job_id) + '\')" ' +
    'class="w-full text-left border border-gray-200 dark:border-gray-700 rounded-xl p-4 bg-white dark:bg-gray-900 hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-sm transition-all cursor-pointer">' +
      '<div class="flex items-center justify-between gap-3 flex-wrap">' +
        '<div>' +
          '<p class="font-medium text-gray-800 dark:text-gray-200">' + esc(when) + '</p>' +
          (sub ? '<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">' + esc(sub) + '</p>' : '') +
        '</div>' +
        '<span class="text-xs text-gray-500 dark:text-gray-400">' + esc(meta.join(' · ')) + '</span>' +
      '</div>' +
    '</button>';
}

// Shared renderer for generic /history/<tool> entries (DVI-789).
function auditorHistRowHtml(entry) {
  var when = entry.created_at ? new Date(entry.created_at).toLocaleString() : '';
  var actor = entry.actor || '—';
  var action = entry.action || '';
  var summary = entry.summary || entry.ref || '';
  return '<div class="border border-gray-200 dark:border-gray-700 rounded-xl p-3 bg-white dark:bg-gray-900">' +
    '<div class="flex items-center justify-between gap-3 flex-wrap">' +
      '<div>' +
        '<p class="font-medium text-sm text-gray-800 dark:text-gray-200">' + esc(summary) + '</p>' +
        '<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">' + esc(actor) +
          (action ? ' &middot; ' + esc(action) : '') + '</p>' +
      '</div>' +
      '<span class="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">' + esc(when) + '</span>' +
    '</div>' +
  '</div>';
}

// Single merged-file card with a toggleable inline PDF preview + download.
function prMergeCardHtml(merge, key) {
  return '<div class="border border-gray-200 dark:border-gray-700 rounded-xl p-4 bg-gray-50 dark:bg-gray-800">' +
      '<div class="flex items-center justify-between mb-3">' +
        '<div>' +
          '<p class="font-semibold text-gray-800 dark:text-gray-200">' + esc(merge.filename) + '</p>' +
          '<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">' + merge.page_count + ' pages merged</p>' +
        '</div>' +
        '<div class="flex gap-2">' +
          '<button onclick="prTogglePreviewId(\'' + key + '\')" class="px-3 py-1.5 bg-gray-600 hover:bg-gray-700 text-white text-xs font-medium rounded-lg transition-colors">Preview</button>' +
          '<a href="' + esc(merge.download_url) + '" download class="px-3 py-1.5 bg-togen-teal hover:bg-togen-teal-dark text-white text-xs font-medium rounded-lg transition-colors inline-block">Download</a>' +
        '</div>' +
      '</div>' +
      '<div id="pr-prev-' + key + '" class="hidden rounded-lg overflow-hidden">' +
        '<embed src="' + esc(merge.preview_url) + '" type="application/pdf" class="w-full" style="height:500px;">' +
      '</div>' +
    '</div>';
}

function prTogglePreviewId(key) {
  var el = document.getElementById('pr-prev-' + key);
  if (el) el.classList.toggle('hidden');
}

function prodRunLoadHistory() {
  var status = document.getElementById('prodrun-history-status');
  var list = document.getElementById('prodrun-history-list');
  if (!list) return;
  status.textContent = 'Loading…'; list.innerHTML = '';
  fetch('/prodrun-history').then(function(r){ return r.json(); }).then(function(data) {
    if (data.error) { status.textContent = 'Error: ' + data.error; return; }
    var runs = data.runs || [];
    if (!runs.length) { status.textContent = 'No production runs yet.'; return; }
    status.textContent = '';
    var html = '';
    runs.forEach(function(run){ html += prHistoryRowHtml(run, 'prodRunOpenHistoryDetail'); });
    list.innerHTML = html;
  }).catch(function(e){ status.textContent = 'Failed: ' + e.message; });
}

function prodRunOpenHistoryDetail(jobId) {
  prodRunSwitchTab('history');
  document.getElementById('prodrun-history-listview').classList.add('hidden');
  document.getElementById('prodrun-history-detailview').classList.remove('hidden');
  var el = document.getElementById('prodrun-history-detail');
  el.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>';
  fetch('/prodrun-status?job_id=' + encodeURIComponent(jobId)).then(function(r){ return r.json(); }).then(function(data) {
    prodRunRenderHistoryDetail(data);
  }).catch(function(e){ el.innerHTML = '<p class="text-sm text-red-600 dark:text-red-400">Failed: ' + esc(e.message) + '</p>'; });
}

function prodRunRenderHistoryDetail(data) {
  var el = document.getElementById('prodrun-history-detail');
  if (!el) return;
  var html = '';
  if (data.drawing_merge) html += prMergeCardHtml(data.drawing_merge, 'hist-drawing');
  if (data.label_merge) html += prMergeCardHtml(data.label_merge, 'hist-label');
  if (!data.drawing_merge && !data.label_merge) {
    html += '<p class="text-sm text-gray-500 dark:text-gray-400">No merged files were produced for this run.</p>';
  }
  if (data.skipped_rows && data.skipped_rows > 0) {
    html += '<p class="text-xs text-gray-500 dark:text-gray-400">' + data.skipped_rows + ' rows had no URL in column O or P and were not included in the merge.</p>';
  }
  if (data.failed_items && data.failed_items.length > 0) {
    html += '<div class="border border-red-300 dark:border-red-700 rounded-xl p-4 bg-red-50 dark:bg-red-900/20">' +
      '<p class="font-semibold text-red-700 dark:text-red-400 mb-2">' + data.failed_items.length + ' download(s) failed</p>' +
      '<div class="space-y-1">';
    data.failed_items.forEach(function(f) {
      var typeLabel = f.type === 'drawing' ? 'Drawing' : 'Label';
      var rowLabel = f.row_num ? 'Row ' + f.row_num + ': ' : '';
      html += '<div class="flex items-start gap-2 text-xs">' +
          '<span class="text-red-600 dark:text-red-400 font-medium whitespace-nowrap">' + esc(rowLabel + (f.doc_num || '')) + ' — ' + typeLabel + ':</span>' +
          '<a href="' + esc(f.url) + '" target="_blank" rel="noopener noreferrer" class="text-red-500 dark:text-red-400 break-all underline hover:text-red-700 dark:hover:text-red-300">' + esc(f.url) + '</a>' +
        '</div>';
    });
    html += '</div></div>';
  }
  el.innerHTML = html;
}

function prodRunHistoryBack() {
  var dv = document.getElementById('prodrun-history-detailview');
  var lv = document.getElementById('prodrun-history-listview');
  if (dv) dv.classList.add('hidden');
  if (lv) lv.classList.remove('hidden');
}

function onProdRunCsvChosen(input) {
  var file = input.files[0]; if (file) uploadProdRunCsv(file);
}

function uploadProdRunCsv(file) {
  var lbl = document.getElementById('prodrun-csv-lbl');
  var btn = document.getElementById('prodrun-csv-btn');
  lbl.textContent = 'Uploading…'; lbl.className = 'text-sm text-gray-500 dark:text-gray-400';
  btn.disabled = true;
  var fd = new FormData(); fd.append('csv', file);
  fetch('prodrun-upload', {method:'POST', body:fd})
    .then(function(r){ return r.json(); }).then(function(data) {
      if (data.error) {
        lbl.textContent = 'Error: ' + data.error;
        lbl.className = 'text-sm text-red-600 dark:text-red-400';
        btn.disabled = false;
        return;
      }
      _prFolder = data.folder;
      _prItems  = data.items;
      _prOverrides = {};
      _prQuantities = {};
      lbl.textContent = '✓ ' + file.name + ' — ' + data.items.length + ' items';
      lbl.className = 'text-sm text-green-700 dark:text-green-400 font-medium';
      btn.disabled = false;
      renderProdRunItems();
    }).catch(function(e) {
      lbl.textContent = 'Upload failed: ' + e.message;
      lbl.className = 'text-sm text-red-600 dark:text-red-400';
      btn.disabled = false;
    });
}

function prUrlInfo(url) {
  if (!url) return null;
  var filename = '';
  var trusted = false;
  var sharepoint = false;
  try {
    var u = new URL(url);
    if (u.hostname === 'docs.google.com' && u.pathname.indexOf('/viewer') !== -1) {
      var inner = u.searchParams.get('url') || '';
      if (inner) {
        filename = decodeURIComponent(inner.split('/').pop() || '');
      }
      trusted = true;
    } else if (u.hostname === 'icastinc.sharepoint.com') {
      sharepoint = true;
      var idParam = u.searchParams.get('id') || '';
      if (idParam) {
        filename = decodeURIComponent(idParam.split('/').pop() || '');
      } else {
        filename = decodeURIComponent(u.pathname.split('/').pop() || '');
      }
    } else {
      filename = decodeURIComponent(u.pathname.split('/').pop() || '');
      trusted = (u.hostname === 'fileshare.icastinc.com' || u.hostname.endsWith('.icastinc.com'));
    }
  } catch(e) {
    filename = url.split('/').pop() || url;
  }
  if (!filename) filename = url.substring(0, 40) + '…';
  if (filename.length > 50) filename = filename.substring(0, 47) + '…';
  return {filename: filename, trusted: trusted, sharepoint: sharepoint};
}

function renderProdRunItems() {
  var section = document.getElementById('prodrun-items-section');
  section.classList.remove('hidden');
  document.getElementById('prodrun-status-section').classList.add('hidden');
  document.getElementById('prodrun-results-section').classList.add('hidden');
  var c = document.getElementById('prodrun-items-container');
  c.innerHTML = '';

  _prItems.forEach(function(item, i) {
    var d = document.createElement('div');
    d.className = 'border border-gray-200 dark:border-gray-700 rounded-xl p-4 bg-gray-50 dark:bg-gray-800';
    d.id = 'pr-card-' + i;

    var hasDrawing = !!item.drawing_url;
    var hasLabel   = !!item.label_url;

    var drawUrlInfo = hasDrawing ? prUrlInfo(item.drawing_url) : null;
    var lblUrlInfo  = hasLabel   ? prUrlInfo(item.label_url) : null;

    var drawIsWarning = drawUrlInfo && !drawUrlInfo.trusted;
    var lblIsWarning  = lblUrlInfo  && !lblUrlInfo.trusted;

    var _btnGreen  = 'px-3 py-1.5 text-xs font-medium border-2 border-green-500 text-green-700 dark:text-green-400 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors';
    var _btnYellow = 'px-3 py-1.5 text-xs font-medium border-2 border-yellow-500 text-yellow-700 dark:text-yellow-400 rounded-lg hover:bg-yellow-50 dark:hover:bg-yellow-900/30 transition-colors';
    var _btnGray   = 'px-3 py-1.5 text-xs font-medium border-2 border-gray-400 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors';

    var drawBtnClass = hasDrawing ? (drawIsWarning ? _btnYellow : _btnGreen) : _btnGray;
    var labelBtnClass = hasLabel ? (lblIsWarning ? _btnYellow : _btnGreen) : _btnGray;

    var drawLbl, lblLbl, drawLblClass, lblLblClass;
    if (hasDrawing) {
      drawLbl = '✓ <a href="' + esc(item.drawing_url) + '" target="_blank" rel="noopener noreferrer" class="underline hover:opacity-75">' + esc(drawUrlInfo.filename) + '</a>';
      drawLblClass = drawIsWarning ? 'text-xs text-yellow-700 dark:text-yellow-400 font-medium' : 'text-xs text-green-700 dark:text-green-400 font-medium';
    } else {
      drawLbl = 'No URL'; drawLblClass = 'text-xs text-gray-500 dark:text-gray-400';
    }
    if (hasLabel) {
      lblLbl = '✓ <a href="' + esc(item.label_url) + '" target="_blank" rel="noopener noreferrer" class="underline hover:opacity-75">' + esc(lblUrlInfo.filename) + '</a>';
      lblLblClass = lblIsWarning ? 'text-xs text-yellow-700 dark:text-yellow-400 font-medium' : 'text-xs text-green-700 dark:text-green-400 font-medium';
    } else {
      lblLbl = 'No URL'; lblLblClass = 'text-xs text-gray-500 dark:text-gray-400';
    }

    var rowNum = item.row_num || (i + 2);
    var titleParts = [esc(item.doc_num || ('Row ' + rowNum))];
    if (item.customer) titleParts.push('<span class="font-normal text-gray-500 dark:text-gray-400 text-xs ml-2">' + esc(item.customer) + '</span>');
    if (item.so_num) titleParts.push('<span class="font-normal text-gray-500 dark:text-gray-400 text-xs ml-2">' + esc(item.so_num.indexOf('Sales Order') === -1 ? 'Sales Order #' + item.so_num : item.so_num) + '</span>');

    d.innerHTML =
      '<div class="flex items-center justify-between mb-2">' +
        '<span class="text-xs font-mono text-gray-400 dark:text-gray-500 mr-2 flex-shrink-0">' + rowNum + '</span>' +
        '<p class="font-medium text-gray-800 dark:text-gray-200 flex items-center flex-wrap gap-x-1 flex-1">' + titleParts.join('') + '</p>' +
        '<div class="flex items-center gap-1">' +
          '<div id="pr-qty-wrap-' + i + '" class="hidden flex items-center gap-1">' +
            '<button onclick="prQtyChange(' + i + ',-1)" class="w-6 h-6 flex items-center justify-center rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 text-sm font-bold transition-colors">−</button>' +
            '<input id="pr-qty-' + i + '" type="number" min="1" max="99" value="1" onchange="prQtyInput(' + i + ',this.value)" class="w-10 h-6 text-center text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-togen-teal">' +
          '</div>' +
          '<button id="pr-qty-plus-' + i + '" onclick="prQtyIncrement(' + i + ')" title="Set quantity" class="w-6 h-6 flex items-center justify-center rounded bg-togen-teal/10 dark:bg-togen-teal/20 text-togen-teal hover:bg-togen-teal/20 dark:hover:bg-togen-teal/30 text-sm font-bold transition-colors">+</button>' +
        '</div>' +
      '</div>' +
      '<div class="flex flex-wrap gap-3 items-center mb-2">' +
        '<button id="pr-btn-draw-' + i + '" onclick="prToggleOverride(' + i + ',\'drawing\')" class="' + drawBtnClass + '">Drawing</button>' +
        '<span id="pr-lbl-draw-' + i + '" class="' + drawLblClass + '">' + drawLbl + '</span>' +
        (hasDrawing ? '<button id="pr-rm-draw-' + i + '" onclick="prRemoveUrl(' + i + ',\'drawing\')" title="Remove drawing" class="w-5 h-5 flex items-center justify-center rounded-full text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors text-xs font-bold">✕</button>' : '') +
        '<button id="pr-btn-label-' + i + '" onclick="prToggleOverride(' + i + ',\'label\')" class="' + labelBtnClass + '">Label</button>' +
        '<span id="pr-lbl-label-' + i + '" class="' + lblLblClass + '">' + lblLbl + '</span>' +
        (hasLabel ? '<button id="pr-rm-label-' + i + '" onclick="prRemoveUrl(' + i + ',\'label\')" title="Remove label" class="w-5 h-5 flex items-center justify-center rounded-full text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors text-xs font-bold">✕</button>' : '') +
      '</div>' +
      (drawIsWarning ? '<div class="flex items-center gap-1.5 mb-1 px-2 py-1 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-700 rounded-lg"><svg class="h-3.5 w-3.5 text-yellow-600 dark:text-yellow-400 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg><span class="text-xs text-yellow-700 dark:text-yellow-400">Drawing URL is not from icastinc.com — ' + (drawUrlInfo && drawUrlInfo.sharepoint ? 'will attempt to autocorrect' : 'may not download correctly') + '</span></div>' : '') +
      (lblIsWarning ? '<div class="flex items-center gap-1.5 mb-1 px-2 py-1 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-700 rounded-lg"><svg class="h-3.5 w-3.5 text-yellow-600 dark:text-yellow-400 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg><span class="text-xs text-yellow-700 dark:text-yellow-400">Label URL is not from icastinc.com — ' + (lblUrlInfo && lblUrlInfo.sharepoint ? 'will attempt to autocorrect' : 'may not download correctly') + '</span></div>' : '') +
      '<div id="pr-override-draw-' + i + '" class="hidden mt-2 pl-2 border-l-2 border-togen-teal/30 space-y-2">' +
        '<div class="flex items-center gap-2">' +
          '<input type="text" id="pr-url-draw-' + i + '" placeholder="Paste drawing URL…" class="flex-1 px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-togen-teal" onchange="prUrlOverride(' + i + ',\'drawing\',this.value)">' +
          '<span class="text-xs text-gray-400">or</span>' +
          '<input type="file" id="pr-fi-draw-' + i + '" accept=".pdf" class="hidden" onchange="prFileOverride(' + i + ',\'drawing\',this)">' +
          '<button id="pr-filebtn-draw-' + i + '" onclick="document.getElementById(\'pr-fi-draw-' + i + '\').click()" class="px-3 py-1.5 text-xs font-medium border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Upload PDF</button>' +
        '</div>' +
      '</div>' +
      '<div id="pr-override-label-' + i + '" class="hidden mt-2 pl-2 border-l-2 border-togen-teal/30 space-y-2">' +
        '<div class="flex items-center gap-2">' +
          '<input type="text" id="pr-url-label-' + i + '" placeholder="Paste label URL…" class="flex-1 px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-togen-teal" onchange="prUrlOverride(' + i + ',\'label\',this.value)">' +
          '<span class="text-xs text-gray-400">or</span>' +
          '<input type="file" id="pr-fi-label-' + i + '" accept=".pdf" class="hidden" onchange="prFileOverride(' + i + ',\'label\',this)">' +
          '<button id="pr-filebtn-label-' + i + '" onclick="document.getElementById(\'pr-fi-label-' + i + '\').click()" class="px-3 py-1.5 text-xs font-medium border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Upload PDF</button>' +
        '</div>' +
      '</div>';

    c.appendChild(d);
    // Wire drag-and-drop on upload buttons
    setupDragDrop(document.getElementById('pr-filebtn-draw-' + i), function(f){ prFileUpload(i, 'drawing', f); }, '.pdf');
    setupDragDrop(document.getElementById('pr-filebtn-label-' + i), function(f){ prFileUpload(i, 'label', f); }, '.pdf');
  });

  prCheckSubmitReady();
}

var _prQuantities = {};

function prQtyIncrement(idx) {
  var wrap = document.getElementById('pr-qty-wrap-' + idx);
  var input = document.getElementById('pr-qty-' + idx);
  if (wrap.classList.contains('hidden')) {
    // First click — show controls, set to 2
    wrap.classList.remove('hidden');
    _prQuantities[idx] = 2;
    input.value = 2;
  } else {
    // Subsequent clicks — increment
    var val = parseInt(input.value) || 1;
    val++;
    if (val > 99) val = 99;
    input.value = val;
    _prQuantities[idx] = val;
  }
}

function prQtyChange(idx, delta) {
  var input = document.getElementById('pr-qty-' + idx);
  var val = parseInt(input.value) || 1;
  val += delta;
  if (val < 1) val = 1;
  if (val > 99) val = 99;
  input.value = val;
  _prQuantities[idx] = val;
  if (val === 1) {
    // Hide controls when back to 1
    document.getElementById('pr-qty-wrap-' + idx).classList.add('hidden');
    delete _prQuantities[idx];
  }
}

function prQtyInput(idx, val) {
  var n = parseInt(val) || 1;
  if (n < 1) n = 1;
  if (n > 99) n = 99;
  var input = document.getElementById('pr-qty-' + idx);
  input.value = n;
  if (n === 1) {
    document.getElementById('pr-qty-wrap-' + idx).classList.add('hidden');
    delete _prQuantities[idx];
  } else {
    _prQuantities[idx] = n;
  }
}

function prToggleOverride(idx, ftype) {
  var el = document.getElementById('pr-override-' + (ftype === 'drawing' ? 'draw' : 'label') + '-' + idx);
  el.classList.toggle('hidden');
}

function prUrlOverride(idx, ftype, url) {
  if (!_prOverrides[idx]) _prOverrides[idx] = {};
  _prOverrides[idx][ftype] = {type: 'url', value: url.trim()};
  var key = ftype === 'drawing' ? 'draw' : 'label';
  var lbl = document.getElementById('pr-lbl-' + key + '-' + idx);
  var btn = document.getElementById('pr-btn-' + key + '-' + idx);
  if (url.trim()) {
    lbl.textContent = '✓ URL override';
    lbl.className = 'text-xs text-green-700 dark:text-green-400 font-medium';
    btn.className = 'px-3 py-1.5 text-xs font-medium border-2 border-green-500 text-green-700 dark:text-green-400 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors';
  }
  prCheckSubmitReady();
}

function prRemoveUrl(idx, ftype) {
  if (!_prOverrides[idx]) _prOverrides[idx] = {};
  _prOverrides[idx][ftype] = {type: 'removed'};
  var key = ftype === 'drawing' ? 'draw' : 'label';
  var lbl = document.getElementById('pr-lbl-' + key + '-' + idx);
  var btn = document.getElementById('pr-btn-' + key + '-' + idx);
  var rmBtn = document.getElementById('pr-rm-' + key + '-' + idx);
  lbl.textContent = 'Removed';
  lbl.className = 'text-xs text-gray-400 dark:text-gray-500 line-through';
  btn.className = 'px-3 py-1.5 text-xs font-medium border-2 border-gray-400 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors';
  if (rmBtn) rmBtn.classList.add('hidden');
  var ovPanel = document.getElementById('pr-override-' + key + '-' + idx);
  if (ovPanel) ovPanel.classList.add('hidden');
  prCheckSubmitReady();
}

function prFileOverride(idx, ftype, input) {
  var file = input.files[0]; if (file) prFileUpload(idx, ftype, file);
}

function prFileUpload(idx, ftype, file) {
  var key = ftype === 'drawing' ? 'draw' : 'label';
  var lbl = document.getElementById('pr-lbl-' + key + '-' + idx);
  var btn = document.getElementById('pr-btn-' + key + '-' + idx);
  lbl.textContent = 'Uploading…'; lbl.className = 'text-xs text-gray-500 dark:text-gray-400';
  var fd = new FormData();
  fd.append('file', file); fd.append('folder', _prFolder);
  fd.append('item_index', idx); fd.append('file_type', ftype);
  fetch('prodrun-upload-file', {method:'POST', body:fd})
    .then(function(r){ return r.json(); }).then(function(data) {
      if (data.error) { lbl.textContent = 'Error: ' + data.error; lbl.className = 'text-xs text-red-600 dark:text-red-400'; return; }
      lbl.textContent = '✓ ' + file.name;
      lbl.className = 'text-xs text-green-700 dark:text-green-400 font-medium';
      btn.className = 'px-3 py-1.5 text-xs font-medium border-2 border-green-500 text-green-700 dark:text-green-400 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors';
      if (!_prOverrides[idx]) _prOverrides[idx] = {};
      _prOverrides[idx][ftype] = {type: 'file', value: data.filename};
      prCheckSubmitReady();
    }).catch(function(){ lbl.textContent = 'Failed'; lbl.className = 'text-xs text-red-600 dark:text-red-400'; });
}

function prCheckSubmitReady() {
  var any = _prItems.some(function(item, i) {
    var ov = _prOverrides[i] || {};
    var hasDraw = (ov.drawing && ov.drawing.type !== 'removed') || (!ov.drawing && item.drawing_url);
    var hasLbl = (ov.label && ov.label.type !== 'removed') || (!ov.label && item.label_url);
    return hasDraw || hasLbl;
  });
  document.getElementById('prodrun-submit-btn').disabled = !any;
}

function prodRunSubmitAll() {
  var btn = document.getElementById('prodrun-submit-btn');
  var lbl = document.getElementById('prodrun-submit-lbl');
  btn.disabled = true; lbl.textContent = 'Submitting…';

  // Build overrides payload
  var overrides = {};
  for (var k in _prOverrides) {
    if (_prOverrides.hasOwnProperty(k)) overrides[k] = _prOverrides[k];
  }

  // Build quantities payload (only include non-default quantities)
  var quantities = {};
  for (var q in _prQuantities) {
    if (_prQuantities.hasOwnProperty(q)) quantities[q] = _prQuantities[q];
  }

  fetch('prodrun-submit', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({folder: _prFolder, overrides: overrides, quantities: quantities})
  }).then(function(r){ return r.json(); }).then(function(data) {
    if (data.error) { lbl.textContent = 'Error: ' + data.error; btn.disabled = false; return; }
    lbl.textContent = '';
    document.getElementById('prodrun-items-section').classList.add('hidden');
    document.getElementById('prodrun-status-section').classList.remove('hidden');
    showProdRunProgress('Downloading and merging PDFs…');
    pollProdRunStatus(data.job_id);
  }).catch(function(e) { lbl.textContent = 'Failed: ' + e.message; btn.disabled = false; });
}

function showProdRunProgress(msg) {
  document.getElementById('prodrun-progress').innerHTML =
    '<div class="flex items-center gap-3">' +
      '<svg class="animate-spin h-5 w-5 text-togen-teal" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">' +
        '<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>' +
        '<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>' +
      '</svg>' +
      '<span class="text-sm text-gray-700 dark:text-gray-300">' + esc(msg) + '</span>' +
    '</div>';
}

function pollProdRunStatus(jobId) {
  if (_prodRunPollTimer) clearInterval(_prodRunPollTimer);
  _prodRunPollTimer = setInterval(function() {
    fetch('prodrun-status?job_id=' + encodeURIComponent(jobId))
      .then(function(r){ return r.json(); }).then(function(data) {
        if (data.status === 'done') {
          clearInterval(_prodRunPollTimer); _prodRunPollTimer = null;
          showProdRunResults(data);
        } else if (data.status === 'error') {
          clearInterval(_prodRunPollTimer); _prodRunPollTimer = null;
          document.getElementById('prodrun-progress').innerHTML =
            '<p class="text-sm text-red-600 dark:text-red-400">Error: ' + esc(data.error || 'Processing failed') + '</p>';
        } else {
          showProdRunProgress(data.message || 'Processing…');
        }
      }).catch(function(){});
  }, 3000);
}

function showProdRunResults(data) {
  document.getElementById('prodrun-status-section').classList.add('hidden');
  document.getElementById('prodrun-results-section').classList.remove('hidden');
  var el = document.getElementById('prodrun-results');
  window._prResultData = data;
  var html = '';

  if (data.drawing_merge) {
    html +=
      '<div class="border border-gray-200 dark:border-gray-700 rounded-xl p-4 bg-gray-50 dark:bg-gray-800">' +
        '<div class="flex items-center justify-between mb-3">' +
          '<div>' +
            '<p class="font-semibold text-gray-800 dark:text-gray-200">' + esc(data.drawing_merge.filename) + '</p>' +
            '<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">' + data.drawing_merge.page_count + ' pages merged</p>' +
          '</div>' +
          '<div class="flex gap-2">' +
            '<button onclick="prTogglePreview(\'drawing\')" class="px-3 py-1.5 bg-gray-600 hover:bg-gray-700 text-white text-xs font-medium rounded-lg transition-colors">Preview</button>' +
            '<a href="' + esc(data.drawing_merge.download_url) + '" download class="px-3 py-1.5 bg-togen-teal hover:bg-togen-teal-dark text-white text-xs font-medium rounded-lg transition-colors inline-block">Download</a>' +
            '<button onclick="prPrintMerge(\'drawing\')" class="togen-print-btn px-3 py-1.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 text-xs font-medium rounded-lg transition-colors">Print</button>' +
          '</div>' +
        '</div>' +
        '<div id="prodrun-drawing-preview" class="hidden rounded-lg overflow-hidden">' +
          '<embed src="' + esc(data.drawing_merge.preview_url) + '" type="application/pdf" class="w-full" style="height:500px;">' +
        '</div>' +
      '</div>';
  }

  if (data.label_merge) {
    html +=
      '<div class="border border-gray-200 dark:border-gray-700 rounded-xl p-4 bg-gray-50 dark:bg-gray-800">' +
        '<div class="flex items-center justify-between mb-3">' +
          '<div>' +
            '<p class="font-semibold text-gray-800 dark:text-gray-200">' + esc(data.label_merge.filename) + '</p>' +
            '<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">' + data.label_merge.page_count + ' pages merged</p>' +
          '</div>' +
          '<div class="flex gap-2">' +
            '<button onclick="prTogglePreview(\'label\')" class="px-3 py-1.5 bg-gray-600 hover:bg-gray-700 text-white text-xs font-medium rounded-lg transition-colors">Preview</button>' +
            '<a href="' + esc(data.label_merge.download_url) + '" download class="px-3 py-1.5 bg-togen-teal hover:bg-togen-teal-dark text-white text-xs font-medium rounded-lg transition-colors inline-block">Download</a>' +
            '<button onclick="prPrintMerge(\'label\')" class="togen-print-btn px-3 py-1.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 text-xs font-medium rounded-lg transition-colors">Print</button>' +
          '</div>' +
        '</div>' +
        '<div id="prodrun-label-preview" class="hidden rounded-lg overflow-hidden">' +
          '<embed src="' + esc(data.label_merge.preview_url) + '" type="application/pdf" class="w-full" style="height:500px;">' +
        '</div>' +
      '</div>';
  }

  if (data.drawing_merge || data.label_merge) {
    html +=
      '<div class="flex items-center gap-3 mt-1">' +
        '<button onclick="prPrintToDefaults()" class="togen-print-btn px-4 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white text-sm font-medium rounded-lg transition-colors">Print to Defaults</button>' +
        '<span id="prodrun-print-status" class="text-xs"></span>' +
      '</div>';
  }

  if (data.skipped_rows && data.skipped_rows > 0) {
    html += '<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">' + data.skipped_rows + ' rows had no URL in column O or P and were not included in the merge.</p>';
  }

  if (data.failed_items && data.failed_items.length > 0) {
    html +=
      '<div class="border border-red-300 dark:border-red-700 rounded-xl p-4 bg-red-50 dark:bg-red-900/20 mt-2">' +
        '<p class="font-semibold text-red-700 dark:text-red-400 mb-2">' + data.failed_items.length + ' download(s) failed</p>' +
        '<div class="space-y-1 mb-3">';
    data.failed_items.forEach(function(f) {
      var typeLabel = f.type === 'drawing' ? 'Drawing' : 'Label';
      var rowLabel = f.row_num ? 'Row ' + f.row_num + ': ' : '';
      html +=
        '<div class="flex items-start gap-2 text-xs">' +
          '<span class="text-red-600 dark:text-red-400 font-medium whitespace-nowrap">' + rowLabel + esc(f.doc_num) + ' — ' + typeLabel + ':</span>' +
          '<a href="' + esc(f.url) + '" target="_blank" rel="noopener noreferrer" class="text-red-500 dark:text-red-400 break-all underline hover:text-red-700 dark:hover:text-red-300">' + esc(f.url) + '</a>' +
        '</div>';
    });
    html +=
        '</div>' +
        '<button onclick="prCorrectFailures()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors">' +
          'Correct Failed Items' +
        '</button>' +
      '</div>';
    // Store failed items for highlighting
    window._prFailedItems = data.failed_items;
  }

  el.innerHTML = html;
}

function prTogglePreview(which) {
  var el = document.getElementById('prodrun-' + which + '-preview');
  if (el) el.classList.toggle('hidden');
}

function _prMergeFileRef(merge) {
  // print file_ref for the prodrun source is "<job_id>/<filename>"
  if (!merge || !merge.job_id || !merge.filename) return null;
  return merge.job_id + '/' + merge.filename;
}

function prPrintMerge(which) {
  var data = window._prResultData || {};
  var merge = which === 'label' ? data.label_merge : data.drawing_merge;
  var ref = _prMergeFileRef(merge);
  if (!ref) { alert('Merge result not available.'); return; }
  togenOpenPrintPopup({source: 'prodrun', fileRef: ref,
                       kind: which === 'label' ? 'label' : 'document',
                       title: which === 'label' ? 'Print Label Merge' : 'Print Drawing Merge'});
}

function prPrintToDefaults() {
  var data = window._prResultData || {};
  var jobs = [];
  var dref = _prMergeFileRef(data.drawing_merge);
  if (dref) jobs.push({source: 'prodrun', fileRef: dref, kind: 'document'});
  var lref = _prMergeFileRef(data.label_merge);
  if (lref) jobs.push({source: 'prodrun', fileRef: lref, kind: 'label'});
  if (!jobs.length) { alert('Nothing to print.'); return; }
  togenPrintToDefaults(jobs, function(text, kind) {
    var el = document.getElementById('prodrun-print-status');
    if (!el) return;
    el.textContent = text;
    el.className = 'text-xs ' + (kind === 'error'
      ? 'text-red-600 dark:text-red-400'
      : (kind === 'success' ? 'text-green-600 dark:text-green-400' : 'text-gray-500 dark:text-gray-400'));
  });
}

function prCorrectFailures() {
  var failed = window._prFailedItems || [];
  // Return to review screen
  document.getElementById('prodrun-results-section').classList.add('hidden');
  document.getElementById('prodrun-status-section').classList.add('hidden');
  renderProdRunItems();

  // Restore quantity state
  for (var qi in _prQuantities) {
    if (_prQuantities.hasOwnProperty(qi) && _prQuantities[qi] > 1) {
      var qWrap = document.getElementById('pr-qty-wrap-' + qi);
      var qInput = document.getElementById('pr-qty-' + qi);
      if (qWrap && qInput) { qWrap.classList.remove('hidden'); qInput.value = _prQuantities[qi]; }
    }
  }

  // Build set of failed item indices and types for highlighting
  var failedSet = {};
  failed.forEach(function(f) {
    if (!failedSet[f.item_idx]) failedSet[f.item_idx] = {};
    failedSet[f.item_idx][f.type] = true;
  });

  // Highlight failed items: red border on card, open override panel, red button
  for (var idx in failedSet) {
    if (!failedSet.hasOwnProperty(idx)) continue;
    var card = document.getElementById('pr-card-' + idx);
    if (card) {
      card.className = 'border-2 border-red-400 dark:border-red-600 rounded-xl p-4 bg-red-50 dark:bg-red-900/20';
    }
    for (var ftype in failedSet[idx]) {
      if (!failedSet[idx].hasOwnProperty(ftype)) continue;
      var key = ftype === 'drawing' ? 'draw' : 'label';
      // Set button to red
      var btn = document.getElementById('pr-btn-' + key + '-' + idx);
      if (btn) {
        btn.className = 'px-3 py-1.5 text-xs font-medium border-2 border-red-400 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors';
      }
      // Set label to failed
      var lbl = document.getElementById('pr-lbl-' + key + '-' + idx);
      if (lbl) {
        lbl.textContent = '✗ Download failed';
        lbl.className = 'text-xs text-red-600 dark:text-red-400 font-medium';
      }
      // Open override panel
      var ov = document.getElementById('pr-override-' + key + '-' + idx);
      if (ov) ov.classList.remove('hidden');
    }
  }

  // Scroll to the first failed item
  var firstIdx = failed.length > 0 ? failed[0].item_idx : null;
  if (firstIdx !== null) {
    var firstCard = document.getElementById('pr-card-' + firstIdx);
    if (firstCard) firstCard.scrollIntoView({behavior: 'smooth', block: 'center'});
  }
}

setupDragDrop(document.getElementById('prodrun-csv-btn'), uploadProdRunCsv, '.csv,.xlsx');

// ===== WORK ORDER TYPE SELECTOR =====
var _worSelectedType = '';

// Order/hide the WO type tiles per Togen Admin settings (DVI-349). Disabled
// types are hidden from everyone except Togen Admins.
// ===== BOTS REGISTRY (DVI-803) — add a bot here + a matching #bot-<id> page div.
// `page` is the id of the bot's content div. Future bots are scaffolding-only:
// drop a registry entry and a page div and they appear automatically. =====
var BOTS = [
  {
    id: 'email-bot',
    title: 'Winston',
    description: 'Email Bot — automated quote drafting.',
    page: 'bot-email-bot',
    icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="h-10 w-10">' +
          '<rect x="4" y="8" width="16" height="11" rx="2" />' +
          '<path d="M12 4v4" />' +
          '<circle cx="12" cy="3" r="1" />' +
          '<circle cx="9" cy="13" r="1" fill="currentColor" stroke="none" />' +
          '<circle cx="15" cy="13" r="1" fill="currentColor" stroke="none" />' +
          '<path d="M9 16.5h6" />' +
          '</svg>'
  }
];

// Bots available to the current user. Bots are not individually access-gated yet;
// visibility is governed by the Bots tool itself (admin Tools-Enabled + per-user).
function _availableBots() {
  return BOTS.slice();
}

// Open the Bots tool: a single bot opens its page directly; multiple bots show a
// tile selector. The per-bot "All Bots" back button only appears when there is
// more than one bot to return to.
function botsRender() {
  var bots = _availableBots();
  var multi = bots.length > 1;
  var backBtns = document.querySelectorAll('#tool-bots .bots-back-btn');
  for (var i = 0; i < backBtns.length; i++) backBtns[i].classList.toggle('hidden', !multi);
  if (bots.length === 1) {
    botsOpen(bots[0].id);
  } else {
    botsShowSelector();
  }
}

// Render and show the bot tile selector; hide all individual bot pages.
function botsShowSelector() {
  var bots = _availableBots();
  BOTS.forEach(function(b){
    var pg = document.getElementById(b.page);
    if (pg) pg.classList.add('hidden');
  });
  var grid = document.getElementById('bots-selector-grid');
  if (grid) {
    grid.innerHTML = '';
    bots.forEach(function(b){
      var btn = document.createElement('button');
      btn.type = 'button';
      btn.setAttribute('data-bot-id', b.id);
      btn.className = 'togen-pane group flex flex-col items-center gap-4 p-8 bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 hover:border-togen-teal dark:hover:border-togen-teal hover:shadow-md transition-all duration-200 text-center cursor-pointer';
      btn.onclick = (function(id){ return function(){ botsOpen(id); }; })(b.id);
      btn.innerHTML =
        '<div class="text-gray-400 dark:text-gray-500 group-hover:text-togen-teal transition-colors">' + b.icon + '</div>' +
        '<div>' +
          '<p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">' + esc(b.title) + '</p>' +
          '<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">' + esc(b.description) + '</p>' +
        '</div>';
      grid.appendChild(btn);
    });
  }
  var sel = document.getElementById('bots-selector');
  if (sel) sel.classList.remove('hidden');
}

// Show a single bot's page; hide the selector and the other bot pages.
function botsOpen(botId) {
  var sel = document.getElementById('bots-selector');
  if (sel) sel.classList.add('hidden');
  BOTS.forEach(function(b){
    var pg = document.getElementById(b.page);
    if (pg) pg.classList.toggle('hidden', b.id !== botId);
  });
  // Per-bot init hooks
  if (botId === 'email-bot') winstonInit();
}

// ===== WINSTON (DVI-817 P4 / DVI-826 P3) =====
var _winstonCurrentTab = 'status';
var _winstonSchedEditId = null;
var _winstonSchedList = [];
var _winstonCfgData = null;
var _winstonRcpTo = [];
var _winstonRcpCc = [];
var _winstonRunPollTimer = null;
var _winstonRunActiveId = null;
var _winstonDiagRuns = [];
var _winstonDiagPendingStepSeq = null;  // step seq to scroll to after a run loads
var _winstonDateLogic = [];             // pipelines from /winston/date-logic
var _winstonBaseRules = [];             // [{value,label}]
var _winstonDateLogicById = {};         // id → pipeline (for schedule table + dropdown)

function winstonInit() {
  winstonSwitchTab('status');  // also loads status
  winstonLoadConfig();
  winstonLoadDateLogic(true);  // populate schedule Date Logic dropdown + label map first
  winstonLoadScheds();
  winstonLoadHistory();
  winstonLoadRetention();
}

function winstonSwitchTab(tab, skipLoad) {
  _winstonCurrentTab = tab;
  var tabs = ['status','schedules','history','diagnostics','datelogic','settings'];
  tabs.forEach(function(t) {
    var btn = document.getElementById('winston-tab-' + t);
    var panel = document.getElementById('winston-panel-' + t);
    var active = t === tab;
    if (btn) {
      btn.classList.toggle('border-togen-teal', active);
      btn.classList.toggle('text-togen-teal', active);
      btn.classList.toggle('border-transparent', !active);
      btn.classList.toggle('text-gray-500', !active);
      btn.classList.toggle('dark:text-gray-400', !active);
    }
    if (panel) panel.classList.toggle('hidden', !active);
  });
  if (skipLoad) return;
  if (tab === 'status') winstonLoadStatus();
  if (tab === 'diagnostics') winstonLoadDiagRuns();
  if (tab === 'datelogic') winstonLoadDateLogic();
}

// --- Status ---
function winstonLoadStatus() {
  var loading = document.getElementById('winston-status-loading');
  var empty = document.getElementById('winston-status-empty');
  var errEl = document.getElementById('winston-status-error');
  var list = document.getElementById('winston-status-list');
  [loading, empty, errEl].forEach(function(el){ if (el) el.classList.add('hidden'); });
  if (list) list.innerHTML = '';
  if (loading) loading.classList.remove('hidden');
  fetch('/winston/run-history').then(function(r){ return r.json(); }).then(function(d){
    if (loading) loading.classList.add('hidden');
    var records = (d.records || []).slice(0, 10);
    if (!records.length) { if (empty) empty.classList.remove('hidden'); return; }
    if (list) list.innerHTML = records.map(winstonStatusCardHtml).join('');
  }).catch(function(){
    if (loading) loading.classList.add('hidden');
    if (errEl) { errEl.classList.remove('hidden'); document.getElementById('winston-status-error-msg').textContent = 'Failed to load status.'; }
  });
}

function winstonStatusBadge(status) {
  if (status === 'running') {
    return '<span class="inline-flex items-center gap-1 text-xs font-medium px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">' +
      '<svg class="animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>Running</span>';
  }
  if (status === 'error') return '<span class="text-xs font-medium px-1.5 py-0.5 rounded bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">Error</span>';
  if (status === 'warn') return '<span class="text-xs font-medium px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">Warning</span>';
  return '<span class="text-xs font-medium px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">Done</span>';
}

function winstonStatusCardHtml(r) {
  var dtLabel = '';
  if (r.ran_at) { try { dtLabel = new Date(r.ran_at).toLocaleString(); } catch(e){ dtLabel = r.ran_at; } }
  // Outputs: drafts created + routed PDF destinations from per-quote results.
  var outputs = [];
  (r.quotes || []).forEach(function(q){
    if (q.draft_status === 'created') {
      outputs.push('<li class="flex items-start gap-2"><span class="mt-0.5 text-togen-teal">&#9993;</span><span>Draft <span class="font-mono">' + esc(q.quote_number || '') + '</span>' + (q.recipient ? ' to ' + esc(q.recipient) : '') + '</span></li>');
    }
    if (q.pdf_route && q.pdf_route !== 'upload_failed' && q.pdf_route.indexOf('skipped') !== 0) {
      var dest = q.pdf_route.split(':').slice(1).join(':') || q.pdf_route;
      outputs.push('<li class="flex items-start gap-2"><span class="mt-0.5 text-togen-teal">&#128196;</span><span>PDF <span class="font-mono">' + esc(q.quote_number || '') + '.pdf</span> &rarr; <span class="font-mono break-all">' + esc(dest) + '</span></span></li>');
    }
  });
  var outputsHtml = outputs.length
    ? '<ul class="mt-3 space-y-1 text-xs text-gray-600 dark:text-gray-300">' + outputs.join('') + '</ul>'
    : '<p class="mt-3 text-xs text-gray-400 dark:text-gray-500 italic">No outputs produced.</p>';
  var errHtml = r.error
    ? '<p class="mt-2 text-xs text-red-600 dark:text-red-400">' + esc(r.error) + '</p>'
    : '';
  var diagBtn = '<button type="button" onclick="winstonOpenDiagForRun(\'' + esc(r.id || '') + '\')" class="text-xs text-togen-teal hover:underline cursor-pointer">View diagnostics &rarr;</button>';
  return '<div class="rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-3">' +
    '<div class="flex flex-wrap items-center justify-between gap-2">' +
      '<div class="flex items-center gap-2">' + winstonStatusBadge(r.status) +
        '<span class="text-sm font-medium text-gray-800 dark:text-gray-100 font-mono">' + esc(r.date || '') + '</span>' +
        '<span class="text-xs text-gray-400 dark:text-gray-500">' + esc(dtLabel) + '</span>' +
      '</div>' + diagBtn +
    '</div>' +
    '<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600 dark:text-gray-300">' +
      '<span>Found: <strong>' + (r.quotes_found || 0) + '</strong></span>' +
      '<span>Drafts: <strong>' + (r.drafts_created || 0) + '</strong></span>' +
      '<span>Routed: <strong>' + (r.pdf_routed || 0) + '</strong></span>' +
      ((r.drafts_failed || r.pdf_route_failed) ? '<span class="text-red-600 dark:text-red-400">Failed: <strong>' + ((r.drafts_failed || 0) + (r.pdf_route_failed || 0)) + '</strong></span>' : '') +
    '</div>' + outputsHtml + errHtml +
  '</div>';
}

// --- Diagnostics retention (Settings tab) ---
function winstonLoadRetention() {
  fetch('/diagnostics/winston/settings').then(function(r){ return r.json(); }).then(function(d){
    var s = d.settings || {};
    var mr = document.getElementById('winston-ret-max-runs');
    var ml = document.getElementById('winston-ret-max-log-lines');
    var rd = document.getElementById('winston-ret-retention-days');
    if (mr) mr.value = (s.max_runs != null ? s.max_runs : '');
    if (ml) ml.value = (s.max_log_lines != null ? s.max_log_lines : '');
    if (rd) rd.value = (s.retention_days != null ? s.retention_days : '');
  }).catch(function(){});
}

function winstonSaveRetention() {
  var st = document.getElementById('winston-ret-status');
  var maxRuns = parseInt((document.getElementById('winston-ret-max-runs') || {}).value, 10);
  var maxLog = parseInt((document.getElementById('winston-ret-max-log-lines') || {}).value, 10);
  var retDaysRaw = ((document.getElementById('winston-ret-retention-days') || {}).value || '').trim();
  var body = {};
  if (!isNaN(maxRuns)) body.max_runs = maxRuns;
  if (!isNaN(maxLog)) body.max_log_lines = maxLog;
  body.retention_days = retDaysRaw === '' ? null : parseInt(retDaysRaw, 10);
  fetch('/diagnostics/winston/settings', {
    method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body)
  }).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, data:d}; }); }).then(function(res){
    if (st) {
      st.textContent = res.ok ? 'Saved.' : 'Error: ' + ((res.data && res.data.error) || 'unknown');
      setTimeout(function(){ st.textContent = ''; }, 3000);
    }
    if (res.ok) winstonLoadRetention();
  }).catch(function(){ if (st) st.textContent = 'Network error.'; });
}

// --- Diagnostics tab ---
function winstonOpenDiagForRun(runId) {
  // Arrived via an Error/Warning row → scroll to the first failed step once loaded.
  _winstonDiagPendingStepSeq = 'failed';
  winstonSwitchTab('diagnostics', true);  // skip auto-load; we load the specific run below
  winstonLoadDiagRuns(runId);
}

function winstonLoadDiagRuns(selectId) {
  var loading = document.getElementById('winston-diag-loading');
  var empty = document.getElementById('winston-diag-empty');
  var errEl = document.getElementById('winston-diag-error');
  var sel = document.getElementById('winston-diag-run-select');
  [empty, errEl].forEach(function(el){ if (el) el.classList.add('hidden'); });
  fetch('/diagnostics/winston/runs?limit=50').then(function(r){ return r.json(); }).then(function(d){
    _winstonDiagRuns = d.runs || [];
    if (!_winstonDiagRuns.length) {
      if (empty) empty.classList.remove('hidden');
      if (sel) sel.innerHTML = '';
      document.getElementById('winston-diag-flow').innerHTML = '';
      return;
    }
    if (sel) {
      sel.innerHTML = _winstonDiagRuns.map(function(run){
        var dt = '';
        if (run.started_at) { try { dt = new Date(run.started_at * 1000).toLocaleString(); } catch(e){} }
        var label = (run.target_date || '(no date)') + ' — ' + (run.status || '') + (dt ? '  (' + dt + ')' : '');
        return '<option value="' + esc(run.id) + '">' + esc(label) + '</option>';
      }).join('');
    }
    var target = selectId && _winstonDiagRuns.some(function(r){ return r.id === selectId; }) ? selectId : _winstonDiagRuns[0].id;
    if (sel) sel.value = target;
    winstonSelectDiagRun(target);
  }).catch(function(){
    if (errEl) { errEl.classList.remove('hidden'); document.getElementById('winston-diag-error-msg').textContent = 'Failed to load runs.'; }
  });
}

function winstonSelectDiagRun(runId) {
  if (!runId) return;
  var loading = document.getElementById('winston-diag-loading');
  var flow = document.getElementById('winston-diag-flow');
  var errEl = document.getElementById('winston-diag-error');
  if (errEl) errEl.classList.add('hidden');
  if (flow) flow.innerHTML = '';
  if (loading) loading.classList.remove('hidden');
  fetch('/diagnostics/winston/runs/' + encodeURIComponent(runId)).then(function(r){ return r.json(); }).then(function(d){
    if (loading) loading.classList.add('hidden');
    if (!d.run) {
      if (errEl) { errEl.classList.remove('hidden'); document.getElementById('winston-diag-error-msg').textContent = 'Run not found.'; }
      return;
    }
    renderDiagnosticFlow(flow, d.run, 'winston');
    // If we arrived via an error-row jump, scroll to the requested/failed step.
    if (_winstonDiagPendingStepSeq != null) {
      winstonScrollToStep(_winstonDiagPendingStepSeq);
      _winstonDiagPendingStepSeq = null;
    }
  }).catch(function(){
    if (loading) loading.classList.add('hidden');
    if (errEl) { errEl.classList.remove('hidden'); document.getElementById('winston-diag-error-msg').textContent = 'Failed to load run.'; }
  });
}

function winstonScrollToStep(seq) {
  var node = document.querySelector('[data-diag-step-seq="' + seq + '"]');
  if (!node) {
    // Default: scroll to first failed/warn step.
    node = document.querySelector('.diag-step-failed');
  }
  if (node) {
    var detailsEl = node.querySelector('details');
    if (detailsEl) detailsEl.open = true;
    node.scrollIntoView({behavior: 'smooth', block: 'center'});
    node.classList.add('ring-2', 'ring-togen-teal');
    setTimeout(function(){ node.classList.remove('ring-2', 'ring-togen-teal'); }, 2500);
  }
}

// --- Reusable diagnostic flow renderer (built for any bot, DVI-826) ---
// el: container element; run: {id, bot, status, steps[], logs[], artifacts[], error}
function renderDiagnosticFlow(el, run, bot) {
  if (!el) return;
  bot = bot || run.bot || 'winston';
  var steps = (run.steps || []).slice().sort(function(a,b){ return (a.seq||0) - (b.seq||0); });
  var logs = run.logs || [];
  var artifacts = run.artifacts || [];
  // Group logs and artifacts by step_id; run-level (null step_id) collected separately.
  var logsByStep = {}, runLogs = [];
  logs.forEach(function(l){
    if (l.step_id) { (logsByStep[l.step_id] = logsByStep[l.step_id] || []).push(l); }
    else runLogs.push(l);
  });
  var artsByStep = {}, runArts = [];
  artifacts.forEach(function(a){
    if (a.step_id) { (artsByStep[a.step_id] = artsByStep[a.step_id] || []).push(a); }
    else runArts.push(a);
  });

  function stepDot(status) {
    var cls = status === 'ok' ? 'bg-green-500' :
              status === 'warn' ? 'bg-amber-500' :
              status === 'error' ? 'bg-red-500' :
              status === 'running' ? 'bg-blue-500 animate-pulse' : 'bg-gray-400';
    return '<span class="inline-block h-3 w-3 rounded-full ' + cls + '"></span>';
  }
  function durLabel(s) {
    if (s.started_at && s.finished_at) {
      var ms = (s.finished_at - s.started_at) * 1000;
      if (ms < 1000) return Math.round(ms) + 'ms';
      return (ms / 1000).toFixed(1) + 's';
    }
    return '';
  }

  var rid = esc(run.id);
  var html = '';
  // Run header
  html += '<div class="mb-4 flex flex-wrap items-center gap-2">' + winstonStatusBadge(run.status) +
    '<span class="text-xs text-gray-500 dark:text-gray-400 font-mono">run ' + rid + '</span>' +
    (run.error ? '<span class="text-xs text-red-600 dark:text-red-400">' + esc(run.error) + '</span>' : '') +
  '</div>';

  html += '<ol class="relative space-y-2">';
  steps.forEach(function(s){
    var sid = esc(s.id);
    var failed = (s.status === 'error' || s.status === 'warn');
    var stepLogs = logsByStep[s.id] || [];
    var stepArts = artsByStep[s.id] || [];
    var detailErr = '';
    if (s.detail_json) {
      try { var dj = JSON.parse(s.detail_json); if (dj && dj.error) detailErr = String(dj.error); } catch(e){}
    }
    html += '<li class="diag-step' + (failed ? ' diag-step-failed' : '') + ' rounded-xl border border-gray-200 dark:border-gray-700 transition-shadow" data-diag-step-seq="' + (s.seq||0) + '" data-diag-step-id="' + sid + '">' +
      '<details class="group">' +
        '<summary class="flex items-center gap-3 px-4 py-2.5 cursor-pointer list-none">' +
          stepDot(s.status) +
          '<span class="flex-1 min-w-0 text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + esc(s.name || ('step ' + (s.seq||0))) + '</span>' +
          (durLabel(s) ? '<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">' + esc(durLabel(s)) + '</span>' : '') +
          '<span class="text-xs px-1.5 py-0.5 rounded ' +
            (s.status === 'error' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' :
             s.status === 'warn' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' :
             s.status === 'ok' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
             'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300') + '">' + esc(s.status || '') + '</span>' +
          '<svg class="h-4 w-4 text-gray-400 transition-transform group-open:rotate-90" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>' +
        '</summary>' +
        '<div class="px-4 pb-3 pt-1 space-y-3">' +
          (detailErr ? '<p class="text-xs text-red-600 dark:text-red-400 font-mono whitespace-pre-wrap">' + esc(detailErr) + '</p>' : '') +
          // Artifact download buttons
          (stepArts.length ? '<div class="flex flex-wrap gap-2">' + stepArts.map(function(a){
            return '<a href="/diagnostics/' + esc(bot) + '/runs/' + rid + '/artifacts/' + esc(a.id) + '" ' +
              'class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors">' +
              '<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 011.414-1.414L9 9.586V4a1 1 0 011-1zm-7 9a1 1 0 011 1v3h12v-3a1 1 0 112 0v4a1 1 0 01-1 1H3a1 1 0 01-1-1v-4a1 1 0 011-1z" clip-rule="evenodd"/></svg>' +
              esc(a.name) + '</a>';
          }).join('') + '</div>' : '') +
          // Log panel with level filter + search
          winstonLogPanelHtml(sid, stepLogs) +
        '</div>' +
      '</details>' +
    '</li>';
  });
  html += '</ol>';

  // Run-level logs (not tied to a step)
  if (runLogs.length || runArts.length) {
    html += '<div class="mt-4 rounded-xl border border-gray-200 dark:border-gray-700">' +
      '<details><summary class="px-4 py-2.5 cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">Run-level logs & artifacts</summary>' +
      '<div class="px-4 pb-3 pt-1 space-y-3">' +
        (runArts.length ? '<div class="flex flex-wrap gap-2">' + runArts.map(function(a){
          return '<a href="/diagnostics/' + esc(bot) + '/runs/' + rid + '/artifacts/' + esc(a.id) + '" class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20">' + esc(a.name) + '</a>';
        }).join('') + '</div>' : '') +
        winstonLogPanelHtml('run', runLogs) +
      '</div></details></div>';
  }

  el.innerHTML = html;
}

function winstonLogLevelClass(level) {
  level = (level || '').toUpperCase();
  if (level === 'ERROR' || level === 'CRITICAL') return 'text-red-600 dark:text-red-400';
  if (level === 'WARNING' || level === 'WARN') return 'text-amber-600 dark:text-amber-400';
  if (level === 'DEBUG') return 'text-gray-400 dark:text-gray-500';
  return 'text-gray-600 dark:text-gray-300';
}

function winstonLogRowHtml(l) {
  var ts = '';
  if (l.ts) { try { ts = new Date(l.ts * 1000).toLocaleTimeString(); } catch(e){} }
  return '<div class="diag-log-row flex gap-2 py-0.5" data-log-level="' + esc((l.level||'').toUpperCase()) + '" data-log-msg="' + esc((l.message||'').toLowerCase()) + '">' +
    '<span class="shrink-0 text-gray-400 dark:text-gray-500 font-mono text-[11px] w-16">' + esc(ts) + '</span>' +
    '<span class="shrink-0 font-mono text-[11px] w-16 ' + winstonLogLevelClass(l.level) + '">' + esc(l.level||'') + '</span>' +
    '<span class="flex-1 font-mono text-[11px] whitespace-pre-wrap break-words ' + winstonLogLevelClass(l.level) + '">' + esc(l.message||'') + '</span>' +
  '</div>';
}

// Unique id per panel so filter controls target the right log block.
var _winstonLogPanelSeq = 0;
function winstonLogPanelHtml(scopeId, logs) {
  if (!logs || !logs.length) {
    return '<p class="text-xs text-gray-400 dark:text-gray-500 italic">No log lines for this step.</p>';
  }
  var pid = 'winston-logpanel-' + (++_winstonLogPanelSeq);
  var rows = logs.map(winstonLogRowHtml).join('');
  return '<div class="rounded-lg border border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-900/40">' +
    '<div class="flex flex-wrap items-center gap-2 px-3 py-2 border-b border-gray-100 dark:border-gray-800">' +
      '<select onchange="winstonFilterLogs(\'' + pid + '\')" data-logfilter-level ' +
        'class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200">' +
        '<option value="">All levels</option><option value="DEBUG">Debug</option><option value="INFO">Info</option>' +
        '<option value="WARNING">Warning</option><option value="ERROR">Error</option><option value="CRITICAL">Critical</option>' +
      '</select>' +
      '<input type="text" oninput="winstonFilterLogs(\'' + pid + '\')" data-logfilter-search placeholder="Search…" ' +
        'class="flex-1 min-w-[8rem] px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200">' +
      '<span data-logfilter-count class="text-[11px] text-gray-400 dark:text-gray-500"></span>' +
    '</div>' +
    '<div id="' + pid + '" class="px-3 py-2 max-h-72 overflow-y-auto">' + rows + '</div>' +
  '</div>';
}

function winstonFilterLogs(panelId) {
  var panel = document.getElementById(panelId);
  if (!panel) return;
  var container = panel.parentElement;
  var levelSel = container.querySelector('[data-logfilter-level]');
  var searchInp = container.querySelector('[data-logfilter-search]');
  var countEl = container.querySelector('[data-logfilter-count]');
  var level = levelSel ? levelSel.value : '';
  var q = searchInp ? searchInp.value.trim().toLowerCase() : '';
  var rows = panel.querySelectorAll('.diag-log-row');
  var shown = 0;
  rows.forEach(function(row){
    var lvl = row.getAttribute('data-log-level') || '';
    var msg = row.getAttribute('data-log-msg') || '';
    var ok = (!level || lvl === level) && (!q || msg.indexOf(q) !== -1);
    row.classList.toggle('hidden', !ok);
    if (ok) shown++;
  });
  if (countEl) countEl.textContent = shown + ' / ' + rows.length;
}

// --- Settings ---
function winstonLoadConfig() {
  var loading = document.getElementById('winston-cfg-loading');
  var body = document.getElementById('winston-cfg-body');
  if (loading) loading.classList.remove('hidden');
  if (body) body.classList.add('hidden');
  fetch('/winston/config').then(function(r){ return r.json(); }).then(function(d) {
    _winstonCfgData = d;
    if (loading) loading.classList.add('hidden');
    if (body) body.classList.remove('hidden');
    var mbx = document.getElementById('winston-cfg-mailbox');
    if (mbx) mbx.value = d.draft_mailbox || '';
    winstonRenderBadge('winston-cfg-clickup-badge', d.clickup_api_key_configured);
    winstonRenderBadge('winston-cfg-azure-badge', d.azure_configured);
    var clList = document.getElementById('winston-cfg-clickuplist-value');
    if (clList) clList.textContent = d.clickup_list_id || '(not set)';
    var oUser = document.getElementById('winston-cfg-onedrive-user');
    if (oUser) oUser.textContent = d.onedrive_user || '(not set)';
    var tFolder = document.getElementById('winston-cfg-takeoffs-folder');
    if (tFolder) tFolder.textContent = d.takeoffs_folder || '(not set)';
    // Pre-fill edit inputs with current values
    var azCid = document.getElementById('winston-cfg-azure-client-id');
    if (azCid) azCid.value = d.azure_client_id || '';
    var azTid = document.getElementById('winston-cfg-azure-tenant-id');
    if (azTid) azTid.value = d.azure_tenant_id || '';
    var clInp = document.getElementById('winston-cfg-clickuplist-input');
    if (clInp) clInp.value = d.clickup_list_id || '';
    var odInp = document.getElementById('winston-cfg-onedrive-input');
    if (odInp) odInp.value = d.onedrive_user || '';
    var tkInp = document.getElementById('winston-cfg-takeoffs-input');
    if (tkInp) tkInp.value = d.takeoffs_folder || '';
    _winstonRcpTo = (d.report_to || []).slice();
    _winstonRcpCc = (d.report_cc || []).slice();
    winstonRenderRecipientList('to');
    winstonRenderRecipientList('cc');
  }).catch(function(e) {
    if (loading) loading.classList.add('hidden');
    if (body) body.classList.remove('hidden');
  });
}

function winstonRenderBadge(elId, configured) {
  var el = document.getElementById(elId);
  if (!el) return;
  el.textContent = configured ? 'Configured' : 'Not configured';
  el.className = 'text-xs font-medium px-2 py-0.5 rounded-full ' +
    (configured
      ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
      : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400');
}

function winstonToggleClickupEdit() {
  var wrap = document.getElementById('winston-cfg-clickup-edit');
  if (!wrap) return;
  wrap.classList.toggle('hidden');
}

function winstonToggleEdit(name) {
  var wrap = document.getElementById('winston-cfg-' + name + '-edit');
  if (!wrap) return;
  wrap.classList.toggle('hidden');
}

// Generic single-field save (clickup_list_id / onedrive_user / takeoffs_folder).
function winstonSaveCfgField(name, field) {
  var inp = document.getElementById('winston-cfg-' + name + '-input');
  var val = inp ? inp.value.trim() : '';
  if (!val) { winstonSetCfgStatus('Enter a value first.'); return; }
  var payload = {};
  payload[field] = val;
  fetch('/winston/config', {
    method: 'POST',
    headers: {'Content-Type':'application/json'},
    body: JSON.stringify(payload)
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d.ok) {
      winstonLoadConfig();
      var w = document.getElementById('winston-cfg-' + name + '-edit');
      if (w) w.classList.add('hidden');
      winstonSetCfgStatus('Saved.');
    } else {
      winstonSetCfgStatus('Error: ' + (d.error || 'unknown'));
    }
  }).catch(function(){ winstonSetCfgStatus('Error saving.'); });
}

function winstonSaveCfgAzure() {
  var cid = (document.getElementById('winston-cfg-azure-client-id') || {}).value || '';
  var tid = (document.getElementById('winston-cfg-azure-tenant-id') || {}).value || '';
  var sec = (document.getElementById('winston-cfg-azure-secret') || {}).value || '';
  var payload = {};
  if (cid.trim()) payload.azure_client_id = cid.trim();
  if (tid.trim()) payload.azure_tenant_id = tid.trim();
  if (sec.trim()) payload.azure_client_secret = sec.trim();
  if (!Object.keys(payload).length) { winstonSetCfgStatus('No changes to save.'); return; }
  fetch('/winston/config', {
    method: 'POST',
    headers: {'Content-Type':'application/json'},
    body: JSON.stringify(payload)
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d.ok) {
      winstonLoadConfig();
      var w = document.getElementById('winston-cfg-azure-edit');
      if (w) w.classList.add('hidden');
      var s = document.getElementById('winston-cfg-azure-secret');
      if (s) s.value = '';
      winstonSetCfgStatus('Azure config saved.');
    } else {
      winstonSetCfgStatus('Error: ' + (d.error || 'unknown'));
    }
  }).catch(function(){ winstonSetCfgStatus('Error saving.'); });
}

function winstonSaveCfgClickup() {
  var key = (document.getElementById('winston-cfg-clickup-key') || {}).value || '';
  if (!key.trim()) return;
  fetch('/winston/config', {
    method: 'POST',
    headers: {'Content-Type':'application/json'},
    body: JSON.stringify({clickup_api_key: key.trim()})
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d.ok) {
      winstonLoadConfig();
      var w = document.getElementById('winston-cfg-clickup-edit');
      if (w) w.classList.add('hidden');
      winstonSetCfgStatus('ClickUp key saved.');
    } else {
      winstonSetCfgStatus('Error: ' + (d.error || 'unknown'));
    }
  });
}

function winstonSaveCfgMailbox() {
  var mbx = (document.getElementById('winston-cfg-mailbox') || {}).value || '';
  fetch('/winston/config', {
    method: 'POST',
    headers: {'Content-Type':'application/json'},
    body: JSON.stringify({draft_mailbox: mbx.trim()})
  }).then(function(r){ return r.json(); }).then(function(d){
    winstonSetCfgStatus(d.ok ? 'Saved.' : 'Error: ' + (d.error || 'unknown'));
  });
}

function winstonSetCfgStatus(msg) {
  var el = document.getElementById('winston-cfg-status');
  if (!el) return;
  el.textContent = msg;
  setTimeout(function(){ el.textContent = ''; }, 3000);
}

// --- Recipients ---
function winstonRenderRecipientList(kind) {
  var list = kind === 'to' ? _winstonRcpTo : _winstonRcpCc;
  var ul = document.getElementById('winston-rcp-' + kind + '-list');
  if (!ul) return;
  if (!list.length) { ul.innerHTML = '<li class="py-2 text-xs text-gray-400 dark:text-gray-500 italic">No recipients.</li>'; return; }
  ul.innerHTML = list.map(function(e) {
    return '<li class="flex items-center justify-between py-1.5">' +
      '<span class="text-sm text-gray-700 dark:text-gray-300">' + esc(e) + '</span>' +
      '<button type="button" onclick="winstonRecipientRemove(\'' + kind + '\',\'' + esc(e) + '\')" ' +
      'class="text-gray-400 hover:text-red-500 transition-colors cursor-pointer">' +
      '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">' +
      '<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>' +
      '</button></li>';
  }).join('');
}

function winstonRecipientAdd(kind) {
  var inp = document.getElementById('winston-rcp-' + kind + '-input');
  if (!inp) return;
  if (inp.classList.contains('hidden')) { inp.classList.remove('hidden'); inp.focus(); return; }
  var val = inp.value.trim().toLowerCase();
  if (!val) return;
  var list = kind === 'to' ? _winstonRcpTo : _winstonRcpCc;
  if (list.indexOf(val) === -1) list.push(val);
  inp.value = '';
  inp.classList.add('hidden');
  winstonRenderRecipientList(kind);
}

function winstonRecipientRemove(kind, email) {
  if (kind === 'to') { _winstonRcpTo = _winstonRcpTo.filter(function(e){ return e !== email; }); }
  else { _winstonRcpCc = _winstonRcpCc.filter(function(e){ return e !== email; }); }
  winstonRenderRecipientList(kind);
}

function winstonSaveRecipients() {
  fetch('/winston/config', {
    method: 'POST',
    headers: {'Content-Type':'application/json'},
    body: JSON.stringify({report_to: _winstonRcpTo, report_cc: _winstonRcpCc})
  }).then(function(r){ return r.json(); }).then(function(d){
    var st = document.getElementById('winston-rcp-status');
    if (!st) return;
    st.textContent = d.ok ? 'Saved.' : 'Error: ' + (d.error || 'unknown');
    setTimeout(function(){ st.textContent = ''; }, 3000);
  });
}

// --- Schedules ---
function winstonLoadScheds() {
  var loading = document.getElementById('winston-sched-loading');
  var empty = document.getElementById('winston-sched-empty');
  var errEl = document.getElementById('winston-sched-error');
  var wrap = document.getElementById('winston-sched-table-wrap');
  [loading, empty, errEl, wrap].forEach(function(el){ if (el) el.classList.add('hidden'); });
  if (loading) loading.classList.remove('hidden');
  fetch('/winston/schedules').then(function(r){ return r.json(); }).then(function(d){
    if (loading) loading.classList.add('hidden');
    _winstonSchedList = d.schedules || [];
    winstonRenderSchedTable();
  }).catch(function(e){
    if (loading) loading.classList.add('hidden');
    if (errEl) { errEl.classList.remove('hidden'); document.getElementById('winston-sched-error-msg').textContent = 'Failed to load schedules.'; }
  });
}

function winstonRenderSchedTable() {
  var empty = document.getElementById('winston-sched-empty');
  var wrap = document.getElementById('winston-sched-table-wrap');
  var tbody = document.getElementById('winston-sched-tbody');
  if (!tbody) return;
  if (!_winstonSchedList.length) {
    if (empty) empty.classList.remove('hidden');
    if (wrap) wrap.classList.add('hidden');
    return;
  }
  if (empty) empty.classList.add('hidden');
  if (wrap) wrap.classList.remove('hidden');
  var DAYS = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
  tbody.innerHTML = _winstonSchedList.map(function(s){
    var cadLabel = s.cadence === 'daily' ? 'Every day' :
                   s.cadence === 'weekdays' ? 'Mon–Fri' :
                   (s.custom_days || []).map(function(d){ return DAYS[d]; }).join(', ');
    var profLabel = (_winstonDateLogicById[s.profile] && _winstonDateLogicById[s.profile].name)
      || (s.profile === 'prev_biz' ? 'Prev business day' : 'Today');
    var timeStr = String(s.hour || 0).padStart(2,'0') + ':' + String(s.minute || 0).padStart(2,'0');
    var enabledBadge = s.enabled
      ? '<span class="text-xs font-medium px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">On</span>'
      : '<span class="text-xs font-medium px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400">Off</span>';
    return '<tr>' +
      '<td class="py-2 pr-4 text-gray-800 dark:text-gray-100">' + esc(s.name) + '</td>' +
      '<td class="py-2 pr-4 text-gray-600 dark:text-gray-300">' + esc(profLabel) + '</td>' +
      '<td class="py-2 pr-4 text-gray-600 dark:text-gray-300">' + esc(cadLabel) + '</td>' +
      '<td class="py-2 pr-4 font-mono text-gray-600 dark:text-gray-300">' + esc(timeStr) + '</td>' +
      '<td class="py-2 pr-4">' + enabledBadge + '</td>' +
      '<td class="py-2 text-right whitespace-nowrap">' +
        '<button type="button" onclick="winstonOpenSched(\'' + s.id + '\')" class="text-xs text-togen-teal hover:underline cursor-pointer mr-3">Edit</button>' +
        '<button type="button" onclick="winstonDeleteSched(\'' + s.id + '\')" class="text-xs text-red-500 hover:underline cursor-pointer">Delete</button>' +
      '</td></tr>';
  }).join('');
}

function winstonOpenSched(id) {
  _winstonSchedEditId = id || null;
  var dlg = document.getElementById('winston-sched-overlay');
  if (!dlg) return;
  var title = document.getElementById('winston-sched-dlg-title');
  var err = document.getElementById('winston-sched-form-error');
  if (title) title.textContent = id ? 'Edit Schedule' : 'Add Schedule';
  if (err) err.classList.add('hidden');
  if (id) {
    var s = _winstonSchedList.find(function(x){ return x.id === id; });
    if (s) {
      document.getElementById('winston-sched-name').value = s.name;
      document.getElementById('winston-sched-profile').value = s.profile || 'today';
      document.getElementById('winston-sched-cadence').value = s.cadence;
      document.getElementById('winston-sched-hour').value = s.hour || 0;
      document.getElementById('winston-sched-minute').value = s.minute || 0;
      document.getElementById('winston-sched-enabled').checked = !!s.enabled;
      winstonSchedCadenceChanged();
      document.querySelectorAll('.winston-day-cb').forEach(function(cb){
        cb.checked = (s.custom_days || []).indexOf(parseInt(cb.value)) !== -1;
      });
    }
  } else {
    document.getElementById('winston-sched-name').value = '';
    document.getElementById('winston-sched-profile').value = 'today';
    document.getElementById('winston-sched-cadence').value = 'daily';
    document.getElementById('winston-sched-hour').value = 6;
    document.getElementById('winston-sched-minute').value = 0;
    document.getElementById('winston-sched-enabled').checked = true;
    document.querySelectorAll('.winston-day-cb').forEach(function(cb){ cb.checked = false; });
    winstonSchedCadenceChanged();
  }
  dlg.classList.remove('hidden');
}

function winstonCloseSched() {
  var dlg = document.getElementById('winston-sched-overlay');
  if (dlg) dlg.classList.add('hidden');
  _winstonSchedEditId = null;
}

function winstonSchedCadenceChanged() {
  var val = (document.getElementById('winston-sched-cadence') || {}).value;
  var wrap = document.getElementById('winston-sched-custom-wrap');
  if (wrap) wrap.classList.toggle('hidden', val !== 'custom');
}

function winstonSaveSched() {
  var name = (document.getElementById('winston-sched-name') || {}).value || '';
  var profile = (document.getElementById('winston-sched-profile') || {}).value || 'today';
  var cadence = (document.getElementById('winston-sched-cadence') || {}).value || 'daily';
  var hour = parseInt((document.getElementById('winston-sched-hour') || {}).value || 6);
  var minute = parseInt((document.getElementById('winston-sched-minute') || {}).value || 0);
  var enabled = !!(document.getElementById('winston-sched-enabled') || {}).checked;
  var custom_days = [];
  if (cadence === 'custom') {
    document.querySelectorAll('.winston-day-cb:checked').forEach(function(cb){ custom_days.push(parseInt(cb.value)); });
  }
  var body = { name: name, profile: profile, cadence: cadence, hour: hour, minute: minute, enabled: enabled, custom_days: custom_days };
  var url = _winstonSchedEditId ? '/winston/schedules/' + _winstonSchedEditId : '/winston/schedules';
  var method = _winstonSchedEditId ? 'PUT' : 'POST';
  fetch(url, { method: method, headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, data: d}; }); })
    .then(function(res){
      if (!res.ok) {
        var err = document.getElementById('winston-sched-form-error');
        if (err) { err.textContent = res.data.error || 'Save failed.'; err.classList.remove('hidden'); }
        return;
      }
      winstonCloseSched();
      winstonLoadScheds();
    });
}

function winstonDeleteSched(id) {
  if (!confirm('Delete this schedule?')) return;
  fetch('/winston/schedules/' + id, { method: 'DELETE' })
    .then(function(){ winstonLoadScheds(); });
}

// --- Date Logic tab ---
function winstonLoadDateLogic(quiet) {
  var loading = document.getElementById('winston-dl-loading');
  var errEl = document.getElementById('winston-dl-error');
  if (!quiet) {
    if (errEl) errEl.classList.add('hidden');
    if (loading) loading.classList.remove('hidden');
  }
  return fetch('/winston/date-logic').then(function(r){ return r.json(); }).then(function(d){
    if (loading) loading.classList.add('hidden');
    _winstonDateLogic = d.pipelines || [];
    _winstonBaseRules = d.base_rules || [];
    _winstonDateLogicById = {};
    _winstonDateLogic.forEach(function(p){ _winstonDateLogicById[p.id] = p; });
    winstonPopulateSchedProfileDropdown();
    if (!quiet) winstonRenderDateLogic();
    // Refresh the schedule table labels if it's already rendered.
    if (_winstonSchedList && _winstonSchedList.length) winstonRenderSchedTable();
  }).catch(function(){
    if (loading) loading.classList.add('hidden');
    if (!quiet && errEl) { errEl.classList.remove('hidden'); document.getElementById('winston-dl-error-msg').textContent = 'Failed to load Date Logic.'; }
  });
}

function winstonPopulateSchedProfileDropdown() {
  var sel = document.getElementById('winston-sched-profile');
  if (!sel) return;
  var current = sel.value;
  sel.innerHTML = _winstonDateLogic.map(function(p){
    return '<option value="' + esc(p.id) + '">' + esc(p.name) + '</option>';
  }).join('');
  if (current && _winstonDateLogicById[current]) sel.value = current;
}

function winstonRenderDateLogic() {
  var list = document.getElementById('winston-dl-list');
  if (!list) return;
  if (!_winstonDateLogic.length) { list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 py-6 text-center">No Date Logic pipelines.</p>'; return; }
  list.innerHTML = _winstonDateLogic.map(winstonDlCardHtml).join('');
}

function winstonDlCardHtml(p) {
  var pencil = '<svg class="h-3.5 w-3.5 inline" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536M9 11l6-6 3 3-6 6H9v-3z"></path></svg>';
  var builtinBadge = p.builtin
    ? '<span class="text-xs font-medium px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400">Built-in</span>'
    : '';
  var delBtn = p.builtin ? ''
    : '<button type="button" onclick="winstonDeleteDateLogic(\'' + p.id + '\')" class="text-xs text-red-500 hover:underline cursor-pointer ml-3">Delete</button>';
  var steps = (p.processes || []).map(function(pr, i){
    var arrow = i > 0 ? '<span class="text-gray-300 dark:text-gray-600 mx-1">&rarr;</span>' : '';
    var customDot = pr.custom ? '<span class="ml-1 text-togen-teal" title="Custom name">&bull;</span>' : '';
    return arrow +
      '<span class="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 px-2.5 py-1 my-1">' +
        '<span class="text-xs font-mono text-gray-400 dark:text-gray-500">' + pr.seq + '</span>' +
        '<button type="button" onclick="winstonOpenProcessDetail(\'' + p.id + '\',\'' + pr.key + '\')" class="text-sm text-gray-700 dark:text-gray-200 hover:text-togen-teal hover:underline cursor-pointer" title="View operation details">' + esc(pr.label) + customDot + '</button>' +
        '<button type="button" onclick="winstonRenameProcess(\'' + p.id + '\',\'' + pr.key + '\')" class="text-gray-400 hover:text-togen-teal cursor-pointer" title="Rename process">' + pencil + '</button>' +
      '</span>';
  }).join('');
  return '<div class="rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-3">' +
    '<div class="flex flex-wrap items-center justify-between gap-2">' +
      '<div class="flex items-center gap-2 min-w-0">' +
        '<span class="text-sm font-semibold text-gray-800 dark:text-gray-100">' + esc(p.name) + '</span>' +
        '<button type="button" onclick="winstonRenameDateLogic(\'' + p.id + '\')" class="text-gray-400 hover:text-togen-teal cursor-pointer" title="Rename Date Logic">' + pencil + '</button>' +
        builtinBadge +
      '</div>' +
      '<div class="flex items-center">' +
        '<span class="text-xs text-gray-500 dark:text-gray-400">Date rule: <span class="font-medium text-gray-700 dark:text-gray-300">' + esc(p.base_rule_label || p.base_rule) + '</span></span>' +
        delBtn +
      '</div>' +
    '</div>' +
    '<div class="mt-2 flex flex-wrap items-center">' + steps + '</div>' +
  '</div>';
}

function winstonRenameDateLogic(id) {
  var p = _winstonDateLogicById[id];
  if (!p) return;
  var name = prompt('Rename Date Logic:', p.name);
  if (name === null) return;
  name = name.trim();
  if (!name) return;
  fetch('/winston/date-logic/' + id, {
    method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify({name: name})
  }).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, data:d}; }); }).then(function(res){
    if (!res.ok) { alert((res.data && res.data.error) || 'Rename failed.'); return; }
    winstonLoadDateLogic();
  });
}

function winstonRenameProcess(id, key) {
  var p = _winstonDateLogicById[id];
  if (!p) return;
  var proc = (p.processes || []).find(function(x){ return x.key === key; });
  if (!proc) return;
  var label = prompt('Rename process (blank = default "' + proc.default_label + '"):', proc.label);
  if (label === null) return;
  var payload = {}; payload[key] = label.trim();
  fetch('/winston/date-logic/' + id, {
    method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify({process_labels: payload})
  }).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, data:d}; }); }).then(function(res){
    if (!res.ok) { alert((res.data && res.data.error) || 'Rename failed.'); return; }
    winstonLoadDateLogic();
  });
}

function winstonOpenProcessDetail(id, key) {
  var p = _winstonDateLogicById[id];
  if (!p) return;
  var procs = p.processes || [];
  var proc = procs.find(function(x){ return x.key === key; });
  if (!proc) return;
  var d = proc.detail || {};
  document.getElementById('winston-dld-title').textContent = proc.label;
  document.getElementById('winston-dld-seq').textContent =
    'Step ' + proc.seq + ' of ' + procs.length + '  ·  ' + p.name;
  var defNote = document.getElementById('winston-dld-defnote');
  if (proc.custom) { defNote.textContent = 'Canonical step: ' + proc.default_label; defNote.classList.remove('hidden'); }
  else { defNote.classList.add('hidden'); }
  function setRow(fieldId, val) { document.getElementById(fieldId).textContent = val || '—'; }
  setRow('winston-dld-summary', d.summary);
  setRow('winston-dld-system', d.system);
  setRow('winston-dld-queries', d.queries);
  setRow('winston-dld-pulls', d.pulls);
  setRow('winston-dld-pushes', d.pushes);
  document.getElementById('winston-dl-detail-overlay').classList.remove('hidden');
}

function winstonCloseProcessDetail() {
  var dlg = document.getElementById('winston-dl-detail-overlay');
  if (dlg) dlg.classList.add('hidden');
}

function winstonDeleteDateLogic(id) {
  if (!confirm('Delete this Date Logic pipeline?')) return;
  fetch('/winston/date-logic/' + id, { method: 'DELETE' })
    .then(function(r){ return r.json().then(function(d){ return {ok:r.ok, data:d}; }); }).then(function(res){
      if (!res.ok) { alert((res.data && res.data.error) || 'Delete failed.'); return; }
      winstonLoadDateLogic();
    });
}

function winstonOpenNewDateLogic() {
  var dlg = document.getElementById('winston-dl-overlay');
  if (!dlg) return;
  document.getElementById('winston-dl-name').value = '';
  var sel = document.getElementById('winston-dl-base-rule');
  if (sel) sel.innerHTML = _winstonBaseRules.map(function(b){
    return '<option value="' + esc(b.value) + '">' + esc(b.label) + '</option>';
  }).join('');
  var err = document.getElementById('winston-dl-form-error');
  if (err) err.classList.add('hidden');
  dlg.classList.remove('hidden');
}

function winstonCloseNewDateLogic() {
  var dlg = document.getElementById('winston-dl-overlay');
  if (dlg) dlg.classList.add('hidden');
}

function winstonSaveNewDateLogic() {
  var name = (document.getElementById('winston-dl-name') || {}).value || '';
  var baseRule = (document.getElementById('winston-dl-base-rule') || {}).value || '';
  var err = document.getElementById('winston-dl-form-error');
  fetch('/winston/date-logic', {
    method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({name: name, base_rule: baseRule})
  }).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, data:d}; }); }).then(function(res){
    if (!res.ok) { if (err) { err.textContent = (res.data && res.data.error) || 'Create failed.'; err.classList.remove('hidden'); } return; }
    winstonCloseNewDateLogic();
    winstonLoadDateLogic();
  }).catch(function(){ if (err) { err.textContent = 'Network error.'; err.classList.remove('hidden'); } });
}

// --- Runs History ---
function winstonLoadHistory() {
  var loading = document.getElementById('winston-hist-loading');
  var empty = document.getElementById('winston-hist-empty');
  var errEl = document.getElementById('winston-hist-error');
  var wrap = document.getElementById('winston-hist-table-wrap');
  [loading, empty, errEl, wrap].forEach(function(el){ if (el) el.classList.add('hidden'); });
  if (loading) loading.classList.remove('hidden');
  fetch('/winston/run-history').then(function(r){ return r.json(); }).then(function(d){
    if (loading) loading.classList.add('hidden');
    var records = d.records || [];
    if (!records.length) { if (empty) empty.classList.remove('hidden'); return; }
    if (wrap) wrap.classList.remove('hidden');
    var tbody = document.getElementById('winston-hist-tbody');
    if (!tbody) return;
    tbody.innerHTML = records.map(function(r){
      var ranAt = r.ran_at || '';
      var dtLabel = '';
      if (ranAt) {
        try { dtLabel = new Date(ranAt).toLocaleString(); } catch(e){ dtLabel = ranAt; }
      }
      var statusBadge;
      if (r.status === 'running') {
        statusBadge = '<span class="inline-flex items-center gap-1 text-xs font-medium px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">' +
          '<svg class="animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>' +
          'Running</span>';
      } else if (r.status === 'error') {
        statusBadge = '<span class="text-xs font-medium px-1.5 py-0.5 rounded bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">Error</span>';
      } else {
        statusBadge = '<span class="text-xs font-medium px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">Done</span>';
      }
      var errMsg = r.error ? '<span class="text-red-600 dark:text-red-400" title="' + esc(r.error) + '">' + esc(r.error.substring(0,60)) + (r.error.length > 60 ? '…' : '') + '</span>' : '';
      var isErr = (r.status === 'error' || r.status === 'warn');
      var rowAttrs = isErr
        ? ' class="align-top cursor-pointer hover:bg-red-50 dark:hover:bg-red-900/10" title="View diagnostics for this run" onclick="winstonOpenDiagForRun(\'' + esc(r.id || '') + '\')"'
        : ' class="align-top"';
      return '<tr' + rowAttrs + '>' +
        '<td class="py-2 pr-4 text-gray-600 dark:text-gray-300 whitespace-nowrap text-xs">' + esc(dtLabel) + '</td>' +
        '<td class="py-2 pr-4 font-mono text-gray-800 dark:text-gray-100">' + esc(r.date || '') + '</td>' +
        '<td class="py-2 pr-4">' + statusBadge + '</td>' +
        '<td class="py-2 pr-4 text-right text-gray-700 dark:text-gray-300">' + (r.quotes_found || 0) + '</td>' +
        '<td class="py-2 pr-4 text-right text-gray-700 dark:text-gray-300">' + (r.drafts_created || 0) + '</td>' +
        '<td class="py-2 pr-4 text-right text-gray-700 dark:text-gray-300">' + (r.pdf_routed || 0) + '</td>' +
        '<td class="py-2 text-gray-500 dark:text-gray-400 text-xs max-w-xs">' + errMsg + '</td>' +
        '</tr>';
    }).join('');
  }).catch(function(){
    if (loading) loading.classList.add('hidden');
    if (errEl) { errEl.classList.remove('hidden'); document.getElementById('winston-hist-error-msg').textContent = 'Failed to load history.'; }
  });
}

// --- Run now ---
function winstonOpenRunNow() {
  var dlg = document.getElementById('winston-run-overlay');
  if (!dlg) return;
  var dateInp = document.getElementById('winston-run-date');
  if (dateInp) {
    var today = new Date();
    var yyyy = today.getFullYear();
    var mm = String(today.getMonth()+1).padStart(2,'0');
    var dd = String(today.getDate()).padStart(2,'0');
    dateInp.value = yyyy + '-' + mm + '-' + dd;
  }
  var err = document.getElementById('winston-run-form-error');
  if (err) err.classList.add('hidden');
  var prog = document.getElementById('winston-run-progress');
  if (prog) prog.classList.add('hidden');
  var submitBtn = document.getElementById('winston-run-submit-btn');
  if (submitBtn) { submitBtn.disabled = false; submitBtn.classList.remove('opacity-50'); }
  dlg.classList.remove('hidden');
}

function winstonCloseRunNow() {
  var dlg = document.getElementById('winston-run-overlay');
  if (dlg) dlg.classList.add('hidden');
  if (_winstonRunPollTimer) { clearInterval(_winstonRunPollTimer); _winstonRunPollTimer = null; }
}

function winstonSubmitRunNow() {
  var date = (document.getElementById('winston-run-date') || {}).value || '';
  if (!date) {
    var err = document.getElementById('winston-run-form-error');
    if (err) { err.textContent = 'Please select a date.'; err.classList.remove('hidden'); }
    return;
  }
  var err = document.getElementById('winston-run-form-error');
  if (err) err.classList.add('hidden');
  var prog = document.getElementById('winston-run-progress');
  if (prog) prog.classList.remove('hidden');
  var submitBtn = document.getElementById('winston-run-submit-btn');
  if (submitBtn) { submitBtn.disabled = true; submitBtn.classList.add('opacity-50'); }

  fetch('/winston/run-now', {
    method: 'POST',
    headers: {'Content-Type':'application/json'},
    body: JSON.stringify({date: date})
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d.run_id) {
      _winstonRunActiveId = d.run_id;
      // Poll history every 5s to pick up completion; auto-switch to history tab
      _winstonRunPollTimer = setInterval(function(){
        winstonLoadHistory();
        // Check if run completed
        fetch('/winston/run-history').then(function(r){ return r.json(); }).then(function(hist){
          var rec = (hist.records || []).find(function(r){ return r.id === _winstonRunActiveId; });
          if (rec && rec.status !== 'running') {
            clearInterval(_winstonRunPollTimer);
            _winstonRunPollTimer = null;
            winstonCloseRunNow();
            winstonSwitchTab('history');
          }
        });
      }, 5000);
    } else {
      var err2 = document.getElementById('winston-run-form-error');
      if (err2) { err2.textContent = d.error || 'Failed to start run.'; err2.classList.remove('hidden'); }
      if (prog) prog.classList.add('hidden');
      if (submitBtn) { submitBtn.disabled = false; submitBtn.classList.remove('opacity-50'); }
    }
  }).catch(function(){
    var err3 = document.getElementById('winston-run-form-error');
    if (err3) { err3.textContent = 'Network error.'; err3.classList.remove('hidden'); }
    if (prog) prog.classList.add('hidden');
    if (submitBtn) { submitBtn.disabled = false; submitBtn.classList.remove('opacity-50'); }
  });
}

function worRenderTypeSelector() {
  var grid = document.querySelector('#wor-type-selector .grid');
  if (!grid) return;
  var order = (_worTypeOrder && _worTypeOrder.length) ? _worTypeOrder.slice() : ['maintenance', 'mobilemachine', 'safety', 'preventive', 'ithelpdesk'];
  // Ensure every known WO type (incl. IT Help Desk, DVI-413) renders even if a
  // previously-saved order predates it.
  Object.keys(_worTypeLabels).forEach(function(w){ if (order.indexOf(w) === -1) order.push(w); });
  order.forEach(function(type) {
    var btn = grid.querySelector('[data-wor-type="' + type + '"]');
    if (!btn) return;
    var visible;
    if (type === 'preventive') {
      // Preventative Maintenance is role-restricted to Work Order Admins and
      // Togen Admins (DVI-462), independent of the admin visibility toggle.
      visible = _isWorAdmin || _isToGenAdmin;
    } else {
      visible = _isToGenAdmin || _worTypeVisibility[type] !== false;
    }
    btn.classList.toggle('hidden', !visible);
    grid.appendChild(btn);
  });
}

function worSelectType(type) {
  _worSelectedType = type;
  document.getElementById('wor-type-selector').classList.add('hidden');
  document.getElementById('wor-maintenance-wrapper').classList.add('hidden');
  document.getElementById('wor-mm-wrapper').classList.add('hidden');
  document.getElementById('wor-safety-wrapper').classList.add('hidden');
  document.getElementById('wor-preventive-wrapper').classList.add('hidden');
  if (type === 'maintenance') {
    document.getElementById('wor-maintenance-wrapper').classList.remove('hidden');
    document.getElementById('tool-active-title').textContent = 'Maintenance Work Order';
    if (!_worInitialized) {
      _worInitialized = true;
      var f = document.getElementById('wor-form');
      if (f) {
        f.elements['date_of_request'].value = new Date().toISOString().slice(0,10);
        var ssoName = '{{ user_name }}';
        var ssoEmail = '{{ user_email }}';
        if (ssoName && ssoName !== 'User') f.elements['requestor_name'].value = ssoName;
        if (ssoEmail) f.elements['email'].value = ssoEmail;
      }
    }
  } else if (type === 'preventive') {
    document.getElementById('wor-preventive-wrapper').classList.remove('hidden');
    document.getElementById('tool-active-title').textContent = 'Preventative Maintenance Work Order';
    if (!_pmInitialized) {
      _pmInitialized = true;
      var f = document.getElementById('pm-form');
      if (f) {
        f.elements['date_of_request'].value = new Date().toISOString().slice(0,10);
        var ssoName = '{{ user_name }}';
        var ssoEmail = '{{ user_email }}';
        if (ssoName && ssoName !== 'User') f.elements['requestor_name'].value = ssoName;
        if (ssoEmail) f.elements['email'].value = ssoEmail;
      }
    }
  } else if (type === 'safety') {
    document.getElementById('wor-safety-wrapper').classList.remove('hidden');
    document.getElementById('tool-active-title').textContent = 'Safety Concerns Work Order';
    if (!_safetyInitialized) {
      _safetyInitialized = true;
      var f = document.getElementById('safety-form');
      if (f) {
        f.elements['date_of_request'].value = new Date().toISOString().slice(0,10);
        var ssoName = '{{ user_name }}';
        var ssoEmail = '{{ user_email }}';
        if (ssoName && ssoName !== 'User') f.elements['requestor_name'].value = ssoName;
        if (ssoEmail) f.elements['email'].value = ssoEmail;
      }
    }
  } else {
    document.getElementById('wor-mm-wrapper').classList.remove('hidden');
    document.getElementById('tool-active-title').textContent = 'Mobile Machine Work Order';
    if (!_mmInitialized) {
      _mmInitialized = true;
      var f = document.getElementById('mm-form');
      if (f) {
        var now = new Date();
        f.elements['order_date_time'].value = now.toISOString().slice(0,16);
      }
    }
  }
}

function worBackToTypeSelector() {
  document.getElementById('wor-type-selector').classList.remove('hidden');
  document.getElementById('wor-maintenance-wrapper').classList.add('hidden');
  document.getElementById('wor-mm-wrapper').classList.add('hidden');
  document.getElementById('wor-safety-wrapper').classList.add('hidden');
  document.getElementById('wor-preventive-wrapper').classList.add('hidden');
  document.getElementById('tool-active-title').textContent = 'Work Order Request';
  _worSelectedType = '';
}

// ===== WORK ORDER REQUEST (MAINTENANCE) =====

var _worCurrentFilename = '';
var _worEditFilename = '';

function worSubmit(e) {
  e.preventDefault();
  var form = document.getElementById('wor-form');
  var btn = document.getElementById('wor-submit-btn');
  var lbl = document.getElementById('wor-submit-lbl');
  btn.disabled = true;
  lbl.textContent = _worEditFilename ? 'Saving changes...' : 'Submitting...';

  var fd = new FormData(form);
  var url = '/wor-submit';
  if (_worEditFilename && _isWorAdmin) {
    url = '/wor-edit';
    fd.append('edit_filename', _worEditFilename);
  }
  fetch(url, {method: 'POST', body: fd})
    .then(function(r){ return r.json(); })
    .then(function(d){
      btn.disabled = false;
      lbl.textContent = '';
      if (d.error) { lbl.textContent = 'Error: ' + d.error; return; }
      lbl.textContent = _worEditFilename ? 'Work order updated!' : 'Work order submitted!';
      _worCurrentFilename = d.filename;
      _worEditFilename = '';
      var editBanner = document.getElementById('wor-edit-banner');
      if (editBanner) editBanner.classList.add('hidden');
      // Show preview
      var sec = document.getElementById('wor-preview-section');
      sec.classList.remove('hidden');
      document.getElementById('wor-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(d.filename) + '&t=' + Date.now();
      var dl = document.getElementById('wor-download-link');
      dl.href = '/wor-download?file=' + encodeURIComponent(d.filename);
      dl.download = d.filename;
      sec.scrollIntoView({behavior: 'smooth', block: 'start'});
    })
    .catch(function(err){
      btn.disabled = false;
      lbl.textContent = 'Error: ' + err.message;
    });
}

function worClearForm() {
  var form = document.getElementById('wor-form');
  form.reset();
  // Re-apply SSO defaults
  form.elements['date_of_request'].value = new Date().toISOString().slice(0,10);
  var ssoName = '{{ user_name }}';
  var ssoEmail = '{{ user_email }}';
  if (ssoName && ssoName !== 'User') form.elements['requestor_name'].value = ssoName;
  if (ssoEmail) form.elements['email'].value = ssoEmail;
  // Hide preview and reset state
  document.getElementById('wor-preview-section').classList.add('hidden');
  document.getElementById('wor-preview-frame').src = 'about:blank';
  document.getElementById('wor-submit-lbl').textContent = '';
  _worCurrentFilename = '';
  _worEditFilename = '';
  var editBanner = document.getElementById('wor-edit-banner');
  if (editBanner) editBanner.classList.add('hidden');
  var btnText = document.getElementById('wor-submit-btn-text');
  if (btnText) btnText.textContent = 'Submit Work Order';
  window.scrollTo({top: 0, behavior: 'smooth'});
}

function _worGetViewerUrl(callback) {
  if (!_worCurrentFilename) return;
  fetch('/wor-viewer-url?file=' + encodeURIComponent(_worCurrentFilename))
    .then(function(r) { return r.json(); })
    .then(function(d) { if (d.url) callback(d.url); });
}

function worOpenViewer() {
  _worGetViewerUrl(function(url) { window.open(url, '_blank'); });
}

function worToggleWorkflowMenu(e) {
  e.stopPropagation();
  var menu = document.getElementById('wor-workflow-menu');
  menu.classList.toggle('hidden');
  if (!menu.classList.contains('hidden')) {
    setTimeout(function() {
      document.addEventListener('click', _worCloseWorkflowMenu, {once: true});
    }, 0);
  }
}

function _worCloseWorkflowMenu() {
  document.getElementById('wor-workflow-menu').classList.add('hidden');
}

function worOpenInQrCreator() {
  _worCloseWorkflowMenu();
  _worGetViewerUrl(function(url) { sqrOpenWithUrl(url); });
}

function worToggleHistory() {
  var sec = document.getElementById('wor-history-section');
  if (sec.classList.contains('hidden')) {
    sec.classList.remove('hidden');
    worLoadHistory();
  } else {
    sec.classList.add('hidden');
  }
}

function worLoadHistory() {
  var container = document.getElementById('wor-history-list');
  container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>';
  fetch('/wor-history?type=maintenance')
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (!d.files || d.files.length === 0) {
        container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No new or in-progress work orders.</p>';
        return;
      }
      var html = '';
      d.files.forEach(function(f){
        var badge = f.status === 'in_progress'
          ? '<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400">In Progress</span>'
          : '<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">New</span>';
        html += '<div class="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 border border-gray-100 dark:border-gray-700">' +
          '<div class="flex-1 min-w-0">' +
            '<p class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate" title="' + esc(f.name) + '">' + esc(f.title || f.name) + ' ' + badge + '</p>' +
            '<p class="text-xs text-gray-500 dark:text-gray-400">' + fmtLocalDate(f.date) + '</p>' +
          '</div>' +
          '<div class="flex items-center gap-2 ml-3">' +
            '<button onclick="worPreviewFile(\'' + esc(f.name).replace(/'/g, "\\'") + '\')" class="px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Preview</button>' +
            '<a href="/wor-download?file=' + encodeURIComponent(f.name) + '" download class="px-3 py-1 text-xs font-medium rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors">Download</a>' +
          '</div>' +
        '</div>';
      });
      container.innerHTML = html;
    })
    .catch(function(err){
      container.innerHTML = '<p class="text-sm text-red-500">Error loading history: ' + esc(err.message) + '</p>';
    });
}

function worPreviewFile(filename) {
  _worCurrentFilename = filename;
  var sec = document.getElementById('wor-preview-section');
  sec.classList.remove('hidden');
  document.getElementById('wor-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(filename);
  var dl = document.getElementById('wor-download-link');
  dl.href = '/wor-download?file=' + encodeURIComponent(filename);
  dl.download = filename;
  sec.scrollIntoView({behavior: 'smooth', block: 'start'});
}

// ===== SAFETY CONCERNS WORK ORDER =====

var _safetyCurrentFilename = '';
var _safetyEditFilename = '';

function safetySubmit(e) {
  e.preventDefault();
  var form = document.getElementById('safety-form');
  var btn = document.getElementById('safety-submit-btn');
  var lbl = document.getElementById('safety-submit-lbl');
  btn.disabled = true;
  lbl.textContent = _safetyEditFilename ? 'Saving changes...' : 'Submitting...';

  var fd = new FormData(form);
  var url = '/safety-wor-submit';
  if (_safetyEditFilename && _isWorAdmin) {
    url = '/safety-wor-edit';
    fd.append('edit_filename', _safetyEditFilename);
  }
  fetch(url, {method: 'POST', body: fd})
    .then(function(r){ return r.json(); })
    .then(function(d){
      btn.disabled = false;
      lbl.textContent = '';
      if (d.error) { lbl.textContent = 'Error: ' + d.error; return; }
      lbl.textContent = _safetyEditFilename ? 'Work order updated!' : 'Work order submitted!';
      _safetyCurrentFilename = d.filename;
      _safetyEditFilename = '';
      var editBanner = document.getElementById('safety-edit-banner');
      if (editBanner) editBanner.classList.add('hidden');
      // Show preview
      var sec = document.getElementById('safety-preview-section');
      sec.classList.remove('hidden');
      document.getElementById('safety-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(d.filename) + '&t=' + Date.now();
      var dl = document.getElementById('safety-download-link');
      dl.href = '/wor-download?file=' + encodeURIComponent(d.filename);
      dl.download = d.filename;
      sec.scrollIntoView({behavior: 'smooth', block: 'start'});
    })
    .catch(function(err){
      btn.disabled = false;
      lbl.textContent = 'Error: ' + err.message;
    });
}

function safetyClearForm() {
  var form = document.getElementById('safety-form');
  form.reset();
  // Re-apply SSO defaults
  form.elements['date_of_request'].value = new Date().toISOString().slice(0,10);
  var ssoName = '{{ user_name }}';
  var ssoEmail = '{{ user_email }}';
  if (ssoName && ssoName !== 'User') form.elements['requestor_name'].value = ssoName;
  if (ssoEmail) form.elements['email'].value = ssoEmail;
  // Hide preview and reset state
  document.getElementById('safety-preview-section').classList.add('hidden');
  document.getElementById('safety-preview-frame').src = 'about:blank';
  document.getElementById('safety-submit-lbl').textContent = '';
  _safetyCurrentFilename = '';
  _safetyEditFilename = '';
  var editBanner = document.getElementById('safety-edit-banner');
  if (editBanner) editBanner.classList.add('hidden');
  var btnText = document.getElementById('safety-submit-btn-text');
  if (btnText) btnText.textContent = 'Submit Work Order';
  window.scrollTo({top: 0, behavior: 'smooth'});
}

function _safetyGetViewerUrl(callback) {
  if (!_safetyCurrentFilename) return;
  fetch('/wor-viewer-url?file=' + encodeURIComponent(_safetyCurrentFilename))
    .then(function(r) { return r.json(); })
    .then(function(d) { if (d.url) callback(d.url); });
}

function safetyOpenViewer() {
  _safetyGetViewerUrl(function(url) { window.open(url, '_blank'); });
}

function safetyToggleWorkflowMenu(e) {
  e.stopPropagation();
  var menu = document.getElementById('safety-workflow-menu');
  menu.classList.toggle('hidden');
  if (!menu.classList.contains('hidden')) {
    setTimeout(function() {
      document.addEventListener('click', _safetyCloseWorkflowMenu, {once: true});
    }, 0);
  }
}

function _safetyCloseWorkflowMenu() {
  document.getElementById('safety-workflow-menu').classList.add('hidden');
}

function safetyOpenInQrCreator() {
  _safetyCloseWorkflowMenu();
  _safetyGetViewerUrl(function(url) { sqrOpenWithUrl(url); });
}

function safetyToggleHistory() {
  var sec = document.getElementById('safety-history-section');
  if (sec.classList.contains('hidden')) {
    sec.classList.remove('hidden');
    safetyLoadHistory();
  } else {
    sec.classList.add('hidden');
  }
}

function safetyLoadHistory() {
  var container = document.getElementById('safety-history-list');
  container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>';
  fetch('/wor-history?type=safety')
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (!d.files || d.files.length === 0) {
        container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No new or in-progress work orders.</p>';
        return;
      }
      var html = '';
      d.files.forEach(function(f){
        var badge = f.status === 'in_progress'
          ? '<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400">In Progress</span>'
          : '<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">New</span>';
        html += '<div class="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 border border-gray-100 dark:border-gray-700">' +
          '<div class="flex-1 min-w-0">' +
            '<p class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate" title="' + esc(f.name) + '">' + esc(f.title || f.name) + ' ' + badge + '</p>' +
            '<p class="text-xs text-gray-500 dark:text-gray-400">' + fmtLocalDate(f.date) + '</p>' +
          '</div>' +
          '<div class="flex items-center gap-2 ml-3">' +
            '<button onclick="safetyPreviewFile(\'' + esc(f.name).replace(/'/g, "\\'") + '\')" class="px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Preview</button>' +
            '<a href="/wor-download?file=' + encodeURIComponent(f.name) + '" download class="px-3 py-1 text-xs font-medium rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors">Download</a>' +
          '</div>' +
        '</div>';
      });
      container.innerHTML = html;
    })
    .catch(function(err){
      container.innerHTML = '<p class="text-sm text-red-500">Error loading history: ' + esc(err.message) + '</p>';
    });
}

function safetyPreviewFile(filename) {
  _safetyCurrentFilename = filename;
  var sec = document.getElementById('safety-preview-section');
  sec.classList.remove('hidden');
  document.getElementById('safety-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(filename);
  var dl = document.getElementById('safety-download-link');
  dl.href = '/wor-download?file=' + encodeURIComponent(filename);
  dl.download = filename;
  sec.scrollIntoView({behavior: 'smooth', block: 'start'});
}

// ===== MOBILE MACHINE WORK ORDER =====

var _mmCurrentFilename = '';
var _mmEditFilename = '';

function mmAddPartRow() {
  var container = document.getElementById('mm-parts-rows');
  var row = document.createElement('div');
  row.className = 'mm-part-row grid grid-cols-12 gap-2 mb-2';
  row.innerHTML =
    '<input type="text" name="part_number[]" class="col-span-3 px-2 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">' +
    '<input type="text" name="part_name[]" class="col-span-5 px-2 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">' +
    '<input type="text" name="part_qty[]" class="col-span-2 px-2 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">' +
    '<input type="text" name="part_amount[]" class="col-span-2 px-2 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">';
  container.appendChild(row);
}

function mmSubmit(e) {
  e.preventDefault();
  var form = document.getElementById('mm-form');
  var btn = document.getElementById('mm-submit-btn');
  var lbl = document.getElementById('mm-submit-lbl');
  btn.disabled = true;
  lbl.textContent = _mmEditFilename ? 'Saving changes...' : 'Submitting...';

  var fd = new FormData(form);
  var url = '/mm-wor-submit';
  if (_mmEditFilename && _isWorAdmin) {
    url = '/mm-wor-edit';
    fd.append('edit_filename', _mmEditFilename);
  }
  fetch(url, {method: 'POST', body: fd})
    .then(function(r){ return r.json(); })
    .then(function(d){
      btn.disabled = false;
      lbl.textContent = '';
      if (d.error) { lbl.textContent = 'Error: ' + d.error; return; }
      lbl.textContent = _mmEditFilename ? 'Work order updated!' : 'Work order submitted!';
      _mmCurrentFilename = d.filename;
      _mmEditFilename = '';
      var editBanner = document.getElementById('mm-edit-banner');
      if (editBanner) editBanner.classList.add('hidden');
      var sec = document.getElementById('mm-preview-section');
      sec.classList.remove('hidden');
      document.getElementById('mm-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(d.filename) + '&t=' + Date.now();
      var dl = document.getElementById('mm-download-link');
      dl.href = '/wor-download?file=' + encodeURIComponent(d.filename);
      dl.download = d.filename;
      sec.scrollIntoView({behavior: 'smooth', block: 'start'});
    })
    .catch(function(err){
      btn.disabled = false;
      lbl.textContent = 'Error: ' + err.message;
    });
}

function mmClearForm() {
  var form = document.getElementById('mm-form');
  form.reset();
  var now = new Date();
  form.elements['order_date_time'].value = now.toISOString().slice(0,16);
  mmDetachManual();
  document.getElementById('mm-preview-section').classList.add('hidden');
  document.getElementById('mm-preview-frame').src = 'about:blank';
  document.getElementById('mm-submit-lbl').textContent = '';
  _mmCurrentFilename = '';
  _mmEditFilename = '';
  var editBanner = document.getElementById('mm-edit-banner');
  if (editBanner) editBanner.classList.add('hidden');
  var btnText = document.getElementById('mm-submit-btn-text');
  if (btnText) btnText.textContent = 'Submit Work Order';
  // Reset part rows to just one
  var container = document.getElementById('mm-parts-rows');
  var rows = container.querySelectorAll('.mm-part-row');
  for (var i = 1; i < rows.length; i++) rows[i].remove();
  var firstInputs = rows[0].querySelectorAll('input');
  for (var j = 0; j < firstInputs.length; j++) firstInputs[j].value = '';
  window.scrollTo({top: 0, behavior: 'smooth'});
}

// ===== Mobile Machine manual attach (DVI-462) =====
function mmShowManualAttached(name, url) {
  document.getElementById('mm-manual-none').classList.add('hidden');
  document.getElementById('mm-manual-attached').classList.remove('hidden');
  document.getElementById('mm-manual-attached-name').textContent = name || '';
  var link = document.getElementById('mm-manual-open-link');
  if (url) { link.href = url; link.classList.remove('hidden'); }
  else { link.href = '#'; link.classList.add('hidden'); }
}

function mmManualFileChosen() {
  var f = document.getElementById('mm-manual-file').files[0];
  if (!f) return;
  document.getElementById('mm-manual-existing').value = '';
  document.getElementById('mm-manual-existing-name').value = '';
  mmShowManualAttached(f.name, '');
}

function mmSetExistingManual(filename, name, url) {
  document.getElementById('mm-manual-file').value = '';
  document.getElementById('mm-manual-existing').value = filename || '';
  document.getElementById('mm-manual-existing-name').value = name || '';
  mmShowManualAttached(name || filename, url || '');
}

function mmDetachManual() {
  var fileEl = document.getElementById('mm-manual-file');
  if (fileEl) fileEl.value = '';
  var ex = document.getElementById('mm-manual-existing');
  if (ex) ex.value = '';
  var exn = document.getElementById('mm-manual-existing-name');
  if (exn) exn.value = '';
  var att = document.getElementById('mm-manual-attached');
  if (att) att.classList.add('hidden');
  var none = document.getElementById('mm-manual-none');
  if (none) none.classList.remove('hidden');
  var link = document.getElementById('mm-manual-open-link');
  if (link) { link.href = '#'; link.classList.add('hidden'); }
}

function mmOpenManualPicker() {
  var machineField = document.querySelector('#mm-form [name="machine"]');
  manualPickerOpen(function(choice) {
    if (choice.mode === 'existing') {
      mmSetExistingManual(choice.filename, choice.original_name, choice.url);
      manualPickerClose();
      return;
    }
    try {
      var dt = new DataTransfer();
      dt.items.add(choice.file);
      document.getElementById('mm-manual-file').files = dt.files;
    } catch (e) {
      manualPickerSetStatus('This browser cannot stage the file. Use the Equipment Manuals tool to upload first.', 'error');
      return;
    }
    if (machineField && choice.equipment && !machineField.value.trim()) machineField.value = choice.equipment;
    mmManualFileChosen();
    manualPickerClose();
  });
}

function _mmGetViewerUrl(callback) {
  if (!_mmCurrentFilename) return;
  fetch('/wor-viewer-url?file=' + encodeURIComponent(_mmCurrentFilename))
    .then(function(r) { return r.json(); })
    .then(function(d) { if (d.url) callback(d.url); });
}

function mmOpenViewer() {
  _mmGetViewerUrl(function(url) { window.open(url, '_blank'); });
}

function mmToggleWorkflowMenu(e) {
  e.stopPropagation();
  var menu = document.getElementById('mm-workflow-menu');
  menu.classList.toggle('hidden');
  if (!menu.classList.contains('hidden')) {
    setTimeout(function() {
      document.addEventListener('click', _mmCloseWorkflowMenu, {once: true});
    }, 0);
  }
}

function _mmCloseWorkflowMenu() {
  document.getElementById('mm-workflow-menu').classList.add('hidden');
}

function mmOpenInQrCreator() {
  _mmCloseWorkflowMenu();
  _mmGetViewerUrl(function(url) { sqrOpenWithUrl(url); });
}

function mmToggleHistory() {
  var sec = document.getElementById('mm-history-section');
  if (sec.classList.contains('hidden')) {
    sec.classList.remove('hidden');
    mmLoadHistory();
  } else {
    sec.classList.add('hidden');
  }
}

function mmLoadHistory() {
  var container = document.getElementById('mm-history-list');
  container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>';
  fetch('/wor-history?type=mobilemachine')
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (!d.files || d.files.length === 0) {
        container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No new or in-progress work orders.</p>';
        return;
      }
      var html = '';
      d.files.forEach(function(f){
        var badge = f.status === 'in_progress'
          ? '<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400">In Progress</span>'
          : '<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">New</span>';
        html += '<div class="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 border border-gray-100 dark:border-gray-700">' +
          '<div class="flex-1 min-w-0">' +
            '<p class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate" title="' + esc(f.name) + '">' + esc(f.title || f.name) + ' ' + badge + '</p>' +
            '<p class="text-xs text-gray-500 dark:text-gray-400">' + fmtLocalDate(f.date) + '</p>' +
          '</div>' +
          '<div class="flex items-center gap-2 ml-3">' +
            '<button onclick="mmPreviewFile(\'' + esc(f.name).replace(/'/g, "\\'") + '\')" class="px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Preview</button>' +
            '<a href="/wor-download?file=' + encodeURIComponent(f.name) + '" download class="px-3 py-1 text-xs font-medium rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors">Download</a>' +
          '</div>' +
        '</div>';
      });
      container.innerHTML = html;
    })
    .catch(function(err){
      container.innerHTML = '<p class="text-sm text-red-500">Error loading history: ' + esc(err.message) + '</p>';
    });
}

function mmPreviewFile(filename) {
  _mmCurrentFilename = filename;
  var sec = document.getElementById('mm-preview-section');
  sec.classList.remove('hidden');
  document.getElementById('mm-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(filename);
  var dl = document.getElementById('mm-download-link');
  dl.href = '/wor-download?file=' + encodeURIComponent(filename);
  dl.download = filename;
  sec.scrollIntoView({behavior: 'smooth', block: 'start'});
}

// ===== WORK ORDER REQUEST (PREVENTATIVE MAINTENANCE) =====
var _pmCurrentFilename = '';
var _pmEditFilename = '';
var _pmCurrentManual = null;     // manual on the most-recently submitted/previewed PM WO
var _pmManualSuggest = null;     // last equipment-match suggestion from the server
var _pmEasOptions = [];          // known Equipment/Asset/System names for type-ahead (DVI-590)

function pmAddPartRow() {
  var container = document.getElementById('pm-parts-rows');
  var row = document.createElement('div');
  row.className = 'pm-part-row grid grid-cols-12 gap-2 mb-2';
  row.innerHTML =
    '<input type="text" name="part_number[]" class="col-span-3 px-2 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">' +
    '<input type="text" name="part_name[]" class="col-span-5 px-2 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">' +
    '<input type="text" name="part_qty[]" class="col-span-2 px-2 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">' +
    '<input type="text" name="part_amount[]" class="col-span-2 px-2 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">';
  container.appendChild(row);
}

function pmSubmit(e) {
  e.preventDefault();
  var form = document.getElementById('pm-form');
  var btn = document.getElementById('pm-submit-btn');
  var lbl = document.getElementById('pm-submit-lbl');
  btn.disabled = true;
  lbl.textContent = _pmEditFilename ? 'Saving changes...' : 'Submitting...';

  var fd = new FormData(form);
  var url = '/preventive-wor-submit';
  if (_pmEditFilename && _isWorAdmin) {
    url = '/preventive-wor-edit';
    fd.append('edit_filename', _pmEditFilename);
  }
  fetch(url, {method: 'POST', body: fd})
    .then(function(r){ return r.json(); })
    .then(function(d){
      btn.disabled = false;
      lbl.textContent = '';
      if (d.error) { lbl.textContent = 'Error: ' + d.error; return; }
      lbl.textContent = _pmEditFilename ? 'Work order updated!' : 'Work order submitted!';
      _pmCurrentFilename = d.filename;
      _pmEditFilename = '';
      _pmCurrentManual = d.manual || null;
      pmUpdatePreviewManualControls();
      var editBanner = document.getElementById('pm-edit-banner');
      if (editBanner) editBanner.classList.add('hidden');
      var sec = document.getElementById('pm-preview-section');
      sec.classList.remove('hidden');
      document.getElementById('pm-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(d.filename) + '&t=' + Date.now();
      var dl = document.getElementById('pm-download-link');
      dl.href = '/wor-download?file=' + encodeURIComponent(d.filename);
      dl.download = d.filename;
      sec.scrollIntoView({behavior: 'smooth', block: 'start'});
    })
    .catch(function(err){
      btn.disabled = false;
      lbl.textContent = 'Error: ' + err.message;
    });
}

function pmClearForm() {
  var form = document.getElementById('pm-form');
  form.reset();
  form.elements['date_of_request'].value = new Date().toISOString().slice(0,10);
  var ssoName = '{{ user_name }}';
  var ssoEmail = '{{ user_email }}';
  if (ssoName && ssoName !== 'User') form.elements['requestor_name'].value = ssoName;
  if (ssoEmail) form.elements['email'].value = ssoEmail;
  document.getElementById('pm-preview-section').classList.add('hidden');
  document.getElementById('pm-preview-frame').src = 'about:blank';
  document.getElementById('pm-submit-lbl').textContent = '';
  _pmCurrentFilename = '';
  _pmEditFilename = '';
  _pmCurrentManual = null;
  _pmManualSuggest = null;
  pmDetachManual();
  pmDismissSuggestedManual();
  pmUpdatePreviewManualControls();
  var editBanner = document.getElementById('pm-edit-banner');
  if (editBanner) editBanner.classList.add('hidden');
  var btnText = document.getElementById('pm-submit-btn-text');
  if (btnText) btnText.textContent = 'Submit Work Order';
  var container = document.getElementById('pm-parts-rows');
  var rows = container.querySelectorAll('.pm-part-row');
  for (var i = 1; i < rows.length; i++) rows[i].remove();
  var firstInputs = rows[0].querySelectorAll('input');
  for (var j = 0; j < firstInputs.length; j++) firstInputs[j].value = '';
  window.scrollTo({top: 0, behavior: 'smooth'});
}

function _pmGetViewerUrl(callback) {
  if (!_pmCurrentFilename) return;
  fetch('/wor-viewer-url?file=' + encodeURIComponent(_pmCurrentFilename))
    .then(function(r) { return r.json(); })
    .then(function(d) { if (d.url) callback(d.url); });
}

function pmOpenViewer() {
  _pmGetViewerUrl(function(url) { window.open(url, '_blank'); });
}

// ----- Device manual (DVI-452) -----
function pmShowManualAttached(name, openUrl) {
  document.getElementById('pm-manual-attached-name').textContent = name || '';
  document.getElementById('pm-manual-attached').classList.remove('hidden');
  document.getElementById('pm-manual-none').classList.add('hidden');
  var link = document.getElementById('pm-manual-open-link');
  if (openUrl) { link.href = openUrl; link.classList.remove('hidden'); }
  else { link.href = '#'; link.classList.add('hidden'); }
}

function pmManualFileChosen() {
  var f = document.getElementById('pm-manual-file').files[0];
  if (!f) return;
  // A freshly chosen file replaces any reused/existing manual reference.
  document.getElementById('pm-manual-existing').value = '';
  document.getElementById('pm-manual-existing-name').value = '';
  pmDismissSuggestedManual();
  pmShowManualAttached(f.name, '');  // no URL until it is uploaded on submit
}

function pmSetExistingManual(filename, name, url) {
  document.getElementById('pm-manual-file').value = '';
  document.getElementById('pm-manual-existing').value = filename || '';
  document.getElementById('pm-manual-existing-name').value = name || '';
  pmShowManualAttached(name || filename, url || '');
}

// Attach Manual button: open the shared Equipment Manual picker (DVI-462).
// A matching manual is still auto-suggested on equipment blur; this lets the
// user browse all manuals or upload a new one when there is no match.
function pmOpenManualPicker() {
  var eqField = document.querySelector('#pm-form [name="equipment"]');
  manualPickerOpen(function(choice) {
    if (choice.mode === 'existing') {
      pmSetExistingManual(choice.filename, choice.original_name, choice.url);
      pmDismissSuggestedManual();
      manualPickerClose();
      return;
    }
    // Upload mode: stage the file into the form so it uploads with the WO.
    try {
      var dt = new DataTransfer();
      dt.items.add(choice.file);
      document.getElementById('pm-manual-file').files = dt.files;
    } catch (e) {
      manualPickerSetStatus('This browser cannot stage the file. Use the Equipment Manuals tool to upload first.', 'error');
      return;
    }
    if (eqField && choice.equipment && !eqField.value.trim()) eqField.value = choice.equipment;
    pmManualFileChosen();
    manualPickerClose();
  });
}

function pmDetachManual() {
  document.getElementById('pm-manual-file').value = '';
  document.getElementById('pm-manual-existing').value = '';
  document.getElementById('pm-manual-existing-name').value = '';
  document.getElementById('pm-manual-attached').classList.add('hidden');
  document.getElementById('pm-manual-none').classList.remove('hidden');
  var link = document.getElementById('pm-manual-open-link');
  link.href = '#'; link.classList.add('hidden');
}

function pmAcceptSuggestedManual() {
  if (!_pmManualSuggest) return;
  pmSetExistingManual(_pmManualSuggest.filename, _pmManualSuggest.original_name, _pmManualSuggest.url);
  pmDismissSuggestedManual();
}

function pmDismissSuggestedManual() {
  document.getElementById('pm-manual-suggest').classList.add('hidden');
}

function pmManualAttached() {
  // True when a new file is staged OR an existing manual is referenced.
  var f = document.getElementById('pm-manual-file');
  return (f && f.files && f.files.length > 0) ||
         !!document.getElementById('pm-manual-existing').value;
}

function pmManualLookup() {
  var eq = document.querySelector('#pm-form [name="equipment"]').value.trim();
  if (!eq) { pmDismissSuggestedManual(); return; }
  // Don't nag if the user already attached/selected a manual for this WO.
  if (pmManualAttached()) return;
  fetch('/pm-manual-lookup?equipment=' + encodeURIComponent(eq))
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (!d.found || pmManualAttached()) { pmDismissSuggestedManual(); return; }
      _pmManualSuggest = d;
      document.getElementById('pm-manual-suggest-name').textContent = d.original_name || d.filename;
      document.getElementById('pm-manual-suggest').classList.remove('hidden');
    })
    .catch(function(){ /* lookup is best-effort */ });
}

// ----- Equipment/Asset/System type-ahead (DVI-590) -----
// The EAS field is a free-text combobox: it suggests names already known to the
// system (past PM work orders + manual associations) but still accepts any typed
// value, which becomes a new known entry once the work order is submitted.
function pmEasOnFocus() {
  pmEasFetchOptions(pmEasRenderDropdown);
}

function pmEasOnInput() {
  pmEasRenderDropdown();
}

function pmEasFetchOptions(cb) {
  fetch('/pm-eas-options')
    .then(function(r){ return r.json(); })
    .then(function(d){ _pmEasOptions = (d && d.options) || []; if (cb) cb(); })
    .catch(function(){ if (cb) cb(); });
}

function pmEasRenderDropdown() {
  var inp = document.getElementById('pm-eas-input');
  var dd = document.getElementById('pm-eas-dropdown');
  if (!inp || !dd) return;
  var q = inp.value.trim().toLowerCase();
  dd.innerHTML = '';

  // "New Entry" is always first; clicking it just returns focus to the field.
  var neu = document.createElement('div');
  neu.className = 'px-3 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 text-togen-teal font-medium border-b border-gray-100 dark:border-gray-700';
  neu.textContent = '+ New Entry';
  neu.addEventListener('mousedown', function(e){ e.preventDefault(); pmEasHideDropdown(); inp.focus(); });
  dd.appendChild(neu);

  var matched = _pmEasOptions.filter(function(o){ return !q || o.toLowerCase().indexOf(q) !== -1; });
  if (matched.length === 0) {
    var none = document.createElement('div');
    none.className = 'px-3 py-2 text-gray-400 dark:text-gray-500 text-xs';
    none.textContent = q ? 'No matching entries — keep typing to create a new one' : 'No saved entries yet';
    dd.appendChild(none);
  } else {
    matched.forEach(function(o){
      var item = document.createElement('div');
      item.className = 'px-3 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200';
      item.textContent = o;
      // mousedown (not click) so it fires before the input's blur handler.
      item.addEventListener('mousedown', function(e){ e.preventDefault(); pmEasSelect(o); });
      dd.appendChild(item);
    });
  }
  dd.classList.remove('hidden');
  inp.setAttribute('aria-expanded', 'true');
}

function pmEasSelect(value) {
  var inp = document.getElementById('pm-eas-input');
  if (!inp) return;
  inp.value = value;
  pmEasHideDropdown();
  if (typeof pmManualLookup === 'function') pmManualLookup();
}

function pmEasHideDropdown() {
  var inp = document.getElementById('pm-eas-input');
  var dd = document.getElementById('pm-eas-dropdown');
  if (dd) dd.classList.add('hidden');
  if (inp) inp.setAttribute('aria-expanded', 'false');
}

// Reflect a submitted/previewed WO's manual in the preview pane controls.
function pmUpdatePreviewManualControls() {
  var hasManual = !!(_pmCurrentManual && _pmCurrentManual.url);
  var btn = document.getElementById('pm-open-manual-btn');
  var entry = document.getElementById('pm-manual-qr-entry');
  if (btn) btn.classList.toggle('hidden', !hasManual);
  if (entry) entry.classList.toggle('hidden', !hasManual);
}

function pmOpenManual() {
  if (_pmCurrentManual && _pmCurrentManual.url) window.open(_pmCurrentManual.url, '_blank');
}

function pmToggleWorkflowMenu(e) {
  e.stopPropagation();
  var menu = document.getElementById('pm-workflow-menu');
  menu.classList.toggle('hidden');
  if (!menu.classList.contains('hidden')) {
    setTimeout(function(){ document.addEventListener('click', _pmCloseWorkflowMenu, {once: true}); }, 0);
  }
}
function _pmCloseWorkflowMenu() {
  var m = document.getElementById('pm-workflow-menu');
  if (m) m.classList.add('hidden');
}
function pmOpenInQrCreator() {
  _pmCloseWorkflowMenu();
  _pmGetViewerUrl(function(url){ sqrOpenWithUrl(url); });
}
function pmOpenManualInQrCreator() {
  _pmCloseWorkflowMenu();
  if (_pmCurrentManual && _pmCurrentManual.url) sqrOpenWithUrl(_pmCurrentManual.url);
}

function pmToggleHistory() {
  var sec = document.getElementById('pm-history-section');
  if (sec.classList.contains('hidden')) {
    sec.classList.remove('hidden');
    pmLoadHistory();
  } else {
    sec.classList.add('hidden');
  }
}

function pmLoadHistory() {
  var container = document.getElementById('pm-history-list');
  container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>';
  fetch('/wor-history?type=preventive')
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (!d.files || d.files.length === 0) {
        container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No new or in-progress work orders.</p>';
        return;
      }
      var html = '';
      d.files.forEach(function(f){
        var badge = f.status === 'in_progress'
          ? '<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400">In Progress</span>'
          : '<span class="inline-block px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">New</span>';
        html += '<div class="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 border border-gray-100 dark:border-gray-700">' +
          '<div class="flex-1 min-w-0">' +
            '<p class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate" title="' + esc(f.name) + '">' + esc(f.title || f.name) + ' ' + badge + '</p>' +
            '<p class="text-xs text-gray-500 dark:text-gray-400">' + fmtLocalDate(f.date) + '</p>' +
          '</div>' +
          '<div class="flex items-center gap-2 ml-3">' +
            '<button onclick="pmPreviewFile(\'' + esc(f.name).replace(/'/g, "\\'") + '\')" class="px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Preview</button>' +
            '<a href="/wor-download?file=' + encodeURIComponent(f.name) + '" download class="px-3 py-1 text-xs font-medium rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors">Download</a>' +
          '</div>' +
        '</div>';
      });
      container.innerHTML = html;
    })
    .catch(function(err){
      container.innerHTML = '<p class="text-sm text-red-500">Error loading history: ' + esc(err.message) + '</p>';
    });
}

function pmPreviewFile(filename) {
  _pmCurrentFilename = filename;
  var sec = document.getElementById('pm-preview-section');
  sec.classList.remove('hidden');
  document.getElementById('pm-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(filename);
  var dl = document.getElementById('pm-download-link');
  dl.href = '/wor-download?file=' + encodeURIComponent(filename);
  dl.download = filename;
  _pmCurrentManual = null;
  pmUpdatePreviewManualControls();
  fetch('/wor-manual-info?file=' + encodeURIComponent(filename))
    .then(function(r){ return r.json(); })
    .then(function(d){ if (d.found) { _pmCurrentManual = d; pmUpdatePreviewManualControls(); } })
    .catch(function(){});
  sec.scrollIntoView({behavior: 'smooth', block: 'start'});
}

// ===== WORK ORDER ADMIN FUNCTIONS =====
// Repair Parts Manager access mirrors the rpm_required backend gate (DVI-604):
// Togen Admins always; Work Order Admins only when the tool is enabled.
var _rpmAccess = _isToGenAdmin || (_isWorAdmin && _toolVisibility['repair-parts-manager'] === true);
var _woradminData = [];
var _woradminCurrentFilename = '';
var _woradminCurrentType = '';
var _woradminManual = null;
var _woradminTab = 'all';
var _woradminActivity = null;
var _woradminActivityCollapsed = false;
var _woradminActivityDefaultApplied = false;

function woradminLoadList() {
  var container = document.getElementById('woradmin-list');
  container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>';
  fetch('/wor-admin-list')
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { container.innerHTML = '<p class="text-sm text-red-500">Error: ' + esc(d.error) + '</p>'; return; }
      _woradminData = d.files || [];
      woradminPopulateUserFilter();
      woradminApplyFilter();
    })
    .catch(function(err){
      container.innerHTML = '<p class="text-sm text-red-500">Error loading list: ' + esc(err.message) + '</p>';
    });
}

// Order/hide the WO Admin type tabs per Togen Admin settings (DVI-349).
function woradminRenderTabs() {
  var recurring = document.getElementById('woradmin-tab-recurring');
  if (!recurring) return;
  var container = recurring.parentNode;
  var order = (_worTypeOrder && _worTypeOrder.length) ? _worTypeOrder : ['maintenance', 'mobilemachine', 'safety', 'preventive'];
  order.forEach(function(type) {
    var tab = document.getElementById('woradmin-tab-' + type);
    if (!tab) return;
    var visible = _isToGenAdmin || _worTypeVisibility[type] !== false;
    tab.classList.toggle('hidden', !visible);
    container.insertBefore(tab, recurring);
    // If the active tab was just hidden, fall back to "All Work Orders".
    if (!visible && _woradminTab === type) woradminSetTab('all');
  });
}

function woradminSetTab(tab) {
  _woradminTab = tab;
  ['all','maintenance','mobilemachine','safety','preventive','recurring','calendar','settings'].forEach(function(t){
    var el = document.getElementById('woradmin-tab-' + t);
    if (!el) return;
    if (t === tab) {
      el.classList.add('border-togen-teal', 'text-togen-teal');
      el.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
    } else {
      el.classList.remove('border-togen-teal', 'text-togen-teal');
      el.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400');
    }
  });
  var listView = document.getElementById('woradmin-listview');
  var settingsView = document.getElementById('woradmin-settingsview');
  var calendarView = document.getElementById('woradmin-calendarview');
  if (tab === 'settings') {
    if (listView) listView.classList.add('hidden');
    if (calendarView) calendarView.classList.add('hidden');
    if (settingsView) settingsView.classList.remove('hidden');
    notifyBuildGridRows('woradmin-notify-grid');
    woradminLoadPolicy();
    woradminLoadPrimary();
    return;
  }
  if (tab === 'calendar') {
    if (listView) listView.classList.add('hidden');
    if (settingsView) settingsView.classList.add('hidden');
    if (calendarView) calendarView.classList.remove('hidden');
    woracalInit();
    return;
  }
  if (listView) listView.classList.remove('hidden');
  if (settingsView) settingsView.classList.add('hidden');
  if (calendarView) calendarView.classList.add('hidden');
  woradminApplyFilter();
}

function woradminSaveNotifications() {
  notifySaveGrid('woradmin-notify-grid', 'woradmin-notify-save-btn', 'woradmin-notify-save-status');
}

// ===== POLICY ENFORCED NOTIFICATIONS (DVI-597) =====
// Company-wide grid: when a (WO type x event) cell is enabled, the requesting
// user of each work order of that type is always notified for that event,
// regardless of opt-in. Stored separately from the per-user notifications grid.
var _woradminPolicy = {};

function woradminLoadPolicy() {
  fetch('/wor-policy')
    .then(function(r){ return r.json(); })
    .then(function(d){ _woradminPolicy = (d && d.policy) || {}; woradminBuildPolicyGrid(); })
    .catch(function(){});
}

function woradminBuildPolicyGrid() {
  var tbody = document.getElementById('woradmin-policy-grid');
  if (!tbody) return;
  tbody.innerHTML = '';
  _effectiveNotifyTypes().forEach(function(t) {
    var prefs = _woradminPolicy[t.id] || {};
    var tr = document.createElement('tr');
    tr.className = 'border-t border-gray-100 dark:border-gray-800';
    var cells = '<td class="text-left py-3 pr-4 font-medium text-gray-800 dark:text-gray-100">' + t.label + '</td>';
    _usNotifyCols.forEach(function(col) {
      var checked = prefs[col] ? ' checked' : '';
      cells += '<td class="text-center px-2 py-3">' +
        '<input type="checkbox" data-policy-type="' + t.id + '" data-policy-col="' + col + '"' + checked +
        ' class="h-5 w-5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer" /></td>';
    });
    tr.innerHTML = cells;
    tbody.appendChild(tr);
  });
}

function woradminSavePolicy() {
  var btn = document.getElementById('woradmin-policy-save-btn');
  var status = document.getElementById('woradmin-policy-save-status');
  var grid = {};
  var checks = document.querySelectorAll('#woradmin-policy-grid input[type="checkbox"]');
  for (var i = 0; i < checks.length; i++) {
    var type = checks[i].getAttribute('data-policy-type');
    var col = checks[i].getAttribute('data-policy-col');
    if (!grid[type]) grid[type] = {};
    grid[type][col] = checks[i].checked;
  }
  if (btn) { btn.disabled = true; btn.classList.add('opacity-50', 'cursor-not-allowed'); }
  if (status) { status.textContent = 'Saving...'; status.className = 'text-sm text-gray-500 dark:text-gray-400'; }
  fetch('/wor-policy', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({policy: grid})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (btn) { btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed'); }
      if (d.error) {
        if (status) { status.textContent = 'Error: ' + d.error; status.className = 'text-sm text-red-500'; }
        return;
      }
      _woradminPolicy = d.policy || {};
      woradminBuildPolicyGrid();
      if (status) {
        status.textContent = 'Saved';
        status.className = 'text-sm text-green-600 dark:text-green-400';
        setTimeout(function(){ if (status) status.textContent = ''; }, 3000);
      }
    })
    .catch(function(e){
      if (btn) { btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed'); }
      if (status) { status.textContent = 'Error: ' + e.message; status.className = 'text-sm text-red-500'; }
    });
}

// ===== PRIMARY RECIPIENTS (DVI-405) =====
var _woradminPrimary = {};

function woradminLoadPrimary() {
  fetch('/wor-primary')
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d && d.primary) { _woradminPrimary = d.primary; woradminRenderPrimary(); }
    })
    .catch(function(){});
}

function woradminRenderPrimary() {
  var container = document.getElementById('woradmin-primary-lists');
  if (!container) return;
  container.innerHTML = '';
  _effectiveNotifyTypes().forEach(function(t) {
    var emails = _woradminPrimary[t.id] || [];
    var block = document.createElement('div');
    var chips = emails.length
      ? emails.map(function(em) {
          return '<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200">' +
            woradminEscapeHtml(em) +
            '<button type="button" onclick="woradminPrimaryRemove(\'' + t.id + '\',\'' + woradminEscapeAttr(em) + '\')" title="Remove" class="text-gray-400 hover:text-red-500 cursor-pointer">&times;</button>' +
            '</span>';
        }).join('')
      : '<span class="text-xs text-gray-400 dark:text-gray-500 italic">No primary recipients.</span>';
    block.innerHTML =
      '<span class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">' + t.label + '</span>' +
      '<div class="flex items-center gap-2">' +
        '<input type="email" id="woradmin-primary-input-' + t.id + '" placeholder="email@example.com" ' +
          'onkeydown="if(event.key===\'Enter\'){event.preventDefault();woradminPrimaryAdd(\'' + t.id + '\');}" ' +
          'class="flex-1 px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 focus:outline-none focus:ring-1 focus:ring-togen-teal">' +
        '<button type="button" onclick="woradminPrimaryAdd(\'' + t.id + '\')" ' +
          'class="px-2.5 py-1 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 cursor-pointer">+ Add</button>' +
      '</div>' +
      '<div class="mt-2 flex flex-wrap gap-1.5">' + chips + '</div>';
    container.appendChild(block);
  });
}

function woradminEscapeHtml(s) {
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function woradminEscapeAttr(s) {
  return String(s).replace(/\\/g,'\\\\').replace(/'/g,"\\'");
}

function woradminPrimaryAdd(worType) {
  var input = document.getElementById('woradmin-primary-input-' + worType);
  if (!input) return;
  var email = (input.value || '').trim().toLowerCase();
  if (!email || email.indexOf('@') === -1) { alert('Enter a valid email address.'); return; }
  woradminPrimaryMutate(worType, 'add', email);
}

function woradminPrimaryRemove(worType, email) {
  woradminPrimaryMutate(worType, 'remove', email);
}

function woradminPrimaryMutate(worType, action, email) {
  var status = document.getElementById('woradmin-primary-status');
  if (status) { status.textContent = 'Saving…'; status.className = 'block mt-3 text-sm text-gray-500 dark:text-gray-400'; }
  fetch('/wor-primary', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({wor_type: worType, action: action, email: email})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) {
        if (status) { status.textContent = 'Error: ' + d.error; status.className = 'block mt-3 text-sm text-red-500'; }
        return;
      }
      _woradminPrimary = d.primary;
      woradminRenderPrimary();
      if (status) {
        status.textContent = action === 'add' ? 'Added' : 'Removed';
        status.className = 'block mt-3 text-sm text-green-600 dark:text-green-400';
        setTimeout(function(){ if (status) status.textContent = ''; }, 2500);
      }
    })
    .catch(function(e){
      if (status) { status.textContent = 'Error: ' + e.message; status.className = 'block mt-3 text-sm text-red-500'; }
    });
}

// ===== List filtering: submitting user + date range (DVI-457) =====
function woradminToggleFilterMenu(event) {
  if (event) event.stopPropagation();
  var menu = document.getElementById('woradmin-filter-menu');
  if (!menu) return;
  var willOpen = menu.classList.contains('hidden');
  menu.classList.toggle('hidden');
  if (willOpen) {
    setTimeout(function(){ document.addEventListener('click', woradminFilterMenuOutside); }, 0);
  } else {
    document.removeEventListener('click', woradminFilterMenuOutside);
  }
}

function woradminFilterMenuOutside(e) {
  var menu = document.getElementById('woradmin-filter-menu');
  var btn = document.getElementById('woradmin-filter-btn');
  if (!menu || menu.classList.contains('hidden')) return;
  if (menu.contains(e.target) || (btn && btn.contains(e.target))) return;
  menu.classList.add('hidden');
  document.removeEventListener('click', woradminFilterMenuOutside);
}

// Populate the "By Submitting User" dropdown from the loaded data, preserving
// the current selection when possible.
function woradminPopulateUserFilter() {
  var sel = document.getElementById('woradmin-filter-user');
  if (!sel) return;
  var prev = sel.value;
  var byEmail = {};
  _woradminData.forEach(function(f){
    var email = (f.submitter_email || '').trim();
    if (!email) return;
    if (!byEmail[email]) byEmail[email] = (f.submitter_name || '').trim();
  });
  var emails = Object.keys(byEmail).sort(function(a, b){
    var la = (byEmail[a] || a).toLowerCase(), lb = (byEmail[b] || b).toLowerCase();
    return la < lb ? -1 : la > lb ? 1 : 0;
  });
  var html = '<option value="">All users</option>';
  emails.forEach(function(email){
    var label = byEmail[email] ? byEmail[email] + ' (' + email + ')' : email;
    html += '<option value="' + esc(email).replace(/"/g, '&quot;') + '">' + esc(label) + '</option>';
  });
  sel.innerHTML = html;
  // Restore prior selection if it still exists.
  if (prev && byEmail[prev]) sel.value = prev;
}

function woradminClearFilters() {
  var sel = document.getElementById('woradmin-filter-user');
  var from = document.getElementById('woradmin-filter-date-from');
  var to = document.getElementById('woradmin-filter-date-to');
  if (sel) sel.value = '';
  if (from) from.value = '';
  if (to) to.value = '';
  woradminApplyFilter();
}

function woradminUpdateFilterCount() {
  var badge = document.getElementById('woradmin-filter-count');
  if (!badge) return;
  var sel = document.getElementById('woradmin-filter-user');
  var from = document.getElementById('woradmin-filter-date-from');
  var to = document.getElementById('woradmin-filter-date-to');
  var n = 0;
  if (sel && sel.value) n++;
  if ((from && from.value) || (to && to.value)) n++;
  if (n > 0) { badge.textContent = String(n); badge.classList.remove('hidden'); }
  else { badge.classList.add('hidden'); }
}

function woradminApplyFilter() {
  var showNew = document.getElementById('woradmin-filter-new').checked;
  var showInProgress = document.getElementById('woradmin-filter-inprogress').checked;
  var showCompleted = document.getElementById('woradmin-filter-completed').checked;
  var showCancelled = document.getElementById('woradmin-filter-cancelled').checked;
  var showRecurring = document.getElementById('woradmin-filter-recurring').checked;

  var userSel = document.getElementById('woradmin-filter-user');
  var userEmail = userSel ? userSel.value : '';
  var fromEl = document.getElementById('woradmin-filter-date-from');
  var toEl = document.getElementById('woradmin-filter-date-to');
  var fromDate = fromEl && fromEl.value ? fromEl.value : '';
  var toDate = toEl && toEl.value ? toEl.value : '';
  woradminUpdateFilterCount();

  var filtered = _woradminData.filter(function(f){
    // Submitting-user filter (applies across all tabs).
    if (userEmail && (f.submitter_email || '') !== userEmail) return false;
    // Date-range filter on the submission date (compare the YYYY-MM-DD part).
    if (fromDate || toDate) {
      var day = (f.date || '').slice(0, 10);
      if (!day) return false;
      if (fromDate && day < fromDate) return false;
      if (toDate && day > toDate) return false;
    }
    if (_woradminTab === 'recurring') { return !!f.recurring; }
    if (_woradminTab !== 'all' && f.wor_type !== _woradminTab) return false;
    if (showRecurring && f.recurring) return true;
    if (f.status === 'new' && showNew) return true;
    if (f.status === 'in_progress' && showInProgress) return true;
    if (f.status === 'completed' && showCompleted) return true;
    if (f.status === 'cancelled' && showCancelled) return true;
    return false;
  });

  var container = document.getElementById('woradmin-list');
  if (filtered.length === 0) {
    var _savedPreviewEmpty = document.getElementById('woradmin-preview-section');
    container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No work orders match the selected filters.</p>';
    woradminPlacePreview(_savedPreviewEmpty);
    return;
  }

  var html = '';
  filtered.forEach(function(f){
    var badgeClass = '';
    var badgeLabel = '';
    if (f.status === 'new') { badgeClass = 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'; badgeLabel = 'New'; }
    else if (f.status === 'in_progress') { badgeClass = 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300'; badgeLabel = 'In Progress'; }
    else if (f.status === 'cancelled') { badgeClass = 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'; badgeLabel = 'Cancelled'; }
    else { badgeClass = 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'; badgeLabel = 'Completed'; }

    var nm = esc(f.name).replace(/'/g, "\\'");
    var cancelBtn =
      '<button onclick="event.stopPropagation();woradminSetStatus(\'' + nm + '\',\'cancelled\')" class="px-2 py-1 text-xs font-medium rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-700 text-red-700 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors">Cancel</button>';

    var statusBtns = '';
    if (f.status === 'new') {
      statusBtns =
        '<button onclick="event.stopPropagation();woradminSetStatus(\'' + nm + '\',\'in_progress\')" class="px-2 py-1 text-xs font-medium rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-700 text-yellow-700 dark:text-yellow-400 hover:bg-yellow-100 dark:hover:bg-yellow-900/40 transition-colors">Mark In Progress</button>' +
        '<button onclick="event.stopPropagation();woradminSetStatus(\'' + nm + '\',\'completed\')" class="px-2 py-1 text-xs font-medium rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-300 dark:border-green-700 text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/40 transition-colors">Mark Complete</button>' +
        cancelBtn;
    } else if (f.status === 'in_progress') {
      statusBtns =
        '<button onclick="event.stopPropagation();woradminSetStatus(\'' + nm + '\',\'completed\')" class="px-2 py-1 text-xs font-medium rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-300 dark:border-green-700 text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/40 transition-colors">Mark Complete</button>' +
        '<button onclick="event.stopPropagation();woradminSetStatus(\'' + nm + '\',\'new\')" class="px-2 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">Back to New</button>' +
        cancelBtn;
    } else if (f.status === 'cancelled') {
      statusBtns =
        '<button onclick="event.stopPropagation();woradminSetStatus(\'' + nm + '\',\'new\')" class="px-2 py-1 text-xs font-medium rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors">Reopen</button>';
    } else {
      statusBtns =
        '<button onclick="event.stopPropagation();woradminSetStatus(\'' + nm + '\',\'in_progress\')" class="px-2 py-1 text-xs font-medium rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-700 text-yellow-700 dark:text-yellow-400 hover:bg-yellow-100 dark:hover:bg-yellow-900/40 transition-colors">Reopen</button>';
    }

    // Recurring button: show "Make Recurring" or "Remove Recurring"
    if (f.recurring) {
      statusBtns += '<button onclick="event.stopPropagation();woradminRemoveRecurring(\'' + esc(f.name).replace(/'/g, "\\'") + '\')" class="px-2 py-1 text-xs font-medium rounded-lg border border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">Remove Recurring</button>';
    } else {
      statusBtns += '<button onclick="event.stopPropagation();recurringModalOpen(\'' + esc(f.name).replace(/'/g, "\\'") + '\')" class="px-2 py-1 text-xs font-medium rounded-lg border border-indigo-300 dark:border-indigo-600 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors">Make Recurring</button>';
    }

    var typeBadge = f.wor_type === 'mobilemachine'
      ? '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">Mobile Machine</span>'
      : f.wor_type === 'safety'
      ? '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300">Safety</span>'
      : f.wor_type === 'preventive'
      ? '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300">Preventative Maintenance</span>'
      : '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300">Maintenance</span>';

    var recurringInfo = '';
    if (f.recurring) {
      recurringInfo = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300">' +
        'Every ' + f.recurring.frequency_days + 'd' +
        (f.recurring.next_run ? ' &middot; Next: ' + f.recurring.next_run : '') +
        '</span>';
    }

    var notifyIcon = '';
    if (f.notify && f.notify.enabled) {
      notifyIcon = '<button type="button" title="Notifications requested — click to disable" ' +
        'onclick="event.stopPropagation();woradminToggleNotify(\'' + nm + '\')" ' +
        'class="inline-flex items-center text-togen-teal hover:text-red-600 transition-colors">' +
        '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">' +
        '<path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a2 2 0 01-1.995-1.85L8 16h4a2 2 0 01-1.85 1.995L10 18z"/>' +
        '</svg></button>';
    }

    // Parts icon (DVI-604): Mobile Machine / Preventative Maintenance rows only,
    // for users with RPM access. Active (teal + count) when the WO has parts,
    // muted otherwise; click opens the per-WO Parts dialog.
    var partsIcon = '';
    if (_rpmAccess && (f.wor_type === 'mobilemachine' || f.wor_type === 'preventive')) {
      var pc = f.parts_count || 0;
      var pcCls = pc > 0
        ? 'text-togen-teal hover:text-togen-teal-dark'
        : 'text-gray-400 dark:text-gray-500 hover:text-togen-teal';
      var pcTitle = pc > 0 ? ('Parts (' + pc + ') — click to manage') : 'No parts yet — click to add';
      partsIcon = '<button type="button" title="' + pcTitle + '" ' +
        'onclick="event.stopPropagation();woradminOpenParts(\'' + nm + '\',\'' + esc(f.wor_type) + '\')" ' +
        'class="inline-flex items-center gap-0.5 ' + pcCls + ' transition-colors">' +
        '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.7">' +
        '<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>' +
        '</svg>' +
        (pc > 0 ? '<span class="text-[11px] font-semibold leading-none">' + pc + '</span>' : '') +
        '</button>';
    }

    html += '<div data-wo-name="' + esc(f.name).replace(/"/g, '&quot;') + '" class="py-3 px-4 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 border border-gray-100 dark:border-gray-700 cursor-pointer transition-colors" onclick="woradminPreview(\'' + esc(f.name).replace(/'/g, "\\'") + '\',\'' + esc(f.wor_type || 'maintenance') + '\')">' +
      '<p class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate" title="' + esc(f.name) + '">' + esc(f.title || f.name) + '</p>' +
      '<div class="flex items-center gap-2 mt-1 flex-wrap">' +
        '<div class="flex items-center gap-2 flex-wrap min-w-0">' +
          '<p class="text-xs text-gray-500 dark:text-gray-400">' + fmtLocalDate(f.date) + '</p>' +
          '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ' + badgeClass + '">' + badgeLabel + '</span>' +
          typeBadge +
          recurringInfo +
          notifyIcon +
          partsIcon +
        '</div>' +
        '<div class="flex items-center gap-2 flex-wrap w-full justify-start sm:w-auto sm:ml-auto sm:justify-end flex-shrink-0">' +
          statusBtns +
        '</div>' +
      '</div>' +
    '</div>';
  });
  // The inline preview pane (DVI-746) may currently be a child of this container;
  // capture it before innerHTML wipes it so it can be re-parented afterwards.
  var _savedPreview = document.getElementById('woradmin-preview-section');
  container.innerHTML = html;
  woradminPlacePreview(_savedPreview);
  woradminRenderActivityChips();
}

// Position the shared preview pane (DVI-746). When a work order is selected and
// the pane is open, it is inserted directly beneath that row so the rows below
// shift down. Otherwise it is parked (hidden) after the list section. The node
// is preserved across list re-renders by passing the reference captured before
// the container's innerHTML is replaced.
function woradminPlacePreview(sec) {
  sec = sec || document.getElementById('woradmin-preview-section');
  if (!sec) return;
  var listContainer = document.getElementById('woradmin-list');
  var open = _woradminCurrentFilename && !sec.classList.contains('hidden');
  if (open && listContainer) {
    var rows = listContainer.querySelectorAll('[data-wo-name]');
    for (var i = 0; i < rows.length; i++) {
      if (rows[i].getAttribute('data-wo-name') === _woradminCurrentFilename) {
        rows[i].insertAdjacentElement('afterend', sec);
        return;
      }
    }
  }
  // Selected row not visible (or pane closed): park after the list section.
  var anchor = document.getElementById('woradmin-list-section');
  if (anchor && anchor.parentElement) anchor.insertAdjacentElement('afterend', sec);
}

function woradminClosePreview() {
  var sec = document.getElementById('woradmin-preview-section');
  if (sec) sec.classList.add('hidden');
  _woradminCurrentFilename = '';
  woradminPlacePreview(sec);
}

// ===== WO Admin per-WO Parts dialog (RPM Phase 3, DVI-604) =====
// Lists the repair parts on a single Mobile Machine / Preventative Maintenance
// work order. Add/Edit reuse the shared Phase 2 form dialog (rpm-part-overlay);
// flag/remove hit the Phase 1 endpoints. Reload refreshes both the dialog and
// the row's Parts-icon count.
var _woradminPartsWO = null; // {name, type, title, parts}

function woradminOpenParts(name, type) {
  _woradminPartsWO = {name: name, type: type, title: name, parts: []};
  document.getElementById('woradmin-parts-wo').textContent = name;
  document.getElementById('woradmin-parts-overlay').classList.remove('hidden');
  woradminPartsLoad();
}

function woradminPartsClose() {
  document.getElementById('woradmin-parts-overlay').classList.add('hidden');
  _woradminPartsWO = null;
}

function woradminPartsLoad() {
  if (!_woradminPartsWO) return;
  var loading = document.getElementById('woradmin-parts-loading');
  var errEl = document.getElementById('woradmin-parts-error');
  var empty = document.getElementById('woradmin-parts-empty');
  var list = document.getElementById('woradmin-parts-list');
  loading.classList.remove('hidden');
  errEl.classList.add('hidden');
  empty.classList.add('hidden');
  list.classList.add('hidden');
  fetch('/rpm/wo-parts?filename=' + encodeURIComponent(_woradminPartsWO.name) +
        '&type=' + encodeURIComponent(_woradminPartsWO.type))
    .then(function(r) { return r.json().then(function(d) { return {ok: r.ok, d: d}; }); })
    .then(function(res) {
      loading.classList.add('hidden');
      if (!res.ok || res.d.error) {
        errEl.textContent = res.d.error || 'Failed to load parts.';
        errEl.classList.remove('hidden');
        return;
      }
      _woradminPartsWO.title = res.d.title || _woradminPartsWO.name;
      _woradminPartsWO.parts = res.d.parts || [];
      document.getElementById('woradmin-parts-wo').textContent =
        (res.d.wo_label ? res.d.wo_label + ' · ' : '') + _woradminPartsWO.title;
      woradminPartsRender();
      woradminSyncPartsCount(_woradminPartsWO.name, _woradminPartsWO.parts.length);
    })
    .catch(function() {
      loading.classList.add('hidden');
      errEl.textContent = 'Network error.';
      errEl.classList.remove('hidden');
    });
}

function woradminPartsRender() {
  var parts = _woradminPartsWO ? _woradminPartsWO.parts : [];
  var empty = document.getElementById('woradmin-parts-empty');
  var list = document.getElementById('woradmin-parts-list');
  if (!parts.length) {
    empty.classList.remove('hidden');
    list.classList.add('hidden');
    list.innerHTML = '';
    return;
  }
  empty.classList.add('hidden');
  list.classList.remove('hidden');
  list.innerHTML = parts.map(function(p, i) { return _woradminPartRow(p, i); }).join('');
}

function _woradminPartRow(p, i) {
  var meta = [];
  if (p.qty) meta.push('Qty ' + esc(p.qty));
  if (p.amount) meta.push('Amt ' + esc(p.amount));
  var numLine = p.number ? '<span class="font-mono text-xs text-gray-500 dark:text-gray-400">' + esc(p.number) + '</span>' : '';
  var btn = function(onclick, label, extra) {
    return '<button onclick="' + onclick + '" class="px-2 py-1 text-xs font-medium rounded-lg ' + extra + ' transition-colors cursor-pointer">' + label + '</button>';
  };
  var approveBtn;
  if (!p.approved && _woradminPartsWO && _woradminPartsWO.can_approve === false) {
    approveBtn = '<button type="button" disabled title="You do not have permission to approve parts." ' +
      'class="px-2 py-1 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 opacity-50 cursor-not-allowed">Mark Approved</button>';
  } else {
    approveBtn = btn('woradminPartsFlag(' + i + ',\'approved\')',
      p.approved ? 'Approved ✓' : 'Mark Approved',
      p.approved ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/50'
                 : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600');
  }
  // Stocked items skip ordering: the Ordered button is disabled (DVI-626).
  var orderBtn;
  if (p.stocked && !p.ordered) {
    orderBtn = '<button type="button" disabled title="Stocked items do not need ordering." ' +
      'class="px-2 py-1 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 opacity-50 cursor-not-allowed">Mark Ordered</button>';
  } else {
    orderBtn = btn('woradminPartsFlag(' + i + ',\'ordered\')',
      p.ordered ? 'Ordered ✓' : 'Mark Ordered',
      p.ordered ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/50'
                : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600');
  }
  var searchBtn = btn('woradminSearchManuals(' + i + ')', 'Manuals',
    'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600');
  var editBtn = btn('woradminPartsEdit(' + i + ')', 'Edit',
    'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600');
  var removeBtn = '<button onclick="woradminPartsRemove(' + i + ')" title="Remove part" ' +
    'class="px-2 py-1 text-xs font-medium rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors cursor-pointer">&minus;</button>';
  return '' +
    '<li class="flex flex-wrap items-center gap-2 px-1 py-2.5">' +
      '<div class="min-w-0 flex-1">' +
        '<div class="text-sm text-gray-800 dark:text-gray-100 truncate">' + esc(p.name || '(unnamed part)') + '</div>' +
        '<div class="flex items-center gap-2 flex-wrap">' + numLine +
          (meta.length ? '<span class="text-xs text-gray-500 dark:text-gray-400">' + meta.join(' · ') + '</span>' : '') +
          _rpmTrackingMeta(p) +
        '</div>' +
      '</div>' +
      '<div class="flex items-center gap-1.5 flex-shrink-0">' +
        removeBtn + searchBtn + editBtn + approveBtn + orderBtn +
      '</div>' +
    '</li>';
}

function woradminPartsFlag(i, field) {
  if (!_woradminPartsWO) return;
  var p = _woradminPartsWO.parts[i]; if (!p) return;
  if (field === 'approved' && !p.approved && _woradminPartsWO.can_approve === false) {
    alert('You do not have permission to approve parts.');
    return;
  }
  if (field === 'ordered' && !p.ordered) {
    rpmTrackingOpen(_woradminPartsWO.name, _woradminPartsWO.type, p.id, p, woradminPartsLoad);
    return;
  }
  var body = {}; body[field] = !p[field];
  _rpmFlagRequest(_woradminPartsWO.name, _woradminPartsWO.type, p.id, body, woradminPartsLoad);
}

function woradminPartsRemove(i) {
  if (!_woradminPartsWO) return;
  var p = _woradminPartsWO.parts[i]; if (!p) return;
  if (!confirm('Remove ' + (p.name || p.number || 'this part') + ' from this work order?')) return;
  fetch('/rpm/part/remove', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({filename: _woradminPartsWO.name, type: _woradminPartsWO.type, id: p.id})})
    .then(function(r) { return r.json(); })
    .then(function(d) { if (d.error) { alert(d.error); return; } woradminPartsLoad(); });
}

function woradminPartsAdd() {
  if (!_woradminPartsWO) return;
  _rpmDlgMode = 'add';
  _rpmDlgFilename = _woradminPartsWO.name;
  _rpmDlgType = _woradminPartsWO.type;
  _rpmDlgPartId = '';
  _rpmPartOnSaved = woradminPartsLoad;
  document.getElementById('rpm-part-title').textContent = 'Add Part';
  document.getElementById('rpm-part-wo').textContent = _woradminPartsWO.title || _woradminPartsWO.name;
  ['number', 'name', 'qty', 'amount'].forEach(function(k) { document.getElementById('rpm-part-' + k).value = ''; });
  _rpmSetTrackingField(false, '');
  _rpmSetStockedField(true);
  _rpmPartShowError('');
  document.getElementById('rpm-part-overlay').classList.remove('hidden');
  setTimeout(function() { document.getElementById('rpm-part-number').focus(); }, 0);
}

function woradminPartsEdit(i) {
  if (!_woradminPartsWO) return;
  var p = _woradminPartsWO.parts[i]; if (!p) return;
  _rpmDlgMode = 'edit';
  _rpmDlgFilename = _woradminPartsWO.name;
  _rpmDlgType = _woradminPartsWO.type;
  _rpmDlgPartId = p.id;
  _rpmPartOnSaved = woradminPartsLoad;
  document.getElementById('rpm-part-title').textContent = 'Edit Part';
  document.getElementById('rpm-part-wo').textContent = _woradminPartsWO.title || _woradminPartsWO.name;
  document.getElementById('rpm-part-number').value = p.number || '';
  document.getElementById('rpm-part-name').value = p.name || '';
  document.getElementById('rpm-part-qty').value = p.qty || '';
  document.getElementById('rpm-part-amount').value = p.amount || '';
  _rpmSetTrackingField(!!p.ordered, p.tracking_number || '');
  _rpmSetStockedField(false);
  _rpmPartShowError('');
  document.getElementById('rpm-part-overlay').classList.remove('hidden');
}

// Keep the in-memory list (and thus the row's Parts icon) in sync after a
// change, then re-render the list so the icon's active state/count updates.
function woradminSyncPartsCount(name, count) {
  var changed = false;
  (_woradminData || []).forEach(function(e) {
    if (e.name === name && e.parts_count !== count) { e.parts_count = count; changed = true; }
  });
  if (changed) woradminApplyFilter();
}

// ===== WO Admin activity header panel (DVI-382) =====
function woradminLoadActivity() {
  fetch('/wor-admin-activity')
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d && !d.error) { _woradminActivity = d; woradminRenderActivity(); }
    })
    .catch(function(){});
}

function woradminToggleActivity() {
  _woradminActivityCollapsed = !_woradminActivityCollapsed;
  woradminApplyActivityCollapse();
}

function woradminApplyActivityCollapse() {
  var body = document.getElementById('woradmin-activity-body');
  var label = document.getElementById('woradmin-activity-toggle-label');
  var chev = document.getElementById('woradmin-activity-chevron');
  if (!body) return;
  body.classList.toggle('hidden', _woradminActivityCollapsed);
  if (label) label.textContent = _woradminActivityCollapsed ? 'Show' : 'Hide';
  if (chev) chev.style.transform = _woradminActivityCollapsed ? 'rotate(-90deg)' : '';
}

function woradminRenderActivity() {
  var panel = document.getElementById('woradmin-activity-panel');
  if (!panel || !_woradminActivity) return;
  panel.classList.remove('hidden');
  woradminRenderActivityChips();
  woradminRenderActivitySpark();
  woradminRenderActivityDueSoon();
  // Default collapse state (applied once): expanded when due-soon alerts exist.
  if (!_woradminActivityDefaultApplied) {
    var due = _woradminActivity.recurring_due_soon || [];
    _woradminActivityCollapsed = due.length === 0;
    _woradminActivityDefaultApplied = true;
  }
  woradminApplyActivityCollapse();
}

function woradminRenderActivityChips() {
  var el = document.getElementById('woradmin-activity-chips');
  if (!el) return;
  // Count by status from the loaded list, scoped to the active type tab so the
  // chips track the current filter; fall back to endpoint totals before load.
  var counts = { new: 0, in_progress: 0, completed: 0, cancelled: 0 };
  if (_woradminData && _woradminData.length) {
    _woradminData.forEach(function(f){
      var scoped = (_woradminTab === 'all' || _woradminTab === 'recurring' || _woradminTab === 'settings'
                    || f.wor_type === _woradminTab);
      if (scoped && counts.hasOwnProperty(f.status)) counts[f.status]++;
    });
  } else if (_woradminActivity && _woradminActivity.status_counts) {
    counts = _woradminActivity.status_counts;
  }
  var defs = [
    { key: 'new', label: 'New', cls: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300' },
    { key: 'in_progress', label: 'In Progress', cls: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' },
    { key: 'completed', label: 'Completed', cls: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300' },
    { key: 'cancelled', label: 'Cancelled', cls: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400' }
  ];
  el.innerHTML = defs.map(function(d){
    return '<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ' + d.cls + '">' +
      d.label + ' <span class="font-bold">' + (counts[d.key] || 0) + '</span></span>';
  }).join('');
}

function woradminRenderActivitySpark() {
  var el = document.getElementById('woradmin-activity-spark');
  if (!el || !_woradminActivity) return;
  var days = (_woradminActivity.submissions_by_day || []).slice(-7);
  var pts = days.map(function(x){ return { time: x.date, value: x.count }; });
  var svg = scadaBuildGraph(pts, null, { mode: 'spark', stroke: '#0d9488' });
  var total = days.reduce(function(a, x){ return a + (x.count || 0); }, 0);
  el.innerHTML = (svg ? '<span style="display:inline-block;width:120px;height:30px;line-height:0">' + svg + '</span>' : '') +
    '<span class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">' + total + ' in 7d</span>';
}

function woradminRenderActivityDueSoon() {
  var el = document.getElementById('woradmin-activity-duesoon');
  if (!el || !_woradminActivity) return;
  var due = _woradminActivity.recurring_due_soon || [];
  if (!due.length) { el.classList.add('hidden'); el.innerHTML = ''; return; }
  el.classList.remove('hidden');
  var items = due.map(function(r){
    var lbl = r.days_until < 0 ? 'Overdue' : (r.days_until === 0 ? 'Today' : 'Tomorrow');
    var nm = esc(r.name).replace(/'/g, "\\'");
    return '<button type="button" onclick="woradminSelectByName(\'' + nm + '\')" ' +
      'class="inline-flex items-center gap-1.5 text-xs text-amber-800 dark:text-amber-200 hover:underline cursor-pointer">' +
      '<span class="font-medium">' + esc(r.name) + '</span>' +
      '<span class="px-1.5 py-0.5 rounded-full bg-amber-200/70 dark:bg-amber-800/50 text-[10px] font-semibold">' + lbl + '</span>' +
      '</button>';
  }).join('<span class="text-amber-400 dark:text-amber-600">&middot;</span>');
  el.innerHTML = '<div class="flex items-start gap-2 rounded-xl border border-amber-300 dark:border-amber-700/60 bg-amber-50 dark:bg-amber-900/20 px-3 py-2">' +
    '<svg class="h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">' +
    '<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>' +
    '</svg>' +
    '<div class="flex items-center gap-2 flex-wrap">' +
      '<span class="text-xs font-semibold text-amber-800 dark:text-amber-200">Due soon:</span>' + items +
    '</div></div>';
}

// Select a due-soon WO in the list: make it visible, open preview, scroll to it.
function woradminSelectByName(name) {
  var entry = (_woradminData || []).filter(function(x){ return x.name === name; })[0];
  var worType = entry ? (entry.wor_type || 'maintenance') : 'maintenance';
  // Due-soon items are recurring; ensure the Recurring filter is on and the
  // view is the All tab so the row is present regardless of current filters.
  var recChk = document.getElementById('woradmin-filter-recurring');
  if (recChk && !recChk.checked) recChk.checked = true;
  if (_woradminTab !== 'all') { woradminSetTab('all'); } else { woradminApplyFilter(); }
  woradminPreview(name, worType);
  var rows = document.querySelectorAll('#woradmin-list [data-wo-name]');
  var target = null;
  for (var i = 0; i < rows.length; i++) {
    if (rows[i].getAttribute('data-wo-name') === name) { target = rows[i]; break; }
  }
  if (target) {
    target.scrollIntoView({ behavior: 'smooth', block: 'center' });
    target.classList.add('ring-2', 'ring-togen-teal');
    setTimeout(function(){ target.classList.remove('ring-2', 'ring-togen-teal'); }, 2000);
  }
}

function woradminSetStatus(filename, newStatus) {
  fetch('/wor-admin-status', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({filename: filename, status: newStatus})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { alert('Error: ' + d.error); return; }
      woradminLoadList();
    })
    .catch(function(err){ alert('Error: ' + err.message); });
}

function woradminPreview(filename, worType) {
  _woradminCurrentFilename = filename;
  _woradminCurrentType = worType || 'maintenance';
  var sec = document.getElementById('woradmin-preview-section');
  sec.classList.remove('hidden');
  // Position the preview pane directly beneath the selected row (DVI-746).
  woradminPlacePreview(sec);
  document.getElementById('woradmin-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(filename);
  var dl = document.getElementById('woradmin-download-link');
  dl.href = '/wor-download?file=' + encodeURIComponent(filename);
  dl.download = filename;
  var _es = document.getElementById('woradmin-email-status');
  if (_es) _es.textContent = '';
  var note = document.getElementById('woradmin-notify-note');
  if (note) {
    var entry = _woradminData.filter(function(x){ return x.name === filename; })[0];
    if (entry && entry.notify && entry.notify.enabled) {
      var who = entry.notify.requestor_name || 'The requestor';
      note.textContent = who + ' has requested notifications about this work order.';
      note.classList.remove('hidden');
    } else {
      note.textContent = '';
      note.classList.add('hidden');
    }
  }
  // Update edit button label based on type
  var editBtn = document.getElementById('woradmin-edit-btn');
  if (editBtn) editBtn.querySelector('span') || (editBtn.childNodes[editBtn.childNodes.length-1].textContent = _woradminCurrentType === 'mobilemachine' ? ' Open in Mobile Machine Form' : (_woradminCurrentType === 'safety' ? ' Open in Safety Form' : (_woradminCurrentType === 'preventive' ? ' Open in Preventative Maintenance Form' : ' Open in Maintenance Form')));
  // Manual controls (PM only)
  _woradminManual = null;
  woradminUpdateManualControls();
  fetch('/wor-manual-info?file=' + encodeURIComponent(filename))
    .then(function(r){ return r.json(); })
    .then(function(d){
      _woradminManual = (d && d.found) ? d : null;
      woradminUpdateManualControls();
    })
    .catch(function(){ _woradminManual = null; woradminUpdateManualControls(); });
  sec.scrollIntoView({behavior: 'smooth', block: 'start'});
}

function woradminUpdateManualControls() {
  var btn = document.getElementById('woradmin-open-manual-btn');
  var qrEntry = document.getElementById('woradmin-manual-qr-entry');
  var has = !!(_woradminManual && _woradminManual.url);
  if (btn) btn.classList.toggle('hidden', !has);
  if (qrEntry) qrEntry.classList.toggle('hidden', !has);
  // Attach Manual is offered for work order types that support manuals (DVI-462):
  // Preventative Maintenance and Mobile Machine.
  var attach = document.getElementById('woradmin-attach-manual-btn');
  if (attach) {
    var supportsManual = (_woradminCurrentType === 'preventive' || _woradminCurrentType === 'mobilemachine');
    attach.classList.toggle('hidden', !supportsManual);
    var lbl = document.getElementById('woradmin-attach-manual-label');
    if (lbl) lbl.textContent = has ? 'Replace Manual' : 'Attach Manual';
  }
}

function woradminOpenManual() {
  if (_woradminManual && _woradminManual.url) window.open(_woradminManual.url, '_blank');
}

function woradminAttachManual() {
  if (!_woradminCurrentFilename) return;
  var targetFile = _woradminCurrentFilename;
  manualPickerOpen(function(choice) {
    manualPickerSetStatus('Attaching...', '');
    var fd = new FormData();
    fd.append('file', targetFile);
    if (choice.mode === 'upload') {
      fd.append('manual', choice.file);
      fd.append('equipment', choice.equipment || '');
    } else {
      fd.append('manual_existing', choice.filename);
      fd.append('manual_existing_name', choice.original_name || '');
      fd.append('equipment', choice.equipment || '');
    }
    fetch('/wor-attach-manual', {method: 'POST', body: fd})
      .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
      .then(function(res){
        if (!res.ok || res.d.error) { manualPickerSetStatus('Error: ' + (res.d.error || 'attach failed'), 'error'); return; }
        _woradminManual = {found: true, filename: res.d.filename, original_name: res.d.original_name, url: res.d.url};
        woradminUpdateManualControls();
        manualPickerClose();
      })
      .catch(function(err){ manualPickerSetStatus('Error: ' + err.message, 'error'); });
  });
}

function woradminOpenManualInQrCreator() {
  _woradminCloseWorkflowMenu();
  if (_woradminManual && _woradminManual.url) sqrOpenWithUrl(_woradminManual.url);
}

function woradminEditInWorTool() {
  if (!_woradminCurrentFilename) return;
  var filename = _woradminCurrentFilename;
  var worType = _woradminCurrentType || 'maintenance';

  fetch('/wor-admin-data?file=' + encodeURIComponent(filename))
    .then(function(r){ return r.json(); })
    .then(function(d){
      var data = d.data || {};
      var detectedType = data._wor_type || worType;

      if (detectedType === 'mobilemachine') {
        // Open in Mobile Machine form
        openTool('work-order', 'Work Order Request');
        worSelectType('mobilemachine');
        _mmCurrentFilename = filename;
        _mmEditFilename = filename;

        var banner = document.getElementById('mm-edit-banner');
        banner.classList.remove('hidden');
        document.getElementById('mm-edit-filename').textContent = filename;
        var btnText = document.getElementById('mm-submit-btn-text');
        if (btnText) btnText.textContent = 'Save Changes';

        var form = document.getElementById('mm-form');
        if (data.machine) form.elements['machine'].value = data.machine;
        if (data.asset_number) form.elements['asset_number'].value = data.asset_number;
        if (data.order_date_time) form.elements['order_date_time'].value = data.order_date_time;
        if (data.date_promised) form.elements['date_promised'].value = data.date_promised;
        if (data.date_delivered) form.elements['date_delivered'].value = data.date_delivered;
        if (data.order_received_by) form.elements['order_received_by'].value = data.order_received_by;
        if (data.make_model) form.elements['make_model'].value = data.make_model;
        if (data.vin) form.elements['vin'].value = data.vin;
        if (data.odometer) form.elements['odometer'].value = data.odometer;
        if (data.license_state) form.elements['license_state'].value = data.license_state;
        if (data.motor_number) form.elements['motor_number'].value = data.motor_number;
        if (data.labor_description) form.elements['labor_description'].value = data.labor_description;
        if (data.labor_time) form.elements['labor_time'].value = data.labor_time;
        if (data.labor_total) form.elements['labor_total'].value = data.labor_total;
        if (data.parts_total) form.elements['parts_total'].value = data.parts_total;
        // Authorization fields (compiled_by/authorized_by/dates) were removed from
        // the Mobile Machine form (DVI-462); they are handled in Work Order Admin.
        if (data.manual && data.manual.filename) {
          mmSetExistingManual(data.manual.filename, data.manual.original_name, data.manual.url);
        } else {
          mmDetachManual();
        }
        // Service type checkboxes
        var cbs = form.querySelectorAll('input[name="service_type"]');
        var stypes = data.service_type || [];
        for (var j = 0; j < cbs.length; j++) {
          cbs[j].checked = stypes.indexOf(cbs[j].value) !== -1;
        }
        // Parts rows
        if (data.parts && data.parts.length > 0) {
          var container = document.getElementById('mm-parts-rows');
          var rows = container.querySelectorAll('.mm-part-row');
          for (var p = 0; p < data.parts.length; p++) {
            if (p >= rows.length) mmAddPartRow();
            rows = container.querySelectorAll('.mm-part-row');
            var inputs = rows[p].querySelectorAll('input');
            inputs[0].value = data.parts[p].number || '';
            inputs[1].value = data.parts[p].name || '';
            inputs[2].value = data.parts[p].qty || '';
            inputs[3].value = data.parts[p].amount || '';
          }
        }

        var sec = document.getElementById('mm-preview-section');
        sec.classList.remove('hidden');
        document.getElementById('mm-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(filename);
        var dl = document.getElementById('mm-download-link');
        dl.href = '/wor-download?file=' + encodeURIComponent(filename);
        dl.download = filename;
        window.scrollTo({top: 0, behavior: 'smooth'});
      } else if (detectedType === 'safety') {
        // Open in Safety form
        openTool('work-order', 'Work Order Request');
        worSelectType('safety');
        _safetyCurrentFilename = filename;
        _safetyEditFilename = filename;

        var banner = document.getElementById('safety-edit-banner');
        banner.classList.remove('hidden');
        document.getElementById('safety-edit-filename').textContent = filename;
        var btnText = document.getElementById('safety-submit-btn-text');
        if (btnText) btnText.textContent = 'Save Changes';

        if (d.data) {
          var form = document.getElementById('safety-form');
          if (data.requestor_name) form.elements['requestor_name'].value = data.requestor_name;
          if (data.phone) form.elements['phone'].value = data.phone;
          if (data.email) form.elements['email'].value = data.email;
          if (data.date_of_request) form.elements['date_of_request'].value = data.date_of_request;
          if (data.location) form.elements['location'].value = data.location;
          if (data.building) form.elements['building'].value = data.building;
          if (data.man_hole) form.elements['man_hole'].value = data.man_hole;
          if (data.equipment) form.elements['equipment'].value = data.equipment;
          if (data.description) form.elements['description'].value = data.description;
          if (data.best_times) form.elements['best_times'].value = data.best_times;
          if (data.desired_completion_date) form.elements['desired_completion_date'].value = data.desired_completion_date;
          if (data.urgency) {
            var radios = form.elements['urgency'];
            for (var i = 0; i < radios.length; i++) {
              radios[i].checked = radios[i].value === data.urgency;
            }
          }
          var cbs = form.querySelectorAll('input[name="issue_type"]');
          var types = data.issue_type || [];
          for (var j = 0; j < cbs.length; j++) {
            cbs[j].checked = types.indexOf(cbs[j].value) !== -1;
          }
          if (data.issue_type_other) {
            form.elements['issue_type_other'].value = data.issue_type_other;
            form.elements['issue_type_other'].disabled = false;
          }
        }

        var sec = document.getElementById('safety-preview-section');
        sec.classList.remove('hidden');
        document.getElementById('safety-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(filename);
        var dl = document.getElementById('safety-download-link');
        dl.href = '/wor-download?file=' + encodeURIComponent(filename);
        dl.download = filename;
        window.scrollTo({top: 0, behavior: 'smooth'});
      } else if (detectedType === 'preventive') {
        // Open in Preventative Maintenance form (standard fields + parts)
        openTool('work-order', 'Work Order Request');
        worSelectType('preventive');
        _pmCurrentFilename = filename;
        _pmEditFilename = filename;

        var banner = document.getElementById('pm-edit-banner');
        banner.classList.remove('hidden');
        document.getElementById('pm-edit-filename').textContent = filename;
        var btnText = document.getElementById('pm-submit-btn-text');
        if (btnText) btnText.textContent = 'Save Changes';

        if (d.data) {
          var form = document.getElementById('pm-form');
          if (data.requestor_name) form.elements['requestor_name'].value = data.requestor_name;
          if (data.phone) form.elements['phone'].value = data.phone;
          if (data.email) form.elements['email'].value = data.email;
          if (data.date_of_request) form.elements['date_of_request'].value = data.date_of_request;
          if (data.location) form.elements['location'].value = data.location;
          if (data.building) form.elements['building'].value = data.building;
          if (data.man_hole) form.elements['man_hole'].value = data.man_hole;
          if (data.equipment) form.elements['equipment'].value = data.equipment;
          if (data.description) form.elements['description'].value = data.description;
          if (data.best_times) form.elements['best_times'].value = data.best_times;
          if (data.desired_completion_date) form.elements['desired_completion_date'].value = data.desired_completion_date;
          if (data.urgency) {
            var radios = form.elements['urgency'];
            for (var i = 0; i < radios.length; i++) {
              radios[i].checked = radios[i].value === data.urgency;
            }
          }
          var cbs = form.querySelectorAll('input[name="issue_type"]');
          var types = data.issue_type || [];
          for (var j = 0; j < cbs.length; j++) {
            cbs[j].checked = types.indexOf(cbs[j].value) !== -1;
          }
          if (data.issue_type_other) {
            form.elements['issue_type_other'].value = data.issue_type_other;
            form.elements['issue_type_other'].disabled = false;
          }
          if (data.parts_total) form.elements['parts_total'].value = data.parts_total;
          if (data.parts && data.parts.length > 0) {
            var container = document.getElementById('pm-parts-rows');
            var rows = container.querySelectorAll('.pm-part-row');
            for (var p = 0; p < data.parts.length; p++) {
              if (p >= rows.length) pmAddPartRow();
              rows = container.querySelectorAll('.pm-part-row');
              var inputs = rows[p].querySelectorAll('input');
              inputs[0].value = data.parts[p].number || '';
              inputs[1].value = data.parts[p].name || '';
              inputs[2].value = data.parts[p].qty || '';
              inputs[3].value = data.parts[p].amount || '';
            }
          }
          // Restore an attached manual (DVI-452) so it is kept / can be replaced.
          pmDetachManual();
          pmDismissSuggestedManual();
          if (data.manual && data.manual.filename) {
            pmSetExistingManual(data.manual.filename, data.manual.original_name, data.manual.url || '');
            _pmCurrentManual = {filename: data.manual.filename,
                                original_name: data.manual.original_name,
                                url: data.manual.url || ''};
          } else {
            _pmCurrentManual = null;
          }
          pmUpdatePreviewManualControls();
        }

        var sec = document.getElementById('pm-preview-section');
        sec.classList.remove('hidden');
        document.getElementById('pm-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(filename);
        var dl = document.getElementById('pm-download-link');
        dl.href = '/wor-download?file=' + encodeURIComponent(filename);
        dl.download = filename;
        window.scrollTo({top: 0, behavior: 'smooth'});
      } else {
        // Open in Maintenance form (original behavior)
        openTool('work-order', 'Work Order Request');
        worSelectType('maintenance');
        _worCurrentFilename = filename;
        _worEditFilename = filename;

        var banner = document.getElementById('wor-edit-banner');
        banner.classList.remove('hidden');
        document.getElementById('wor-edit-filename').textContent = filename;
        var btnText = document.getElementById('wor-submit-btn-text');
        if (btnText) btnText.textContent = 'Save Changes';

        if (d.data) {
          var form = document.getElementById('wor-form');
          if (data.requestor_name) form.elements['requestor_name'].value = data.requestor_name;
          if (data.phone) form.elements['phone'].value = data.phone;
          if (data.email) form.elements['email'].value = data.email;
          if (data.date_of_request) form.elements['date_of_request'].value = data.date_of_request;
          if (data.location) form.elements['location'].value = data.location;
          if (data.building) form.elements['building'].value = data.building;
          if (data.man_hole) form.elements['man_hole'].value = data.man_hole;
          if (data.equipment) form.elements['equipment'].value = data.equipment;
          if (data.description) form.elements['description'].value = data.description;
          if (data.best_times) form.elements['best_times'].value = data.best_times;
          if (data.desired_completion_date) form.elements['desired_completion_date'].value = data.desired_completion_date;
          if (data.urgency) {
            var radios = form.elements['urgency'];
            for (var i = 0; i < radios.length; i++) {
              radios[i].checked = radios[i].value === data.urgency;
            }
          }
          var cbs = form.querySelectorAll('input[name="issue_type"]');
          var types = data.issue_type || [];
          for (var j = 0; j < cbs.length; j++) {
            cbs[j].checked = types.indexOf(cbs[j].value) !== -1;
          }
          if (data.issue_type_other) {
            form.elements['issue_type_other'].value = data.issue_type_other;
            form.elements['issue_type_other'].disabled = false;
          }
        }

        var sec = document.getElementById('wor-preview-section');
        sec.classList.remove('hidden');
        document.getElementById('wor-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(filename);
        var dl = document.getElementById('wor-download-link');
        dl.href = '/wor-download?file=' + encodeURIComponent(filename);
        dl.download = filename;
        window.scrollTo({top: 0, behavior: 'smooth'});
      }
    })
    .catch(function(err){
      openTool('work-order', 'Work Order Request');
      worSelectType(worType || 'maintenance');
      if (worType === 'mobilemachine') {
        _mmCurrentFilename = filename;
        var sec = document.getElementById('mm-preview-section');
        sec.classList.remove('hidden');
        document.getElementById('mm-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(filename);
      } else {
        _worCurrentFilename = filename;
        var sec = document.getElementById('wor-preview-section');
        sec.classList.remove('hidden');
        document.getElementById('wor-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(filename);
      }
      setTimeout(function(){ if (sec) sec.scrollIntoView({behavior: 'smooth', block: 'start'}); }, 100);
    });
}

// ----- Share menu + Email dialog (DVI-746) -----
function woradminToggleShareMenu(e) {
  e.stopPropagation();
  var menu = document.getElementById('woradmin-share-menu');
  if (!menu) return;
  menu.classList.toggle('hidden');
  if (!menu.classList.contains('hidden')) {
    setTimeout(function() {
      document.addEventListener('click', _woradminCloseShareMenu, {once: true});
    }, 0);
  }
}

function _woradminCloseShareMenu() {
  var menu = document.getElementById('woradmin-share-menu');
  if (menu) menu.classList.add('hidden');
}

function woradminOpenEmailDialog() {
  _woradminCloseShareMenu();
  if (!_woradminCurrentFilename) { alert('Open a work order first.'); return; }
  var status = document.getElementById('woradmin-email-status');
  if (status) { status.textContent = ''; status.className = 'text-sm'; }
  var modal = document.getElementById('woradmin-email-modal');
  if (modal) modal.classList.remove('hidden');
  var input = document.getElementById('woradmin-email-input');
  if (input) setTimeout(function(){ input.focus(); }, 0);
}

function woradminCloseEmailDialog() {
  var modal = document.getElementById('woradmin-email-modal');
  if (modal) modal.classList.add('hidden');
}

function woradminEmailPdf() {
  var email = document.getElementById('woradmin-email-input').value.trim();
  var status = document.getElementById('woradmin-email-status');
  if (!email) { status.textContent = 'Please enter an email address.'; status.className = 'text-sm mt-2 text-red-500'; return; }
  if (!_woradminCurrentFilename) { status.textContent = 'No work order selected. Click a work order to preview it first.'; status.className = 'text-sm mt-2 text-red-500'; return; }

  var btn = document.getElementById('woradmin-email-btn');
  btn.disabled = true;
  status.textContent = 'Sending...';
  status.className = 'text-sm mt-2 text-gray-500 dark:text-gray-400';

  fetch('/wor-email', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({filename: _woradminCurrentFilename, email: email})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      btn.disabled = false;
      if (d.error) { status.textContent = 'Error: ' + d.error; status.className = 'text-sm mt-2 text-red-500'; return; }
      status.textContent = 'Email sent to ' + email + '!';
      status.className = 'text-sm mt-2 text-green-600 dark:text-green-400';
    })
    .catch(function(err){
      btn.disabled = false;
      status.textContent = 'Error: ' + err.message;
      status.className = 'text-sm mt-2 text-red-500';
    });
}

function _woradminGetViewerUrl(callback) {
  if (!_woradminCurrentFilename) return;
  fetch('/wor-viewer-url?file=' + encodeURIComponent(_woradminCurrentFilename))
    .then(function(r) { return r.json(); })
    .then(function(d) { if (d.url) callback(d.url); });
}

function woradminOpenViewer() {
  _woradminGetViewerUrl(function(url) { window.open(url, '_blank'); });
}

function woradminToggleWorkflowMenu(e) {
  e.stopPropagation();
  var menu = document.getElementById('woradmin-workflow-menu');
  menu.classList.toggle('hidden');
  if (!menu.classList.contains('hidden')) {
    setTimeout(function() {
      document.addEventListener('click', _woradminCloseWorkflowMenu, {once: true});
    }, 0);
  }
}

function _woradminCloseWorkflowMenu() {
  document.getElementById('woradmin-workflow-menu').classList.add('hidden');
}

function woradminOpenInQrCreator() {
  _woradminCloseWorkflowMenu();
  _woradminGetViewerUrl(function(url) { sqrOpenWithUrl(url); });
}

function woradminPrint() {
  if (!_woradminCurrentFilename) { alert('Open a work order first.'); return; }
  togenOpenPrintPopup({source: 'wor', fileRef: _woradminCurrentFilename,
                       kind: 'document', title: 'Print Work Order'});
}

// ===== RECURRING WORK ORDER FUNCTIONS =====

var _recurringModalFilename = '';
var _recurringFreq = 0;

function worSubmitAndRecurring(e) {
  e.preventDefault();
  var form = document.getElementById('wor-form');
  if (!form.checkValidity()) { form.reportValidity(); return; }
  var btn = document.getElementById('wor-make-recurring-submit-btn');
  var lbl = document.getElementById('wor-submit-lbl');
  btn.disabled = true;
  lbl.textContent = _worEditFilename ? 'Saving & setting recurring...' : 'Submitting & setting recurring...';

  var fd = new FormData(form);
  var url = '/wor-submit';
  if (_worEditFilename && _isWorAdmin) {
    url = '/wor-edit';
    fd.append('edit_filename', _worEditFilename);
  }
  fetch(url, {method: 'POST', body: fd})
    .then(function(r){ return r.json(); })
    .then(function(d){
      btn.disabled = false;
      lbl.textContent = '';
      if (d.error) { lbl.textContent = 'Error: ' + d.error; return; }
      lbl.textContent = _worEditFilename ? 'Work order updated!' : 'Work order submitted!';
      _worCurrentFilename = d.filename;
      _worEditFilename = '';
      var editBanner = document.getElementById('wor-edit-banner');
      if (editBanner) editBanner.classList.add('hidden');
      var sec = document.getElementById('wor-preview-section');
      sec.classList.remove('hidden');
      document.getElementById('wor-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(d.filename) + '&t=' + Date.now();
      var dl = document.getElementById('wor-download-link');
      dl.href = '/wor-download?file=' + encodeURIComponent(d.filename);
      dl.download = d.filename;
      recurringModalOpen(d.filename);
    })
    .catch(function(err){
      btn.disabled = false;
      lbl.textContent = 'Error: ' + err.message;
    });
}

function mmSubmitAndRecurring(e) {
  e.preventDefault();
  var form = document.getElementById('mm-form');
  if (!form.checkValidity()) { form.reportValidity(); return; }
  var btn = document.getElementById('mm-make-recurring-submit-btn');
  var lbl = document.getElementById('mm-submit-lbl');
  btn.disabled = true;
  lbl.textContent = _mmEditFilename ? 'Saving & setting recurring...' : 'Submitting & setting recurring...';

  var fd = new FormData(form);
  var url = '/mm-wor-submit';
  if (_mmEditFilename && _isWorAdmin) {
    url = '/mm-wor-edit';
    fd.append('edit_filename', _mmEditFilename);
  }
  fetch(url, {method: 'POST', body: fd})
    .then(function(r){ return r.json(); })
    .then(function(d){
      btn.disabled = false;
      lbl.textContent = '';
      if (d.error) { lbl.textContent = 'Error: ' + d.error; return; }
      lbl.textContent = _mmEditFilename ? 'Work order updated!' : 'Work order submitted!';
      _mmCurrentFilename = d.filename;
      _mmEditFilename = '';
      var editBanner = document.getElementById('mm-edit-banner');
      if (editBanner) editBanner.classList.add('hidden');
      var sec = document.getElementById('mm-preview-section');
      sec.classList.remove('hidden');
      document.getElementById('mm-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(d.filename) + '&t=' + Date.now();
      var dl = document.getElementById('mm-download-link');
      dl.href = '/wor-download?file=' + encodeURIComponent(d.filename);
      dl.download = d.filename;
      recurringModalOpen(d.filename);
    })
    .catch(function(err){
      btn.disabled = false;
      lbl.textContent = 'Error: ' + err.message;
    });
}

function pmSubmitAndRecurring(e) {
  e.preventDefault();
  var form = document.getElementById('pm-form');
  if (!form.checkValidity()) { form.reportValidity(); return; }
  var btn = document.getElementById('pm-make-recurring-submit-btn');
  var lbl = document.getElementById('pm-submit-lbl');
  btn.disabled = true;
  lbl.textContent = _pmEditFilename ? 'Saving & setting recurring...' : 'Submitting & setting recurring...';

  var fd = new FormData(form);
  var url = '/preventive-wor-submit';
  if (_pmEditFilename && _isWorAdmin) {
    url = '/preventive-wor-edit';
    fd.append('edit_filename', _pmEditFilename);
  }
  fetch(url, {method: 'POST', body: fd})
    .then(function(r){ return r.json(); })
    .then(function(d){
      btn.disabled = false;
      lbl.textContent = '';
      if (d.error) { lbl.textContent = 'Error: ' + d.error; return; }
      lbl.textContent = _pmEditFilename ? 'Work order updated!' : 'Work order submitted!';
      _pmCurrentFilename = d.filename;
      _pmEditFilename = '';
      _pmCurrentManual = d.manual || null;
      pmUpdatePreviewManualControls();
      var editBanner = document.getElementById('pm-edit-banner');
      if (editBanner) editBanner.classList.add('hidden');
      var sec = document.getElementById('pm-preview-section');
      sec.classList.remove('hidden');
      document.getElementById('pm-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(d.filename) + '&t=' + Date.now();
      var dl = document.getElementById('pm-download-link');
      dl.href = '/wor-download?file=' + encodeURIComponent(d.filename);
      dl.download = d.filename;
      recurringModalOpen(d.filename);
    })
    .catch(function(err){
      btn.disabled = false;
      lbl.textContent = 'Error: ' + err.message;
    });
}

function safetySubmitAndRecurring(e) {
  e.preventDefault();
  var form = document.getElementById('safety-form');
  if (!form.checkValidity()) { form.reportValidity(); return; }
  var btn = document.getElementById('safety-make-recurring-submit-btn');
  var lbl = document.getElementById('safety-submit-lbl');
  btn.disabled = true;
  lbl.textContent = _safetyEditFilename ? 'Saving & setting recurring...' : 'Submitting & setting recurring...';

  var fd = new FormData(form);
  var url = '/safety-wor-submit';
  if (_safetyEditFilename && _isWorAdmin) {
    url = '/safety-wor-edit';
    fd.append('edit_filename', _safetyEditFilename);
  }
  fetch(url, {method: 'POST', body: fd})
    .then(function(r){ return r.json(); })
    .then(function(d){
      btn.disabled = false;
      lbl.textContent = '';
      if (d.error) { lbl.textContent = 'Error: ' + d.error; return; }
      lbl.textContent = _safetyEditFilename ? 'Work order updated!' : 'Work order submitted!';
      _safetyCurrentFilename = d.filename;
      _safetyEditFilename = '';
      var editBanner = document.getElementById('safety-edit-banner');
      if (editBanner) editBanner.classList.add('hidden');
      var sec = document.getElementById('safety-preview-section');
      sec.classList.remove('hidden');
      document.getElementById('safety-preview-frame').src = '/wor-preview?file=' + encodeURIComponent(d.filename) + '&t=' + Date.now();
      var dl = document.getElementById('safety-download-link');
      dl.href = '/wor-download?file=' + encodeURIComponent(d.filename);
      dl.download = d.filename;
      recurringModalOpen(d.filename);
    })
    .catch(function(err){
      btn.disabled = false;
      lbl.textContent = 'Error: ' + err.message;
    });
}

function recurringModalOpen(filename) {
  _recurringModalFilename = filename;
  _recurringFreq = 0;
  document.getElementById('recurring-freq-custom').value = '';
  document.getElementById('recurring-start-date').value = new Date().toISOString().slice(0,10);
  document.getElementById('recurring-end-date').value = '';
  document.getElementById('recurring-status').textContent = '';
  _recurringHighlightFreqBtns();
  document.getElementById('recurring-modal').classList.remove('hidden');
}

function recurringModalClose() {
  document.getElementById('recurring-modal').classList.add('hidden');
  _recurringModalFilename = '';
}

function recurringSetFreq(days) {
  _recurringFreq = days;
  if (days === 30 || days === 90 || days === 180) {
    document.getElementById('recurring-freq-custom').value = '';
  } else if (days > 0) {
    document.getElementById('recurring-freq-custom').value = days;
  }
  _recurringHighlightFreqBtns();
}

function _recurringHighlightFreqBtns() {
  var btn30 = document.getElementById('recurring-freq-30');
  var btn90 = document.getElementById('recurring-freq-90');
  var btn180 = document.getElementById('recurring-freq-180');
  var active = 'border-togen-teal text-togen-teal bg-togen-teal/10';
  var inactive = 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300';
  btn30.className = 'flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ' + (_recurringFreq === 30 ? active : inactive);
  btn90.className = 'flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ' + (_recurringFreq === 90 ? active : inactive);
  btn180.className = 'flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ' + (_recurringFreq === 180 ? active : inactive);
}

function recurringModalSave() {
  var status = document.getElementById('recurring-status');
  if (!_recurringModalFilename) { status.textContent = 'No work order selected.'; status.className = 'text-sm text-red-500'; return; }
  if (!_recurringFreq || _recurringFreq < 1) { status.textContent = 'Please select or enter a frequency.'; status.className = 'text-sm text-red-500'; return; }

  var startDate = document.getElementById('recurring-start-date').value;
  var endDate = document.getElementById('recurring-end-date').value;
  if (!startDate) { status.textContent = 'Start date is required.'; status.className = 'text-sm text-red-500'; return; }

  var btn = document.getElementById('recurring-save-btn');
  btn.disabled = true;
  status.textContent = 'Saving...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';

  fetch('/wor-make-recurring', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      filename: _recurringModalFilename,
      frequency_days: _recurringFreq,
      start_date: startDate,
      end_date: endDate
    })
  })
    .then(function(r){
      var ct = (r.headers.get('content-type') || '');
      if (!ct.includes('application/json')) {
        if (r.status === 401 || r.redirected) throw new Error('Session expired — please reload the page and log in again.');
        throw new Error('Server returned an unexpected response (HTTP ' + r.status + '). Please try again.');
      }
      return r.json();
    })
    .then(function(d){
      btn.disabled = false;
      if (d.error) { status.textContent = 'Error: ' + d.error; status.className = 'text-sm text-red-500'; return; }
      status.textContent = 'Recurring schedule saved! Next run: ' + (d.next_run || 'N/A');
      status.className = 'text-sm text-green-600 dark:text-green-400';
      setTimeout(function(){ recurringModalClose(); woradminLoadList(); }, 1500);
    })
    .catch(function(err){
      btn.disabled = false;
      status.textContent = 'Error: ' + err.message;
      status.className = 'text-sm text-red-500';
    });
}

function woradminRemoveRecurring(filename) {
  if (!confirm('Remove recurring schedule from this work order?')) return;
  fetch('/wor-remove-recurring', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({filename: filename})
  })
    .then(function(r){
      var ct = (r.headers.get('content-type') || '');
      if (!ct.includes('application/json')) {
        if (r.status === 401 || r.redirected) throw new Error('Session expired — please reload the page and log in again.');
        throw new Error('Server returned an unexpected response (HTTP ' + r.status + '). Please try again.');
      }
      return r.json();
    })
    .then(function(d){
      if (d.error) { alert('Error: ' + d.error); return; }
      woradminLoadList();
    })
    .catch(function(err){ alert('Error: ' + err.message); });
}

function woradminToggleNotify(filename) {
  if (!confirm('Are you sure you want to disable notifications for this Work Order?')) return;
  fetch('/wor-admin-toggle-notify', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({filename: filename})
  })
    .then(function(r){
      var ct = (r.headers.get('content-type') || '');
      if (!ct.includes('application/json')) {
        if (r.status === 401 || r.redirected) throw new Error('Session expired — please reload the page and log in again.');
        throw new Error('Server returned an unexpected response (HTTP ' + r.status + '). Please try again.');
      }
      return r.json();
    })
    .then(function(d){
      if (d.error) { alert('Error: ' + d.error); return; }
      woradminLoadList();
    })
    .catch(function(err){ alert('Error: ' + err.message); });
}

// ===== SYNC ADMIN FUNCTIONS =====

function syncadminLoadLog() {
  fetch('/sync-admin/log')
    .then(function(r){ return r.json(); })
    .then(function(d){
      var container = document.getElementById('syncadmin-log-container');
      if (d.error) {
        container.innerHTML = '<p class="text-red-400">' + esc(d.error) + '</p>';
        document.getElementById('syncadmin-resync-section').classList.add('hidden');
        return;
      }
      var wasScrolledToBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
      var lines = d.lines || [];
      container.innerHTML = lines.map(function(line){
        var cls = 'text-green-400';
        if (/error|aborted|failed/i.test(line)) cls = 'text-red-400';
        else if (/warn/i.test(line)) cls = 'text-yellow-400';
        return '<div class="' + cls + ' whitespace-pre-wrap break-all">' + esc(line) + '</div>';
      }).join('');
      if (wasScrolledToBottom || !_syncadminHasScrolled) {
        container.scrollTop = container.scrollHeight;
        _syncadminHasScrolled = true;
      }
      var resyncSection = document.getElementById('syncadmin-resync-section');
      if (d.needs_resync) {
        resyncSection.classList.remove('hidden');
      } else {
        resyncSection.classList.add('hidden');
      }
      var lockSection = document.getElementById('syncadmin-lock-section');
      if (d.needs_lock_fix) {
        lockSection.classList.remove('hidden');
      } else {
        lockSection.classList.add('hidden');
      }
      var supportSection = document.getElementById('syncadmin-support-section');
      if (d.has_errors) {
        supportSection.classList.remove('hidden');
      } else {
        supportSection.classList.add('hidden');
      }
      // Update navbar sync status dot
      var dot = document.getElementById('sync-status-dot');
      var sbtn = document.getElementById('sync-status-btn');
      if (dot && sbtn) {
        if (d.needs_resync || d.needs_lock_fix) {
          dot.className = 'absolute top-1 right-1 h-2.5 w-2.5 rounded-full border-2 border-white dark:border-gray-900 bg-red-500';
          sbtn.title = 'Sync Status: resync required';
        } else if (d.has_errors) {
          dot.className = 'absolute top-1 right-1 h-2.5 w-2.5 rounded-full border-2 border-white dark:border-gray-900 bg-yellow-400';
          sbtn.title = 'Sync Status: issues detected';
        } else {
          dot.className = 'absolute top-1 right-1 h-2.5 w-2.5 rounded-full border-2 border-white dark:border-gray-900 bg-green-500';
          sbtn.title = 'Sync Status: OK';
        }
      }
    })
    .catch(function(err){
      var container = document.getElementById('syncadmin-log-container');
      container.innerHTML = '<p class="text-red-400">Failed to load log: ' + esc(err.message) + '</p>';
    });
}

var _syncadminHasScrolled = false;

// Parse an rclone log line's leading timestamp (e.g. "2026/06/14 12:34:56") to ms, or null.
function _syncParseLineTime(line) {
  var m = /(\d{4})\/(\d{2})\/(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/.exec(line || '');
  if (!m) return null;
  var t = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]).getTime();
  return isNaN(t) ? null : t;
}

// Short relative-time label, e.g. "4m ago", "2h ago", "just now".
function _syncFmtAgo(ms) {
  if (!ms) return null;
  var diff = Math.max(0, Date.now() - ms);
  var s = Math.floor(diff / 1000);
  if (s < 45) return 'just now';
  var m = Math.floor(s / 60);
  if (m < 60) return m + 'm ago';
  var h = Math.floor(m / 60);
  if (h < 24) return h + 'h ago';
  return Math.floor(h / 24) + 'd ago';
}

function syncStatusPoll() {
  fetch('/sync-admin/log?lines=20')
    .then(function(r){ return r.json(); })
    .then(function(d){
      var dot = document.getElementById('sync-status-dot');
      var btn = document.getElementById('sync-status-btn');
      if (d.error) {
        _syncTileStatus = { state: 'unknown', recent: [], lastActivity: null };
        if (dot && btn) {
          dot.className = 'absolute top-1 right-1 h-2.5 w-2.5 rounded-full border-2 border-white dark:border-gray-900 bg-gray-400';
          btn.title = 'Sync Status: unknown';
        }
      } else {
        var state;
        if (d.needs_resync || d.needs_lock_fix) state = 'resync';
        else if (d.has_errors) state = 'issues';
        else state = 'ok';
        // Cursory recent-activity strip (C1): classify the last ~8 log lines using the
        // same regex as the Sync Admin log view (~10209). Approximation of recent
        // activity, not true bisync run boundaries.
        var lines = d.lines || [];
        var recentLines = lines.slice(-8);
        var recent = recentLines.map(function(line){
          return /error|aborted|failed/i.test(line) ? 'err' : 'ok';
        });
        var lastActivity = null;
        for (var i = lines.length - 1; i >= 0; i--) {
          var t = _syncParseLineTime(lines[i]);
          if (t) { lastActivity = t; break; }
        }
        _syncTileStatus = { state: state, recent: recent, lastActivity: lastActivity };
        if (dot && btn) {
          if (state === 'resync') {
            dot.className = 'absolute top-1 right-1 h-2.5 w-2.5 rounded-full border-2 border-white dark:border-gray-900 bg-red-500';
            btn.title = 'Sync Status: resync required';
          } else if (state === 'issues') {
            dot.className = 'absolute top-1 right-1 h-2.5 w-2.5 rounded-full border-2 border-white dark:border-gray-900 bg-yellow-400';
            btn.title = 'Sync Status: issues detected';
          } else {
            dot.className = 'absolute top-1 right-1 h-2.5 w-2.5 rounded-full border-2 border-white dark:border-gray-900 bg-green-500';
            btn.title = 'Sync Status: OK';
          }
        }
      }
      // If Home is showing and the user is entitled, refresh the tile.
      if (_activeTool === null && (_isTakeoffWorkflow || _isToGenAdmin)) {
        renderTileGrid();
      }
    })
    .catch(function(){});
}
if (_isTakeoffWorkflow || _isToGenAdmin) {
  syncStatusPoll();
  _syncStatusTimer = setInterval(syncStatusPoll, 30000);
}

// DVI-629: topbar Assistant icon indicator, modeled on the Sync dot above.
//   green  = connected & Assistant available
//   red    = not connected to MCP
//   yellow = a background prompt/workflow is running
// Background processes don't exist yet, so _assistantBusy stays false; the
// future feature calls setAssistantBusy(true/false) to flip the dot yellow
// without any server round-trip.
var _assistantStatusTimer = null;
var _assistantBusy = false;
var _assistantAvailable = false;

function _renderAssistantDot() {
  var dot = document.getElementById('assistant-status-dot');
  var btn = document.getElementById('assistant-btn');
  if (!dot || !btn) return;
  var base = 'absolute top-1 right-1 h-2.5 w-2.5 rounded-full border-2 border-white dark:border-gray-900 ';
  if (_assistantBusy) {
    dot.className = base + 'bg-yellow-400';
    btn.title = 'Assistant: working…';
  } else if (_assistantAvailable) {
    dot.className = base + 'bg-green-500';
    btn.title = 'Assistant: connected';
  } else {
    dot.className = base + 'bg-red-500';
    btn.title = 'Assistant: not connected';
  }
}

// Public hook for future background prompts/workflows to drive the yellow state.
function setAssistantBusy(busy) {
  _assistantBusy = !!busy;
  _renderAssistantDot();
}

function assistantStatusPoll() {
  fetch('/assistant/status')
    .then(function(r){ return r.json(); })
    .then(function(d){
      _assistantAvailable = !!(d && d.available);
      if (d && typeof d.busy !== 'undefined') _assistantBusy = !!d.busy;
      // Keep the click-handler gate in sync with the live status.
      var btn = document.getElementById('assistant-btn');
      if (btn) btn.dataset.available = _assistantAvailable ? 'true' : 'false';
      _renderAssistantDot();
    })
    .catch(function(){});
}
if (document.getElementById('assistant-btn')) {
  assistantStatusPoll();
  _assistantStatusTimer = setInterval(assistantStatusPoll, 30000);
}

// DVI-449: poll deployed-vs-remote version for the Togen Admin tile update chip.
// /togen-admin/version hits the GitHub API, so poll infrequently (5 min).
var _updateStatusTimer = null;
function updateStatusPoll() {
  fetch('/togen-admin/version')
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d && !d.error && typeof d.update_available !== 'undefined') {
        _updateTileStatus = { state: d.update_available ? 'available' : 'current' };
      } else {
        _updateTileStatus = { state: 'unknown' };
      }
      if (_activeTool === null && _isToGenAdmin) renderTileGrid();
    })
    .catch(function(){
      _updateTileStatus = { state: 'unknown' };
      if (_activeTool === null && _isToGenAdmin) renderTileGrid();
    });
}
if (_isToGenAdmin) {
  updateStatusPoll();
  _updateStatusTimer = setInterval(updateStatusPoll, 300000);
}

function syncadminStartAutoRefresh() {
  if (_syncadminTimer) clearInterval(_syncadminTimer);
  _syncadminTimer = setInterval(function(){
    if (_activeTool !== 'sync-admin') return;
    var cb = document.getElementById('syncadmin-auto-refresh');
    if (cb && cb.checked) syncadminLoadLog();
  }, 5000);
}

function syncadminRunResync() {
  var btn = document.getElementById('syncadmin-resync-btn');
  var status = document.getElementById('syncadmin-resync-status');
  btn.disabled = true;
  btn.classList.add('opacity-50', 'cursor-not-allowed');
  status.textContent = 'Running resync script...';
  status.className = 'text-sm mt-2 text-gray-600 dark:text-gray-400';

  fetch('/sync-admin/resync', {method: 'POST'})
    .then(function(r){ return r.json(); })
    .then(function(d){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      if (d.error) {
        status.textContent = 'Error: ' + d.error;
        status.className = 'text-sm mt-2 text-red-500';
        return;
      }
      status.textContent = 'Resync started. The log will update as it progresses.';
      status.className = 'text-sm mt-2 text-green-600 dark:text-green-400';
      setTimeout(syncadminLoadLog, 2000);
    })
    .catch(function(err){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      status.textContent = 'Error: ' + err.message;
      status.className = 'text-sm mt-2 text-red-500';
    });
}

function syncadminForceResync() {
  var btn = document.getElementById('syncadmin-force-resync-btn');
  var status = document.getElementById('syncadmin-force-resync-status');
  btn.disabled = true;
  btn.classList.add('opacity-50', 'cursor-not-allowed');
  status.textContent = 'Stopping rclone, clearing lock, starting fresh sync...';
  status.className = 'text-sm mt-2 text-gray-600 dark:text-gray-400';

  fetch('/sync-admin/force-resync', {method: 'POST'})
    .then(function(r){ return r.json(); })
    .then(function(d){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      if (d.error) {
        status.textContent = 'Error: ' + d.error;
        status.className = 'text-sm mt-2 text-red-500';
        return;
      }
      status.textContent = 'Force resync started. The log will update as it progresses.';
      status.className = 'text-sm mt-2 text-green-600 dark:text-green-400';
      setTimeout(syncadminLoadLog, 2000);
    })
    .catch(function(err){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      status.textContent = 'Error: ' + err.message;
      status.className = 'text-sm mt-2 text-red-500';
    });
}

function syncadminFixLockError() {
  var btn = document.getElementById('syncadmin-lock-fix-btn');
  var status = document.getElementById('syncadmin-lock-fix-status');
  btn.disabled = true;
  btn.classList.add('opacity-50', 'cursor-not-allowed');
  status.textContent = 'Clearing lock file and starting fresh sync...';
  status.className = 'text-sm mt-2 text-gray-600 dark:text-gray-400';

  fetch('/sync-admin/force-resync', {method: 'POST'})
    .then(function(r){ return r.json(); })
    .then(function(d){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      if (d.error) {
        status.textContent = 'Error: ' + d.error;
        status.className = 'text-sm mt-2 text-red-500';
        return;
      }
      status.textContent = 'Lock cleared and fresh sync started. The log will update as it progresses.';
      status.className = 'text-sm mt-2 text-green-600 dark:text-green-400';
      setTimeout(syncadminLoadLog, 2000);
    })
    .catch(function(err){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      status.textContent = 'Error: ' + err.message;
      status.className = 'text-sm mt-2 text-red-500';
    });
}

function syncadminOpenSupportRequest() {
  fetch('/sync-admin/log-tail')
    .then(function(r){ return r.json(); })
    .then(function(d){
      var logText = d.text || '(Could not retrieve log)';
      var prevTool = _activeTool;
      openTool('support-request', 'Support Request');
      var f = document.getElementById('sr-form');
      if (f) {
        var ssoName = '{{ user_name }}';
        var ssoEmail = '{{ user_email }}';
        if (ssoName && ssoName !== 'User') f.elements['sr_name'].value = ssoName;
        if (ssoEmail) f.elements['sr_email'].value = ssoEmail;
        f.elements['sr_datetime'].value = new Date().toLocaleString();
        f.elements['sr_active_tool'].value = 'Sync Admin';
        f.elements['sr_description'].value = 'rclone sync error detected. Last 50 lines of rclone.log:\\n\\n' + logText;
        var state = {
          activeTool: 'sync-admin',
          availableTools: TOOLS.map(function(t){ return {id: t.id, title: t.title}; }),
          url: window.location.href,
          userAgent: navigator.userAgent,
          screenSize: window.innerWidth + 'x' + window.innerHeight,
          timestamp: new Date().toISOString()
        };
        f.elements['sr_app_state'].value = JSON.stringify(state, null, 2);
        f.elements['sr_send_to'].value = 'icast.voyagetech.com@email.autotask.net';
      }
      document.getElementById('sr-submit-status').textContent = '';
    })
    .catch(function(err){
      openTool('support-request', 'Support Request');
      var f = document.getElementById('sr-form');
      if (f) {
        f.elements['sr_description'].value = 'rclone sync error detected. (Could not retrieve log: ' + err.message + ')';
      }
    });
}

// ===== TOGEN ADMIN =====
function togenadminLoadSyncStatus() {
  fetch('/sync-admin/log')
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) return;
      var lockSection = document.getElementById('togenadmin-lock-section');
      if (lockSection) {
        if (d.needs_lock_fix) lockSection.classList.remove('hidden');
        else lockSection.classList.add('hidden');
      }
      var supportSection = document.getElementById('togenadmin-support-section');
      if (supportSection) {
        if (d.has_errors) supportSection.classList.remove('hidden');
        else supportSection.classList.add('hidden');
      }
    })
    .catch(function(){});
}

// ===== SYNC SERVICE STOP/START (DVI-412) =====
var _syncServiceRunning = null;

function syncadminRenderServiceState() {
  var stateEl = document.getElementById('syncadmin-service-state');
  var btn = document.getElementById('syncadmin-service-btn');
  var label = document.getElementById('syncadmin-service-btn-label');
  if (!stateEl || !btn || !label) return;
  if (_syncServiceRunning === null) {
    stateEl.innerHTML = '<span class="inline-block h-1.5 w-1.5 rounded-full bg-gray-400"></span>Checking…';
    stateEl.className = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400';
    btn.disabled = true; btn.className = 'mt-3 px-4 py-2 text-sm font-medium rounded-xl bg-gray-300 dark:bg-gray-700 text-white transition-colors inline-flex items-center gap-2 cursor-pointer opacity-50';
    label.textContent = 'Loading…';
    return;
  }
  btn.disabled = false;
  if (_syncServiceRunning) {
    stateEl.innerHTML = '<span class="inline-block h-1.5 w-1.5 rounded-full bg-green-500"></span>Running';
    stateEl.className = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300';
    btn.className = 'mt-3 px-4 py-2 text-sm font-medium rounded-xl bg-red-600 hover:bg-red-700 text-white transition-colors inline-flex items-center gap-2 cursor-pointer';
    label.textContent = 'Stop Service';
  } else {
    stateEl.innerHTML = '<span class="inline-block h-1.5 w-1.5 rounded-full bg-gray-400"></span>Stopped';
    stateEl.className = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400';
    btn.className = 'mt-3 px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors inline-flex items-center gap-2 cursor-pointer';
    label.textContent = 'Start Service';
  }
}

function syncadminLoadServiceStatus() {
  fetch('/togen-admin/sync-service/status')
    .then(function(r){ return r.json(); })
    .then(function(d){ _syncServiceRunning = !!d.running; syncadminRenderServiceState(); })
    .catch(function(){});
}

function syncadminToggleService() {
  var btn = document.getElementById('syncadmin-service-btn');
  var status = document.getElementById('syncadmin-service-status');
  var action = _syncServiceRunning ? 'stop' : 'start';
  btn.disabled = true;
  status.textContent = (action === 'stop' ? 'Stopping' : 'Starting') + ' service…';
  status.className = 'text-sm mt-2 text-gray-500 dark:text-gray-400';
  fetch('/togen-admin/sync-service/' + action, {method: 'POST'})
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { status.textContent = 'Error: ' + d.error; status.className = 'text-sm mt-2 text-red-500'; syncadminRenderServiceState(); return; }
      _syncServiceRunning = !!d.running;
      syncadminRenderServiceState();
      status.textContent = _syncServiceRunning ? 'Service started.' : 'Service stopped.';
      status.className = 'text-sm mt-2 text-green-600 dark:text-green-400';
    })
    .catch(function(e){ status.textContent = 'Error: ' + e.message; status.className = 'text-sm mt-2 text-red-500'; syncadminRenderServiceState(); });
}

// ===== BACKUP (DVI-412) — history wiring pending Linux Automation Engineer subtask. =====
function togenadminBackupLoadMore() { /* Wired up when the backup service lands. */ }

function togenadminSwitchTab(tab) {
  var tabs = document.querySelectorAll('[id^="togenadmin-tab-"]');
  var panels = document.querySelectorAll('[id^="togenadmin-panel-"]');
  for (var i = 0; i < tabs.length; i++) {
    tabs[i].classList.remove('border-togen-teal', 'text-togen-teal');
    tabs[i].classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400', 'hover:text-gray-700', 'dark:hover:text-gray-300');
  }
  for (var i = 0; i < panels.length; i++) panels[i].classList.add('hidden');
  var activeTab = document.getElementById('togenadmin-tab-' + tab);
  var activePanel = document.getElementById('togenadmin-panel-' + tab);
  if (activeTab) {
    activeTab.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400', 'hover:text-gray-700', 'dark:hover:text-gray-300');
    activeTab.classList.add('border-togen-teal', 'text-togen-teal');
  }
  if (activePanel) activePanel.classList.remove('hidden');
  if (tab === 'branding') togenadminLoadBranding();
  if (tab === 'notifications' && !_subsListExpanded) togenadminToggleSubsList();
  if (tab === 'documents') { syncadminLoadServiceStatus(); ocrLoadBackends(); togenadminLoadQuotesLocation(); }
  if (tab === 'general' && _aboutContent == null) aboutLoadContent();
  if (tab === 'assistant') togenadminLoadAssistant();
  if (tab === 'agents') { togenadminLoadAgents(); togenadminLoadPrintAgents(); }
  if (tab === 'printing') togenadminLoadPrinting();
  if (tab === 'history') togenadminLoadHistory();
}

// DVI-530: Togen Admin → Assistant tab. Global enabled / accessible_to_users /
// company key (masked) / default context md.
function togenadminLoadAssistant() {
  fetch('/togen-admin/assistant')
    .then(function(r) { return r.ok ? r.json() : null; })
    .then(function(d) {
      if (!d) return;
      document.getElementById('togenadmin-assistant-enabled').checked = !!d.enabled;
      document.getElementById('togenadmin-assistant-accessible').checked = !!d.accessible_to_users;
      var ta = document.getElementById('togenadmin-assistant-context');
      ta.value = d.default_context_md || '';
      ta.disabled = true;
      var ks = document.getElementById('togenadmin-assistant-key-state');
      if (d.api_key && d.api_key.configured) {
        ks.textContent = 'Configured (' + d.api_key.masked + ')';
        document.getElementById('togenadmin-assistant-key').placeholder = 'Leave blank to keep current key';
      } else {
        ks.textContent = 'Not configured';
        document.getElementById('togenadmin-assistant-key').placeholder = 'Enter API key';
      }
    })
    .catch(function() {});
}

function togenadminEditAssistantContext() {
  var ta = document.getElementById('togenadmin-assistant-context');
  ta.disabled = false;
  ta.focus();
}

function togenadminSaveAssistant() {
  var status = document.getElementById('togenadmin-assistant-save-status');
  var body = {
    enabled: document.getElementById('togenadmin-assistant-enabled').checked,
    accessible_to_users: document.getElementById('togenadmin-assistant-accessible').checked,
    default_context_md: document.getElementById('togenadmin-assistant-context').value
  };
  var key = document.getElementById('togenadmin-assistant-key').value.trim();
  if (key) body.api_key = key;  // omit to keep existing key; never send the mask
  status.textContent = 'Saving...';
  fetch('/togen-admin/assistant', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  })
    .then(function(r) { return r.json(); })
    .then(function(d) {
      if (d && d.ok) {
        status.textContent = 'Saved';
        document.getElementById('togenadmin-assistant-key').value = '';
        togenadminLoadAssistant();
      } else {
        status.textContent = (d && d.error) || 'Save failed';
      }
    })
    .catch(function(e) { status.textContent = 'Save failed: ' + e.message; });
}

// DVI-548: Togen Admin → Agents tab. Vision pane mirrors the Enroll-tab
// pending/enrolled display (read-only monitor); Print pane is a placeholder.
function togenadminLoadAgents() {
  var loading = document.getElementById('togenadmin-vision-loading');
  var empty = document.getElementById('togenadmin-vision-empty');
  var list = document.getElementById('togenadmin-vision-list');
  var pendingEmpty = document.getElementById('togenadmin-vision-pending-empty');
  var pendingList = document.getElementById('togenadmin-vision-pending-list');
  loading.classList.remove('hidden'); empty.classList.add('hidden'); list.classList.add('hidden');
  pendingList.innerHTML = ''; pendingEmpty.classList.add('hidden');
  fetch('/vision/agents')
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      loading.classList.add('hidden');
      var agents = (res.ok && res.d.agents) || [];
      var pending = agents.filter(function(a){ return a.status === 'pending'; });
      var enrolled = agents.filter(function(a){ return a.status !== 'pending'; });

      if (!pending.length) { pendingEmpty.classList.remove('hidden'); }
      else {
        pendingList.innerHTML = pending.map(function(a){
          var fp = a.fingerprint || {};
          var seen = a.last_seen ? new Date(a.last_seen * 1000).toLocaleString() : 'never';
          var bits = [
            fp.os_version ? 'OS: ' + esc(fp.os_version) : '',
            fp.logged_in_user ? 'User: ' + esc(fp.logged_in_user) : '',
            fp.agent_version ? 'Agent: ' + esc(fp.agent_version) : '',
            'First seen: ' + esc(seen)
          ].filter(Boolean).join(' · ');
          var name = esc(a.display_name || fp.hostname || a.hostname || a.machine_id);
          return '<li class="rounded-lg border border-amber-300 dark:border-amber-800/60 bg-amber-50 dark:bg-amber-900/15 px-3 py-2">' +
            '<div class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + name + '</div>' +
            '<div class="text-xs text-gray-600 dark:text-gray-300 break-all">' + bits + '</div></li>';
        }).join('');
      }

      if (!enrolled.length) { empty.classList.remove('hidden'); return; }
      list.innerHTML = enrolled.map(function(a){
        var seen = a.last_seen ? new Date(a.last_seen * 1000).toLocaleString() : 'never';
        var stateCls, stateTxt;
        if (a.revoked) { stateCls = 'text-red-500'; stateTxt = 'revoked'; }
        else if (a.online) { stateCls = 'text-green-600 dark:text-green-400'; stateTxt = 'online'; }
        else { stateCls = 'text-gray-400'; stateTxt = 'offline'; }
        return '<li class="rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2">' +
          '<div class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + esc(a.display_name || a.hostname || a.machine_id) + '</div>' +
          '<div class="text-xs text-gray-500 dark:text-gray-400">Last seen: ' + esc(seen) + ' · <span class="' + stateCls + '">' + stateTxt + '</span></div></li>';
      }).join('');
      list.classList.remove('hidden');
    })
    .catch(function(){ loading.classList.add('hidden'); empty.classList.remove('hidden'); });
}

// DVI-667: Togen Admin → Agents → Print pane. Lists pending/enrolled Print
// agents with approve/deny/revoke, plus a per-agent Edit popup to configure
// which discovered printers Togen may use (documents/labels) + published name.
function togenadminLoadPrintAgents() {
  var loading = document.getElementById('togenadmin-print-loading');
  var empty = document.getElementById('togenadmin-print-empty');
  var list = document.getElementById('togenadmin-print-list');
  var pendingEmpty = document.getElementById('togenadmin-print-pending-empty');
  var pendingList = document.getElementById('togenadmin-print-pending-list');
  loading.classList.remove('hidden'); empty.classList.add('hidden'); list.classList.add('hidden');
  pendingList.innerHTML = ''; pendingEmpty.classList.add('hidden');
  fetch('/print/agents')
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      loading.classList.add('hidden');
      var agents = (res.ok && res.d.agents) || [];
      var pending = agents.filter(function(a){ return a.status === 'pending'; });
      var enrolled = agents.filter(function(a){ return a.status !== 'pending'; });

      if (!pending.length) { pendingEmpty.classList.remove('hidden'); }
      else {
        pendingList.innerHTML = pending.map(function(a){
          var fp = a.fingerprint || {};
          var seen = a.last_seen ? new Date(a.last_seen * 1000).toLocaleString() : 'never';
          var bits = [
            fp.os_version ? 'OS: ' + esc(fp.os_version) : '',
            fp.logged_in_user ? 'User: ' + esc(fp.logged_in_user) : '',
            fp.agent_version ? 'Agent: ' + esc(fp.agent_version) : '',
            'First seen: ' + esc(seen)
          ].filter(Boolean).join(' · ');
          var name = esc(fp.hostname || a.hostname || a.machine_id);
          var mid = esc(a.machine_id);
          return '<li class="rounded-lg border border-amber-300 dark:border-amber-800/60 bg-amber-50 dark:bg-amber-900/15 px-3 py-2">' +
            '<div class="flex items-center justify-between gap-2">' +
              '<div class="min-w-0">' +
                '<div class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + name + '</div>' +
                '<div class="text-xs text-gray-600 dark:text-gray-300 break-all">' + bits + '</div>' +
              '</div>' +
              '<div class="flex items-center gap-2 shrink-0">' +
                '<button onclick="printAgentAction(\'' + mid + '\',\'approve\')" class="px-2.5 py-1 text-xs font-medium rounded-lg bg-green-600 hover:bg-green-700 text-white cursor-pointer">Approve</button>' +
                '<button onclick="printAgentAction(\'' + mid + '\',\'deny\')" class="px-2.5 py-1 text-xs font-medium rounded-lg bg-red-600 hover:bg-red-700 text-white cursor-pointer">Deny</button>' +
              '</div>' +
            '</div></li>';
        }).join('');
      }

      if (!enrolled.length) { empty.classList.remove('hidden'); return; }
      list.innerHTML = enrolled.map(function(a){
        var seen = a.last_seen ? new Date(a.last_seen * 1000).toLocaleString() : 'never';
        var stateCls, stateTxt;
        if (a.revoked) { stateCls = 'text-red-500'; stateTxt = 'revoked'; }
        else if (a.online) { stateCls = 'text-green-600 dark:text-green-400'; stateTxt = 'online'; }
        else { stateCls = 'text-gray-400'; stateTxt = 'offline'; }
        var mid = esc(a.machine_id);
        var hostname = esc(a.hostname || a.machine_id);
        var revokeBtn = a.revoked ? '' :
          '<button onclick="printAgentAction(\'' + mid + '\',\'revoke\')" class="px-2.5 py-1 text-xs font-medium rounded-lg border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer">Revoke</button>';
        var editBtn = a.revoked ? '' :
          '<button onclick="printPrintersModalOpen(\'' + mid + '\')" class="px-2.5 py-1 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 cursor-pointer">Edit</button>';
        var deleteBtn = a.revoked ?
          '<button onclick="printDeleteAgent(\'' + mid + '\',\'' + hostname + '\')" class="px-2.5 py-1 text-xs font-medium rounded-lg border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer">Delete</button>' : '';
        return '<li class="rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2">' +
          '<div class="flex items-center justify-between gap-2">' +
            '<div class="min-w-0">' +
              '<div class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + hostname + '</div>' +
              '<div class="text-xs text-gray-500 dark:text-gray-400">Last seen: ' + esc(seen) + ' · <span class="' + stateCls + '">' + stateTxt + '</span></div>' +
            '</div>' +
            '<div class="flex items-center gap-2 shrink-0">' +
              editBtn +
              revokeBtn +
              deleteBtn +
            '</div>' +
          '</div></li>';
      }).join('');
      list.classList.remove('hidden');
    })
    .catch(function(){ loading.classList.add('hidden'); empty.classList.remove('hidden'); });
}

function printAgentAction(machineId, action) {
  fetch('/print/agents/' + encodeURIComponent(machineId) + '/' + action, { method: 'POST' })
    .then(function(r){ return r.json(); })
    .then(function(){ togenadminLoadPrintAgents(); })
    .catch(function(){ togenadminLoadPrintAgents(); });
}

function printDeleteAgent(machineId, hostname) {
  if (!confirm('Delete the revoked agent ' + hostname + '? This removes it from the list along with its stored printers, commands and jobs. It can re-enroll later.')) return;
  fetch('/print/agents/' + encodeURIComponent(machineId), { method: 'DELETE' })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){ if (!res.ok) { alert('Error: ' + (res.d.error || 'delete failed')); } togenadminLoadPrintAgents(); })
    .catch(function(e){ alert('Error: ' + e.message); togenadminLoadPrintAgents(); });
}

// DVI-672: Status / Enroll subtab switcher for the Agents-tab Vision & Print panes.
// Status is the default-focused tab (set in markup); Enroll lazy-loads on first show.
var _tgaEnrollLoaded = { vision: false, print: false };
function togenadminAgentSubtab(pane, sub) {
  ['status', 'enroll'].forEach(function(s){
    var btn = document.getElementById('tga-' + pane + '-subtab-' + s);
    var panel = document.getElementById('tga-' + pane + '-panel-' + s);
    var active = (s === sub);
    if (panel) panel.classList.toggle('hidden', !active);
    if (btn) {
      btn.classList.toggle('border-togen-teal', active);
      btn.classList.toggle('text-togen-teal', active);
      btn.classList.toggle('border-transparent', !active);
      btn.classList.toggle('text-gray-500', !active);
    }
  });
  if (sub === 'enroll' && !_tgaEnrollLoaded[pane]) {
    _tgaEnrollLoaded[pane] = true;
    if (pane === 'vision') togenadminVisionEnrollLoad();
    else togenadminPrintEnrollLoad();
  }
}

// --- Vision Enroll admin tab (mirrors Vision tool → Enroll tab) ---------------
function togenadminVisionEnrollLoad() {
  fetch('/vision/enroll-secret')
    .then(function(r){ return r.json(); })
    .then(function(d){ if (d.open_enroll_secret) document.getElementById('tga-ve-secret').textContent = d.open_enroll_secret; })
    .catch(function(){});

  var loading = document.getElementById('tga-ve-loading');
  var empty = document.getElementById('tga-ve-empty');
  var list = document.getElementById('tga-ve-list');
  var pendingEmpty = document.getElementById('tga-ve-pending-empty');
  var pendingList = document.getElementById('tga-ve-pending-list');
  loading.classList.remove('hidden'); empty.classList.add('hidden'); list.classList.add('hidden');
  pendingList.innerHTML = ''; pendingEmpty.classList.add('hidden');
  fetch('/vision/agents')
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      loading.classList.add('hidden');
      var agents = (res.ok && res.d.agents) || [];
      var pending = agents.filter(function(a){ return a.status === 'pending'; });
      var enrolled = agents.filter(function(a){ return a.status !== 'pending'; });

      if (!pending.length) { pendingEmpty.classList.remove('hidden'); }
      else {
        pendingList.innerHTML = pending.map(function(a){
          var fp = a.fingerprint || {};
          var seen = a.last_seen ? new Date(a.last_seen * 1000).toLocaleString() : 'never';
          var bits = [
            fp.os_version ? 'OS: ' + esc(fp.os_version) : '',
            fp.logged_in_user ? 'User: ' + esc(fp.logged_in_user) : '',
            fp.agent_version ? 'Agent: ' + esc(fp.agent_version) : '',
            'First seen: ' + esc(seen)
          ].filter(Boolean).join(' · ');
          var mid = esc(a.machine_id);
          var name = esc(a.display_name || fp.hostname || a.hostname || a.machine_id);
          return '<li class="rounded-lg border border-amber-300 dark:border-amber-800/60 bg-amber-50 dark:bg-amber-900/15 px-3 py-2">' +
            '<div class="flex items-center justify-between gap-2 flex-wrap">' +
              '<div class="min-w-0">' +
                '<div class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + name + '</div>' +
                '<div class="text-xs text-gray-600 dark:text-gray-300 break-all">' + bits + '</div>' +
              '</div>' +
              '<div class="flex items-center gap-2">' +
                '<button onclick="togenadminVisionApprove(\'' + mid + '\',\'' + name + '\')" class="px-2.5 py-1 text-xs rounded-md border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/30 cursor-pointer">Approve</button>' +
                '<button onclick="togenadminVisionDeny(\'' + mid + '\',\'' + name + '\')" class="px-2.5 py-1 text-xs rounded-md border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer">Deny</button>' +
              '</div>' +
            '</div></li>';
        }).join('');
      }

      if (!enrolled.length) { empty.classList.remove('hidden'); return; }
      list.innerHTML = enrolled.map(function(a){
        var seen = a.last_seen ? new Date(a.last_seen * 1000).toLocaleString() : 'never';
        var stateCls, stateTxt;
        if (a.revoked) { stateCls = 'text-red-500'; stateTxt = 'revoked'; }
        else if (a.online) { stateCls = 'text-green-600 dark:text-green-400'; stateTxt = 'online'; }
        else { stateCls = 'text-gray-400'; stateTxt = 'offline'; }
        var mid = esc(a.machine_id);
        var hostname = esc(a.hostname || a.machine_id);
        var revokeBtn = a.revoked ? '' :
          '<button onclick="togenadminVisionRevoke(\'' + mid + '\',\'' + hostname + '\')" ' +
          'class="px-2 py-1 text-xs rounded-md border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer">Revoke</button>';
        var deleteBtn = a.revoked ?
          '<button onclick="togenadminVisionDelete(\'' + mid + '\',\'' + hostname + '\')" ' +
          'class="px-2 py-1 text-xs rounded-md border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer">Delete</button>' : '';
        return '<li class="flex items-center justify-between gap-2 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2">' +
          '<div class="min-w-0">' +
            '<div class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + hostname + '</div>' +
            '<div class="text-xs text-gray-500 dark:text-gray-400">Last seen: ' + esc(seen) + ' · <span class="' + stateCls + '">' + stateTxt + '</span></div>' +
          '</div><div class="flex items-center gap-2 shrink-0">' + revokeBtn + deleteBtn + '</div></li>';
      }).join('');
      list.classList.remove('hidden');
    })
    .catch(function(){ loading.classList.add('hidden'); empty.classList.remove('hidden'); });
}

function togenadminVisionQuickEnroll() {
  var btn = document.getElementById('tga-ve-quick-btn');
  btn.disabled = true; btn.classList.add('opacity-50', 'cursor-not-allowed');
  fetch('/vision/agents/enroll', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({}) })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed');
      if (!res.ok || !res.d.enrollment_token) { alert('Error: ' + (res.d.error || 'enrollment failed')); return; }
      var ps1 = window.location.origin + '/enroll/bootstrap.ps1?token=' + encodeURIComponent(res.d.enrollment_token);
      document.getElementById('tga-ve-quick-cmd').textContent = 'iwr "' + ps1 + '" -UseBasicParsing | iex';
      document.getElementById('tga-ve-quick-ps1').href = ps1;
      document.getElementById('tga-ve-quick-ttl').textContent = 'Valid for 60 minutes, single use. The machine enrolls as active automatically.';
      document.getElementById('tga-ve-quick-result').classList.remove('hidden');
    })
    .catch(function(e){ btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed'); alert('Error: ' + e.message); });
}

function togenadminVisionRotateSecret() {
  if (!confirm('Rotate the open-enroll secret? The current secret stops working immediately for new enrollments. Already-enrolled agents are unaffected.')) return;
  var btn = document.getElementById('tga-ve-rotate-btn');
  btn.disabled = true; btn.classList.add('opacity-50', 'cursor-not-allowed');
  fetch('/vision/enroll-secret/rotate', { method: 'POST' })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed');
      if (!res.ok || !res.d.open_enroll_secret) { alert('Error: ' + (res.d.error || 'rotate failed')); return; }
      document.getElementById('tga-ve-secret').textContent = res.d.open_enroll_secret;
    })
    .catch(function(e){ btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed'); alert('Error: ' + e.message); });
}

function togenadminVisionApprove(machineId, name) {
  if (!confirm('Approve ' + name + '? Its check-ins will be accepted and its footage will surface.')) return;
  fetch('/vision/agents/' + encodeURIComponent(machineId) + '/approve', { method: 'POST' })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){ if (!res.ok) { alert('Error: ' + (res.d.error || 'approve failed')); return; } togenadminVisionEnrollLoad(); togenadminLoadAgents(); })
    .catch(function(e){ alert('Error: ' + e.message); });
}

function togenadminVisionDeny(machineId, name) {
  if (!confirm('Deny ' + name + '? Its check-ins will be refused until it re-enrolls and is approved.')) return;
  fetch('/vision/agents/' + encodeURIComponent(machineId) + '/deny', { method: 'POST' })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){ if (!res.ok) { alert('Error: ' + (res.d.error || 'deny failed')); return; } togenadminVisionEnrollLoad(); togenadminLoadAgents(); })
    .catch(function(e){ alert('Error: ' + e.message); });
}

function togenadminVisionRevoke(machineId, hostname) {
  if (!confirm('Revoke the agent key for ' + hostname + '? Future check-ins from this machine will be rejected until it re-enrolls.')) return;
  fetch('/vision/agents/' + encodeURIComponent(machineId) + '/revoke', { method: 'POST' })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){ if (!res.ok) { alert('Error: ' + (res.d.error || 'revoke failed')); return; } togenadminVisionEnrollLoad(); togenadminLoadAgents(); })
    .catch(function(e){ alert('Error: ' + e.message); });
}

function togenadminVisionDelete(machineId, hostname) {
  if (!confirm('Delete the revoked agent ' + hostname + '? This removes it from the list along with its stored check-ins and commands. It can re-enroll later.')) return;
  fetch('/vision/agents/' + encodeURIComponent(machineId), { method: 'DELETE' })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){ if (!res.ok) { alert('Error: ' + (res.d.error || 'delete failed')); } togenadminVisionEnrollLoad(); togenadminLoadAgents(); })
    .catch(function(e){ alert('Error: ' + e.message); togenadminVisionEnrollLoad(); togenadminLoadAgents(); });
}

// --- Print Enroll admin tab (Print Agent onboarding workflow) -----------------
function togenadminPrintEnrollLoad() {
  fetch('/print/enroll-secret')
    .then(function(r){ return r.json(); })
    .then(function(d){ if (d.open_enroll_secret) document.getElementById('tga-pe-secret').textContent = d.open_enroll_secret; })
    .catch(function(){});
}

function togenadminPrintQuickEnroll() {
  var btn = document.getElementById('tga-pe-quick-btn');
  btn.disabled = true; btn.classList.add('opacity-50', 'cursor-not-allowed');
  fetch('/print/agents/enroll', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({}) })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed');
      if (!res.ok || !res.d.enrollment_token) { alert('Error: ' + (res.d.error || 'enrollment failed')); return; }
      var ps1 = window.location.origin + '/enroll/print-bootstrap.ps1?token=' + encodeURIComponent(res.d.enrollment_token);
      document.getElementById('tga-pe-quick-cmd').textContent = 'iwr "' + ps1 + '" -UseBasicParsing | iex';
      document.getElementById('tga-pe-quick-ps1').href = ps1;
      document.getElementById('tga-pe-quick-ttl').textContent = 'Valid for 60 minutes, single use. The machine enrolls as active automatically.';
      document.getElementById('tga-pe-quick-result').classList.remove('hidden');
    })
    .catch(function(e){ btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed'); alert('Error: ' + e.message); });
}

function togenadminPrintRotateSecret() {
  if (!confirm('Rotate the open-enroll secret? The current secret stops working immediately for new enrollments. Already-enrolled agents are unaffected.')) return;
  var btn = document.getElementById('tga-pe-rotate-btn');
  btn.disabled = true; btn.classList.add('opacity-50', 'cursor-not-allowed');
  fetch('/print/enroll-secret/rotate', { method: 'POST' })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed');
      if (!res.ok || !res.d.open_enroll_secret) { alert('Error: ' + (res.d.error || 'rotate failed')); return; }
      document.getElementById('tga-pe-secret').textContent = res.d.open_enroll_secret;
    })
    .catch(function(e){ btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed'); alert('Error: ' + e.message); });
}

var _printPrintersMachineId = null;

function printPrintersModalOpen(machineId) {
  _printPrintersMachineId = machineId;
  var modal = document.getElementById('print-printers-modal');
  var tbody = document.getElementById('print-printers-tbody');
  var tableWrap = document.getElementById('print-printers-table-wrap');
  var emptyEl = document.getElementById('print-printers-empty');
  tbody.innerHTML = '';
  tableWrap.classList.add('hidden'); emptyEl.classList.add('hidden');
  document.getElementById('print-printers-save-status').textContent = '';
  document.getElementById('print-printers-modal-sub').textContent = 'Loading...';
  modal.classList.remove('hidden');
  fetch('/print/agents/' + encodeURIComponent(machineId) + '/printers')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) { document.getElementById('print-printers-modal-sub').textContent = 'Failed to load.'; return; }
      document.getElementById('print-printers-modal-sub').textContent =
        (d.hostname || d.machine_id) + ' · ' + (d.printers || []).length + ' printer(s)';
      var printers = d.printers || [];
      if (!printers.length) { emptyEl.classList.remove('hidden'); return; }
      tbody.innerHTML = printers.map(function(p){
        var def = p.is_default ? ' <span class="text-xs text-togen-teal">(default)</span>' : '';
        var meta = [p.driver, p.port, p.printer_status].filter(Boolean).map(esc).join(' · ');
        return '<tr data-pid="' + p.id + '" class="border-b border-gray-100 dark:border-gray-800">' +
          '<td class="py-2 pr-3 align-top">' +
            '<div class="text-sm font-medium text-gray-800 dark:text-gray-100">' + esc(p.raw_name) + def + '</div>' +
            '<div class="text-xs text-gray-400 dark:text-gray-500">' + meta + '</div>' +
          '</td>' +
          '<td class="py-2 px-2 text-center align-top"><input type="checkbox" class="pp-included cursor-pointer" ' + (p.included ? 'checked' : '') + '></td>' +
          '<td class="py-2 px-2 text-center align-top"><input type="checkbox" class="pp-docs cursor-pointer" ' + (p.for_documents ? 'checked' : '') + '></td>' +
          '<td class="py-2 px-2 text-center align-top"><input type="checkbox" class="pp-labels cursor-pointer" ' + (p.for_labels ? 'checked' : '') + '></td>' +
          '<td class="py-2 pl-2 align-top"><input type="text" class="pp-pub w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm px-2 py-1" placeholder="' + esc(p.raw_name) + '" value="' + esc(p.published_name || '') + '"></td>' +
          '</tr>';
      }).join('');
      tableWrap.classList.remove('hidden');
    })
    .catch(function(){ document.getElementById('print-printers-modal-sub').textContent = 'Failed to load.'; });
}

function printPrintersModalClose() {
  document.getElementById('print-printers-modal').classList.add('hidden');
  _printPrintersMachineId = null;
}

function printPrintersSave() {
  if (!_printPrintersMachineId) return;
  var rows = document.querySelectorAll('#print-printers-tbody tr');
  var printers = [];
  rows.forEach(function(tr){
    printers.push({
      id: parseInt(tr.getAttribute('data-pid'), 10),
      included: tr.querySelector('.pp-included').checked,
      for_documents: tr.querySelector('.pp-docs').checked,
      for_labels: tr.querySelector('.pp-labels').checked,
      published_name: tr.querySelector('.pp-pub').value.trim()
    });
  });
  var status = document.getElementById('print-printers-save-status');
  status.textContent = 'Saving...';
  fetch('/print/agents/' + encodeURIComponent(_printPrintersMachineId) + '/printers', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ printers: printers })
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d && d.ok) { status.textContent = 'Saved'; setTimeout(printPrintersModalClose, 600); }
      else { status.textContent = (d && d.error) || 'Save failed'; }
    })
    .catch(function(e){ status.textContent = 'Save failed: ' + e.message; });
}

// DVI-548: Togen Admin → Printing tab. Default document/label printer selection.
// Printer options come from Print agents (none yet); selections persist.
function togenadminLoadPrinting() {
  fetch('/togen-admin/printing')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      var avail = d.available_printers || {};
      var docPrinters = avail.documents || [];
      var labelPrinters = avail.labels || [];
      var hint = document.getElementById('togenadmin-printing-empty-hint');
      if (docPrinters.length || labelPrinters.length) hint.classList.add('hidden'); else hint.classList.remove('hidden');
      _togenadminFillPrinterSelect('togenadmin-printing-document', docPrinters, d.document_printer);
      _togenadminFillPrinterSelect('togenadmin-printing-label', labelPrinters, d.label_printer);
      var enabledCb = document.getElementById('togenadmin-printing-enabled');
      if (enabledCb) enabledCb.checked = (d.enabled !== false);
      document.getElementById('togenadmin-printing-save-status').textContent = '';
    })
    .catch(function(){});
}

function _togenadminFillPrinterSelect(id, printers, selected) {
  var sel = document.getElementById(id);
  var opts = ['<option value="">— None —</option>'];
  printers.forEach(function(p){
    var v = esc(p);
    opts.push('<option value="' + v + '">' + v + '</option>');
  });
  sel.innerHTML = opts.join('');
  sel.value = selected || '';
}

function togenadminSavePrinting() {
  var status = document.getElementById('togenadmin-printing-save-status');
  status.textContent = 'Saving...';
  fetch('/togen-admin/printing', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      document_printer: document.getElementById('togenadmin-printing-document').value,
      label_printer: document.getElementById('togenadmin-printing-label').value,
      enabled: document.getElementById('togenadmin-printing-enabled').checked
    })
  })
    .then(function(r){ return r.json(); })
    .then(function(d){ status.textContent = (d && d.ok) ? 'Saved' : ((d && d.error) || 'Save failed'); })
    .catch(function(e){ status.textContent = 'Save failed: ' + e.message; });
}

// DVI-808: Togen Admin → Documents → Quotes pane
function togenadminLoadQuotesLocation() {
  fetch('/togen-admin/quotes-location')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      var el = document.getElementById('togenadmin-quotes-location');
      if (el) el.value = d.shared_quotes_location || '';
      var s = document.getElementById('togenadmin-quotes-location-status');
      if (s) s.textContent = '';
    })
    .catch(function(){});
}

function togenadminSaveQuotesLocation() {
  var status = document.getElementById('togenadmin-quotes-location-status');
  status.textContent = 'Saving...';
  fetch('/togen-admin/quotes-location', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ shared_quotes_location: document.getElementById('togenadmin-quotes-location').value.trim() })
  })
    .then(function(r){ return r.json(); })
    .then(function(d){ status.textContent = (d && d.ok) ? 'Saved' : ((d && d.error) || 'Save failed'); })
    .catch(function(e){ status.textContent = 'Save failed: ' + e.message; });
}

function togenadminLoadHistory() {
  fetch('/togen-admin/history')
    .then(function(r) { return r.ok ? r.json() : null; })
    .then(function(d) {
      if (!d) return;
      var cap = (d && d.capture) || {};
      ['print','wor-admin','rpm','single-carrier-sheet','mts-label',
       'file-browser','ocr-tool','takeoff-bridge','project-builder'
      ].forEach(function(key) {
        var cb = document.getElementById('togenadmin-history-capture-' + key);
        if (cb) cb.checked = cap[key] !== false;
      });
      document.getElementById('togenadmin-history-save-status').textContent = '';
    });
}

function togenadminSaveHistory() {
  var status = document.getElementById('togenadmin-history-save-status');
  status.textContent = 'Saving…';
  var cap = {};
  ['print','wor-admin','rpm','single-carrier-sheet','mts-label',
   'file-browser','ocr-tool','takeoff-bridge','project-builder'].forEach(function(key) {
    var cb = document.getElementById('togenadmin-history-capture-' + key);
    if (cb) cap[key] = cb.checked;
  });
  fetch('/togen-admin/history', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ capture: cap })
  })
    .then(function(r) { return r.json(); })
    .then(function(d) { status.textContent = (d && d.ok) ? 'Saved' : ((d && d.error) || 'Save failed'); })
    .catch(function(e) { status.textContent = 'Save failed: ' + e.message; });
}

function togenadminLoadVersion() {
  var loading = document.getElementById('togenadmin-loading');
  var info = document.getElementById('togenadmin-info');
  var err = document.getElementById('togenadmin-error');

  loading.classList.remove('hidden');
  info.classList.add('hidden');
  err.classList.add('hidden');

  fetch('/togen-admin/version')
    .then(function(r){ return r.json(); })
    .then(function(d){
      loading.classList.add('hidden');
      if (d.error) {
        err.classList.remove('hidden');
        document.getElementById('togenadmin-error-msg').textContent = d.error;
        return;
      }
      info.classList.remove('hidden');
      document.getElementById('togenadmin-local-hash').textContent = d.local.hash;
      document.getElementById('togenadmin-local-date').textContent = 'Deployed: ' + new Date(d.local.date).toLocaleString();
      document.getElementById('togenadmin-local-msg').textContent = d.local.message;
      document.getElementById('togenadmin-remote-hash').textContent = d.remote.hash;
      document.getElementById('togenadmin-remote-date').textContent = 'Committed: ' + new Date(d.remote.date).toLocaleString();
      document.getElementById('togenadmin-remote-msg').textContent = d.remote.message;
      if (d.update_available) {
        document.getElementById('togenadmin-update-available').classList.remove('hidden');
        document.getElementById('togenadmin-uptodate').classList.add('hidden');
      } else {
        document.getElementById('togenadmin-uptodate').classList.remove('hidden');
        document.getElementById('togenadmin-update-available').classList.add('hidden');
      }
      // DVI-449: keep the Home tile update chip in sync with the manual check.
      _updateTileStatus = { state: d.update_available ? 'available' : 'current' };
    })
    .catch(function(e){
      loading.classList.add('hidden');
      err.classList.remove('hidden');
      document.getElementById('togenadmin-error-msg').textContent = 'Failed to load version info: ' + e.message;
    });
  togenadminLoadSchedule();
}

// DVI-768: reflect the persisted scheduled-update state in the checkbox/banner.
function togenadminLoadSchedule() {
  fetch('/togen-admin/scheduled-update')
    .then(function(r){ return r.json(); })
    .then(function(d){
      var s = (d && d.schedule) || {};
      var cb = document.getElementById('togenadmin-sched-update-cb');
      var status = document.getElementById('togenadmin-sched-update-status');
      if (!cb || !status) return;
      if (s.enabled && s.status === 'pending') {
        cb.checked = true;
        var when = s.scheduled_for ? new Date(s.scheduled_for).toLocaleString() : '2:00 AM';
        status.textContent = 'Update scheduled for ' + when + ' — version "' + (s.version || '') + '".';
        status.classList.remove('hidden');
      } else {
        cb.checked = false;
        status.classList.add('hidden');
        status.textContent = '';
      }
    })
    .catch(function(){});
}

// DVI-768: checkbox toggled — open the version popup to schedule, or cancel.
function togenadminScheduleToggle() {
  var cb = document.getElementById('togenadmin-sched-update-cb');
  if (cb.checked) {
    var modal = document.getElementById('togenadmin-sched-version-modal');
    var input = document.getElementById('togenadmin-sched-version-input');
    var error = document.getElementById('togenadmin-sched-version-error');
    error.classList.add('hidden');
    error.textContent = '';
    input.value = '';
    fetch('/togen-admin/public-version')
      .then(function(r){ return r.json(); })
      .then(function(d){
        var cur = (d && d.version && d.version !== 'Unknown') ? d.version : '';
        input.value = _togenadminIncrementVersion(cur) || cur;
      })
      .catch(function(){})
      .finally(function(){
        modal.classList.remove('hidden');
        input.focus();
        input.select();
      });
  } else {
    // Unchecking cancels any pending schedule.
    fetch('/togen-admin/scheduled-update', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({enabled: false})
    }).then(function(){ togenadminLoadSchedule(); }).catch(function(){});
  }
}

function togenadminScheduleCancel() {
  document.getElementById('togenadmin-sched-version-modal').classList.add('hidden');
  // Revert the checkbox since the user backed out.
  togenadminLoadSchedule();
}

function togenadminScheduleApply() {
  var input = document.getElementById('togenadmin-sched-version-input');
  var error = document.getElementById('togenadmin-sched-version-error');
  var version = input.value.trim();
  if (!version) {
    error.textContent = 'Version is required.';
    error.classList.remove('hidden');
    return;
  }
  error.classList.add('hidden');
  fetch('/togen-admin/scheduled-update', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({enabled: true, version: version})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) {
        error.textContent = d.error;
        error.classList.remove('hidden');
        return;
      }
      document.getElementById('togenadmin-sched-version-modal').classList.add('hidden');
      togenadminLoadSchedule();
    })
    .catch(function(e){
      error.textContent = 'Failed to schedule update: ' + e.message;
      error.classList.remove('hidden');
    });
}

// DVI-554: increment the trailing number of a version string by 1.
// "1.0.3" -> "1.0.4", "v2" -> "v3", "Build 41" -> "Build 42".
function _togenadminIncrementVersion(v) {
  if (!v) return v;
  var m = v.match(/(\d+)(\D*)$/);
  if (!m) return v;
  var next = (parseInt(m[1], 10) + 1).toString();
  return v.slice(0, m.index) + next + m[2];
}

// DVI-554: prompt admin for the published version number before applying an update.
function togenadminPromptUpdateVersion() {
  var modal = document.getElementById('togenadmin-update-version-modal');
  var input = document.getElementById('togenadmin-update-version-input');
  var error = document.getElementById('togenadmin-update-version-error');
  error.classList.add('hidden');
  error.textContent = '';
  input.value = '';
  // Prefill with current published version incremented by 1.
  fetch('/togen-admin/public-version')
    .then(function(r){ return r.json(); })
    .then(function(d){
      var cur = (d && d.version && d.version !== 'Unknown') ? d.version : '';
      input.value = _togenadminIncrementVersion(cur) || cur;
    })
    .catch(function(){})
    .finally(function(){
      modal.classList.remove('hidden');
      input.focus();
      input.select();
    });
}

function togenadminUpdateVersionCancel() {
  document.getElementById('togenadmin-update-version-modal').classList.add('hidden');
}

// DVI-554: save the published version, then apply the update.
function togenadminUpdateVersionApply() {
  var input = document.getElementById('togenadmin-update-version-input');
  var error = document.getElementById('togenadmin-update-version-error');
  var version = input.value.trim();
  if (!version) {
    error.textContent = 'Version number is required.';
    error.classList.remove('hidden');
    return;
  }
  error.classList.add('hidden');
  fetch('/togen-admin/public-version', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({version: version})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) {
        error.textContent = d.error;
        error.classList.remove('hidden');
        return;
      }
      // Keep the Public Version pane in sync, then run the update.
      var pv = document.getElementById('togenadmin-public-version');
      if (pv) pv.value = d.version;
      document.getElementById('togenadmin-update-version-modal').classList.add('hidden');
      togenadminRunUpdate();
    })
    .catch(function(e){
      error.textContent = 'Failed to save version: ' + e.message;
      error.classList.remove('hidden');
    });
}

function togenadminRunUpdate() {
  var btn = document.getElementById('togenadmin-update-btn');
  var status = document.getElementById('togenadmin-update-status');
  btn.disabled = true;
  btn.classList.add('opacity-50', 'cursor-not-allowed');
  status.textContent = 'Initiating update...';
  status.className = 'text-sm mt-2 text-gray-600 dark:text-gray-400';

  fetch('/togen-admin/update', {method: 'POST'})
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) {
        btn.disabled = false;
        btn.classList.remove('opacity-50', 'cursor-not-allowed');
        status.textContent = 'Error: ' + d.error;
        status.className = 'text-sm mt-2 text-red-500';
        return;
      }
      status.textContent = 'Update in progress — waiting for restart…';
      status.className = 'text-sm mt-2 text-green-600 dark:text-green-400';
      // Poll the server until it comes back up, then reload
      var poll = setInterval(function() {
        fetch('/togen-admin/version', {method: 'GET'})
          .then(function(r){ if (r.ok) { clearInterval(poll); location.reload(); } })
          .catch(function(){});
      }, 3000);
    })
    .catch(function(e){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      status.textContent = 'Error: ' + e.message;
      status.className = 'text-sm mt-2 text-red-500';
    });
}

// ===== TOOL VISIBILITY ADMIN (DVI-254) =====
var _toolVisibilityLabels = {
  'project-builder': 'Project Builder',
  'takeoff-bridge': 'Take-off Bridge',
  'prod-run-merge': 'Production Run',
  'work-order': 'Work Order Request',
  'wor-admin': 'Work Order Admin',
  'sync-admin': 'Sync Admin',
  'file-browser': 'File Browser',
  'simple-qr': 'Simple QR Creator',
  'single-carrier-sheet': 'Single Carrier Sheet',
  'mts-label': 'MTS Label Creator',
  'scada-view': 'SCADA View',
  'vision': 'Vision',
  'equipment-manuals': 'Equipment Manuals',
  'calendar': 'Calendar',
  'ocr-tool': 'OCR',
  'email': 'Email',
  'auditor': 'Auditor',
  'repair-parts-manager': 'Repair Parts Manager',
  'bots': 'Bots'
};

var _dragSrcWrap = null;
var _dragSrcSub = null;

var _TOGENADMIN_DRAG_SVG =
  '<div class="cursor-grab active:cursor-grabbing text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 shrink-0 select-none" title="Drag to reorder">' +
    '<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">' +
      '<path d="M7 2a2 2 0 10.001 4.001A2 2 0 007 2zm0 6a2 2 0 10.001 4.001A2 2 0 007 8zm0 6a2 2 0 10.001 4.001A2 2 0 007 14zm6-8a2 2 0 10-.001-4.001A2 2 0 0013 6zm0 2a2 2 0 10.001 4.001A2 2 0 0013 8zm0 6a2 2 0 10.001 4.001A2 2 0 0013 14z" />' +
    '</svg>' +
  '</div>';

// Top-level tool drag: reorders whole wrappers (a wrapper carries its subitems).
function _togenadminAttachToolDrag(wrap, container) {
  wrap.setAttribute('draggable', 'true');
  wrap.addEventListener('dragstart', function(e) {
    _dragSrcWrap = wrap;
    wrap.classList.add('opacity-50');
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData('text/plain', wrap.getAttribute('data-tool-id'));
  });
  wrap.addEventListener('dragend', function() {
    wrap.classList.remove('opacity-50');
    var kids = container.querySelectorAll('[data-tool-id]');
    for (var i = 0; i < kids.length; i++) kids[i].classList.remove('ring-2', 'ring-togen-teal');
    _dragSrcWrap = null;
  });
  wrap.addEventListener('dragover', function(e) {
    if (!_dragSrcWrap) return;
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
    wrap.classList.add('ring-2', 'ring-togen-teal');
  });
  wrap.addEventListener('dragleave', function() {
    wrap.classList.remove('ring-2', 'ring-togen-teal');
  });
  wrap.addEventListener('drop', function(e) {
    if (!_dragSrcWrap) return;
    e.preventDefault();
    wrap.classList.remove('ring-2', 'ring-togen-teal');
    if (_dragSrcWrap !== wrap) {
      var all = Array.prototype.slice.call(container.children);
      var s = all.indexOf(_dragSrcWrap), d = all.indexOf(wrap);
      if (s < d) container.insertBefore(_dragSrcWrap, wrap.nextSibling);
      else container.insertBefore(_dragSrcWrap, wrap);
    }
  });
}

// Subitem (WO type) drag: reorders only within the subitem container.
function _togenadminAttachSubDrag(subRow, subWrap) {
  subRow.setAttribute('draggable', 'true');
  subRow.addEventListener('dragstart', function(e) {
    e.stopPropagation();
    _dragSrcSub = subRow;
    subRow.classList.add('opacity-50');
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData('text/plain', subRow.getAttribute('data-wor-type'));
  });
  subRow.addEventListener('dragend', function(e) {
    e.stopPropagation();
    subRow.classList.remove('opacity-50');
    var kids = subWrap.querySelectorAll('[data-wor-type]');
    for (var i = 0; i < kids.length; i++) kids[i].classList.remove('border-togen-teal');
    _dragSrcSub = null;
  });
  subRow.addEventListener('dragover', function(e) {
    if (!_dragSrcSub) return;
    e.preventDefault();
    e.stopPropagation();
    e.dataTransfer.dropEffect = 'move';
    subRow.classList.add('border-togen-teal');
  });
  subRow.addEventListener('dragleave', function(e) {
    e.stopPropagation();
    subRow.classList.remove('border-togen-teal');
  });
  subRow.addEventListener('drop', function(e) {
    if (!_dragSrcSub) return;
    e.preventDefault();
    e.stopPropagation();
    subRow.classList.remove('border-togen-teal');
    if (_dragSrcSub !== subRow) {
      var all = Array.prototype.slice.call(subWrap.children);
      var s = all.indexOf(_dragSrcSub), d = all.indexOf(subRow);
      if (s < d) subWrap.insertBefore(_dragSrcSub, subRow.nextSibling);
      else subWrap.insertBefore(_dragSrcSub, subRow);
    }
  });
}

function togenadminRenderToolbarVisibility() {
  var container = document.getElementById('togenadmin-toolbars-list');
  if (!container) return;
  container.innerHTML = '';
  _toolbarOrder.forEach(function(tid) {
    var row = _toolbarVisibility[tid] || {admins: true, users: true};
    var label = _toolbarLabels[tid] || tid;
    var wrap = document.createElement('div');
    wrap.className = 'flex items-center gap-3 p-3 rounded-xl bg-gray-50 dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700';
    wrap.innerHTML =
      '<span class="flex-1 text-sm font-medium text-gray-800 dark:text-gray-100">' + esc(label) + '</span>' +
      '<span class="w-16 flex justify-center">' +
        '<input type="checkbox" data-toolbar-id="' + tid + '" data-scope="admins"' + (row.admins ? ' checked' : '') +
        ' class="h-5 w-5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer" />' +
      '</span>' +
      '<span class="w-16 flex justify-center">' +
        '<input type="checkbox" data-toolbar-id="' + tid + '" data-scope="users"' + (row.users ? ' checked' : '') +
        ' class="h-5 w-5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer" />' +
      '</span>';
    container.appendChild(wrap);
  });
}

function togenadminSaveToolbarVisibility() {
  var btn = document.getElementById('togenadmin-toolbars-save-btn');
  var status = document.getElementById('togenadmin-toolbars-save-status');
  var payload = {};
  _toolbarOrder.forEach(function(tid){ payload[tid] = {admins: false, users: false}; });
  document.querySelectorAll('#togenadmin-toolbars-list input[data-toolbar-id]').forEach(function(c){
    var tid = c.getAttribute('data-toolbar-id');
    var scope = c.getAttribute('data-scope');
    if (payload[tid]) payload[tid][scope] = c.checked;
  });
  btn.disabled = true;
  btn.classList.add('opacity-50', 'cursor-not-allowed');
  status.textContent = 'Saving...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';
  fetch('/togen-admin/toolbar-visibility', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({toolbars: payload})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      if (d.error) {
        status.textContent = 'Error: ' + d.error;
        status.className = 'text-sm text-red-500';
      } else {
        _toolbarVisibility = d.toolbars;
        status.textContent = 'Saved — reload to apply to your top bar.';
        status.className = 'text-sm text-green-600 dark:text-green-400';
        setTimeout(function(){ status.textContent = ''; }, 4000);
      }
    })
    .catch(function(e){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      status.textContent = 'Error: ' + e.message;
      status.className = 'text-sm text-red-500';
    });
}

function togenadminRenderToolVisibility() {
  var container = document.getElementById('togenadmin-tools-list');
  if (!container) return;
  container.innerHTML = '';
  // Use saved order, falling back to default
  var ids = _toolOrder.filter(function(tid){ return _toolVisibilityLabels[tid]; });
  var allKnown = Object.keys(_toolVisibilityLabels);
  allKnown.forEach(function(tid){ if (ids.indexOf(tid) === -1) ids.push(tid); });

  ids.forEach(function(tid) {
    var checked = _toolVisibility[tid] !== false;
    var label = _toolVisibilityLabels[tid] || tid;

    var wrap = document.createElement('div');
    wrap.setAttribute('data-tool-id', tid);
    wrap.className = 'rounded-xl';

    var row = document.createElement('div');
    row.className = 'flex items-center gap-3 p-3 rounded-xl bg-gray-50 dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors';
    row.innerHTML =
      _TOGENADMIN_DRAG_SVG +
      '<label class="flex items-center gap-3 flex-1 cursor-pointer">' +
        '<input type="checkbox" data-tool-id="' + tid + '"' + (checked ? ' checked' : '') +
        ' class="h-5 w-5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer" />' +
        '<span class="text-sm font-medium text-gray-800 dark:text-gray-100">' + esc(label) + '</span>' +
      '</label>';
    // Every tool carries a right-justified settings gear (DVI-588). Vision keeps
    // its dedicated Vision Settings modal (now also editing name/description);
    // all other tools open the generic Tool Settings dialog.
    var gear = document.createElement('button');
    gear.type = 'button';
    gear.title = (tid === 'vision') ? 'Vision Settings' : 'Tool Settings';
    gear.className = 'shrink-0 p-1.5 rounded-lg text-gray-400 hover:text-togen-teal hover:bg-togen-teal/10 transition-colors cursor-pointer';
    (function(t){
      gear.onclick = function(e){
        e.preventDefault();
        e.stopPropagation();
        if (t === 'vision') visionSettingsOpen();
        else toolEditOpen(t);
      };
    })(tid);
    gear.innerHTML =
      '<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">' +
        '<path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z" />' +
        '<circle cx="12" cy="12" r="3" />' +
      '</svg>';
    row.appendChild(gear);
    wrap.appendChild(row);
    _togenadminAttachToolDrag(wrap, container);

    // Work Order Request carries its WO-type subitems (DVI-349).
    if (tid === 'work-order') {
      var subWrap = document.createElement('div');
      subWrap.id = 'togenadmin-wortype-list';
      subWrap.className = 'ml-9 mt-2 space-y-2';
      var subIds = _worTypeOrder.filter(function(w){ return _worTypeLabels[w]; });
      Object.keys(_worTypeLabels).forEach(function(w){ if (subIds.indexOf(w) === -1) subIds.push(w); });
      subIds.forEach(function(w) {
        var subChecked = _worTypeVisibility[w] !== false;
        var subLabel = _worTypeLabels[w] || w;
        var subRow = document.createElement('div');
        subRow.setAttribute('data-wor-type', w);
        subRow.className = 'flex items-center gap-3 p-3 rounded-xl bg-white dark:bg-gray-900 border-2 border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors';
        subRow.innerHTML =
          _TOGENADMIN_DRAG_SVG +
          '<label class="flex items-center gap-3 flex-1 cursor-pointer">' +
            '<input type="checkbox" data-wor-type="' + w + '"' + (subChecked ? ' checked' : '') +
            ' class="h-5 w-5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer" />' +
            '<span class="text-sm font-medium text-gray-700 dark:text-gray-200">' + esc(subLabel) + '</span>' +
          '</label>';
        _togenadminAttachSubDrag(subRow, subWrap);
        subWrap.appendChild(subRow);
      });
      wrap.appendChild(subWrap);
    }

    container.appendChild(wrap);
  });
}

function togenadminSaveToolVisibility() {
  var btn = document.getElementById('togenadmin-tools-save-btn');
  var status = document.getElementById('togenadmin-tools-save-status');

  var visPayload = {};
  document.querySelectorAll('#togenadmin-tools-list input[data-tool-id]').forEach(function(c){
    visPayload[c.getAttribute('data-tool-id')] = c.checked;
  });
  // Top-level order comes from the wrapper positions (direct children).
  var orderPayload = [];
  document.querySelectorAll('#togenadmin-tools-list > [data-tool-id]').forEach(function(w){
    orderPayload.push(w.getAttribute('data-tool-id'));
  });

  // WO-type subitems (visibility + order) live under the work-order wrapper.
  var worVisPayload = {};
  document.querySelectorAll('#togenadmin-wortype-list input[data-wor-type]').forEach(function(c){
    worVisPayload[c.getAttribute('data-wor-type')] = c.checked;
  });
  var worOrderPayload = [];
  document.querySelectorAll('#togenadmin-wortype-list > [data-wor-type]').forEach(function(r){
    worOrderPayload.push(r.getAttribute('data-wor-type'));
  });

  btn.disabled = true;
  btn.classList.add('opacity-50', 'cursor-not-allowed');
  status.textContent = 'Saving...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';

  var pending = 4, anyError = null;
  function checkDone() {
    if (--pending > 0) return;
    btn.disabled = false;
    btn.classList.remove('opacity-50', 'cursor-not-allowed');
    if (anyError) {
      status.textContent = 'Error: ' + anyError;
      status.className = 'text-sm text-red-500';
    } else {
      status.textContent = 'Saved';
      status.className = 'text-sm text-green-600 dark:text-green-400';
      renderTileGrid();
      if (typeof worRenderTypeSelector === 'function') worRenderTypeSelector();
      if (typeof woradminRenderTabs === 'function') woradminRenderTabs();
      notifyBuildGridRows('us-notify-grid');
      notifyBuildGridRows('woradmin-notify-grid');
      setTimeout(function(){ status.textContent = ''; }, 3000);
    }
  }

  function post(url, body, onOk) {
    fetch(url, {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify(body)
    })
      .then(function(r){ return r.json(); })
      .then(function(d){ if (d.error) { anyError = d.error; } else { onOk(d); } checkDone(); })
      .catch(function(e){ anyError = e.message; checkDone(); });
  }

  post('/togen-admin/tool-visibility', {tools: visPayload}, function(d){ _toolVisibility = d.tools; });
  post('/togen-admin/tool-order', {order: orderPayload}, function(d){ _toolOrder = d.order; });
  post('/togen-admin/wor-type-visibility', {types: worVisPayload}, function(d){ _worTypeVisibility = d.types; });
  post('/togen-admin/wor-type-order', {order: worOrderPayload}, function(d){ _worTypeOrder = d.order; });
}

// ===== VISION SETTINGS (Togen Admin) =====
function visionSettingsOpen() {
  var status = document.getElementById('vision-settings-status');
  status.textContent = '';
  // Prefill the name/description overrides (DVI-588). Placeholders show the
  // built-in defaults so a blank field clearly means "use the default".
  var ov = _toolOverrides['vision'] || {};
  var nameInput = document.getElementById('vision-name-input');
  var descInput = document.getElementById('vision-desc-input');
  if (nameInput) {
    nameInput.value = ov.name || '';
    nameInput.placeholder = (_toolDefaultMeta && _toolDefaultMeta.titles['vision']) || 'Vision';
  }
  if (descInput) {
    descInput.value = ov.description || '';
    descInput.placeholder = (_toolDefaultMeta && _toolDefaultMeta.descriptions['vision']) || '';
  }
  document.getElementById('vision-settings-overlay').classList.remove('hidden');
  fetch('/togen-admin/vision-settings')
    .then(function(r){ return r.json(); })
    .then(function(d){
      document.getElementById('vision-mock-checkbox').checked = !!(d.settings && d.settings.mock_data);
    })
    .catch(function(){ status.textContent = 'Failed to load settings.'; status.className = 'text-sm text-red-500'; });
}

// ===== ENROLL TAB (DVI-437): onboarding, open-enroll, approvals, agent mgmt =====
function visionCopyText(id) {
  var t = document.getElementById(id).textContent;
  if (navigator.clipboard) navigator.clipboard.writeText(t);
}

// DVI-771: reveal the SSH-key upload remediation one-liner for the current server.
function enrollShowUploadCmd(prefix) {
  var wrap = document.getElementById(prefix + '-upload-cmd-wrap');
  var code = document.getElementById(prefix + '-upload-cmd');
  if (!wrap || !code) return;
  code.textContent = "iwr '" + window.location.origin + "/enroll/upload-ssh-key.ps1' -UseBasicParsing | iex";
  wrap.classList.remove('hidden');
}

// Load the open-enroll secret + agents (split into pending approvals + enrolled).
function visionEnrollLoad() {
  fetch('/vision/enroll-secret')
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.open_enroll_secret) document.getElementById('vision-open-enroll-secret').textContent = d.open_enroll_secret;
    })
    .catch(function(){});

  var loading = document.getElementById('vision-agents-loading');
  var empty = document.getElementById('vision-agents-empty');
  var list = document.getElementById('vision-agents-list');
  var pendingEmpty = document.getElementById('vision-pending-empty');
  var pendingList = document.getElementById('vision-pending-list');
  loading.classList.remove('hidden'); empty.classList.add('hidden'); list.classList.add('hidden');
  pendingList.innerHTML = ''; pendingEmpty.classList.add('hidden');
  fetch('/vision/agents')
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      loading.classList.add('hidden');
      var agents = (res.ok && res.d.agents) || [];
      var pending = agents.filter(function(a){ return a.status === 'pending'; });
      var enrolled = agents.filter(function(a){ return a.status !== 'pending'; });

      // Pending approvals
      if (!pending.length) { pendingEmpty.classList.remove('hidden'); }
      else {
        pendingList.innerHTML = pending.map(function(a){
          var fp = a.fingerprint || {};
          var seen = a.last_seen ? new Date(a.last_seen * 1000).toLocaleString() : 'never';
          var fp = a.fingerprint || {};
          var bits = [
            fp.os_version ? 'OS: ' + esc(fp.os_version) : '',
            fp.logged_in_user ? 'User: ' + esc(fp.logged_in_user) : '',
            fp.agent_version ? 'Agent: ' + esc(fp.agent_version) : '',
            'First seen: ' + esc(seen)
          ].filter(Boolean).join(' · ');
          var mid = esc(a.machine_id);
          var name = esc(a.display_name || fp.hostname || a.hostname || a.machine_id);
          var sshBadge = a.has_ssh_key
            ? '<span class="ml-1 text-xs text-green-600 dark:text-green-400" title="SSH key on file — will auto-authorize on approval">&#x1F511; SSH key ready</span>'
            : '<span class="ml-1 text-xs text-amber-500" title="No SSH key on file — run upgrade.ps1 on the machine before approving">&#x26A0; No SSH key</span>';
          return '<li class="rounded-lg border border-amber-300 dark:border-amber-800/60 bg-amber-50 dark:bg-amber-900/15 px-3 py-2">' +
            '<div class="flex items-center justify-between gap-2 flex-wrap">' +
              '<div class="min-w-0">' +
                '<div class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + name + sshBadge + '</div>' +
                '<div class="text-xs text-gray-600 dark:text-gray-300 break-all">' + bits + '</div>' +
              '</div>' +
              '<div class="flex items-center gap-2">' +
                '<button onclick="visionApproveAgent(\'' + mid + '\',\'' + name + '\')" class="px-2.5 py-1 text-xs rounded-md border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/30 cursor-pointer">Approve</button>' +
                '<button onclick="visionDenyAgent(\'' + mid + '\',\'' + name + '\')" class="px-2.5 py-1 text-xs rounded-md border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer">Deny</button>' +
              '</div>' +
            '</div></li>';
        }).join('');
      }

      // Enrolled agents
      if (!enrolled.length) { empty.classList.remove('hidden'); return; }
      list.innerHTML = enrolled.map(function(a){
        var seen = a.last_seen ? new Date(a.last_seen * 1000).toLocaleString() : 'never';
        var stateCls, stateTxt;
        if (a.revoked) { stateCls = 'text-red-500'; stateTxt = 'revoked'; }
        else if (a.online) { stateCls = 'text-green-600 dark:text-green-400'; stateTxt = 'online'; }
        else { stateCls = 'text-gray-400'; stateTxt = 'offline'; }
        var mid = esc(a.machine_id);
        var hostname = esc(a.display_name || a.hostname || a.machine_id);
        var revokeBtn = a.revoked ? '' :
          '<button onclick="visionRevokeAgent(\'' + mid + '\',\'' + hostname + '\')" ' +
          'class="px-2 py-1 text-xs rounded-md border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer">Revoke</button>';
        var deleteBtn = a.revoked ?
          '<button onclick="visionDeleteAgent(\'' + mid + '\',\'' + hostname + '\')" ' +
          'class="px-2 py-1 text-xs rounded-md border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer">Delete</button>' : '';
        var sshStatus = a.ssh_key_written
          ? '<span class="text-green-600 dark:text-green-400" title="SSH key authorized on rsync server">&#x1F511;</span>'
          : (a.has_ssh_key ? '<span class="text-amber-500" title="SSH key on file but not yet written to rsync server">&#x1F511;?</span>' : '');
        return '<li class="flex items-center justify-between gap-2 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2">' +
          '<div class="min-w-0">' +
            '<div class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + hostname + (sshStatus ? ' ' + sshStatus : '') + '</div>' +
            '<div class="text-xs text-gray-500 dark:text-gray-400">Last seen: ' + esc(seen) + ' · <span class="' + stateCls + '">' + stateTxt + '</span></div>' +
          '</div><div class="flex items-center gap-2 shrink-0">' + revokeBtn + deleteBtn + '</div></li>';
      }).join('');
      list.classList.remove('hidden');
    })
    .catch(function(){ loading.classList.add('hidden'); empty.classList.remove('hidden'); });
}

function visionQuickEnroll() {
  var btn = document.getElementById('vision-quick-enroll-btn');
  btn.disabled = true; btn.classList.add('opacity-50', 'cursor-not-allowed');
  fetch('/vision/agents/enroll', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({})
  })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed');
      if (!res.ok || !res.d.enrollment_token) { alert('Error: ' + (res.d.error || 'enrollment failed')); return; }
      var token = res.d.enrollment_token;
      var base = window.location.origin;
      var ps1 = base + '/enroll/bootstrap.ps1?token=' + encodeURIComponent(token);
      document.getElementById('vision-quick-enroll-cmd').textContent = 'iwr "' + ps1 + '" -UseBasicParsing | iex';
      document.getElementById('vision-quick-enroll-ps1').href = ps1;
      document.getElementById('vision-quick-enroll-ttl').textContent =
        'Valid for 60 minutes, single use. The machine enrolls as active automatically.';
      document.getElementById('vision-quick-enroll-result').classList.remove('hidden');
    })
    .catch(function(e){
      btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed');
      alert('Error: ' + e.message);
    });
}

function visionRotateSecret() {
  if (!confirm('Rotate the open-enroll secret? The current secret stops working immediately for new enrollments. Already-enrolled agents are unaffected.')) return;
  var btn = document.getElementById('vision-rotate-secret-btn');
  btn.disabled = true; btn.classList.add('opacity-50', 'cursor-not-allowed');
  fetch('/vision/enroll-secret/rotate', { method: 'POST' })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed');
      if (!res.ok || !res.d.open_enroll_secret) { alert('Error: ' + (res.d.error || 'rotate failed')); return; }
      document.getElementById('vision-open-enroll-secret').textContent = res.d.open_enroll_secret;
    })
    .catch(function(e){
      btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed');
      alert('Error: ' + e.message);
    });
}

function visionApproveAgent(machineId, name) {
  if (!confirm('Approve ' + name + '? Its check-ins will be accepted and its footage will surface.')) return;
  fetch('/vision/agents/' + encodeURIComponent(machineId) + '/approve', { method: 'POST' })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok) { alert('Error: ' + (res.d.error || 'approve failed')); return; }
      if (res.d.ssh_key_written) {
        alert(name + ' approved.\n✅ SSH key written to rsync server — this machine can transfer footage.');
      } else if (res.d.ssh_key_error) {
        alert(name + ' approved.\n⚠ SSH key not written: ' + res.d.ssh_key_error + '\nRun upgrade.ps1 on the machine and then re-approve, or add the key manually.');
      }
      visionEnrollLoad();
    })
    .catch(function(e){ alert('Error: ' + e.message); });
}

function visionDenyAgent(machineId, name) {
  if (!confirm('Deny ' + name + '? Its check-ins will be refused until it re-enrolls and is approved.')) return;
  fetch('/vision/agents/' + encodeURIComponent(machineId) + '/deny', { method: 'POST' })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok) { alert('Error: ' + (res.d.error || 'deny failed')); return; }
      visionEnrollLoad();
    })
    .catch(function(e){ alert('Error: ' + e.message); });
}

function visionRevokeAgent(machineId, hostname) {
  if (!confirm('Revoke the agent key for ' + hostname + '? Future check-ins from this machine will be rejected until it re-enrolls.')) return;
  fetch('/vision/agents/' + encodeURIComponent(machineId) + '/revoke', { method: 'POST' })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok) { alert('Error: ' + (res.d.error || 'revoke failed')); return; }
      visionEnrollLoad();
    })
    .catch(function(e){ alert('Error: ' + e.message); });
}

function visionDeleteAgent(machineId, hostname) {
  if (!confirm('Delete the revoked agent ' + hostname + '? This removes it from the list along with its stored check-ins and commands. It can re-enroll later.')) return;
  fetch('/vision/agents/' + encodeURIComponent(machineId), { method: 'DELETE' })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){ if (!res.ok) { alert('Error: ' + (res.d.error || 'delete failed')); } visionEnrollLoad(); })
    .catch(function(e){ alert('Error: ' + e.message); visionEnrollLoad(); });
}

function visionSettingsClose() {
  document.getElementById('vision-settings-overlay').classList.add('hidden');
}

function visionSettingsSave() {
  var btn = document.getElementById('vision-settings-save-btn');
  var status = document.getElementById('vision-settings-status');
  var mock = document.getElementById('vision-mock-checkbox').checked;
  var name = (document.getElementById('vision-name-input').value || '').trim();
  var desc = (document.getElementById('vision-desc-input').value || '').trim();
  btn.disabled = true;
  btn.classList.add('opacity-50', 'cursor-not-allowed');
  status.textContent = 'Saving...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';
  Promise.all([
    fetch('/togen-admin/vision-settings', {
      method: 'POST', headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({mock_data: mock})
    }).then(function(r){ return r.json(); }),
    fetch('/togen-admin/tool-overrides', {
      method: 'POST', headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({tool_id: 'vision', name: name, description: desc})
    }).then(function(r){ return r.json(); })
  ])
    .then(function(results){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      var err = (results[0] && results[0].error) || (results[1] && results[1].error);
      if (err) { status.textContent = 'Error: ' + err; status.className = 'text-sm text-red-500'; return; }
      if (results[1] && results[1].overrides) _toolOverridesApply(results[1].overrides);
      status.textContent = 'Saved';
      status.className = 'text-sm text-green-600 dark:text-green-400';
      setTimeout(function(){ visionSettingsClose(); }, 800);
    })
    .catch(function(e){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      status.textContent = 'Error: ' + e.message;
      status.className = 'text-sm text-red-500';
    });
}

// Apply a fresh overrides map returned by the server, then re-render anything
// that shows tool names/descriptions (DVI-588).
function _toolOverridesApply(overrides) {
  _toolOverrides = overrides || {};
  _applyToolOverrides();
  renderTileGrid();
  if (typeof togenadminRenderToolVisibility === 'function') togenadminRenderToolVisibility();
}

// ===== GENERIC TOOL SETTINGS DIALOG (DVI-588) — name/description for non-Vision tools =====
function toolEditOpen(tid) {
  var ov = _toolOverrides[tid] || {};
  document.getElementById('tool-edit-id').value = tid;
  var defaultName = (_toolDefaultMeta && _toolDefaultMeta.labels[tid]) ||
                    (_toolDefaultMeta && _toolDefaultMeta.titles[tid]) || tid;
  var defaultDesc = (_toolDefaultMeta && _toolDefaultMeta.descriptions[tid]) || '';
  document.getElementById('tool-edit-title').textContent = defaultName + ' Settings';
  var nameInput = document.getElementById('tool-edit-name');
  var descInput = document.getElementById('tool-edit-desc');
  nameInput.value = ov.name || '';
  nameInput.placeholder = defaultName;
  descInput.value = ov.description || '';
  descInput.placeholder = defaultDesc;
  var status = document.getElementById('tool-edit-status');
  status.textContent = '';
  document.getElementById('tool-edit-overlay').classList.remove('hidden');
}

function toolEditClose() {
  document.getElementById('tool-edit-overlay').classList.add('hidden');
}

function toolEditSave() {
  var tid = document.getElementById('tool-edit-id').value;
  var name = (document.getElementById('tool-edit-name').value || '').trim();
  var desc = (document.getElementById('tool-edit-desc').value || '').trim();
  var btn = document.getElementById('tool-edit-save-btn');
  var status = document.getElementById('tool-edit-status');
  btn.disabled = true;
  btn.classList.add('opacity-50', 'cursor-not-allowed');
  status.textContent = 'Saving...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';
  fetch('/togen-admin/tool-overrides', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({tool_id: tid, name: name, description: desc})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      if (d.error) { status.textContent = 'Error: ' + d.error; status.className = 'text-sm text-red-500'; return; }
      _toolOverridesApply(d.overrides);
      status.textContent = 'Saved';
      status.className = 'text-sm text-green-600 dark:text-green-400';
      setTimeout(function(){ toolEditClose(); }, 800);
    })
    .catch(function(e){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      status.textContent = 'Error: ' + e.message;
      status.className = 'text-sm text-red-500';
    });
}

// ===== PUBLIC VERSION ADMIN (DVI-267) =====

function togenadminLoadPublicVersion() {
  fetch('/togen-admin/public-version')
    .then(function(r){ return r.json(); })
    .then(function(d){
      var el = document.getElementById('togenadmin-public-version');
      if (el && d.version) el.value = d.version;
    })
    .catch(function(){});
}

function togenadminSavePublicVersion() {
  var input = document.getElementById('togenadmin-public-version');
  var status = document.getElementById('togenadmin-public-version-status');
  var version = input.value.trim();
  if (!version) { status.textContent = 'Version text is required.'; status.className = 'text-sm text-red-500'; return; }

  status.textContent = 'Saving...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';

  fetch('/togen-admin/public-version', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({version: version})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { status.textContent = 'Error: ' + d.error; status.className = 'text-sm text-red-500'; }
      else { status.textContent = 'Saved'; status.className = 'text-sm text-green-600 dark:text-green-400'; }
    })
    .catch(function(e){ status.textContent = 'Error: ' + e.message; status.className = 'text-sm text-red-500'; });
}

// ===== SHARED URL PREFIX (DVI-271) =====
var _urlPrefixLoaded = false;

function togenadminLoadUrlPrefix() {
  if (_urlPrefixLoaded) return;
  fetch('/togen-admin/shared-url-prefix')
    .then(function(r){ return r.json(); })
    .then(function(data) {
      var sel = document.getElementById('togenadmin-url-prefix');
      sel.innerHTML = '';
      (data.options || []).forEach(function(opt) {
        var o = document.createElement('option');
        o.value = opt.value;
        o.textContent = opt.label;
        if (opt.value === data.prefix) o.selected = true;
        sel.appendChild(o);
      });
      _urlPrefixLoaded = true;
    });
}

function togenadminSaveUrlPrefix() {
  var sel = document.getElementById('togenadmin-url-prefix');
  var status = document.getElementById('togenadmin-url-prefix-status');
  status.textContent = 'Saving...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';

  fetch('/togen-admin/shared-url-prefix', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({prefix: sel.value})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { status.textContent = 'Error: ' + d.error; status.className = 'text-sm text-red-500'; }
      else { _sharedUrlPrefix = sel.value; status.textContent = 'Saved'; status.className = 'text-sm text-green-600 dark:text-green-400'; }
    })
    .catch(function(e){ status.textContent = 'Error: ' + e.message; status.className = 'text-sm text-red-500'; });
}

// ===== BRANDING ADMIN (DVI-357) =====
var _brandingLoaded = false;

function togenadminLoadBranding() {
  if (_brandingLoaded) return;
  fetch('/togen-admin/branding')
    .then(function(r){ return r.json(); })
    .then(function(d){
      // Logo radios
      var logoRadios = document.getElementsByName('branding-logo-mode');
      for (var i = 0; i < logoRadios.length; i++) {
        logoRadios[i].checked = (logoRadios[i].value === d.logo_mode);
        if (logoRadios[i].value === 'corporate') {
          logoRadios[i].disabled = !d.has_logo;
        }
      }
      var logoLabel = document.getElementById('branding-logo-corporate-label');
      if (logoLabel) {
        logoLabel.classList.toggle('opacity-50', !d.has_logo);
        logoLabel.title = d.has_logo ? '' : 'Upload corporate_logo.svg to enable.';
      }
      var logoMissing = document.getElementById('branding-logo-missing');
      if (logoMissing) logoMissing.classList.toggle('hidden', d.has_logo);

      // Background radios
      var hasBg = d.has_bg_light && d.has_bg_dark;
      var bgRadios = document.getElementsByName('branding-bg-mode');
      for (var j = 0; j < bgRadios.length; j++) {
        bgRadios[j].checked = (bgRadios[j].value === d.bg_mode);
        if (bgRadios[j].value === 'corporate') {
          bgRadios[j].disabled = !hasBg;
        }
      }
      var bgLabel = document.getElementById('branding-bg-corporate-label');
      if (bgLabel) {
        bgLabel.classList.toggle('opacity-50', !hasBg);
        bgLabel.title = hasBg ? '' : 'Upload corporate_bg_light.svg and corporate_bg_dark.svg to enable.';
      }
      var bgMissing = document.getElementById('branding-bg-missing');
      if (bgMissing) bgMissing.classList.toggle('hidden', hasBg);

      // Opacity controls (DVI-366) — separate light/dark
      _brandingSetOpacityInputs('light', (typeof d.bg_opacity_light === 'number') ? d.bg_opacity_light : 100);
      _brandingSetOpacityInputs('dark', (typeof d.bg_opacity_dark === 'number') ? d.bg_opacity_dark : 100);
      for (var k = 0; k < bgRadios.length; k++) {
        bgRadios[k].addEventListener('change', _brandingUpdateOpacityEnabled);
      }
      _brandingUpdateOpacityEnabled();

      // Tile Layout (DVI-512)
      var tileSel = document.getElementById('branding-tile-columns');
      if (tileSel) tileSel.value = (d.tile_columns === 'unlimited') ? 'unlimited' : String(d.tile_columns || 3);
      var padInput = document.getElementById('branding-tile-padding');
      if (padInput) padInput.value = String(typeof d.tile_side_padding === 'number' ? d.tile_side_padding : 0);

      _brandingLoaded = true;
    });
}

function _brandingSetOpacityInputs(theme, val) {
  val = Math.max(40, Math.min(100, parseInt(val, 10) || 100));
  var slider = document.getElementById('branding-opacity-' + theme + '-slider');
  var num = document.getElementById('branding-opacity-' + theme + '-number');
  if (slider) slider.value = val;
  if (num) num.value = val;
}

function _brandingUpdateOpacityEnabled() {
  var isCorporate = _brandingSelectedRadio('branding-bg-mode') === 'corporate';
  var block = document.getElementById('branding-opacity-block');
  ['light', 'dark'].forEach(function(theme) {
    var slider = document.getElementById('branding-opacity-' + theme + '-slider');
    var num = document.getElementById('branding-opacity-' + theme + '-number');
    if (slider) slider.disabled = !isCorporate;
    if (num) num.disabled = !isCorporate;
  });
  if (block) block.classList.toggle('opacity-50', !isCorporate);
}

function _brandingSyncOpacity(theme, src) {
  var slider = document.getElementById('branding-opacity-' + theme + '-slider');
  var num = document.getElementById('branding-opacity-' + theme + '-number');
  var v = parseInt((src === 'number' ? num.value : slider.value), 10);
  if (isNaN(v)) return;
  v = Math.max(40, Math.min(100, v));
  if (slider) slider.value = v;
  if (num && src !== 'number') num.value = v;
}

function _brandingSelectedRadio(name) {
  var radios = document.getElementsByName(name);
  for (var i = 0; i < radios.length; i++) {
    if (radios[i].checked) return radios[i].value;
  }
  return null;
}

function togenadminSaveBranding() {
  var status = document.getElementById('togenadmin-branding-status');
  var logoMode = _brandingSelectedRadio('branding-logo-mode');
  var bgMode = _brandingSelectedRadio('branding-bg-mode');
  var lightNum = document.getElementById('branding-opacity-light-number');
  var darkNum = document.getElementById('branding-opacity-dark-number');
  var bgOpacityLight = Math.max(40, Math.min(100, parseInt(lightNum && lightNum.value, 10) || 100));
  var bgOpacityDark = Math.max(40, Math.min(100, parseInt(darkNum && darkNum.value, 10) || 100));
  var tileSel = document.getElementById('branding-tile-columns');
  var tileColumns = tileSel ? (tileSel.value === 'unlimited' ? 'unlimited' : parseInt(tileSel.value, 10)) : 3;
  var padInput = document.getElementById('branding-tile-padding');
  var tilePadding = Math.max(0, Math.min(40, parseInt(padInput && padInput.value, 10) || 0));
  if (padInput) padInput.value = String(tilePadding);
  status.textContent = 'Saving...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';

  fetch('/togen-admin/branding', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({logo_mode: logoMode, bg_mode: bgMode, bg_opacity_light: bgOpacityLight, bg_opacity_dark: bgOpacityDark, tile_columns: tileColumns, tile_side_padding: tilePadding})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { status.textContent = 'Error: ' + d.error; status.className = 'text-sm text-red-500'; return; }
      window._brandingSettings = {logo_mode: d.logo_mode, bg_mode: d.bg_mode, bg_opacity_light: d.bg_opacity_light, bg_opacity_dark: d.bg_opacity_dark, tile_columns: d.tile_columns, tile_side_padding: d.tile_side_padding};
      applyBrandingBackground();
      applyPaneAlpha();
      renderTileGrid();
      status.textContent = 'Saved — reload to update the logo.';
      status.className = 'text-sm text-green-600 dark:text-green-400';
    })
    .catch(function(e){ status.textContent = 'Error: ' + e.message; status.className = 'text-sm text-red-500'; });
}

// Effective background mode/opacity = per-user override falls back to the
// company default (DVI-364).
function effectiveBgMode() {
  var ov = (typeof _userSettings !== 'undefined' && _userSettings) ? _userSettings.bg_mode_override : null;
  if (ov === 'togen_default' || ov === 'flat' || ov === 'corporate' || ov === 'custom') return ov;
  var co = (window._brandingSettings || {}).bg_mode;
  return co || 'togen_default';
}

// Cache-buster for the per-user custom background image (DVI-518). Bumped on
// each upload so the <img>/body background re-fetches the new file.
var _customBgBust = Date.now();

function effectiveOpacity() {
  var isDark = document.documentElement.classList.contains('dark');
  var key = isDark ? 'bg_opacity_dark' : 'bg_opacity_light';
  var u = (typeof _userSettings !== 'undefined' && _userSettings) ? _userSettings[key] : null;
  if (typeof u === 'number') return u;
  var c = (window._brandingSettings || {})[key];
  return (typeof c === 'number') ? c : 100;
}

function applyBrandingBackground() {
  var body = document.body;
  var isMobile = window.innerWidth < 768;
  var mode = effectiveBgMode();
  var hasCustom = (typeof _userSettings !== 'undefined' && _userSettings) && _userSettings.custom_bg_ext;
  if (mode === 'corporate' && !isMobile) {
    var isDark = document.documentElement.classList.contains('dark');
    var url = isDark ? '/togen-admin/branding/bg-dark.svg' : '/togen-admin/branding/bg-light.svg';
    body.style.backgroundImage = "url('" + url + "')";
    body.style.backgroundSize = 'contain';
    body.style.backgroundPosition = 'center';
    body.style.backgroundAttachment = 'fixed';
    body.style.backgroundRepeat = 'no-repeat';
  } else if (mode === 'custom' && hasCustom) {
    body.style.backgroundImage = "url('/user-settings/custom-bg?v=" + _customBgBust + "')";
    body.style.backgroundSize = 'cover';
    body.style.backgroundPosition = 'center';
    body.style.backgroundAttachment = 'fixed';
    body.style.backgroundRepeat = 'no-repeat';
  } else {
    body.style.backgroundImage = '';
  }
}

// Background modes that render a full-bleed image behind the UI, so pane/tile
// transparency is meaningful (corporate brand bg and per-user custom bg, DVI-521).
function bgModeSupportsTransparency(mode) {
  return mode === 'corporate' || mode === 'custom';
}

// Set the --togen-pane-alpha CSS variable so marked .togen-pane containers let
// the image background show through. Gated: image-backed bg (corporate/custom),
// desktop only (>=768px), and opacity < 100. Otherwise forced fully opaque (1).
function applyPaneAlpha() {
  var isMobile = window.innerWidth < 768;
  var opacity = effectiveOpacity();
  var alpha = 1;
  if (bgModeSupportsTransparency(effectiveBgMode()) && !isMobile && opacity < 100) {
    alpha = Math.max(40, Math.min(100, opacity)) / 100;
  }
  document.documentElement.style.setProperty('--togen-pane-alpha', String(alpha));
}

// ===== NEWS SUBSCRIPTIONS ADMIN (DVI-271) =====
var _subsListLoaded = false;
var _subsListExpanded = false;

function togenadminLoadSubsList() {
  if (_subsListLoaded) return;
  fetch('/togen-admin/subscribers')
    .then(function(r){ return r.json(); })
    .then(function(data) {
      var subs = data.subscribers || [];
      document.getElementById('togenadmin-subs-count').textContent = subs.length + ' subscriber' + (subs.length !== 1 ? 's' : '');
      document.getElementById('togenadmin-subs-loading').classList.add('hidden');
      var list = document.getElementById('togenadmin-subs-emails');
      list.innerHTML = '';
      if (subs.length === 0) {
        document.getElementById('togenadmin-subs-empty').classList.remove('hidden');
      } else {
        document.getElementById('togenadmin-subs-empty').classList.add('hidden');
        subs.forEach(function(email) {
          var li = document.createElement('li');
          li.className = 'py-2 px-1 text-sm text-gray-700 dark:text-gray-300 flex items-center gap-2';
          var mail = '<svg class="h-4 w-4 text-gray-400 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/><path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/></svg>';
          var emailSpan = document.createElement('span');
          emailSpan.className = 'truncate';
          emailSpan.textContent = email;
          var removeBtn = document.createElement('button');
          removeBtn.type = 'button';
          removeBtn.title = 'Unsubscribe this user';
          removeBtn.className = 'ml-auto shrink-0 p-1 rounded-lg text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer';
          removeBtn.innerHTML = '<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 10a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>';
          removeBtn.onclick = function(){ togenadminRemoveSub(email, removeBtn); };
          li.innerHTML = mail;
          li.appendChild(emailSpan);
          li.appendChild(removeBtn);
          list.appendChild(li);
        });
      }
      _subsListLoaded = true;
    });
}

function togenadminToggleSubsList() {
  _subsListExpanded = !_subsListExpanded;
  var listDiv = document.getElementById('togenadmin-subs-list');
  var chevron = document.getElementById('togenadmin-subs-chevron');
  if (_subsListExpanded) {
    listDiv.classList.remove('hidden');
    chevron.style.transform = 'rotate(180deg)';
    togenadminLoadSubsList();
  } else {
    listDiv.classList.add('hidden');
    chevron.style.transform = '';
  }
}

// Unsubscribe a user on their behalf (DVI-385). Uses the shared /news/unsubscribe
// endpoint so admin removal toggles the same setting as every other interface.
function togenadminRemoveSub(email, btn) {
  if (btn) btn.disabled = true;
  fetch('/news/unsubscribe', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({email: email})
  }).then(function(r){ return r.json(); }).then(function(){
    _subsListLoaded = false;
    togenadminLoadSubsList();
  }).catch(function(){
    if (btn) btn.disabled = false;
  });
}

// ===== ADD SUBSCRIBER (DVI-278) =====
function togenadminOpenAddSub() {
  document.getElementById('addsub-email').value = '';
  document.getElementById('addsub-status').textContent = '';
  document.getElementById('addsub-overlay').classList.remove('hidden');
  document.getElementById('addsub-email').focus();
}

function togenadminCloseAddSub() {
  document.getElementById('addsub-overlay').classList.add('hidden');
}

function togenadminSubmitAddSub() {
  var email = document.getElementById('addsub-email').value.trim();
  var status = document.getElementById('addsub-status');
  if (!email || email.indexOf('@') === -1) {
    status.textContent = 'Enter a valid email address.';
    status.className = 'text-sm text-red-500';
    return;
  }
  status.textContent = 'Adding…';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';
  fetch('/news/subscribe', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({email: email})
  }).then(function(r){ return r.json(); }).then(function(d) {
    if (d.error) {
      status.textContent = 'Error: ' + d.error;
      status.className = 'text-sm text-red-500';
      return;
    }
    status.textContent = 'Added!';
    status.className = 'text-sm text-green-600 dark:text-green-400';
    _subsListLoaded = false;
    togenadminLoadSubsList();
    setTimeout(function(){ togenadminCloseAddSub(); }, 1000);
  }).catch(function(e) {
    status.textContent = 'Error: ' + e.message;
    status.className = 'text-sm text-red-500';
  });
}

// ===== NEWS (DVI-267) =====
var _newsInitialized = false;
var _newsSubscribed = false;
var _newsPosts = [];
var _newsUserEmail = '{{ user_email }}';
var _newsEditId = null;

function openNewsPage() {
  document.getElementById('help-menu').classList.add('hidden');
  closeTutorialsPane();
  openTool('news', 'News');
  if (!_newsInitialized) {
    _newsInitialized = true;
    newsLoadPosts();
    newsCheckSubscription();
  }
}

// Lightweight Markdown renderer (mirrors server-side _render_news_markdown).
// Keeps the News page and subscription emails formatted consistently.
function renderNewsMarkdown(src) {
  function inline(s) {
    s = esc(s);
    var stash = [];
    var MK = String.fromCharCode(0xE000);
    function keep(html) { stash.push(html); return MK + (stash.length - 1) + MK; }
    s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, function(_, t, u) {
      return keep('<a href="' + u + '" target="_blank" rel="noopener" class="text-togen-teal underline">' + t + '</a>');
    });
    s = s.replace(/(^|[^"'>=])(https?:\/\/[^\s<]+)/g, function(_, pre, u) {
      return pre + keep('<a href="' + u + '" target="_blank" rel="noopener" class="text-togen-teal underline">' + u + '</a>');
    });
    s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
    s = s.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, '$1<em>$2</em>');
    s = s.replace(new RegExp(MK + '(\\d+)' + MK, 'g'), function(_, i) { return stash[+i]; });
    return s;
  }
  var lines = String(src).replace(/\r\n?/g, '\n').split('\n');
  var out = [], para = [], items = [], listTag = null;
  function flushPara() { if (para.length) { out.push('<p class="mb-3 leading-relaxed">' + para.join('<br>') + '</p>'); para = []; } }
  function flushList() { if (items.length) { var cls = listTag === 'ol' ? 'list-decimal' : 'list-disc'; out.push('<' + listTag + ' class="' + cls + ' ml-5 mb-3 space-y-1">' + items.map(function(it){ return '<li>' + it + '</li>'; }).join('') + '</' + listTag + '>'); items = []; } listTag = null; }
  lines.forEach(function(line) {
    var t = line.trim();
    if (!t) { flushPara(); flushList(); return; }
    var h = /^(#{1,3})\s+(.*)$/.exec(t);
    if (h) {
      flushPara(); flushList();
      var lvl = h[1].length, size = lvl === 1 ? 'text-xl' : (lvl === 2 ? 'text-lg' : 'text-base');
      out.push('<h' + (lvl + 2) + ' class="' + size + ' font-semibold text-gray-800 dark:text-gray-100 mt-3 mb-2">' + inline(h[2]) + '</h' + (lvl + 2) + '>');
      return;
    }
    var ul = /^[-*]\s+(.*)$/.exec(t), ol = /^\d+\.\s+(.*)$/.exec(t);
    if (ul || ol) {
      flushPara();
      var want = ul ? 'ul' : 'ol';
      if (listTag && listTag !== want) flushList();
      listTag = want;
      items.push(inline((ul || ol)[1]));
      return;
    }
    flushList();
    para.push(inline(t));
  });
  flushPara(); flushList();
  return out.join('') || '';
}

function newsLoadPosts() {
  fetch('/news/posts')
    .then(function(r){ return r.json(); })
    .then(function(d){
      var feed = document.getElementById('news-feed');
      if (!d.posts || !d.posts.length) {
        feed.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-8">No news posts yet.</p>';
        return;
      }
      feed.innerHTML = '';
      _newsPosts = d.posts;
      d.posts.forEach(function(post) {
        var article = document.createElement('article');
        article.className = 'togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden';
        var isScheduled = post.status === 'scheduled';
        var dateStr = new Date(post.date).toLocaleString('en-US', isScheduled
          ? {year:'numeric', month:'long', day:'numeric', hour:'numeric', minute:'2-digit'}
          : {year:'numeric', month:'long', day:'numeric'});
        // Only the original poster may edit a not-yet-published scheduled post.
        var canEdit = isScheduled && _newsUserEmail &&
          (post.author_email || '').toLowerCase() === _newsUserEmail.toLowerCase();
        var editBtn = canEdit
          ? '<button onclick="newsOpenEdit(\'' + post.id + '\')" class="text-xs text-togen-teal hover:text-togen-teal-dark transition-colors ml-auto">Edit</button>'
          : '';
        var deleteBtn = _isToGenAdmin
          ? '<button onclick="newsDeletePost(\'' + post.id + '\')" class="text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors' + (editBtn ? ' ml-3' : ' ml-auto') + '">Delete</button>'
          : '';
        var schedBadge = isScheduled
          ? '<span class="text-xs font-medium px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">Scheduled &middot; ' + esc(dateStr) + '</span>'
          : '<span class="text-xs text-gray-500 dark:text-gray-400">' + esc(dateStr) + '</span>';
        article.innerHTML =
          '<div class="px-6 py-4">' +
            '<div class="flex items-center gap-2 mb-2">' +
              schedBadge +
              '<span class="text-xs text-gray-400 dark:text-gray-500">&middot;</span>' +
              '<span class="text-xs text-togen-teal font-medium">' + esc(post.author) + '</span>' +
              editBtn +
              deleteBtn +
            '</div>' +
            '<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-2">' + esc(post.title) + '</h3>' +
            '<div class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">' + renderNewsMarkdown(post.body) + '</div>' +
          '</div>';
        feed.appendChild(article);
      });
    })
    .catch(function(){
      document.getElementById('news-feed').innerHTML = '<p class="text-sm text-red-500 text-center py-8">Failed to load news.</p>';
    });
}

function newsCreatePost() {
  var title = document.getElementById('news-title').value.trim();
  var body = document.getElementById('news-body').value.trim();
  var status = document.getElementById('news-post-status');

  if (!title || !body) { status.textContent = 'Title and body are required.'; status.className = 'text-sm text-red-500'; return; }

  status.textContent = 'Publishing...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';

  fetch('/news/posts', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({title: title, body: body})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { status.textContent = 'Error: ' + d.error; status.className = 'text-sm text-red-500'; return; }
      status.textContent = 'Published!';
      status.className = 'text-sm text-green-600 dark:text-green-400';
      document.getElementById('news-title').value = '';
      document.getElementById('news-body').value = '';
      newsLoadPosts();
    })
    .catch(function(e){ status.textContent = 'Error: ' + e.message; status.className = 'text-sm text-red-500'; });
}

// ===== SCHEDULE PUBLISH (DVI-391) =====

// Date (YYYY-MM-DD) that is `days` ahead of "now" as observed in tz.
// Noon UTC anchor avoids DST/midnight rollover ambiguity.
function newsDateInTzPlusDays(tz, days) {
  var fmt = new Intl.DateTimeFormat('en-CA', {timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit'});
  var today = fmt.format(new Date()); // "2026-06-14"
  var d = new Date(today + 'T12:00:00Z');
  d.setUTCDate(d.getUTCDate() + days);
  return d.toISOString().slice(0, 10);
}

// Convert a wall-clock date+time in tz to the matching UTC instant.
function newsZonedToUtc(dateStr, timeStr, tz) {
  var guess = new Date(dateStr + 'T' + timeStr + ':00Z');
  if (isNaN(guess.getTime())) return null;
  var asTz = new Date(guess.toLocaleString('en-US', {timeZone: tz}));
  var asUtc = new Date(guess.toLocaleString('en-US', {timeZone: 'UTC'}));
  var offset = asTz.getTime() - asUtc.getTime();
  return new Date(guess.getTime() - offset);
}

function newsOpenSchedule() {
  var title = document.getElementById('news-title').value.trim();
  var body = document.getElementById('news-body').value.trim();
  var status = document.getElementById('news-post-status');
  if (!title || !body) {
    status.textContent = 'Add a title and body before scheduling.';
    status.className = 'text-sm text-red-500';
    return;
  }
  // Prepopulate to tomorrow 7:15am CST regardless of the user's timezone.
  document.getElementById('news-sched-tz').value = 'America/Chicago';
  document.getElementById('news-sched-date').value = newsDateInTzPlusDays('America/Chicago', 1);
  document.getElementById('news-sched-time').value = '07:15';
  document.getElementById('news-sched-status').textContent = '';
  newsUpdateScheduleHint();
  document.getElementById('news-schedule-overlay').classList.remove('hidden');
}

function newsCloseSchedule() {
  document.getElementById('news-schedule-overlay').classList.add('hidden');
}

function newsUpdateScheduleHint() {
  var hint = document.getElementById('news-sched-hint');
  var dateStr = document.getElementById('news-sched-date').value;
  var timeStr = document.getElementById('news-sched-time').value;
  var tz = document.getElementById('news-sched-tz').value;
  if (!dateStr || !timeStr) { hint.textContent = ''; return; }
  var utc = newsZonedToUtc(dateStr, timeStr, tz);
  if (!utc) { hint.textContent = ''; return; }
  hint.textContent = 'Publishes ' + utc.toLocaleString('en-US', {
    dateStyle: 'medium', timeStyle: 'short'
  }) + ' your time.';
}

function newsSubmitSchedule() {
  var title = document.getElementById('news-title').value.trim();
  var body = document.getElementById('news-body').value.trim();
  var dateStr = document.getElementById('news-sched-date').value;
  var timeStr = document.getElementById('news-sched-time').value;
  var tz = document.getElementById('news-sched-tz').value;
  var status = document.getElementById('news-sched-status');

  if (!title || !body) { status.textContent = 'Title and body are required.'; status.className = 'text-sm text-red-500'; return; }
  if (!dateStr || !timeStr) { status.textContent = 'Pick a date and time.'; status.className = 'text-sm text-red-500'; return; }

  var utc = newsZonedToUtc(dateStr, timeStr, tz);
  if (!utc) { status.textContent = 'Invalid date/time.'; status.className = 'text-sm text-red-500'; return; }
  if (utc.getTime() <= Date.now()) { status.textContent = 'Pick a time in the future.'; status.className = 'text-sm text-red-500'; return; }
  var publishAt = utc.toISOString().replace(/\.\d{3}Z$/, 'Z');

  status.textContent = 'Scheduling...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';

  fetch('/news/posts', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({title: title, body: body, publish_at: publishAt})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { status.textContent = 'Error: ' + d.error; status.className = 'text-sm text-red-500'; return; }
      var postStatus = document.getElementById('news-post-status');
      postStatus.textContent = 'Scheduled!';
      postStatus.className = 'text-sm text-green-600 dark:text-green-400';
      document.getElementById('news-title').value = '';
      document.getElementById('news-body').value = '';
      newsCloseSchedule();
      newsLoadPosts();
    })
    .catch(function(e){ status.textContent = 'Error: ' + e.message; status.className = 'text-sm text-red-500'; });
}

// ===== EDIT SCHEDULED POST (DVI-579) =====

// Render a UTC publish_at instant into the date/time inputs for a given tz.
function newsUtcToZonedParts(iso, tz) {
  var d = new Date(iso);
  if (isNaN(d.getTime())) return null;
  var parts = new Intl.DateTimeFormat('en-CA', {
    timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', hour12: false
  }).formatToParts(d).reduce(function(acc, p) { acc[p.type] = p.value; return acc; }, {});
  var hour = parts.hour === '24' ? '00' : parts.hour;
  return {
    date: parts.year + '-' + parts.month + '-' + parts.day,
    time: hour + ':' + parts.minute
  };
}

function newsOpenEdit(postId) {
  var post = _newsPosts.filter(function(p){ return p.id === postId; })[0];
  if (!post) return;
  _newsEditId = postId;
  document.getElementById('news-edit-title').value = post.title || '';
  document.getElementById('news-edit-body').value = post.body || '';
  var tz = 'America/Chicago';
  document.getElementById('news-edit-tz').value = tz;
  var parts = newsUtcToZonedParts(post.publish_at || post.date, tz);
  if (parts) {
    document.getElementById('news-edit-date').value = parts.date;
    document.getElementById('news-edit-time').value = parts.time;
  }
  document.getElementById('news-edit-status').textContent = '';
  newsUpdateEditHint();
  document.getElementById('news-edit-overlay').classList.remove('hidden');
}

function newsCloseEdit() {
  document.getElementById('news-edit-overlay').classList.add('hidden');
  _newsEditId = null;
}

function newsUpdateEditHint() {
  var hint = document.getElementById('news-edit-hint');
  var dateStr = document.getElementById('news-edit-date').value;
  var timeStr = document.getElementById('news-edit-time').value;
  var tz = document.getElementById('news-edit-tz').value;
  if (!dateStr || !timeStr) { hint.textContent = ''; return; }
  var utc = newsZonedToUtc(dateStr, timeStr, tz);
  if (!utc) { hint.textContent = ''; return; }
  hint.textContent = 'Publishes ' + utc.toLocaleString('en-US', {
    dateStyle: 'medium', timeStyle: 'short'
  }) + ' your time.';
}

function newsSubmitEdit() {
  if (!_newsEditId) return;
  var title = document.getElementById('news-edit-title').value.trim();
  var body = document.getElementById('news-edit-body').value.trim();
  var dateStr = document.getElementById('news-edit-date').value;
  var timeStr = document.getElementById('news-edit-time').value;
  var tz = document.getElementById('news-edit-tz').value;
  var status = document.getElementById('news-edit-status');

  if (!title || !body) { status.textContent = 'Title and body are required.'; status.className = 'text-sm text-red-500'; return; }
  if (!dateStr || !timeStr) { status.textContent = 'Pick a date and time.'; status.className = 'text-sm text-red-500'; return; }

  var utc = newsZonedToUtc(dateStr, timeStr, tz);
  if (!utc) { status.textContent = 'Invalid date/time.'; status.className = 'text-sm text-red-500'; return; }
  if (utc.getTime() <= Date.now()) { status.textContent = 'Pick a time in the future.'; status.className = 'text-sm text-red-500'; return; }
  var publishAt = utc.toISOString().replace(/\.\d{3}Z$/, 'Z');

  status.textContent = 'Saving...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';

  fetch('/news/posts/' + _newsEditId, {
    method: 'PUT',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({title: title, body: body, publish_at: publishAt})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { status.textContent = 'Error: ' + d.error; status.className = 'text-sm text-red-500'; return; }
      newsCloseEdit();
      newsLoadPosts();
    })
    .catch(function(e){ status.textContent = 'Error: ' + e.message; status.className = 'text-sm text-red-500'; });
}

function newsDeletePost(postId) {
  if (!confirm('Delete this post?')) return;
  fetch('/news/posts/' + postId, {method: 'DELETE'})
    .then(function(r){ return r.json(); })
    .then(function(){ newsLoadPosts(); })
    .catch(function(){});
}

function newsCheckSubscription() {
  fetch('/news/subscribers')
    .then(function(r){ return r.json(); })
    .then(function(d){
      _newsSubscribed = d.subscribed;
      newsUpdateSubscribeBtn();
    })
    .catch(function(){});
}

function newsUpdateSubscribeBtn() {
  var label = document.getElementById('news-subscribe-label');
  var btn = document.getElementById('news-subscribe-btn');
  if (!label || !btn) return;
  if (_newsSubscribed) {
    label.textContent = 'Unsubscribe';
    btn.className = 'px-4 py-2 text-sm font-medium rounded-xl border border-red-400 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors inline-flex items-center gap-2 cursor-pointer';
  } else {
    label.textContent = 'Subscribe';
    btn.className = 'px-4 py-2 text-sm font-medium rounded-xl border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors inline-flex items-center gap-2 cursor-pointer';
  }
}

function newsToggleSubscribe() {
  if (_newsSubscribed) {
    var userEmail = '{{ user_email }}';
    fetch('/news/unsubscribe', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({email: userEmail})
    })
      .then(function(r){ return r.json(); })
      .then(function(d){
        if (!d.error) { _newsSubscribed = false; newsUpdateSubscribeBtn(); }
      })
      .catch(function(){});
  } else {
    document.getElementById('subscribe-overlay').classList.remove('hidden');
  }
}

function closeSubscribe() {
  document.getElementById('subscribe-overlay').classList.add('hidden');
  document.getElementById('subscribe-status').textContent = '';
}

function newsSubmitSubscribe() {
  var email = document.getElementById('subscribe-email').value.trim();
  var status = document.getElementById('subscribe-status');

  if (!email || email.indexOf('@') === -1) { status.textContent = 'Enter a valid email.'; status.className = 'text-sm text-red-500'; return; }

  status.textContent = 'Subscribing...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';

  fetch('/news/subscribe', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({email: email})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { status.textContent = 'Error: ' + d.error; status.className = 'text-sm text-red-500'; return; }
      _newsSubscribed = true;
      newsUpdateSubscribeBtn();
      closeSubscribe();
    })
    .catch(function(e){ status.textContent = 'Error: ' + e.message; status.className = 'text-sm text-red-500'; });
}

// ===== ABOUT POPUP (DVI-267) =====

// Raw Markdown source of the About text (DVI-412). Seeded by aboutLoadContent().
var _aboutContent = null;

function aboutApplyContent(src) {
  _aboutContent = src;
  var html = renderNewsMarkdown(src);
  var popup = document.getElementById('about-content');
  if (popup) popup.innerHTML = html;
  var preview = document.getElementById('togenadmin-about-preview');
  if (preview) preview.innerHTML = html;
}

function aboutLoadContent(cb) {
  fetch('/togen-admin/about')
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (typeof d.content === 'string') aboutApplyContent(d.content);
      if (cb) cb();
    })
    .catch(function(){ if (cb) cb(); });
}

function openAbout() {
  document.getElementById('help-menu').classList.add('hidden');
  document.getElementById('about-overlay').classList.remove('hidden');
  aboutLoadContent();
}

function closeAbout() {
  document.getElementById('about-overlay').classList.add('hidden');
}

// ===== ABOUT EDIT (DVI-412, admin only) =====

function openAboutEdit() {
  var ta = document.getElementById('about-edit-body');
  ta.value = (_aboutContent != null) ? _aboutContent : '';
  document.getElementById('about-edit-status').textContent = '';
  aboutEditUpdatePreview();
  document.getElementById('about-edit-overlay').classList.remove('hidden');
  // Pick up any external edits if we never loaded the source yet.
  if (_aboutContent == null) aboutLoadContent(function(){ ta.value = _aboutContent || ''; aboutEditUpdatePreview(); });
}

function closeAboutEdit() {
  document.getElementById('about-edit-overlay').classList.add('hidden');
}

function aboutEditUpdatePreview() {
  document.getElementById('about-edit-preview').innerHTML =
    renderNewsMarkdown(document.getElementById('about-edit-body').value);
}

function saveAboutEdit() {
  var content = document.getElementById('about-edit-body').value.trim();
  var status = document.getElementById('about-edit-status');
  if (!content) { status.textContent = 'About text is required.'; status.className = 'text-sm text-red-500'; return; }
  status.textContent = 'Saving...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';
  fetch('/togen-admin/about', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({content: content})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { status.textContent = 'Error: ' + d.error; status.className = 'text-sm text-red-500'; return; }
      aboutApplyContent(d.content);
      status.textContent = 'Saved';
      status.className = 'text-sm text-green-600 dark:text-green-400';
      setTimeout(closeAboutEdit, 600);
    })
    .catch(function(e){ status.textContent = 'Error: ' + e.message; status.className = 'text-sm text-red-500'; });
}

// ===== TITLE EDIT (DVI-555, admin only) =====

function openTitleEdit() {
  var title = (document.getElementById('togenadmin-title-preview') || {}).textContent || '';
  var tagline = (document.getElementById('togenadmin-tagline-preview') || {}).textContent || '';
  document.getElementById('title-edit-title').value = title.trim();
  document.getElementById('title-edit-tagline').value = tagline.trim();
  document.getElementById('title-edit-status').textContent = '';
  document.getElementById('title-edit-overlay').classList.remove('hidden');
}

function closeTitleEdit() {
  document.getElementById('title-edit-overlay').classList.add('hidden');
}

function _applySiteText(title, tagline) {
  // Header copies (home view + tool view) and the admin preview.
  document.querySelectorAll('.site-title-text').forEach(function(el){ el.textContent = title; });
  document.querySelectorAll('.site-tagline-text').forEach(function(el){ el.textContent = tagline; });
  var tp = document.getElementById('togenadmin-title-preview');
  if (tp) tp.textContent = title;
  var gp = document.getElementById('togenadmin-tagline-preview');
  if (gp) gp.textContent = tagline;
}

function saveTitleEdit() {
  var title = document.getElementById('title-edit-title').value.trim();
  var tagline = document.getElementById('title-edit-tagline').value.trim();
  var status = document.getElementById('title-edit-status');
  if (!title) { status.textContent = 'Title is required.'; status.className = 'text-sm text-red-500'; return; }
  if (!tagline) { status.textContent = 'Tagline is required.'; status.className = 'text-sm text-red-500'; return; }
  status.textContent = 'Saving...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';
  fetch('/togen-admin/site-text', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({title: title, tagline: tagline})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { status.textContent = 'Error: ' + d.error; status.className = 'text-sm text-red-500'; return; }
      _applySiteText(d.title, d.tagline);
      status.textContent = 'Saved';
      status.className = 'text-sm text-green-600 dark:text-green-400';
      setTimeout(closeTitleEdit, 600);
    })
    .catch(function(e){ status.textContent = 'Error: ' + e.message; status.className = 'text-sm text-red-500'; });
}

// ===== FILE BROWSER =====
var _fbCurrentPath = '';
var _fbSelectedItem = null; // {name, is_dir, path}
var _fbRawEntries = [];     // raw entries from last browse response
var _fbSortField = 'date';  // 'date' or 'name'
var _fbSortAsc = false;     // false = descending (newest first by default)

function fbApplyFilterSort() {
  var query = (document.getElementById('fb-search').value || '').toLowerCase();
  var filtered = _fbRawEntries;
  if (query) {
    filtered = _fbRawEntries.filter(function(e) { return e.name.toLowerCase().indexOf(query) !== -1; });
  }
  var sorted = fbSortEntries(filtered);
  fbRenderList(sorted);
}

function fbSortEntries(entries) {
  var field = _fbSortField;
  var asc = _fbSortAsc;
  var copy = entries.slice();
  copy.sort(function(a, b) {
    // Folders always first
    if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
    var cmp = 0;
    if (field === 'name') {
      cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
    } else {
      cmp = (a.modified || '').localeCompare(b.modified || '');
    }
    return asc ? cmp : -cmp;
  });
  return copy;
}

function fbToggleSortDir() {
  _fbSortAsc = !_fbSortAsc;
  document.getElementById('fb-sort-desc-icon').classList.toggle('hidden', _fbSortAsc);
  document.getElementById('fb-sort-asc-icon').classList.toggle('hidden', !_fbSortAsc);
  fbApplyFilterSort();
}

function fbNavigate(relPath) {
  _fbSelectedItem = null;
  _fbCurrentPath = relPath;
  _fbRawEntries = [];
  fbUpdateButtons();
  fbClosePreview();

  // Clear search on navigation
  var searchEl = document.getElementById('fb-search');
  if (searchEl) searchEl.value = '';

  var listEl = document.getElementById('fb-list');
  listEl.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 px-4 py-3">Loading…</p>';

  fetch('/fb/browse?path=' + encodeURIComponent(relPath)).then(function(r){ return r.json(); }).then(function(data) {
    if (data.error) {
      listEl.innerHTML = '<p class="text-sm text-red-500 px-4 py-3">' + esc(data.error) + '</p>';
      return;
    }
    _fbCurrentPath = data.path;
    _fbRawEntries = data.entries;
    fbRenderBreadcrumb(data.path);
    fbApplyFilterSort();
    fbUpdateButtons();

    // Show/hide back button
    var backBtn = document.getElementById('fb-back-btn');
    if (data.path) { backBtn.classList.remove('hidden'); }
    else { backBtn.classList.add('hidden'); }
  }).catch(function(err) {
    listEl.innerHTML = '<p class="text-sm text-red-500 px-4 py-3">Failed to load directory.</p>';
  });
}

function fbRenderBreadcrumb(path) {
  var nav = document.getElementById('fb-breadcrumb');
  var html = '<span class="cursor-pointer hover:text-togen-teal transition-colors" onclick="fbNavigate(\'\')"> / </span>';
  if (path) {
    var parts = path.split('/');
    var cumulative = '';
    for (var i = 0; i < parts.length; i++) {
      cumulative += (cumulative ? '/' : '') + parts[i];
      html += ' <span class="text-gray-300 dark:text-gray-600">/</span> ';
      if (i === parts.length - 1) {
        html += '<span class="text-gray-700 dark:text-gray-200 font-medium">' + esc(parts[i]) + '</span>';
      } else {
        html += '<span class="cursor-pointer hover:text-togen-teal transition-colors" onclick="fbNavigate(\'' + esc(cumulative).replace(/'/g, "\\'") + '\')">' + esc(parts[i]) + '</span>';
      }
    }
  }
  nav.innerHTML = html;
}

function fbRenderList(entries) {
  var listEl = document.getElementById('fb-list');
  if (!entries.length) {
    listEl.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 px-4 py-8 text-center">This folder is empty.</p>';
    return;
  }
  var html = '';
  for (var i = 0; i < entries.length; i++) {
    var e = entries[i];
    var fullPath = _fbCurrentPath ? (_fbCurrentPath + '/' + e.name) : e.name;
    var sizeStr = e.is_dir ? '' : fbFormatSize(e.size);
    var dateStr = fbFormatDate(e.modified);
    var iconSvg = e.is_dir
      ? '<svg class="h-5 w-5 text-togen-teal flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/></svg>'
      : '<svg class="h-5 w-5 text-gray-400 dark:text-gray-500 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg>';

    html += '<div class="flex items-center gap-3 px-4 py-2.5 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none" ' +
            'data-path="' + esc(fullPath) + '" data-name="' + esc(e.name) + '" data-isdir="' + e.is_dir + '" ' +
            'onclick="fbSelectItem(this, event)" ondblclick="fbDblClick(this)">' +
            iconSvg +
            '<span class="flex-1 text-sm text-gray-700 dark:text-gray-200 truncate">' + esc(e.name) + '</span>' +
            '<span class="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">' + sizeStr + '</span>' +
            '<span class="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap hidden sm:inline">' + dateStr + '</span>' +
            '</div>';
  }
  listEl.innerHTML = html;
}

function fbFormatSize(bytes) {
  if (bytes === 0) return '0 B';
  var units = ['B', 'KB', 'MB', 'GB'];
  var i = Math.floor(Math.log(bytes) / Math.log(1024));
  if (i >= units.length) i = units.length - 1;
  return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
}

function fbFormatDate(iso) {
  var d = new Date(iso);
  if (isNaN(d)) return '';
  return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
}

function fbSelectItem(el, event) {
  // Deselect previous
  var prev = document.querySelector('#fb-list .bg-togen-teal\\/10');
  if (prev) {
    prev.classList.remove('bg-togen-teal/10', 'dark:bg-togen-teal/20', 'border-l-2', 'border-togen-teal');
  }

  var path = el.getAttribute('data-path');
  var name = el.getAttribute('data-name');
  var isDir = el.getAttribute('data-isdir') === 'true';

  _fbSelectedItem = {name: name, is_dir: isDir, path: path};
  el.classList.add('bg-togen-teal/10', 'dark:bg-togen-teal/20', 'border-l-2', 'border-togen-teal');
  fbUpdateButtons();

  // Show preview for files
  if (!isDir) {
    fbShowPreview(path, name);
  } else {
    fbClosePreview();
  }
}

function fbDblClick(el) {
  var isDir = el.getAttribute('data-isdir') === 'true';
  var path = el.getAttribute('data-path');
  if (isDir) {
    fbNavigate(path);
  } else {
    // Download on double-click for files
    window.location.href = '/fb/download?path=' + encodeURIComponent(path);
  }
}

function fbGoBack() {
  if (!_fbCurrentPath) return;
  var parts = _fbCurrentPath.split('/');
  parts.pop();
  fbNavigate(parts.join('/'));
}

function _fbUpdateBar(prefix) {
  var dlBtn = document.getElementById(prefix + 'download-btn');
  var renameBtn = document.getElementById(prefix + 'rename-btn');
  var delBtn = document.getElementById(prefix + 'delete-btn');
  var openBtn = document.getElementById(prefix + 'open-btn');
  var printBtn = document.getElementById(prefix + 'print-btn');
  var shareWrap = document.getElementById(prefix + 'share-wrapper');
  var shareBtn = document.getElementById(prefix + 'share-btn');
  var workflowWrap = document.getElementById(prefix + 'workflow-wrapper');
  var workflowBtn = document.getElementById(prefix + 'workflow-btn');
  var wfQr = document.getElementById(prefix + 'wf-qr');
  var wfBridge = document.getElementById(prefix + 'wf-bridge');
  var wfPb = document.getElementById(prefix + 'wf-pb');

  var ext = _fbSelectedItem && !_fbSelectedItem.is_dir
    ? _fbSelectedItem.name.split('.').pop().toLowerCase() : '';
  var isPdf = ext === 'pdf';
  var isCsv = ext === 'csv';
  var isRootFolder = _fbSelectedItem && _fbSelectedItem.is_dir && !_fbCurrentPath;

  if (_fbSelectedItem) {
    dlBtn.disabled = false;
    renameBtn.disabled = false;
    delBtn.disabled = false;
  } else if (_fbCurrentPath) {
    dlBtn.disabled = false;
    renameBtn.disabled = true;
    delBtn.disabled = true;
  } else {
    dlBtn.disabled = true;
    renameBtn.disabled = true;
    delBtn.disabled = true;
  }

  if (isPdf) {
    openBtn.classList.remove('hidden');
    openBtn.disabled = false;
    if (printBtn) { printBtn.classList.remove('hidden'); printBtn.disabled = false; }
    shareWrap.classList.remove('hidden');
    shareBtn.disabled = false;
  } else {
    openBtn.classList.add('hidden');
    openBtn.disabled = true;
    if (printBtn) { printBtn.classList.add('hidden'); printBtn.disabled = true; }
    shareWrap.classList.add('hidden');
    shareBtn.disabled = true;
  }

  var showWorkflow = false;
  if (isPdf) {
    wfQr.classList.remove('hidden');
    showWorkflow = true;
  } else {
    wfQr.classList.add('hidden');
  }
  if ((isCsv || isRootFolder) && (_isTakeoffWorkflow || _isToGenAdmin)) {
    wfBridge.classList.remove('hidden');
    wfPb.classList.remove('hidden');
    showWorkflow = true;
  } else {
    wfBridge.classList.add('hidden');
    wfPb.classList.add('hidden');
  }
  if (showWorkflow) {
    workflowWrap.classList.remove('hidden');
    workflowBtn.disabled = false;
  } else {
    workflowWrap.classList.add('hidden');
    workflowBtn.disabled = true;
  }
}

function fbUpdateButtons() {
  _fbUpdateBar('fb-');
  _fbUpdateBar('fbt-');
  _fbCloseShareMenu();
  _fbtCloseShareMenu();
  _fbCloseWorkflowMenu();
  _fbtCloseWorkflowMenu();
}

function fbShowPreview(path, name) {
  var pane = document.getElementById('fb-preview-pane');
  var title = document.getElementById('fb-preview-title');
  var content = document.getElementById('fb-preview-content');
  pane.classList.remove('hidden');
  title.textContent = name;

  var ext = name.split('.').pop().toLowerCase();
  var previewUrl = '/fb/preview?path=' + encodeURIComponent(path);

  if (ext === 'pdf') {
    content.innerHTML = '<iframe src="' + previewUrl + '" class="w-full h-full rounded-lg" style="min-height:500px"></iframe>';
  } else if (['png','jpg','jpeg','gif','svg','webp','bmp','ico'].indexOf(ext) !== -1) {
    content.innerHTML = '<img src="' + previewUrl + '" class="max-w-full rounded-lg" alt="' + esc(name) + '">';
  } else if (['txt','csv','json','xml','log','md','py','js','html','css','yml','yaml','ini','cfg','conf','sh'].indexOf(ext) !== -1) {
    fetch(previewUrl).then(function(r){ return r.text(); }).then(function(text) {
      content.innerHTML = '<pre class="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words font-mono bg-gray-50 dark:bg-gray-800 rounded-lg p-3 max-h-96 overflow-auto">' + esc(text.substring(0, 50000)) + '</pre>';
    }).catch(function(){ content.innerHTML = '<p class="text-sm text-gray-400">Could not load preview.</p>'; });
  } else {
    content.innerHTML = '<div class="flex flex-col items-center justify-center py-12 text-gray-400 dark:text-gray-500">' +
      '<svg class="h-16 w-16 mb-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1"><path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>' +
      '<p class="text-sm">No preview available</p>' +
      '<p class="text-xs mt-1">' + esc(ext.toUpperCase()) + ' file — ' + esc(name) + '</p>' +
      '</div>';
  }
}

function fbClosePreview() {
  document.getElementById('fb-preview-pane').classList.add('hidden');
  document.getElementById('fb-preview-content').innerHTML = '';
}

function _fbSetStatus(text) {
  document.getElementById('fb-status').textContent = text;
  document.getElementById('fbt-status').textContent = text;
}

// Upload
// DVI-783: per-file upload progress (Option C). Frontend-only, reuses /fb/upload
// one-file-per-request with a worker pool of up to 3 concurrent XHRs.
var _fbUploadSeq = 0;

function _fbFormatBytes(n) {
  if (n == null) return '';
  if (n < 1024) return n + ' B';
  if (n < 1048576) return (n / 1024).toFixed(1) + ' KB';
  if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB';
  return (n / 1073741824).toFixed(1) + ' GB';
}

function fbCloseUploadPanel() {
  var panel = document.getElementById('fb-upload-panel');
  if (panel) panel.classList.add('hidden');
}

function fbUploadFiles(files) {
  if (!files || !files.length) return;

  var jobs = [];
  for (var i = 0; i < files.length; i++) {
    jobs.push({id: 'fbup-' + (++_fbUploadSeq), file: files[i],
               name: files[i].name, size: files[i].size, status: 'queued', error: null});
  }

  var targetPath = _fbCurrentPath;
  var total = jobs.length;
  var completed = 0;
  var failures = [];

  var panel = document.getElementById('fb-upload-panel');
  var rowsEl = document.getElementById('fb-upload-rows');
  var titleEl = document.getElementById('fb-upload-panel-title');
  var closeBtn = document.getElementById('fb-upload-panel-close');
  rowsEl.innerHTML = '';
  closeBtn.classList.add('hidden');
  panel.classList.remove('hidden');
  _fbSetStatus('');

  function escapeHtml(s) {
    return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
                    .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  }

  function updateHeader() {
    titleEl.textContent = 'Uploading… (' + completed + '/' + total + ' complete)';
  }
  updateHeader();

  // Render one row per job
  jobs.forEach(function(job) {
    var row = document.createElement('div');
    row.id = job.id;
    row.className = 'px-4 py-2.5';
    row.innerHTML =
      '<div class="flex items-center justify-between gap-3 mb-1.5">' +
        '<span class="text-sm text-gray-700 dark:text-gray-200 truncate" title="' + escapeHtml(job.name) + '">' + escapeHtml(job.name) + '</span>' +
        '<span class="flex items-center gap-2 flex-shrink-0">' +
          '<span class="text-xs text-gray-400">' + _fbFormatBytes(job.size) + '</span>' +
          '<span class="fb-up-badge text-xs font-semibold px-2 py-0.5 rounded-full bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-300">Queued</span>' +
        '</span>' +
      '</div>' +
      '<div class="flex items-center gap-2">' +
        '<div class="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">' +
          '<div class="fb-up-bar h-full bg-togen-teal transition-all duration-150" style="width:0%"></div>' +
        '</div>' +
        '<span class="fb-up-pct text-xs text-gray-500 dark:text-gray-400 w-9 text-right">0%</span>' +
      '</div>';
    rowsEl.appendChild(row);
  });

  function setBadge(job, text, cls) {
    var row = document.getElementById(job.id);
    if (!row) return;
    var b = row.querySelector('.fb-up-badge');
    b.textContent = text;
    b.className = 'fb-up-badge text-xs font-semibold px-2 py-0.5 rounded-full ' + cls;
  }
  function setProgress(job, pct) {
    var row = document.getElementById(job.id);
    if (!row) return;
    row.querySelector('.fb-up-bar').style.width = pct + '%';
    row.querySelector('.fb-up-pct').textContent = pct + '%';
  }

  function finish() {
    var failed = failures.length;
    var ok = total - failed;
    if (failed === 0) {
      titleEl.textContent = 'Uploaded ' + ok + ' of ' + total;
    } else {
      titleEl.textContent = 'Uploaded ' + ok + ' of ' + total + ' — ' + failed + ' failed: ' + failures.join(', ');
    }
    closeBtn.classList.remove('hidden');
    fbNavigate(targetPath);
    if (failed === 0) {
      setTimeout(function() { fbCloseUploadPanel(); }, 5000);
    }
  }

  var next = 0;
  var active = 0;
  var MAX = 3;

  function pump() {
    while (active < MAX && next < jobs.length) {
      startJob(jobs[next++]);
    }
    if (active === 0 && next >= jobs.length) {
      finish();
    }
  }

  function startJob(job) {
    active++;
    job.status = 'uploading';
    setBadge(job, 'Uploading', 'bg-togen-teal/10 text-togen-teal');

    var fd = new FormData();
    fd.append('path', targetPath);
    fd.append('files', job.file);

    var xhr = new XMLHttpRequest();
    xhr.open('POST', '/fb/upload');
    xhr.upload.onprogress = function(e) {
      if (e.lengthComputable) {
        setProgress(job, Math.round((e.loaded / e.total) * 100));
      }
    };
    xhr.onload = function() {
      var data = null;
      try { data = JSON.parse(xhr.responseText); } catch (err) {}
      if (xhr.status >= 200 && xhr.status < 300 && data && !data.error) {
        job.status = 'done';
        setProgress(job, 100);
        setBadge(job, 'Done', 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300');
      } else {
        job.status = 'failed';
        job.error = (data && data.error) ? data.error : ('HTTP ' + xhr.status);
        setBadge(job, 'Failed', 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300');
        var row = document.getElementById(job.id);
        if (row) row.querySelector('.fb-up-pct').textContent = '';
        failures.push(job.name);
      }
      completed++;
      active--;
      updateHeader();
      pump();
    };
    xhr.onerror = function() {
      job.status = 'failed';
      job.error = 'Network error';
      setBadge(job, 'Failed', 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300');
      failures.push(job.name);
      completed++;
      active--;
      updateHeader();
      pump();
    };
    xhr.send(fd);
  }

  pump();

  // Reset file input so re-selecting the same file fires onchange again
  document.getElementById('fb-upload-input').value = '';
}

// Download
function fbDownload() {
  if (_fbSelectedItem) {
    window.location.href = '/fb/download?path=' + encodeURIComponent(_fbSelectedItem.path);
  } else if (_fbCurrentPath) {
    window.location.href = '/fb/download?path=' + encodeURIComponent(_fbCurrentPath);
  }
}

// Document viewer (Open / Share)
function _fbDocViewerUrl() {
  if (!_fbSelectedItem || _fbSelectedItem.is_dir) return null;
  var ext = _fbSelectedItem.name.split('.').pop().toLowerCase();
  if (ext !== 'pdf') return null;
  return _sharedUrlPrefix + '/shared/' + encodeURI(_fbSelectedItem.path);
}

function fbOpenViewer() {
  var url = _fbDocViewerUrl();
  if (url) window.open(url, '_blank');
}

function fbPrint() {
  if (!_fbSelectedItem || _fbSelectedItem.is_dir) { alert('Select a PDF first.'); return; }
  togenOpenPrintPopup({source: 'shared', fileRef: _fbSelectedItem.path,
                       kind: 'document', title: 'Print Document', allowLabelToggle: true});
}

function fbToggleShareMenu(e) {
  e.stopPropagation();
  var menu = document.getElementById('fb-share-menu');
  menu.classList.toggle('hidden');
  if (!menu.classList.contains('hidden')) {
    setTimeout(function() {
      document.addEventListener('click', _fbCloseShareMenu, {once: true});
    }, 0);
  }
}

function _fbCloseShareMenu() {
  document.getElementById('fb-share-menu').classList.add('hidden');
}

function fbToggleWorkflowMenu(e) {
  e.stopPropagation();
  var menu = document.getElementById('fb-workflow-menu');
  menu.classList.toggle('hidden');
  if (!menu.classList.contains('hidden')) {
    setTimeout(function() {
      document.addEventListener('click', _fbCloseWorkflowMenu, {once: true});
    }, 0);
  }
}

function _fbCloseWorkflowMenu() {
  document.getElementById('fb-workflow-menu').classList.add('hidden');
}

function fbtToggleShareMenu(e) {
  e.stopPropagation();
  var menu = document.getElementById('fbt-share-menu');
  menu.classList.toggle('hidden');
  if (!menu.classList.contains('hidden')) {
    setTimeout(function() {
      document.addEventListener('click', _fbtCloseShareMenu, {once: true});
    }, 0);
  }
}

function _fbtCloseShareMenu() {
  document.getElementById('fbt-share-menu').classList.add('hidden');
}

function fbtToggleWorkflowMenu(e) {
  e.stopPropagation();
  var menu = document.getElementById('fbt-workflow-menu');
  menu.classList.toggle('hidden');
  if (!menu.classList.contains('hidden')) {
    setTimeout(function() {
      document.addEventListener('click', _fbtCloseWorkflowMenu, {once: true});
    }, 0);
  }
}

function _fbtCloseWorkflowMenu() {
  document.getElementById('fbt-workflow-menu').classList.add('hidden');
}

function fbCopyLink() {
  var url = _fbDocViewerUrl();
  if (!url) return;
  navigator.clipboard.writeText(url).then(function() {
    _fbSetStatus('Link copied to clipboard');
    setTimeout(function() { _fbSetStatus(''); }, 3000);
  });
  _fbCloseShareMenu();
  _fbtCloseShareMenu();
}

function fbEmailLink() {
  var url = _fbDocViewerUrl();
  if (!url) return;
  _fbCloseShareMenu();
  _fbtCloseShareMenu();
  document.getElementById('fb-email-status').textContent = '';
  document.getElementById('fb-email-overlay').classList.remove('hidden');
  setTimeout(function(){ document.getElementById('fb-email-input').focus(); document.getElementById('fb-email-input').select(); }, 100);
}

function fbEmailClose() {
  document.getElementById('fb-email-overlay').classList.add('hidden');
}

function fbEmailSend() {
  var email = document.getElementById('fb-email-input').value.trim();
  if (!email) { document.getElementById('fb-email-status').textContent = 'Enter an email address.'; return; }
  var url = _fbDocViewerUrl();
  if (!url) return;
  var name = _fbSelectedItem ? _fbSelectedItem.name : '';
  var statusEl = document.getElementById('fb-email-status');
  statusEl.textContent = 'Sending...';
  document.getElementById('fb-email-send-btn').disabled = true;
  fetch('/fb/email-link', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({email: email, url: url, name: name})
  }).then(function(r){ return r.json(); }).then(function(data) {
    document.getElementById('fb-email-send-btn').disabled = false;
    if (data.error) { statusEl.textContent = 'Error: ' + data.error; return; }
    statusEl.textContent = 'Sent!';
    setTimeout(function(){ fbEmailClose(); }, 1500);
  }).catch(function() {
    document.getElementById('fb-email-send-btn').disabled = false;
    statusEl.textContent = 'Failed to send.';
  });
}

function fbRefresh() {
  fbNavigate(_fbCurrentPath);
}

function fbOpenInBridge() {
  _fbCloseWorkflowMenu();
  _fbtCloseWorkflowMenu();
  if (!_fbSelectedItem) return;
  var folderName = '';
  if (_fbSelectedItem.is_dir && !_fbCurrentPath) {
    folderName = _fbSelectedItem.name;
  } else if (!_fbSelectedItem.is_dir) {
    var ext = _fbSelectedItem.name.split('.').pop().toLowerCase();
    if (ext !== 'csv') return;
    var parts = _fbSelectedItem.path.split('/');
    folderName = parts.length > 1 ? parts[0] : '';
  }
  if (!folderName) return;
  openTool('takeoff-bridge', 'Take-off Bridge');
  fetch('list-shared-folders')
    .then(function(r){ return r.json(); })
    .then(function(data) {
      _allSharedFolders = data.folders || [];
      renderSharedFolders(_allSharedFolders);
      _sharedFoldersLoaded = true;
      var btns = document.querySelectorAll('#shared-folders-list button');
      for (var i = 0; i < btns.length; i++) {
        if (btns[i].textContent === folderName) {
          selectSharedFolder(folderName, btns[i]);
          break;
        }
      }
    });
}

function fbOpenInProjectBuilder() {
  _fbCloseWorkflowMenu();
  _fbtCloseWorkflowMenu();
  if (!_fbSelectedItem) return;
  var folderName = '';
  if (_fbSelectedItem.is_dir && !_fbCurrentPath) {
    folderName = _fbSelectedItem.name;
  } else if (!_fbSelectedItem.is_dir) {
    var ext = _fbSelectedItem.name.split('.').pop().toLowerCase();
    if (ext !== 'csv') return;
    var parts = _fbSelectedItem.path.split('/');
    folderName = parts.length > 1 ? parts[0] : '';
  }
  if (!folderName) return;
  openTool('project-builder', 'Project Builder');
  // A project without Togen metadata is external. Bringing it in via the File
  // Browser workflow forces duplicate/import mode so it follows project
  // numbering conventions; metadata-bearing projects open for editing (DVI-451).
  fetch('project-builder-load-project', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({folder_name: folderName})
  }).then(function(r){ return r.json(); }).then(function(data) {
    if (data && data.meta) {
      spLoadProject(folderName);
    } else {
      spStartImport(folderName);
    }
  }).catch(function() {
    spLoadProject(folderName);
  });
}

// ===== EMAIL TOOL + ATTACHMENTS (DVI-470) =====
var _emailMessages = [];        // cached messages from /email/messages
var _emailTileData = null;      // {available, count} for the Tools-page badge
var _emailTileLoading = false;
var _emailAction = null;        // {mi, ai, name, ext} — attachment a modal is acting on
var _emailSaveMode = 'save';    // 'save' | 'openwith'
var _emailSaveTarget = null;    // {id, title} when mode === 'openwith'
var _emailSavePath = '';        // current folder path in the Save picker

// Filters across the top of the Email tool (DVI-510). Attachments on by default.
var _emailFilters = { attachments: true, workflow: true };

// Document file types get the Copy/Manuals workflow options.
var _EMAIL_DOC_EXTS = ['pdf','doc','docx','png','jpg','jpeg'];

// Attachment types we can render inline in the Preview modal (DVI-510).
var _EMAIL_PREVIEW_EXTS = ['pdf','png','jpg','jpeg','gif','webp','svg','bmp','txt'];

function _emailFilterChipCls(active) {
  return active
    ? 'border-togen-teal bg-togen-teal/10 text-togen-teal'
    : 'border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300';
}

// True when a message has at least one attachment whose file type supports a
// Workflow action (mirrors the hasWorkflow logic in _emailAttButtons).
function _emailHasWorkflow(m) {
  return (m.attachments || []).some(function(a){
    var ext = _emailExt(a.name);
    return _EMAIL_DOC_EXTS.indexOf(ext) !== -1 || ext === 'xlsx' || ext === 'csv';
  });
}

function _emailRenderFilterChip(id, key, label) {
  var btn = document.getElementById(id);
  if (!btn) return;
  var on = !!_emailFilters[key];
  btn.className = 'inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-colors ' + _emailFilterChipCls(on);
  btn.innerHTML = (on
      ? '<svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>'
      : '') +
    label;
  btn.setAttribute('aria-pressed', on ? 'true' : 'false');
}

function _emailRenderFilterChips() {
  _emailRenderFilterChip('email-filter-attachments', 'attachments', 'Has attachments');
  _emailRenderFilterChip('email-filter-workflow', 'workflow', 'Workflows available');
}

function emailToggleFilter(key) {
  _emailFilters[key] = !_emailFilters[key];
  _emailRenderFilterChips();
  _emailRenderInbox();
}

function _emailExt(name) {
  var dot = (name || '').lastIndexOf('.');
  return dot === -1 ? '' : name.substring(dot + 1).toLowerCase();
}

function _emailFetchTileData(cb) {
  if (_emailTileLoading) { if (cb) cb(); return; }
  _emailTileLoading = true;
  fetch('/email/unread-count')
    .then(function(r){ return r.ok ? r.json() : {}; })
    .then(function(d){ _emailTileData = d || {}; })
    .catch(function(){})
    .then(function(){ _emailTileLoading = false; if (cb) cb(); });
}

function emailLoadMessages() {
  var statusEls = [document.getElementById('email-status'), document.getElementById('fb-att-status')];
  var listEl = document.getElementById('email-list');
  var attEl = document.getElementById('fb-att-list');
  statusEls.forEach(function(el){ if (el) el.textContent = 'Loading mailbox…'; });
  if (listEl) listEl.innerHTML = '';
  if (attEl) attEl.innerHTML = '';
  fetch('/email/messages')
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.available === false) {
        var msg = d.error || 'Mailbox access is not available.';
        statusEls.forEach(function(el){ if (el) el.innerHTML = '<span class="text-yellow-600 dark:text-yellow-400">' + esc(msg) + '</span>'; });
        _emailMessages = [];
        return;
      }
      if (d.error) {
        statusEls.forEach(function(el){ if (el) el.innerHTML = '<span class="text-red-500">' + esc(d.error) + '</span>'; });
        return;
      }
      _emailMessages = d.messages || [];
      var label = document.getElementById('email-mailbox-label');
      if (label) label.textContent = d.mailbox ? ('Mailbox: ' + d.mailbox) : '';
      _emailRenderInbox();
      _emailRenderAttachments();
      var n = _emailMessages.length;
      var unread = d.unread || 0;
      var summary = n + ' message' + (n !== 1 ? 's' : '') + (unread ? ' · ' + unread + ' unread' : '');
      var sEmail = document.getElementById('email-status');
      if (sEmail) sEmail.textContent = summary;
      var withAtt = _emailMessages.filter(function(m){ return m.has_attachments; }).length;
      var sAtt = document.getElementById('fb-att-status');
      if (sAtt) sAtt.textContent = withAtt + ' email' + (withAtt !== 1 ? 's' : '') + ' with attachments';
      // Keep the tile badge in sync with what we just loaded (DVI-635:
      // also derive the unread-with-attachment and workflow-supported counts).
      var unreadAtch = 0, unreadWf = 0;
      _emailMessages.forEach(function(m){
        if (m.is_read || !m.has_attachments) return;
        unreadAtch++;
        var hasWf = (m.attachments || []).some(function(a){
          var ext = _emailExt(a.name);
          return _EMAIL_DOC_EXTS.indexOf(ext) !== -1 || ext === 'xlsx' || ext === 'csv';
        });
        if (hasWf) unreadWf++;
      });
      _emailTileData = { available: true, count: unread, attachments: unreadAtch, workflow: unreadWf };
    })
    .catch(function(){
      statusEls.forEach(function(el){ if (el) el.innerHTML = '<span class="text-red-500">Could not load mailbox.</span>'; });
    });
}

function _emailAttButtons(mi, ai, ext) {
  var isDoc = _EMAIL_DOC_EXTS.indexOf(ext) !== -1;
  var isXlsx = ext === 'xlsx';
  var isCsv = ext === 'csv';
  var hasWorkflow = isDoc || isXlsx || isCsv;
  var btnCls = 'px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors';
  var canPreview = _EMAIL_PREVIEW_EXTS.indexOf(ext) !== -1;
  var html = '<div class="flex items-center gap-2 flex-shrink-0">';
  if (canPreview) {
    html += '<button class="' + btnCls + '" onclick="emailPreviewOpen(' + mi + ',' + ai + ')">Preview</button>';
  }
  html += '<button class="' + btnCls + '" onclick="emailSaveOpen(' + mi + ',' + ai + ')">Save</button>' +
    '<button class="' + btnCls + '" onclick="emailShareOpen(' + mi + ',' + ai + ')">Share</button>';
  if (hasWorkflow) {
    html += '<button class="' + btnCls + '" onclick="emailWorkflowOpen(' + mi + ',' + ai + ')">Workflow</button>';
  }
  html += '</div>';
  return html;
}

function _emailAttRow(mi, ai, att) {
  var ext = _emailExt(att.name);
  var viewUrl = '/email/attachment?message_id=' + encodeURIComponent(_emailMessages[mi].id) +
                '&attachment_id=' + encodeURIComponent(att.id);
  var size = att.size ? fbFormatSize(att.size) : '';
  return '<div class="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800/60">' +
    '<svg class="h-4 w-4 text-gray-400 dark:text-gray-500 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" clip-rule="evenodd"/></svg>' +
    '<a href="' + viewUrl + '" target="_blank" rel="noopener" class="flex-1 text-sm text-gray-700 dark:text-gray-200 truncate hover:text-togen-teal" title="' + esc(att.name) + '">' + esc(att.name) + '</a>' +
    '<span class="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap hidden sm:inline">' + size + '</span>' +
    _emailAttButtons(mi, ai, ext) +
    '</div>';
}

function _emailRenderInbox() {
  var listEl = document.getElementById('email-list');
  if (!listEl) return;
  _emailRenderFilterChips();
  if (!_emailMessages.length) {
    listEl.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 py-6 text-center">No messages.</p>';
    return;
  }
  var visible = [];
  for (var vi = 0; vi < _emailMessages.length; vi++) {
    if (_emailFilters.attachments && !_emailMessages[vi].has_attachments) continue;
    if (_emailFilters.workflow && !_emailHasWorkflow(_emailMessages[vi])) continue;
    visible.push(vi);
  }
  if (!visible.length) {
    listEl.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 py-6 text-center">No emails match the selected filters.</p>';
    return;
  }
  var html = '';
  for (var vk = 0; vk < visible.length; vk++) {
    var mi = visible[vk];
    var m = _emailMessages[mi];
    var highlight = m.has_attachments
      ? 'border-togen-teal/40 bg-togen-teal/5 dark:bg-togen-teal/10'
      : 'border-gray-200 dark:border-gray-700';
    var unreadDot = m.is_read ? '' :
      '<span class="inline-block h-2 w-2 rounded-full bg-togen-teal flex-shrink-0" title="Unread"></span>';
    var fromTxt = m.from_name || m.from || '';
    html += '<div class="rounded-xl border ' + highlight + ' p-4">' +
      '<div class="flex items-start gap-2">' +
        unreadDot +
        '<div class="flex-1 min-w-0">' +
          '<p class="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">' + esc(m.subject) + '</p>' +
          '<p class="text-xs text-gray-500 dark:text-gray-400 truncate">' + esc(fromTxt) + (m.received ? ' · ' + esc(fmtLocalDate(m.received)) : '') + '</p>' +
        '</div>' +
        (m.has_attachments
          ? '<span class="inline-flex items-center gap-1 text-xs font-medium text-togen-teal flex-shrink-0">' +
              '<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" clip-rule="evenodd"/></svg>' +
              m.attachments.length + '</span>'
          : '') +
      '</div>';
    if (m.preview) {
      html += '<p class="text-xs text-gray-400 dark:text-gray-500 mt-2 line-clamp-2">' + esc(m.preview) + '</p>';
    }
    if (m.has_attachments) {
      html += '<div class="mt-3 space-y-2">';
      for (var ai = 0; ai < m.attachments.length; ai++) {
        html += _emailAttRow(mi, ai, m.attachments[ai]);
      }
      html += '</div>';
    }
    html += '</div>';
  }
  listEl.innerHTML = html;
}

function _emailRenderAttachments() {
  var attEl = document.getElementById('fb-att-list');
  if (!attEl) return;
  var withAtt = [];
  for (var mi = 0; mi < _emailMessages.length; mi++) {
    if (_emailMessages[mi].has_attachments) withAtt.push(mi);
  }
  if (!withAtt.length) {
    attEl.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 py-6 text-center">No emails with attachments.</p>';
    return;
  }
  var html = '';
  for (var k = 0; k < withAtt.length; k++) {
    var mi2 = withAtt[k];
    var m = _emailMessages[mi2];
    var fromTxt = m.from_name || m.from || '';
    html += '<div class="rounded-xl border border-gray-200 dark:border-gray-700 p-4">' +
      '<p class="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">' + esc(m.subject) + '</p>' +
      '<p class="text-xs text-gray-500 dark:text-gray-400 truncate mb-3">' + esc(fromTxt) + (m.received ? ' · ' + esc(fmtLocalDate(m.received)) : '') + '</p>' +
      '<div class="space-y-2">';
    for (var ai2 = 0; ai2 < m.attachments.length; ai2++) {
      html += _emailAttRow(mi2, ai2, m.attachments[ai2]);
    }
    html += '</div></div>';
  }
  attEl.innerHTML = html;
}

// Attachments tab mirrors Email tool visibility (DVI-496): when Email is
// disabled (admin- or user-level) for a non-admin, hide it from File Browser.
function _emailToolEnabled() {
  if (_isToGenAdmin) return true;
  if (_toolVisibility.hasOwnProperty('email') && !_toolVisibility['email']) return false;
  var userVis = _userSettings.tool_visibility;
  if (userVis && userVis.hasOwnProperty('email') && !userVis['email']) return false;
  return true;
}

function fbSwitchTab(tab) {
  var projBtn = document.getElementById('fb-tab-btn-projects');
  var attBtn = document.getElementById('fb-tab-btn-attachments');
  var setBtn = document.getElementById('fb-tab-btn-settings');
  var visBtn = document.getElementById('fb-tab-btn-vision');
  var prodBtn = document.getElementById('fb-tab-btn-prodrun');
  var scadaBtn = document.getElementById('fb-tab-btn-scada');
  var odBtn = document.getElementById('fb-tab-btn-onedrive');
  var projPane = document.getElementById('fb-tab-projects');
  var attPane = document.getElementById('fb-tab-attachments');
  var setPane = document.getElementById('fb-tab-settings');
  var visPane = document.getElementById('fb-tab-vision');
  var prodPane = document.getElementById('fb-tab-prodrun');
  var scadaPane = document.getElementById('fb-tab-scada');
  var odPane = document.getElementById('fb-tab-onedrive');
  if (!attBtn) return;
  // Tab availability depends on group membership (Projects/Settings = Take-off,
  // Vision = Vision, Prod Run = Production Run) and the Email tool (Attachments).
  // If the requested tab is unavailable, fall back to the first available tab.
  var avail = {
    projects: !!projBtn,
    attachments: _emailToolEnabled(),
    vision: !!visBtn,
    prodrun: !!prodBtn,
    scada: !!scadaBtn,
    onedrive: !!odBtn,
    settings: !!setBtn
  };
  if (!avail[tab]) {
    tab = avail.projects ? 'projects'
        : avail.attachments ? 'attachments'
        : avail.vision ? 'vision'
        : avail.prodrun ? 'prodrun'
        : avail.scada ? 'scada'
        : avail.onedrive ? 'onedrive'
        : avail.settings ? 'settings'
        : 'projects';
  }
  var activeCls = ['border-togen-teal', 'text-togen-teal'];
  var idleCls = ['border-transparent', 'text-gray-500', 'dark:text-gray-400', 'hover:text-gray-700', 'dark:hover:text-gray-300'];
  function setActive(btn, on) {
    if (!btn) return;
    if (on) { btn.classList.add.apply(btn.classList, activeCls); btn.classList.remove.apply(btn.classList, idleCls); }
    else { btn.classList.remove.apply(btn.classList, activeCls); btn.classList.add.apply(btn.classList, idleCls); }
  }
  if (projPane) projPane.classList.add('hidden');
  attPane.classList.add('hidden');
  if (setPane) setPane.classList.add('hidden');
  if (visPane) visPane.classList.add('hidden');
  if (prodPane) prodPane.classList.add('hidden');
  if (scadaPane) scadaPane.classList.add('hidden');
  if (odPane) odPane.classList.add('hidden');
  setActive(projBtn, false); setActive(attBtn, false); setActive(setBtn, false); setActive(visBtn, false); setActive(prodBtn, false); setActive(scadaBtn, false); setActive(odBtn, false);
  if (tab === 'attachments') {
    setActive(attBtn, true);
    attPane.classList.remove('hidden');
    emailLoadMessages();
  } else if (tab === 'vision') {
    setActive(visBtn, true);
    if (visPane) visPane.classList.remove('hidden');
    fbVisionLoad();
  } else if (tab === 'prodrun') {
    setActive(prodBtn, true);
    if (prodPane) prodPane.classList.remove('hidden');
    fbProdRunLoad();
  } else if (tab === 'scada') {
    setActive(scadaBtn, true);
    if (scadaPane) scadaPane.classList.remove('hidden');
    fbScadaLoad();
  } else if (tab === 'onedrive') {
    setActive(odBtn, true);
    if (odPane) odPane.classList.remove('hidden');
    fbOneDriveLoad();
  } else if (tab === 'settings') {
    setActive(setBtn, true);
    if (setPane) setPane.classList.remove('hidden');
    fbSettingsLoad();
  } else {
    setActive(projBtn, true);
    if (projPane) projPane.classList.remove('hidden');
  }
}

// ===== File Browser OneDrive tab (DVI-810): browse signed-in user's OneDrive =====
var _fbOdStack = [];   // breadcrumb stack [{id, name}]
var _fbOdLoading = false;

function fbOneDriveLoad() {
  _fbOdStack = [];
  fbOneDriveNavigate(null, 'root');
}

function fbOneDriveRefresh() {
  var cur = _fbOdStack.length ? _fbOdStack[_fbOdStack.length - 1] : null;
  fbOneDriveFetch(cur ? cur.id : null);
}

function fbOneDriveNavigate(itemId, name) {
  if (itemId === null) {
    _fbOdStack = [];
  } else {
    var existIdx = _fbOdStack.findIndex(function(e) { return e.id === itemId; });
    if (existIdx >= 0) {
      _fbOdStack = _fbOdStack.slice(0, existIdx + 1);
    } else {
      _fbOdStack.push({id: itemId, name: name});
    }
  }
  fbOneDriveRenderBreadcrumb();
  fbOneDriveFetch(itemId);
}

function fbOneDriveRenderBreadcrumb() {
  var bc = document.getElementById('fb-onedrive-breadcrumb');
  if (!bc) return;
  var html = '<button onclick="fbOneDriveNavigate(null,\'root\')" class="hover:text-togen-teal transition-colors cursor-pointer">OneDrive</button>';
  _fbOdStack.forEach(function(seg, i) {
    html += '<svg class="h-3 w-3 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>';
    var escapedName = seg.name.replace(/'/g, "\\'");
    var escapedId = seg.id.replace(/'/g, "\\'");
    html += '<button onclick="fbOneDriveNavigate(\'' + escapedId + '\',\'' + escapedName + '\')" class="hover:text-togen-teal transition-colors cursor-pointer truncate max-w-[120px]" title="' + seg.name.replace(/"/g, '&quot;') + '">' + seg.name + '</button>';
  });
  bc.innerHTML = html;
}

function fbOneDriveFetch(itemId) {
  if (_fbOdLoading) return;
  _fbOdLoading = true;
  var status = document.getElementById('fb-onedrive-status');
  var list = document.getElementById('fb-onedrive-list');
  if (!list) { _fbOdLoading = false; return; }
  status.textContent = 'Loading…';
  list.innerHTML = '';
  var url = '/onedrive/ls' + (itemId ? '?item_id=' + encodeURIComponent(itemId) : '');
  fetch(url).then(function(r) { return r.json(); }).then(function(d) {
    _fbOdLoading = false;
    if (d.error) { status.textContent = 'Error: ' + d.error; return; }
    status.textContent = '';
    var items = d.items || [];
    if (!items.length) { list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 italic">This folder is empty.</p>'; return; }
    items.sort(function(a, b) {
      if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
      return (a.name || '').localeCompare(b.name || '');
    });
    list.innerHTML = items.map(function(item) {
      if (item.type === 'folder') {
        var escapedId = item.id.replace(/'/g, "\\'").replace(/"/g, '&quot;');
        var escapedName = item.name.replace(/'/g, "\\'").replace(/"/g, '&quot;');
        return '<div class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer group" onclick="fbOneDriveNavigate(\'' + escapedId + '\',\'' + escapedName + '\')">'
          + '<svg class="h-5 w-5 text-togen-teal flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/></svg>'
          + '<span class="text-sm text-gray-800 dark:text-gray-100 flex-1 min-w-0 truncate">' + item.name + '</span>'
          + '<svg class="h-4 w-4 text-gray-400 flex-shrink-0 opacity-0 group-hover:opacity-100" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>'
          + '</div>';
      } else {
        var size = item.size ? (item.size < 1048576 ? Math.round(item.size / 1024) + ' KB' : (item.size / 1048576).toFixed(1) + ' MB') : '';
        var dlUrl = '/onedrive/download?item_id=' + encodeURIComponent(item.id) + '&name=' + encodeURIComponent(item.name);
        return '<div class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors group">'
          + '<svg class="h-5 w-5 text-gray-400 dark:text-gray-500 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg>'
          + '<span class="text-sm text-gray-800 dark:text-gray-100 flex-1 min-w-0 truncate">' + item.name + '</span>'
          + (size ? '<span class="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">' + size + '</span>' : '')
          + '<a href="' + dlUrl + '" download="' + item.name.replace(/"/g, '&quot;') + '" class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700" title="Download">'
          + '<svg class="h-4 w-4 text-gray-500 dark:text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>'
          + '</a>'
          + '</div>';
      }
    }).join('');
  }).catch(function(e) {
    _fbOdLoading = false;
    var status = document.getElementById('fb-onedrive-status');
    if (status) status.textContent = 'Failed to load OneDrive: ' + e.message;
  });
}

// ===== File Browser Prod Run tab (DVI-528): Production Run history =====
// Reuses /prodrun-history (prod_run OR togen_admin). Clicking a run opens it in
// the Production Run tool's History tab via prodRunOpenHistoryDetail.
function fbProdRunLoad() {
  var status = document.getElementById('fb-prodrun-status');
  var list = document.getElementById('fb-prodrun-list');
  if (!list) return;
  status.textContent = 'Loading…'; list.innerHTML = '';
  fetch('/prodrun-history').then(function(r){ return r.json(); }).then(function(data) {
    if (data.error) { status.textContent = 'Error: ' + data.error; return; }
    var runs = data.runs || [];
    if (!runs.length) { status.textContent = 'No production runs yet.'; return; }
    status.textContent = '';
    var html = '';
    runs.forEach(function(run){ html += prHistoryRowHtml(run, 'fbProdRunOpenEntry'); });
    list.innerHTML = html;
  }).catch(function(e){ status.textContent = 'Failed: ' + e.message; });
}

function fbProdRunOpenEntry(jobId) {
  openTool('prod-run-merge', 'Production Run');
  prodRunOpenHistoryDetail(jobId);
}

// ===== File Browser SCADA tab (DVI-541): mailbox log emails + produced reports =====
// Merges /scada/logs (same source as SCADA View > Logs) with /scada/reports
// (produced XLSX files), renders newest-first, filterable by type + date range.
var _fbScadaLogs = [];
var _fbScadaReports = [];

function fbScadaLoad() {
  var loading = document.getElementById('fb-scada-loading');
  var empty = document.getElementById('fb-scada-empty');
  var error = document.getElementById('fb-scada-error');
  var wrap = document.getElementById('fb-scada-table-wrap');
  var detail = document.getElementById('fb-scada-detail');
  var time = document.getElementById('fb-scada-time');
  loading.classList.remove('hidden');
  empty.classList.add('hidden');
  error.classList.add('hidden');
  wrap.classList.add('hidden');
  detail.classList.add('hidden');
  Promise.all([
    fetch('/scada/logs').then(function(r){ if (!r.ok) throw new Error('logs HTTP ' + r.status); return r.json(); }),
    fetch('/scada/reports').then(function(r){ if (!r.ok) throw new Error('reports HTTP ' + r.status); return r.json(); })
  ]).then(function(res) {
    loading.classList.add('hidden');
    _fbScadaLogs = (res[0] && res[0].logs) || [];
    _fbScadaReports = (res[1] && res[1].reports) || [];
    if (time) time.textContent = 'Last refreshed: ' + new Date().toLocaleTimeString();
    fbScadaRender();
  }).catch(function(err) {
    loading.classList.add('hidden');
    error.classList.remove('hidden');
    document.getElementById('fb-scada-error-msg').textContent = 'Failed to load SCADA data: ' + err.message;
  });
}

function _fbScadaParseDate(s) {
  // Log dates are "YYYY-MM-DD HH:MM"; make them Date-parseable.
  var d = new Date((s || '').replace(' ', 'T'));
  return isNaN(d.getTime()) ? null : d;
}

function fbScadaRender() {
  var type = document.getElementById('fb-scada-type').value;
  var fromStr = document.getElementById('fb-scada-from').value;
  var toStr = document.getElementById('fb-scada-to').value;
  var from = fromStr ? new Date(fromStr + 'T00:00:00') : null;
  var to = toStr ? new Date(toStr + 'T23:59:59') : null;
  var rows = [];
  if (type !== 'reports') {
    _fbScadaLogs.forEach(function(log, idx) {
      rows.push({ kind: 'log', date: _fbScadaParseDate(log.date), dateStr: log.date || '',
                  name: log.subject || '(no subject)', logIdx: idx });
    });
  }
  if (type !== 'logs') {
    _fbScadaReports.forEach(function(rep) {
      rows.push({ kind: 'report', date: _fbScadaParseDate(rep.modified), dateStr: rep.modified || '',
                  name: rep.name || '', fileName: rep.name || '', size: rep.size || 0 });
    });
  }
  rows = rows.filter(function(r) {
    if (!r.date) return !(from || to); // undated rows only show when no range set
    if (from && r.date < from) return false;
    if (to && r.date > to) return false;
    return true;
  });
  rows.sort(function(a, b) {
    var at = a.date ? a.date.getTime() : 0;
    var bt = b.date ? b.date.getTime() : 0;
    return bt - at;
  });

  var wrap = document.getElementById('fb-scada-table-wrap');
  var empty = document.getElementById('fb-scada-empty');
  document.getElementById('fb-scada-detail').classList.add('hidden');
  if (!rows.length) {
    wrap.classList.add('hidden');
    empty.classList.remove('hidden');
    return;
  }
  empty.classList.add('hidden');
  wrap.classList.remove('hidden');
  var tbody = document.getElementById('fb-scada-tbody');
  tbody.innerHTML = '';
  rows.forEach(function(r) {
    var tr = document.createElement('tr');
    tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors';
    var badge, action;
    if (r.kind === 'log') {
      badge = '<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-togen-teal/10 text-togen-teal">Log</span>';
      action = '<button onclick="fbScadaShowDetail(' + r.logIdx + ')" class="text-xs font-medium text-togen-teal hover:underline cursor-pointer">View</button>';
    } else {
      badge = '<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">Report</span>';
      action = '<a href="/scada/reports/download?name=' + encodeURIComponent(r.fileName) + '" class="text-xs font-medium text-togen-teal hover:underline">Download</a>';
    }
    tr.innerHTML =
      '<td class="py-2.5 pr-4 text-gray-600 dark:text-gray-400 whitespace-nowrap">' + esc(r.dateStr) + '</td>' +
      '<td class="py-2.5 pr-4 whitespace-nowrap">' + badge + '</td>' +
      '<td class="py-2.5 pr-4 text-gray-800 dark:text-gray-200 font-medium">' + esc(r.name) + '</td>' +
      '<td class="py-2.5 whitespace-nowrap">' + action + '</td>';
    tbody.appendChild(tr);
  });
}

function fbScadaShowDetail(idx) {
  var log = _fbScadaLogs[idx];
  if (!log) return;
  document.getElementById('fb-scada-table-wrap').classList.add('hidden');
  document.getElementById('fb-scada-detail').classList.remove('hidden');
  document.getElementById('fb-scada-detail-subject').textContent = (log.date || '') + ' — ' + (log.subject || '');
  document.getElementById('fb-scada-detail-body').textContent = log.body || '(no content)';
}

function fbScadaCloseDetail() {
  document.getElementById('fb-scada-detail').classList.add('hidden');
  document.getElementById('fb-scada-table-wrap').classList.remove('hidden');
}

function fbScadaClear() {
  document.getElementById('fb-scada-type').value = 'all';
  document.getElementById('fb-scada-from').value = '';
  document.getElementById('fb-scada-to').value = '';
  fbScadaRender();
}

// ===== File Browser Vision tab (DVI-507): Recordings gallery =====
// Reuses the Vision endpoints (/vision/collectors, /vision/recordings/<id>),
// both gated server-side by vision_access_required (togen_admin OR togen_vision).
var _fbVisionMachines = [];

function fbVisionLoad() {
  fbVisionLoadMachines();
}

function fbVisionLoadMachines() {
  var sel = document.getElementById('fbv-rec-machine');
  if (!sel) return;
  var prev = sel.value;
  var error = document.getElementById('fbv-rec-error');
  fetch('/vision/collectors')
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok || res.d.error) {
        document.getElementById('fbv-rec-error-msg').textContent = res.d.error || 'Failed to load machines.';
        error.classList.remove('hidden');
        return;
      }
      error.classList.add('hidden');
      _fbVisionMachines = res.d.collectors || [];
      sel.innerHTML = '';
      if (!_fbVisionMachines.length) {
        var o = document.createElement('option');
        o.value = ''; o.textContent = 'No machines';
        sel.appendChild(o);
        document.getElementById('fbv-rec-grid').classList.add('hidden');
        document.getElementById('fbv-rec-loading').classList.add('hidden');
        document.getElementById('fbv-rec-empty').classList.remove('hidden');
        return;
      }
      _fbVisionMachines.forEach(function(m){
        var o = document.createElement('option');
        o.value = m.machine_id;
        o.textContent = (m.hostname || m.machine_id);
        sel.appendChild(o);
      });
      if (prev && _fbVisionMachines.some(function(m){ return m.machine_id === prev; })) sel.value = prev;
      fbVisionLoadRecordings();
    })
    .catch(function(e){
      document.getElementById('fbv-rec-error-msg').textContent = e.message;
      error.classList.remove('hidden');
    });
}

function fbVisionRecordingsClear() {
  document.getElementById('fbv-rec-from').value = '';
  document.getElementById('fbv-rec-to').value = '';
  fbVisionLoadRecordings();
}

function fbVisionLoadRecordings() {
  var sel = document.getElementById('fbv-rec-machine');
  var loading = document.getElementById('fbv-rec-loading');
  var empty = document.getElementById('fbv-rec-empty');
  var error = document.getElementById('fbv-rec-error');
  var grid = document.getElementById('fbv-rec-grid');
  loading.classList.add('hidden'); empty.classList.add('hidden');
  error.classList.add('hidden'); grid.classList.add('hidden');
  var machineId = sel ? sel.value : '';
  if (!machineId) { empty.classList.remove('hidden'); return; }
  var qs = [];
  var frm = _visionDtToEpoch(document.getElementById('fbv-rec-from').value);
  var to = _visionDtToEpoch(document.getElementById('fbv-rec-to').value);
  if (frm !== null) qs.push('from=' + frm);
  if (to !== null) qs.push('to=' + to);
  loading.classList.remove('hidden');
  fetch('/vision/recordings/' + encodeURIComponent(machineId) + (qs.length ? '?' + qs.join('&') : ''))
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      loading.classList.add('hidden');
      if (!res.ok || res.d.error) {
        document.getElementById('fbv-rec-error-msg').textContent = res.d.error || 'Failed to load recordings.';
        error.classList.remove('hidden');
        return;
      }
      document.getElementById('fbv-rec-time').textContent = 'Updated ' + new Date().toLocaleTimeString();
      var frames = res.d.frames || [];
      if (!frames.length) { empty.classList.remove('hidden'); return; }
      fbVisionRenderRecordings(frames);
      grid.classList.remove('hidden');
    })
    .catch(function(e){
      loading.classList.add('hidden');
      document.getElementById('fbv-rec-error-msg').textContent = e.message;
      error.classList.remove('hidden');
    });
}

function fbVisionRenderRecordings(frames) {
  var grid = document.getElementById('fbv-rec-grid');
  grid.innerHTML = frames.map(_visionRecordingCardHtml).join('');
}

// ===== File Browser Settings tab (DVI-471) =====
function fbSettingsLoad() {
  fbSyncStatusLoad();
  fbWatchersLoad();
}

function fbSyncStatusLoad() {
  var dot = document.getElementById('fb-sync-dot');
  var stateEl = document.getElementById('fb-sync-state');
  if (stateEl) stateEl.textContent = 'Loading…';
  fetch('/sync-admin/log?lines=20')
    .then(function(r) { return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res) {
      var d = res.d || {};
      var state, color, word;
      if (!res.ok || d.error) { state = 'unknown'; }
      else if (d.needs_resync || d.needs_lock_fix) { state = 'resync'; }
      else if (d.has_errors) { state = 'issues'; }
      else { state = 'ok'; }
      var map = {
        ok:      ['bg-green-500',  'Sync OK'],
        issues:  ['bg-yellow-500', 'Issues detected in recent sync'],
        resync:  ['bg-red-500',    'Resync required'],
        unknown: ['bg-gray-400',   'Status unknown']
      };
      color = map[state][0]; word = map[state][1];
      if (dot) dot.className = 'inline-block h-3 w-3 rounded-full ' + color;
      if (stateEl) stateEl.textContent = word;
    })
    .catch(function() {
      if (dot) dot.className = 'inline-block h-3 w-3 rounded-full bg-gray-400';
      if (stateEl) stateEl.textContent = 'Status unavailable';
    });
}

function fbSyncNow() {
  var btn = document.getElementById('fb-sync-now-btn');
  var msg = document.getElementById('fb-sync-status-msg');
  if (btn) btn.disabled = true;
  if (msg) { msg.textContent = 'Starting sync…'; msg.className = 'text-sm text-gray-500 dark:text-gray-400 mt-3 block'; }
  fetch('/sync-admin/resync', { method: 'POST' })
    .then(function(r) { return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res) {
      if (!res.ok || (res.d && res.d.error)) throw new Error((res.d && res.d.error) || 'Sync failed to start.');
      if (msg) msg.textContent = 'Sync started. Status will update shortly.';
      setTimeout(fbSyncStatusLoad, 4000);
    })
    .catch(function(err) {
      if (msg) { msg.textContent = 'Could not start sync: ' + err.message; msg.className = 'text-sm text-red-500 mt-3 block'; }
    })
    .finally(function() { if (btn) btn.disabled = false; });
}

// ----- Folder watchers -----
function _fbWatchers() {
  return (_userSettings && Array.isArray(_userSettings.fb_watchers)) ? _userSettings.fb_watchers : [];
}

function _fbEsc(s) {
  return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
    .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

function fbWatchersLoad() {
  fetch('/fb/watchers')
    .then(function(r) { return r.json(); })
    .then(function(d) {
      if (d && Array.isArray(d.watchers)) {
        if (!_userSettings) _userSettings = {};
        _userSettings.fb_watchers = d.watchers;
      }
      fbWatchersRender();
      if (typeof usRenderFbWatchers === 'function') usRenderFbWatchers();
    })
    .catch(function() { fbWatchersRender(); });
}

function fbWatchersRender() {
  var list = document.getElementById('fb-watchers-list');
  var empty = document.getElementById('fb-watchers-empty');
  if (!list) return;
  var watchers = _fbWatchers();
  list.innerHTML = '';
  if (empty) empty.classList.toggle('hidden', watchers.length > 0);
  watchers.forEach(function(w) {
    var row = document.createElement('div');
    row.className = 'flex items-center gap-3 px-3 py-2 rounded-xl border border-gray-200 dark:border-gray-700';
    row.innerHTML =
      '<svg class="h-5 w-5 text-togen-teal flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/></svg>' +
      '<div class="min-w-0 flex-1">' +
      '<p class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + _fbEsc(w.label || w.path) + '</p>' +
      '<p class="text-xs text-gray-500 dark:text-gray-400 truncate">' + _fbEsc(w.path) + (w.enabled === false ? ' · notifications off' : '') + _fbEsc(_fbExpiryText(w)) + '</p>' +
      '</div>' +
      '<button data-act="edit" title="Edit expiration" class="p-1.5 rounded-lg text-gray-400 hover:text-togen-teal hover:bg-togen-teal/10 transition-colors flex-shrink-0">' +
      '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>' +
      '</button>' +
      '<button data-act="del" title="Delete watcher" class="p-1.5 rounded-lg text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors flex-shrink-0">' +
      '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>' +
      '</button>';
    row.querySelector('[data-act="edit"]').onclick = function() { fbWatcherEditOpen(w.id); };
    row.querySelector('[data-act="del"]').onclick = function() { fbWatcherDelete(w.id); };
    list.appendChild(row);
  });
}

function fbWatcherDelete(id) {
  var status = document.getElementById('fb-watchers-status');
  fetch('/fb/watchers/' + encodeURIComponent(id), { method: 'DELETE' })
    .then(function(r) { return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res) {
      if (!res.ok || (res.d && res.d.error)) throw new Error((res.d && res.d.error) || 'Delete failed.');
      if (!_userSettings) _userSettings = {};
      _userSettings.fb_watchers = res.d.watchers || [];
      fbWatchersRender();
      if (typeof usRenderFbWatchers === 'function') usRenderFbWatchers();
      if (status) { status.textContent = 'Watcher removed.'; setTimeout(function(){ if (status) status.textContent = ''; }, 2000); }
    })
    .catch(function(err) {
      if (status) { status.textContent = 'Error: ' + err.message; status.className = 'text-sm text-red-500 mt-3 block'; }
    });
}

function fbWatcherToggle(id, enabled, statusId) {
  var status = statusId && document.getElementById(statusId);
  fetch('/fb/watchers/' + encodeURIComponent(id) + '/toggle', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ enabled: enabled })
  })
    .then(function(r) { return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res) {
      if (!res.ok || (res.d && res.d.error)) throw new Error((res.d && res.d.error) || 'Save failed.');
      if (!_userSettings) _userSettings = {};
      _userSettings.fb_watchers = res.d.watchers || [];
      fbWatchersRender();
      if (typeof usRenderFbWatchers === 'function') usRenderFbWatchers();
      if (status) { status.textContent = 'Saved.'; setTimeout(function(){ if (status) status.textContent = ''; }, 2000); }
    })
    .catch(function(err) {
      if (status) { status.textContent = 'Error: ' + err.message; status.className = 'text-xs text-red-500 mt-2 block'; }
    });
}

// Human-readable "· expires <date>" / "· expired" suffix for a watcher.
function _fbExpiryText(w) {
  if (!w || !w.expires_at) return '';
  var d = new Date(w.expires_at + 'T00:00:00');
  if (isNaN(d.getTime())) return '';
  var label = d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
  var today = new Date(); today.setHours(0, 0, 0, 0);
  return (d < today) ? ' · expired' : ' · expires ' + label;
}

// ----- Folder watcher edit (expiration) -----
var _fbEditId = null;
function fbWatcherEditOpen(id) {
  var w = null, list = _fbWatchers();
  for (var i = 0; i < list.length; i++) { if (list[i].id === id) { w = list[i]; break; } }
  if (!w) return;
  _fbEditId = id;
  var labelEl = document.getElementById('fb-watcher-edit-label');
  var cb = document.getElementById('fb-watcher-edit-cb');
  var row = document.getElementById('fb-watcher-edit-row');
  var date = document.getElementById('fb-watcher-edit-date');
  var status = document.getElementById('fb-watcher-edit-status');
  if (labelEl) labelEl.textContent = w.label || w.path;
  if (cb) cb.checked = !!w.expires_at;
  if (date) date.value = w.expires_at || '';
  if (row) row.classList.toggle('hidden', !w.expires_at);
  if (status) { status.classList.add('hidden'); status.textContent = ''; }
  document.getElementById('fb-watcher-edit-modal').classList.remove('hidden');
}
function fbWatcherEditToggle() {
  var cb = document.getElementById('fb-watcher-edit-cb');
  var row = document.getElementById('fb-watcher-edit-row');
  if (row) row.classList.toggle('hidden', !(cb && cb.checked));
}
function fbWatcherEditClose() {
  _fbEditId = null;
  document.getElementById('fb-watcher-edit-modal').classList.add('hidden');
}
function fbWatcherEditSave() {
  if (!_fbEditId) return;
  var cb = document.getElementById('fb-watcher-edit-cb');
  var date = document.getElementById('fb-watcher-edit-date');
  var status = document.getElementById('fb-watcher-edit-status');
  var expires = (cb && cb.checked && date) ? date.value : '';
  if (cb && cb.checked && !expires) {
    if (status) { status.textContent = 'Please choose a termination date.'; status.classList.remove('hidden'); }
    return;
  }
  fetch('/fb/watchers/' + encodeURIComponent(_fbEditId), {
    method: 'PUT', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ expires_at: expires })
  })
    .then(function(r) { return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res) {
      if (!res.ok || (res.d && res.d.error)) throw new Error((res.d && res.d.error) || 'Save failed.');
      if (!_userSettings) _userSettings = {};
      _userSettings.fb_watchers = res.d.watchers || [];
      fbWatchersRender();
      if (typeof usRenderFbWatchers === 'function') usRenderFbWatchers();
      fbWatcherEditClose();
    })
    .catch(function(err) {
      if (status) { status.textContent = 'Error: ' + err.message; status.classList.remove('hidden'); }
    });
}

// ----- Folder watcher removal confirmation (email 1-click link) -----
var _fbRemoveId = null;
function fbWatcherRemovePrompt(id, label) {
  _fbRemoveId = id;
  var btn = document.getElementById('fb-watcher-remove-btn');
  if (btn) btn.classList.remove('hidden');
  var msg = document.getElementById('fb-watcher-remove-msg');
  if (msg) msg.textContent = 'Remove the folder watcher' + (label ? ' for "' + label + '"' : '') + '? You will stop receiving change notifications for it.';
  document.getElementById('fb-watcher-remove-modal').classList.remove('hidden');
}
function fbWatcherRemoveClose() {
  _fbRemoveId = null;
  document.getElementById('fb-watcher-remove-modal').classList.add('hidden');
}
function fbWatcherRemoveConfirm() {
  if (!_fbRemoveId) return;
  var id = _fbRemoveId;
  fbWatcherRemoveClose();
  fbWatcherDelete(id);
}
// Entry point for the ?fb_remove_watcher=<id> email deep link.
function fbWatcherRemoveFromLink(id) {
  fetch('/fb/watchers')
    .then(function(r) { return r.ok ? r.json() : null; })
    .then(function(d) {
      var label = '';
      if (d && Array.isArray(d.watchers)) {
        if (!_userSettings) _userSettings = {};
        _userSettings.fb_watchers = d.watchers;
        for (var i = 0; i < d.watchers.length; i++) {
          if (d.watchers[i].id === id) { label = d.watchers[i].label || d.watchers[i].path; break; }
        }
      }
      if (!label) {
        var msg = document.getElementById('fb-watcher-remove-msg');
        if (msg) msg.textContent = 'This folder watcher has already been removed.';
        var btn = document.getElementById('fb-watcher-remove-btn');
        if (btn) btn.classList.add('hidden');
        _fbRemoveId = null;
        document.getElementById('fb-watcher-remove-modal').classList.remove('hidden');
        return;
      }
      fbWatcherRemovePrompt(id, label);
    })
    .catch(function() {});
}

// ----- Folder picker modal -----
var _fbPickerPath = '';
function fbWatcherPickerOpen() {
  _fbPickerPath = '';
  var cb = document.getElementById('fb-picker-expire-cb');
  var row = document.getElementById('fb-picker-expire-row');
  var date = document.getElementById('fb-picker-expire-date');
  if (cb) cb.checked = false;
  if (row) row.classList.add('hidden');
  if (date) date.value = '';
  document.getElementById('fb-watcher-picker-modal').classList.remove('hidden');
  fbWatcherPickerNavigate('');
}
function fbPickerExpireToggle() {
  var cb = document.getElementById('fb-picker-expire-cb');
  var row = document.getElementById('fb-picker-expire-row');
  if (row) row.classList.toggle('hidden', !(cb && cb.checked));
}
function fbWatcherPickerClose() {
  document.getElementById('fb-watcher-picker-modal').classList.add('hidden');
}
function fbWatcherPickerUp() {
  if (!_fbPickerPath) return;
  var parts = _fbPickerPath.split('/');
  parts.pop();
  fbWatcherPickerNavigate(parts.join('/'));
}
function fbWatcherPickerNavigate(path) {
  _fbPickerPath = path || '';
  var listEl = document.getElementById('fb-picker-list');
  var crumb = document.getElementById('fb-picker-crumb');
  var upBtn = document.getElementById('fb-picker-up');
  var target = document.getElementById('fb-picker-target');
  var selBtn = document.getElementById('fb-picker-select-btn');
  if (crumb) crumb.textContent = _fbPickerPath ? ('Projects / ' + _fbPickerPath) : 'Projects';
  if (upBtn) upBtn.disabled = !_fbPickerPath;
  if (target) target.textContent = _fbPickerPath ? _fbPickerPath : 'Projects (root — pick a subfolder)';
  if (selBtn) selBtn.disabled = !_fbPickerPath;
  if (listEl) listEl.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 px-4 py-3">Loading…</p>';
  fetch('/fb/browse?path=' + encodeURIComponent(_fbPickerPath))
    .then(function(r) { return r.json(); })
    .then(function(d) {
      if (!listEl) return;
      listEl.innerHTML = '';
      var dirs = (d.entries || []).filter(function(e) { return e.is_dir; });
      if (!dirs.length) {
        listEl.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 px-4 py-3">No subfolders here. Use "Watch this folder" to watch the current folder.</p>';
        return;
      }
      dirs.forEach(function(e) {
        var btn = document.createElement('button');
        btn.className = 'w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors flex items-center gap-2';
        btn.innerHTML = '<svg class="h-4 w-4 text-togen-teal" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/></svg><span class="truncate">' + _fbEsc(e.name) + '</span>';
        btn.onclick = function() {
          fbWatcherPickerNavigate(_fbPickerPath ? (_fbPickerPath + '/' + e.name) : e.name);
        };
        listEl.appendChild(btn);
      });
    })
    .catch(function() {
      if (listEl) listEl.innerHTML = '<p class="text-sm text-red-500 px-4 py-3">Could not load folders.</p>';
    });
}
function fbWatcherPickerSelect() {
  if (!_fbPickerPath) return;
  var cb = document.getElementById('fb-picker-expire-cb');
  var date = document.getElementById('fb-picker-expire-date');
  var status = document.getElementById('fb-watchers-status');
  var expires = (cb && cb.checked && date) ? date.value : '';
  if (cb && cb.checked && !expires) {
    if (status) { status.textContent = 'Please choose a termination date.'; status.className = 'text-sm text-red-500 mt-3 block'; }
    return;
  }
  var selBtn = document.getElementById('fb-picker-select-btn');
  if (selBtn) selBtn.disabled = true;
  fetch('/fb/watchers', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ path: _fbPickerPath, expires_at: expires })
  })
    .then(function(r) { return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res) {
      if (!res.ok || (res.d && res.d.error)) throw new Error((res.d && res.d.error) || 'Could not add watcher.');
      if (!_userSettings) _userSettings = {};
      _userSettings.fb_watchers = res.d.watchers || [];
      fbWatchersRender();
      if (typeof usRenderFbWatchers === 'function') usRenderFbWatchers();
      fbWatcherPickerClose();
      if (status) { status.textContent = 'Folder watcher added.'; status.className = 'text-sm text-gray-500 dark:text-gray-400 mt-3 block'; setTimeout(function(){ if (status) status.textContent = ''; }, 2500); }
    })
    .catch(function(err) {
      if (status) { status.textContent = 'Error: ' + err.message; status.className = 'text-sm text-red-500 mt-3 block'; }
      fbWatcherPickerClose();
    })
    .finally(function() { if (selBtn) selBtn.disabled = false; });
}

function emailCloseModals() {
  ['email-workflow-overlay','email-save-overlay','email-share-overlay','email-equip-overlay','email-preview-overlay'].forEach(function(id){
    var el = document.getElementById(id);
    if (el) el.classList.add('hidden');
  });
  var pv = document.getElementById('email-preview-body');
  if (pv) pv.innerHTML = '';  // release embedded resource
}

// ----- Attachment preview (DVI-510) -----
function emailPreviewOpen(mi, ai) {
  var a = _emailSetAction(mi, ai);
  var url = '/email/attachment?message_id=' + encodeURIComponent(a.message_id) +
            '&attachment_id=' + encodeURIComponent(a.attachment_id);
  var imgExts = ['png','jpg','jpeg','gif','webp','svg','bmp'];
  var body = document.getElementById('email-preview-body');
  document.getElementById('email-preview-name').textContent = a.name;
  document.getElementById('email-preview-download').href = url;
  if (a.ext === 'pdf') {
    body.innerHTML = '<iframe src="' + url + '" class="w-full" style="height:78vh;border:0" title="' + esc(a.name) + '"></iframe>';
  } else if (imgExts.indexOf(a.ext) !== -1) {
    body.innerHTML = '<img src="' + url + '" alt="' + esc(a.name) + '" class="max-w-full max-h-[78vh] object-contain" />';
  } else if (a.ext === 'txt') {
    body.innerHTML = '<iframe src="' + url + '" class="w-full bg-white" style="height:78vh;border:0" title="' + esc(a.name) + '"></iframe>';
  } else {
    body.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 p-6 text-center">Preview is not available for this file type. Use “Open in new tab” to download it.</p>';
  }
  document.getElementById('email-preview-overlay').classList.remove('hidden');
}

function _emailSetAction(mi, ai) {
  var att = _emailMessages[mi].attachments[ai];
  _emailAction = { mi: mi, ai: ai, name: att.name, ext: _emailExt(att.name),
                   message_id: _emailMessages[mi].id, attachment_id: att.id };
  return _emailAction;
}

// ----- Workflow menu -----
function emailWorkflowOpen(mi, ai) {
  var a = _emailSetAction(mi, ai);
  var isDoc = _EMAIL_DOC_EXTS.indexOf(a.ext) !== -1;
  var isXlsx = a.ext === 'xlsx';
  var isCsv = a.ext === 'csv';
  var canTakeoff = (typeof _isTakeoffWorkflow !== 'undefined' && _isTakeoffWorkflow) || _isToGenAdmin;
  var canProdRun = (typeof _isProdRun !== 'undefined' && _isProdRun) || _isToGenAdmin;
  function vis(id, on) { document.getElementById(id).classList.toggle('hidden', !on); }
  vis('email-wf-copy', isDoc);
  vis('email-wf-manuals', isDoc);
  vis('email-wf-prodrun', (isXlsx || isCsv) && canProdRun);
  vis('email-wf-pb', isCsv && canTakeoff);
  vis('email-wf-bridge', isCsv && canTakeoff);
  document.getElementById('email-workflow-name').textContent = a.name;
  document.getElementById('email-workflow-overlay').classList.remove('hidden');
}

function emailWfCopyToPath() {
  document.getElementById('email-workflow-overlay').classList.add('hidden');
  _emailOpenSavePicker('save', null);
}

function emailWfOpenWith(toolId, title) {
  document.getElementById('email-workflow-overlay').classList.add('hidden');
  _emailOpenSavePicker('openwith', { id: toolId, title: title });
}

function emailWfAddToManuals() {
  document.getElementById('email-workflow-overlay').classList.add('hidden');
  if (!_emailAction) return;
  document.getElementById('email-equip-name').textContent = _emailAction.name;
  document.getElementById('email-equip-input').value = '';
  document.getElementById('email-equip-status').textContent = '';
  document.getElementById('email-equip-overlay').classList.remove('hidden');
  setTimeout(function(){ document.getElementById('email-equip-input').focus(); }, 100);
}

// ----- Save / Copy / Open-with folder picker -----
function emailSaveOpen(mi, ai) {
  _emailSetAction(mi, ai);
  _emailOpenSavePicker('save', null);
}

function _emailOpenSavePicker(mode, target) {
  if (!_emailAction) return;
  _emailSaveMode = mode;
  _emailSaveTarget = target;
  _emailSavePath = '';
  var title = mode === 'openwith'
    ? 'Choose a Projects folder, then open in ' + (target ? target.title : '')
    : 'Save to Projects';
  var titleEl = document.querySelector('#email-save-overlay h3');
  if (titleEl) titleEl.textContent = title;
  document.getElementById('email-save-name').textContent = _emailAction.name;
  document.getElementById('email-save-status').textContent = '';
  var confirmBtn = document.getElementById('email-save-confirm-btn');
  confirmBtn.textContent = mode === 'openwith' ? 'Save & open' : 'Save here';
  document.getElementById('email-save-overlay').classList.remove('hidden');
  _emailSaveBrowse('');
}

function _emailSaveBrowse(path) {
  var foldersEl = document.getElementById('email-save-folders');
  foldersEl.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 px-3 py-3">Loading…</p>';
  fetch('/fb/browse?path=' + encodeURIComponent(path))
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { foldersEl.innerHTML = '<p class="text-sm text-red-500 px-3 py-3">' + esc(d.error) + '</p>'; return; }
      _emailSavePath = d.path || '';
      _emailRenderSaveBreadcrumb(_emailSavePath);
      var folders = (d.entries || []).filter(function(e){ return e.is_dir; });
      var html = '';
      if (_emailSavePath) {
        html += '<div class="px-3 py-2 text-sm text-togen-teal cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800" onclick="_emailSaveGoUp()">.. (up one level)</div>';
      }
      if (!folders.length) {
        html += '<p class="text-xs text-gray-400 dark:text-gray-500 px-3 py-2">No subfolders here.</p>';
      } else {
        for (var i = 0; i < folders.length; i++) {
          var sub = _emailSavePath ? (_emailSavePath + '/' + folders[i].name) : folders[i].name;
          html += '<div class="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800" onclick="_emailSaveBrowse(\'' + esc(sub).replace(/'/g, "\\'") + '\')">' +
            '<svg class="h-4 w-4 text-togen-teal flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/></svg>' +
            esc(folders[i].name) + '</div>';
        }
      }
      foldersEl.innerHTML = html;
    })
    .catch(function(){ foldersEl.innerHTML = '<p class="text-sm text-red-500 px-3 py-3">Could not load folders.</p>'; });
}

function _emailSaveGoUp() {
  var parts = (_emailSavePath || '').split('/');
  parts.pop();
  _emailSaveBrowse(parts.join('/'));
}

function _emailRenderSaveBreadcrumb(path) {
  var nav = document.getElementById('email-save-breadcrumb');
  var html = '<span class="cursor-pointer hover:text-togen-teal" onclick="_emailSaveBrowse(\'\')">Projects</span>';
  if (path) {
    var parts = path.split('/');
    var cumulative = '';
    for (var i = 0; i < parts.length; i++) {
      cumulative += (cumulative ? '/' : '') + parts[i];
      html += ' <span class="text-gray-300 dark:text-gray-600">/</span> ';
      if (i === parts.length - 1) {
        html += '<span class="text-gray-700 dark:text-gray-200 font-medium">' + esc(parts[i]) + '</span>';
      } else {
        html += '<span class="cursor-pointer hover:text-togen-teal" onclick="_emailSaveBrowse(\'' + esc(cumulative).replace(/'/g, "\\'") + '\')">' + esc(parts[i]) + '</span>';
      }
    }
  }
  nav.innerHTML = html;
}

function emailSaveConfirm() {
  if (!_emailAction) return;
  var statusEl = document.getElementById('email-save-status');
  var btn = document.getElementById('email-save-confirm-btn');
  statusEl.textContent = 'Saving…';
  btn.disabled = true;
  fetch('/email/save-attachment', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      message_id: _emailAction.message_id,
      attachment_id: _emailAction.attachment_id,
      dest_path: _emailSavePath,
      filename: _emailAction.name
    })
  }).then(function(r){ return r.json(); }).then(function(d){
    btn.disabled = false;
    if (d.error) { statusEl.innerHTML = '<span class="text-red-500">' + esc(d.error) + '</span>'; return; }
    var mode = _emailSaveMode, target = _emailSaveTarget;
    statusEl.innerHTML = '<span class="text-green-600 dark:text-green-400">Saved to ' + esc(d.saved_path) + '</span>';
    setTimeout(function(){
      emailCloseModals();
      if (mode === 'openwith' && target) {
        openTool(target.id, target.title);
        // Land the user in the File Browser folder where the file now lives so they
        // can pick it up in the target tool. Cross-tool auto-load varies per tool.
      }
    }, 800);
  }).catch(function(){
    btn.disabled = false;
    statusEl.innerHTML = '<span class="text-red-500">Save failed.</span>';
  });
}

// ----- Share via email -----
function emailShareOpen(mi, ai) {
  _emailSetAction(mi, ai);
  document.getElementById('email-share-name').textContent = _emailAction.name;
  document.getElementById('email-share-input').value = '';
  document.getElementById('email-share-status').textContent = '';
  document.getElementById('email-share-overlay').classList.remove('hidden');
  setTimeout(function(){ document.getElementById('email-share-input').focus(); }, 100);
}

function emailShareSend() {
  if (!_emailAction) return;
  var email = document.getElementById('email-share-input').value.trim();
  var statusEl = document.getElementById('email-share-status');
  if (!email) { statusEl.innerHTML = '<span class="text-red-500">Enter a recipient email.</span>'; return; }
  var btn = document.getElementById('email-share-send-btn');
  statusEl.textContent = 'Sending…';
  btn.disabled = true;
  fetch('/email/share-attachment', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      message_id: _emailAction.message_id,
      attachment_id: _emailAction.attachment_id,
      email: email
    })
  }).then(function(r){ return r.json(); }).then(function(d){
    btn.disabled = false;
    if (d.error) { statusEl.innerHTML = '<span class="text-red-500">' + esc(d.error) + '</span>'; return; }
    statusEl.innerHTML = '<span class="text-green-600 dark:text-green-400">Sent!</span>';
    setTimeout(emailCloseModals, 1200);
  }).catch(function(){
    btn.disabled = false;
    statusEl.innerHTML = '<span class="text-red-500">Failed to send.</span>';
  });
}

// ----- Add to Equipment Manuals -----
function emailEquipSubmit() {
  if (!_emailAction) return;
  var equipment = document.getElementById('email-equip-input').value.trim();
  var statusEl = document.getElementById('email-equip-status');
  if (!equipment) { statusEl.innerHTML = '<span class="text-red-500">Enter an equipment name.</span>'; return; }
  var btn = document.getElementById('email-equip-submit-btn');
  statusEl.textContent = 'Adding…';
  btn.disabled = true;
  fetch('/email/add-to-manuals', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      message_id: _emailAction.message_id,
      attachment_id: _emailAction.attachment_id,
      equipment: equipment
    })
  }).then(function(r){ return r.json(); }).then(function(d){
    btn.disabled = false;
    if (d.error) { statusEl.innerHTML = '<span class="text-red-500">' + esc(d.error) + '</span>'; return; }
    statusEl.innerHTML = '<span class="text-green-600 dark:text-green-400">Added to Equipment Manuals.</span>';
    setTimeout(emailCloseModals, 1200);
  }).catch(function(){
    btn.disabled = false;
    statusEl.innerHTML = '<span class="text-red-500">Failed to add.</span>';
  });
}

// Rename
function fbRenamePrompt() {
  if (!_fbSelectedItem) return;
  document.getElementById('fb-rename-input').value = _fbSelectedItem.name;
  document.getElementById('fb-rename-modal').classList.remove('hidden');
  setTimeout(function(){ document.getElementById('fb-rename-input').focus(); document.getElementById('fb-rename-input').select(); }, 100);
}

function fbRenameCancel() {
  document.getElementById('fb-rename-modal').classList.add('hidden');
}

function fbRenameSubmit() {
  var newName = document.getElementById('fb-rename-input').value.trim();
  if (!newName || !_fbSelectedItem) { fbRenameCancel(); return; }
  if (newName === _fbSelectedItem.name) { fbRenameCancel(); return; }

  fetch('/fb/rename', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({path: _fbSelectedItem.path, new_name: newName})
  }).then(function(r){ return r.json(); }).then(function(data) {
    fbRenameCancel();
    if (data.error) { _fbSetStatus('Error: ' + data.error); return; }
    _fbSetStatus('Renamed to ' + data.renamed);
    fbNavigate(_fbCurrentPath);
    setTimeout(function(){ _fbSetStatus(''); }, 4000);
  }).catch(function(err) {
    fbRenameCancel();
    _fbSetStatus('Rename failed.');
  });
}

// Delete
function fbDeleteConfirm() {
  if (!_fbSelectedItem) return;
  var msg = 'Are you sure you want to delete "' + _fbSelectedItem.name + '"?';
  if (_fbSelectedItem.is_dir) msg += ' This will delete the folder and all its contents.';
  document.getElementById('fb-delete-msg').textContent = msg;
  document.getElementById('fb-delete-modal').classList.remove('hidden');
}

function fbDeleteCancel() {
  document.getElementById('fb-delete-modal').classList.add('hidden');
}

function fbDeleteSubmit() {
  if (!_fbSelectedItem) { fbDeleteCancel(); return; }

  fetch('/fb/delete', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({path: _fbSelectedItem.path})
  }).then(function(r){ return r.json(); }).then(function(data) {
    fbDeleteCancel();
    if (data.error) { _fbSetStatus('Error: ' + data.error); return; }
    _fbSetStatus('Deleted.');
    _fbSelectedItem = null;
    fbClosePreview();
    fbNavigate(_fbCurrentPath);
    setTimeout(function(){ _fbSetStatus(''); }, 4000);
  }).catch(function(err) {
    fbDeleteCancel();
    _fbSetStatus('Delete failed.');
  });
}

// Drag and drop on the file list pane
(function() {
  var listPane = document.getElementById('fb-list-pane');
  var overlay = document.getElementById('fb-drop-overlay');
  if (!listPane) return;

  listPane.style.position = 'relative';

  var dragCounter = 0;
  listPane.addEventListener('dragenter', function(e) {
    e.preventDefault();
    dragCounter++;
    overlay.classList.remove('hidden');
  });
  listPane.addEventListener('dragover', function(e) {
    e.preventDefault();
  });
  listPane.addEventListener('dragleave', function(e) {
    dragCounter--;
    if (dragCounter <= 0) {
      dragCounter = 0;
      overlay.classList.add('hidden');
    }
  });
  listPane.addEventListener('drop', function(e) {
    e.preventDefault();
    dragCounter = 0;
    overlay.classList.add('hidden');
    if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
      fbUploadFiles(e.dataTransfer.files);
    }
  });
})();

// ===== USER SETTINGS (DVI-274) =====
var _usInitialized = false;
var _usDragSrcRow = null;

function openUserSettings() {
  document.getElementById('profile-menu').classList.add('hidden');
  closeTutorialsPane();
  openTool('user-settings', 'User Settings');
  if (!_usInitialized) {
    _usInitialized = true;
    usRenderThemeToggle();
    usInitBackground();
    usInitTileLayout();
    usRenderToolsList();
    usRenderNotificationsGrid();
    rpmNotifLoad();
    usLoadNewsSubscription();
    usInitScadaNotifications();
    usInitFbWatchers();
    usInitAssistant();
    usLoadPrinting();
  }
}

// DVI-530: User Settings tab strip. "all" shows every pane in order; otherwise
// only the matching pane. Pure show/hide — pane behavior is unchanged.
function usSwitchTab(tab) {
  var tabs = document.querySelectorAll('[id^="us-tab-"]');
  for (var i = 0; i < tabs.length; i++) {
    tabs[i].classList.remove('border-togen-teal', 'text-togen-teal');
    tabs[i].classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400', 'hover:text-gray-700', 'dark:hover:text-gray-300');
  }
  var activeTab = document.getElementById('us-tab-' + tab);
  if (activeTab) {
    activeTab.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400', 'hover:text-gray-700', 'dark:hover:text-gray-300');
    activeTab.classList.add('border-togen-teal', 'text-togen-teal');
  }
  var panes = ['account', 'interface', 'printing', 'notifications', 'tools', 'assistant', 'documents'];
  for (var j = 0; j < panes.length; j++) {
    var el = document.getElementById('us-pane-' + panes[j]);
    if (el) el.classList.toggle('hidden', tab !== 'all' && tab !== panes[j]);
  }
  if (tab === 'printing' || tab === 'all') usLoadPrinting();
  if (tab === 'documents' || tab === 'all') usLoadDocuments();
}

// DVI-668 / DVI-657 P2: User Settings → Printing tab. Per-user default printer
// overrides. Each dropdown offers a "Use Default" sentinel that inherits the
// company default; otherwise it persists the chosen published printer name.
var _usPrintDefaultSentinel = '__default__';
function usLoadPrinting() {
  fetch('/user-settings/printing')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      _usPrintDefaultSentinel = d.default_sentinel || '__default__';
      var avail = d.available_printers || {};
      var docPrinters = avail.documents || [];
      var labelPrinters = avail.labels || [];
      var hint = document.getElementById('us-printing-empty-hint');
      if (docPrinters.length || labelPrinters.length) hint.classList.add('hidden'); else hint.classList.remove('hidden');
      var g = d.global_defaults || {};
      var u = d.user || {};
      _usFillPrinterSelect('us-printing-document', docPrinters, u.document_printer, g.document_printer);
      _usFillPrinterSelect('us-printing-label', labelPrinters, u.label_printer, g.label_printer);
      document.getElementById('us-printing-save-status').textContent = '';
    })
    .catch(function(){});
}

function _usFillPrinterSelect(id, printers, selected, globalDefault) {
  var sel = document.getElementById(id);
  if (!sel) return;
  var defLabel = globalDefault ? ('Use Default (' + globalDefault + ')') : 'Use Default (none set)';
  var opts = ['<option value="' + _usPrintDefaultSentinel + '">' + esc(defLabel) + '</option>'];
  printers.forEach(function(p){
    var v = esc(p);
    opts.push('<option value="' + v + '">' + v + '</option>');
  });
  sel.innerHTML = opts.join('');
  sel.value = (selected && selected !== _usPrintDefaultSentinel) ? selected : _usPrintDefaultSentinel;
}

function usSavePrinting() {
  var status = document.getElementById('us-printing-save-status');
  status.textContent = 'Saving...';
  fetch('/user-settings/printing', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      document_printer: document.getElementById('us-printing-document').value,
      label_printer: document.getElementById('us-printing-label').value
    })
  })
    .then(function(r){ return r.json(); })
    .then(function(d){ status.textContent = (d && d.ok) ? 'Saved' : ((d && d.error) || 'Save failed'); })
    .catch(function(e){ status.textContent = 'Save failed: ' + e.message; });
}

// DVI-808: User Settings → Documents tab
function usLoadDocuments() {
  fetch('/user-settings/documents')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      var el = document.getElementById('us-documents-quotes-location');
      if (el) el.value = d.quotes_location || '';
      var s = document.getElementById('us-documents-save-status');
      if (s) s.textContent = '';
    })
    .catch(function(){});
}

function usSaveDocuments() {
  var status = document.getElementById('us-documents-save-status');
  status.textContent = 'Saving...';
  fetch('/user-settings/documents', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ quotes_location: document.getElementById('us-documents-quotes-location').value.trim() })
  })
    .then(function(r){ return r.json(); })
    .then(function(d){ status.textContent = (d && d.ok) ? 'Saved' : ((d && d.error) || 'Save failed'); })
    .catch(function(e){ status.textContent = 'Save failed: ' + e.message; });
}

// DVI-549: add-account affordance. Wiring deferred — for now just reveal the
// note explaining that only the iCast tenant is configured.
function usAccountAdd() {
  var note = document.getElementById('us-account-add-note');
  if (note) note.classList.remove('hidden');
}

// DVI-530: User Settings → Assistant pane. Loads the user's connection
// (key masked, never displayed) + effective context md.
var _usAssistantProviders = [];
function usInitAssistant() {
  fetch('/assistant/connection')
    .then(function(r) { return r.ok ? r.json() : null; })
    .then(function(d) {
      if (!d) return;
      _usAssistantProviders = d.providers || [];
      var psel = document.getElementById('us-assistant-provider');
      psel.innerHTML = '';
      _usAssistantProviders.forEach(function(p) {
        var o = document.createElement('option');
        o.value = p.id; o.textContent = p.label;
        psel.appendChild(o);
      });
      usAssistantProviderChanged();
      if (d.model) document.getElementById('us-assistant-model').value = d.model;
      usSetAssistantConnState(d.api_key && d.api_key.configured, d.api_key && d.api_key.masked);
    })
    .catch(function() {});
  fetch('/assistant/context')
    .then(function(r) { return r.ok ? r.json() : null; })
    .then(function(d) {
      if (d) document.getElementById('us-assistant-context').value = d.context_md || '';
    })
    .catch(function() {});
}

function usAssistantProviderChanged() {
  var pid = document.getElementById('us-assistant-provider').value;
  var msel = document.getElementById('us-assistant-model');
  var prov = _usAssistantProviders.find(function(p) { return p.id === pid; });
  msel.innerHTML = '';
  // Provider API-key docs link (DVI-629): point users to where they create a key.
  var docsWrap = document.getElementById('us-assistant-docs-wrap');
  var docsLink = document.getElementById('us-assistant-docs');
  if (docsWrap && docsLink) {
    if (prov && prov.docs_url) {
      docsLink.href = prov.docs_url;
      document.getElementById('us-assistant-docs-label').textContent =
        'How to create a ' + (prov.label || 'provider') + ' API key';
      docsWrap.classList.remove('hidden');
    } else {
      docsWrap.classList.add('hidden');
    }
  }
  if (!prov) return;
  (prov.models || []).forEach(function(m) {
    var o = document.createElement('option');
    o.value = m.id; o.textContent = m.label;
    msel.appendChild(o);
  });
  if (prov.default_model) msel.value = prov.default_model;
}

function usSetAssistantConnState(configured, masked) {
  var el = document.getElementById('us-assistant-conn-state');
  if (configured) {
    el.innerHTML = '<span class="inline-block h-1.5 w-1.5 rounded-full bg-green-500"></span>Configured';
    el.className = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-400';
    document.getElementById('us-assistant-key').placeholder = masked || 'Key set — leave blank to keep';
  } else {
    el.innerHTML = '<span class="inline-block h-1.5 w-1.5 rounded-full bg-gray-400"></span>Not configured';
    el.className = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400';
    document.getElementById('us-assistant-key').placeholder = 'Enter API key';
  }
}

function usSaveAssistantConnection() {
  var status = document.getElementById('us-assistant-conn-status');
  var keyInput = document.getElementById('us-assistant-key');
  var body = { model: document.getElementById('us-assistant-model').value };
  var key = keyInput.value.trim();
  if (key) body.api_key = key;  // omit to keep existing key; never send the mask
  status.textContent = 'Saving...';
  fetch('/assistant/connection', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  })
    .then(function(r) { return r.json(); })
    .then(function(d) {
      if (d && d.ok) {
        status.textContent = 'Saved';
        keyInput.value = '';
        usInitAssistant();  // refresh connection state
      } else {
        status.textContent = (d && d.error) || 'Save failed';
      }
    })
    .catch(function(e) { status.textContent = 'Save failed: ' + e.message; });
}

function usSaveAssistantContext() {
  var status = document.getElementById('us-assistant-context-status');
  status.textContent = 'Saving...';
  fetch('/assistant/context', {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ context_md: document.getElementById('us-assistant-context').value })
  })
    .then(function(r) { return r.json(); })
    .then(function(d) { status.textContent = (d && d.ok) ? 'Saved' : ((d && d.error) || 'Save failed'); })
    .catch(function(e) { status.textContent = 'Save failed: ' + e.message; });
}

// DVI-471: File Browser folder-watcher notifications segment. Loads the
// current watchers and renders one toggle (+ delete) per watcher. The section
// hides itself when the user has no File Browser access / no watchers.
function usInitFbWatchers() {
  fetch('/fb/watchers')
    .then(function(r) { return r.ok ? r.json() : null; })
    .then(function(d) {
      if (d && Array.isArray(d.watchers)) {
        if (!_userSettings) _userSettings = {};
        _userSettings.fb_watchers = d.watchers;
      }
      usRenderFbWatchers();
    })
    .catch(function() { usRenderFbWatchers(); });
}

function usRenderFbWatchers() {
  var section = document.getElementById('us-fb-notify-section');
  var list = document.getElementById('us-fb-watchers-list');
  var empty = document.getElementById('us-fb-watchers-empty');
  if (!section || !list) return;
  var watchers = (_userSettings && Array.isArray(_userSettings.fb_watchers)) ? _userSettings.fb_watchers : [];
  var hasFb = (typeof _isTakeoffWorkflow !== 'undefined' && _isTakeoffWorkflow) ||
              (typeof _isToGenAdmin !== 'undefined' && _isToGenAdmin);
  // Show the section to File Browser users, or whenever any watcher exists.
  if (!hasFb && !watchers.length) { section.classList.add('hidden'); return; }
  section.classList.remove('hidden');
  if (empty) empty.classList.toggle('hidden', watchers.length > 0);
  list.innerHTML = '';
  watchers.forEach(function(w) {
    var enabled = w.enabled !== false;
    var row = document.createElement('label');
    row.className = 'flex items-center gap-3 cursor-pointer';
    var cb = document.createElement('input');
    cb.type = 'checkbox';
    cb.checked = enabled;
    cb.className = 'h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer';
    cb.onchange = function() { fbWatcherToggle(w.id, cb.checked, 'us-fb-notify-status'); };
    var info = document.createElement('span');
    info.className = 'min-w-0 flex-1';
    info.innerHTML =
      '<span class="block text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + _fbEsc(w.label || w.path) + '</span>' +
      '<span class="block text-xs text-gray-500 dark:text-gray-400 truncate">' + _fbEsc(w.path) + _fbEsc(_fbExpiryText(w)) + '</span>';
    var edit = document.createElement('button');
    edit.type = 'button';
    edit.title = 'Edit expiration';
    edit.className = 'p-1.5 rounded-lg text-gray-400 hover:text-togen-teal hover:bg-togen-teal/10 transition-colors flex-shrink-0';
    edit.innerHTML = '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/></svg>';
    edit.onclick = function(e) { e.preventDefault(); fbWatcherEditOpen(w.id); };
    var del = document.createElement('button');
    del.type = 'button';
    del.title = 'Delete watcher';
    del.className = 'p-1.5 rounded-lg text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors flex-shrink-0';
    del.innerHTML = '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 8a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>';
    del.onclick = function(e) { e.preventDefault(); fbWatcherDelete(w.id); };
    row.appendChild(cb);
    row.appendChild(info);
    row.appendChild(edit);
    row.appendChild(del);
    list.appendChild(row);
  });
}

// DVI-465: show the SCADA notifications subsection only to users with SCADA
// access, and sync its checkboxes with the shared per-user prefs.
function usInitScadaNotifications() {
  var section = document.getElementById('us-scada-notify-section');
  if (!section) return;
  if (typeof _isScada !== 'undefined' && (_isScada || (typeof _isToGenAdmin !== 'undefined' && _isToGenAdmin))) {
    section.classList.remove('hidden');
    scadaSyncNotifUI();
  } else {
    section.classList.add('hidden');
  }
}

// ===== USER BACKGROUND OVERRIDE (DVI-364) =====
function usInitBackground() {
  var sel = document.getElementById('us-bg-mode');
  if (sel) {
    var ov = _userSettings && _userSettings.bg_mode_override;
    sel.value = (ov === 'togen_default' || ov === 'flat' || ov === 'corporate' || ov === 'custom') ? ov : '';
  }
  ['light', 'dark'].forEach(function(theme) {
    usSetBgOpacityInputs(theme, _usEffectiveOpacityFor(theme));
  });
  usUpdateBgOpacityEnabled();
  usInitCustomBg();
}

// Wire the custom-background drag-and-drop zone once and render the current
// preview (DVI-518).
function usInitCustomBg() {
  var drop = document.getElementById('us-bg-custom-drop');
  if (drop && !drop._wired) {
    drop._wired = true;
    ['dragenter', 'dragover'].forEach(function(ev) {
      drop.addEventListener(ev, function(e) {
        e.preventDefault(); e.stopPropagation();
        drop.classList.add('border-togen-teal');
      });
    });
    ['dragleave', 'drop'].forEach(function(ev) {
      drop.addEventListener(ev, function(e) {
        e.preventDefault(); e.stopPropagation();
        drop.classList.remove('border-togen-teal');
      });
    });
    drop.addEventListener('drop', function(e) {
      var dt = e.dataTransfer;
      if (dt && dt.files && dt.files.length) usCustomBgFileChosen(dt.files);
    });
  }
  usRenderCustomBgPreview();
}

function usRenderCustomBgPreview() {
  var wrap = document.getElementById('us-bg-custom-preview-wrap');
  var img = document.getElementById('us-bg-custom-preview');
  if (!wrap || !img) return;
  var has = (typeof _userSettings !== 'undefined' && _userSettings) && _userSettings.custom_bg_ext;
  if (has) {
    img.src = '/user-settings/custom-bg?v=' + _customBgBust;
    wrap.classList.remove('hidden');
  } else {
    img.removeAttribute('src');
    wrap.classList.add('hidden');
  }
}

function usCustomBgFileChosen(files) {
  if (!files || !files.length) return;
  var file = files[0];
  var status = document.getElementById('us-bg-custom-status');
  if (!/\.(png|jpe?g|webp|gif|svg)$/i.test(file.name)) {
    status.textContent = 'Unsupported image type. Use PNG, JPG, WEBP, GIF, or SVG.';
    status.className = 'block mt-2 text-sm text-red-500';
    return;
  }
  if (file.size > 8 * 1024 * 1024) {
    status.textContent = 'Image too large (max 8 MB).';
    status.className = 'block mt-2 text-sm text-red-500';
    return;
  }
  var fd = new FormData();
  fd.append('background', file);
  status.textContent = 'Uploading...';
  status.className = 'block mt-2 text-sm text-gray-500 dark:text-gray-400';
  fetch('/user-settings/custom-bg', { method: 'POST', body: fd })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { status.textContent = 'Error: ' + d.error; status.className = 'block mt-2 text-sm text-red-500'; return; }
      _userSettings = d.settings;
      _customBgBust = Date.now();
      var sel = document.getElementById('us-bg-mode');
      if (sel) sel.value = 'custom';
      usUpdateBgOpacityEnabled();
      usRenderCustomBgPreview();
      applyBrandingBackground();
      applyPaneAlpha();
      status.textContent = 'Saved';
      status.className = 'block mt-2 text-sm text-green-600 dark:text-green-400';
      setTimeout(function(){ status.textContent = ''; }, 3000);
    })
    .catch(function(e){ status.textContent = 'Error: ' + e.message; status.className = 'block mt-2 text-sm text-red-500'; });
}

function usRemoveCustomBg() {
  var status = document.getElementById('us-bg-custom-status');
  status.textContent = 'Removing...';
  status.className = 'block mt-2 text-sm text-gray-500 dark:text-gray-400';
  fetch('/user-settings/custom-bg', { method: 'DELETE' })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { status.textContent = 'Error: ' + d.error; status.className = 'block mt-2 text-sm text-red-500'; return; }
      _userSettings = d.settings;
      var sel = document.getElementById('us-bg-mode');
      if (sel) sel.value = (_userSettings && _userSettings.bg_mode_override) || '';
      usUpdateBgOpacityEnabled();
      usRenderCustomBgPreview();
      applyBrandingBackground();
      applyPaneAlpha();
      status.textContent = 'Removed';
      status.className = 'block mt-2 text-sm text-gray-500 dark:text-gray-400';
      setTimeout(function(){ status.textContent = ''; }, 3000);
    })
    .catch(function(e){ status.textContent = 'Error: ' + e.message; status.className = 'block mt-2 text-sm text-red-500'; });
}

// ===== USER TILE LAYOUT OVERRIDE (DVI-512) =====
function usInitTileLayout() {
  var sel = document.getElementById('us-tile-columns');
  if (!sel) return;
  var ov = _userSettings && _userSettings.tile_columns;
  if (ov === 'unlimited') sel.value = 'unlimited';
  else if (typeof ov === 'number') sel.value = String(ov);
  else sel.value = '';
  // Side padding override (DVI-515): checkbox = inherit company default.
  var padInput = document.getElementById('us-tile-padding');
  var padDefault = document.getElementById('us-tile-padding-default');
  if (padInput && padDefault) {
    var pov = _userSettings && _userSettings.tile_side_padding;
    var hasOverride = (typeof pov === 'number');
    padDefault.checked = !hasOverride;
    padInput.disabled = !hasOverride;
    padInput.value = String(hasOverride ? pov : ((window._brandingSettings || {}).tile_side_padding || 0));
  }
}

function usTogglePaddingDefault() {
  var padInput = document.getElementById('us-tile-padding');
  var padDefault = document.getElementById('us-tile-padding-default');
  if (padInput && padDefault) padInput.disabled = padDefault.checked;
  usSaveTileLayout();
}

function usSaveTileLayout() {
  var sel = document.getElementById('us-tile-columns');
  var status = document.getElementById('us-tile-layout-status');
  if (!sel) return;
  var raw = sel.value;
  var payload = (raw === '') ? null : (raw === 'unlimited' ? 'unlimited' : parseInt(raw, 10));
  var body = {tile_columns: payload};
  var padInput = document.getElementById('us-tile-padding');
  var padDefault = document.getElementById('us-tile-padding-default');
  if (padInput && padDefault) {
    if (padDefault.checked) {
      body.tile_side_padding = null;
    } else {
      var pad = Math.max(0, Math.min(40, parseInt(padInput.value, 10) || 0));
      padInput.value = String(pad);
      body.tile_side_padding = pad;
    }
  }
  status.textContent = 'Saving...';
  status.className = 'block mt-2 text-sm text-gray-500 dark:text-gray-400';
  fetch('/user-settings', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body)
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { status.textContent = 'Error: ' + d.error; status.className = 'block mt-2 text-sm text-red-500'; return; }
      _userSettings = d.settings;
      renderTileGrid();
      status.textContent = 'Saved';
      status.className = 'block mt-2 text-sm text-green-600 dark:text-green-400';
      setTimeout(function(){ status.textContent = ''; }, 3000);
    })
    .catch(function(e){ status.textContent = 'Error: ' + e.message; status.className = 'block mt-2 text-sm text-red-500'; });
}

// Effective per-theme opacity (DVI-366): user override → company default.
function _usEffectiveOpacityFor(theme) {
  var key = theme === 'dark' ? 'bg_opacity_dark' : 'bg_opacity_light';
  var u = (typeof _userSettings !== 'undefined' && _userSettings) ? _userSettings[key] : null;
  if (typeof u === 'number') return u;
  var c = (window._brandingSettings || {})[key];
  return (typeof c === 'number') ? c : 100;
}

function usSetBgOpacityInputs(theme, val) {
  val = Math.max(40, Math.min(100, parseInt(val, 10) || 100));
  var slider = document.getElementById('us-bg-opacity-' + theme + '-slider');
  var num = document.getElementById('us-bg-opacity-' + theme + '-number');
  if (slider) slider.value = val;
  if (num) num.value = val;
}

function usSyncBgOpacity(theme, src) {
  var slider = document.getElementById('us-bg-opacity-' + theme + '-slider');
  var num = document.getElementById('us-bg-opacity-' + theme + '-number');
  var v = parseInt((src === 'number' ? num.value : slider.value), 10);
  if (isNaN(v)) return;
  v = Math.max(40, Math.min(100, v));
  if (slider) slider.value = v;
  if (num && src !== 'number') num.value = v;
}

function usUpdateBgOpacityEnabled() {
  var sel = document.getElementById('us-bg-mode');
  var chosen = sel ? sel.value : '';
  var effective = chosen || ((window._brandingSettings || {}).bg_mode || 'togen_default');
  var supportsAlpha = bgModeSupportsTransparency(effective);
  var block = document.getElementById('us-bg-opacity-block');
  ['light', 'dark'].forEach(function(theme) {
    var slider = document.getElementById('us-bg-opacity-' + theme + '-slider');
    var num = document.getElementById('us-bg-opacity-' + theme + '-number');
    if (slider) slider.disabled = !supportsAlpha;
    if (num) num.disabled = !supportsAlpha;
  });
  if (block) block.classList.toggle('opacity-50', !supportsAlpha);
  // Custom background uploader is only relevant when "Custom" is selected (DVI-518).
  var customBlock = document.getElementById('us-bg-custom-block');
  if (customBlock) customBlock.classList.toggle('hidden', chosen !== 'custom');
}

function usSaveBackground() {
  var btn = document.getElementById('us-bg-save-btn');
  var status = document.getElementById('us-bg-save-status');
  var sel = document.getElementById('us-bg-mode');
  var lightNum = document.getElementById('us-bg-opacity-light-number');
  var darkNum = document.getElementById('us-bg-opacity-dark-number');
  var modeOverride = sel ? sel.value : '';
  var opacityLight = Math.max(40, Math.min(100, parseInt(lightNum && lightNum.value, 10) || 100));
  var opacityDark = Math.max(40, Math.min(100, parseInt(darkNum && darkNum.value, 10) || 100));

  btn.disabled = true;
  btn.classList.add('opacity-50', 'cursor-not-allowed');
  status.textContent = 'Saving...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';

  fetch('/user-settings', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      bg_mode_override: modeOverride === '' ? null : modeOverride,
      bg_opacity_light: opacityLight,
      bg_opacity_dark: opacityDark
    })
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      if (d.error) {
        status.textContent = 'Error: ' + d.error;
        status.className = 'text-sm text-red-500';
        return;
      }
      _userSettings = d.settings;
      applyBrandingBackground();
      applyPaneAlpha();
      status.textContent = 'Saved';
      status.className = 'text-sm text-green-600 dark:text-green-400';
      setTimeout(function(){ status.textContent = ''; }, 3000);
    })
    .catch(function(e){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      status.textContent = 'Error: ' + e.message;
      status.className = 'text-sm text-red-500';
    });
}

var _usNotifyTypes = [
  {id: 'maintenance', label: 'Maintenance'},
  {id: 'mobilemachine', label: 'Mobile Machine'},
  {id: 'safety', label: 'Safety'},
  {id: 'preventive', label: 'Preventative Maintenance'}
];
var _usNotifyCols = ['new', 'status_change', 'completed', 'due_soon', 'overdue'];

// WO types shown in notification grids, in admin order, excluding types
// disabled by Togen Admin (Togen Admins always see all types). DVI-349.
function _effectiveNotifyTypes() {
  var byId = {};
  _usNotifyTypes.forEach(function(t){ byId[t.id] = t; });
  var order = (_worTypeOrder && _worTypeOrder.length) ? _worTypeOrder.slice() : [];
  _usNotifyTypes.forEach(function(t){ if (order.indexOf(t.id) === -1) order.push(t.id); });
  var out = [];
  order.forEach(function(id) {
    if (!byId[id]) return;
    if (!_isToGenAdmin && _worTypeVisibility[id] === false) return;
    out.push(byId[id]);
  });
  return out;
}

// Shared WO notifications grid renderer. Populates the given tbody from
// _userSettings.notifications so every surface (User Settings pane + WO Admin
// Settings tab) reads from the same data model and stays in sync.
// Rows for a WO notifications grid. Primary-enforced WO types (DVI-405) render
// every checkbox checked + disabled + greyed, with a "Primary" badge, since
// Primary recipients always receive all events for that type. When
// forceReadOnly is true (a non-admin viewing the grid), all rows are locked.
function notifyBuildGridRows(tbodyId, forceReadOnly) {
  var tbody = document.getElementById(tbodyId);
  if (!tbody) return;
  tbody.innerHTML = '';
  var grid = _userSettings.notifications || {};
  var enforced = window._worPrimaryEnforced || [];
  _effectiveNotifyTypes().forEach(function(t) {
    var prefs = grid[t.id] || {};
    var isEnforced = enforced.indexOf(t.id) !== -1;
    var tr = document.createElement('tr');
    tr.className = 'border-t border-gray-100 dark:border-gray-800';
    var badge = isEnforced
      ? ' <span class="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-togen-teal/10 text-togen-teal dark:bg-teal-900/30 dark:text-teal-300 align-middle">Primary</span>'
      : '';
    var cells = '<td class="text-left py-3 pr-4 font-medium text-gray-800 dark:text-gray-100">' + t.label + badge + '</td>';
    _usNotifyCols.forEach(function(col) {
      var locked = isEnforced || forceReadOnly;
      var checked = (isEnforced || prefs[col]) ? ' checked' : '';
      var disabled = locked ? ' disabled' : '';
      var cls = locked
        ? 'h-5 w-5 rounded border-gray-300 dark:border-gray-600 text-togen-teal opacity-50 cursor-not-allowed'
        : 'h-5 w-5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer';
      cells += '<td class="text-center px-2 py-3">' +
        '<input type="checkbox" data-notify-type="' + t.id + '" data-notify-col="' + col + '"' + checked + disabled +
        ' class="' + cls + '" /></td>';
    });
    tr.innerHTML = cells;
    tbody.appendChild(tr);
  });
}

// Shared save: collects the given tbody's checkboxes, persists via the single
// /notifications-settings route, then re-renders every grid so both surfaces
// reflect the saved state.
function notifySaveGrid(tbodyId, btnId, statusId) {
  var btn = document.getElementById(btnId);
  var status = document.getElementById(statusId);
  // Preserve prefs for types not shown in this grid (e.g. disabled WO types);
  // only reset the rows actually rendered.
  var grid = JSON.parse(JSON.stringify(_userSettings.notifications || {}));
  _effectiveNotifyTypes().forEach(function(t) { grid[t.id] = {}; });
  var checks = document.querySelectorAll('#' + tbodyId + ' input[type="checkbox"]');
  for (var i = 0; i < checks.length; i++) {
    var type = checks[i].getAttribute('data-notify-type');
    var col = checks[i].getAttribute('data-notify-col');
    if (grid[type]) grid[type][col] = checks[i].checked;
  }

  btn.disabled = true;
  btn.classList.add('opacity-50', 'cursor-not-allowed');
  status.textContent = 'Saving...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';

  fetch('/notifications-settings', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({notifications: grid})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      if (d.error) {
        status.textContent = 'Error: ' + d.error;
        status.className = 'text-sm text-red-500';
      } else {
        _userSettings.notifications = d.notifications;
        notifyBuildGridRows('us-notify-grid');
        notifyBuildGridRows('woradmin-notify-grid');
        status.textContent = 'Saved';
        status.className = 'text-sm text-green-600 dark:text-green-400';
        setTimeout(function(){ status.textContent = ''; }, 3000);
      }
    })
    .catch(function(e){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      status.textContent = 'Error: ' + e.message;
      status.className = 'text-sm text-red-500';
    });
}

function usRenderNotificationsGrid() {
  // The editable WO notifications grid applies to Work Order Admins. Non-admins
  // see it only when they are a Primary recipient (DVI-405) — read-only, with
  // their enforced types locked on; otherwise an empty-state placeholder.
  var woSection = document.getElementById('us-notify-wo-section');
  var emptyMsg = document.getElementById('us-notify-empty');
  var enforced = window._worPrimaryEnforced || [];
  var isPrimary = enforced.length > 0;
  var saveBtn = document.getElementById('us-notify-save-btn');
  var note = document.getElementById('us-notify-primary-note');
  if (!_isWorAdmin && !isPrimary) {
    if (woSection) woSection.classList.add('hidden');
    if (emptyMsg) emptyMsg.classList.remove('hidden');
    return;
  }
  if (woSection) woSection.classList.remove('hidden');
  if (emptyMsg) emptyMsg.classList.add('hidden');
  // Non-admin Primary users cannot edit grid prefs (admin-gated), so the whole
  // grid is read-only for them and the Save button is hidden.
  var readOnly = !_isWorAdmin;
  if (saveBtn) saveBtn.classList.toggle('hidden', readOnly);
  if (note) note.classList.toggle('hidden', !isPrimary);
  notifyBuildGridRows('us-notify-grid', readOnly);
}

function usSaveNotifications() {
  notifySaveGrid('us-notify-grid', 'us-notify-save-btn', 'us-notify-save-status');
}

// ===== TOGEN NEWS SUBSCRIPTION (DVI-385) =====
// The News subscription checkbox shares the same store/endpoints as the News
// page Subscribe button and the WO Admin subscribers pane, so all interfaces
// toggle one setting.
function usLoadNewsSubscription() {
  var cb = document.getElementById('us-news-subscribe');
  if (!cb) return;
  fetch('/news/subscribers')
    .then(function(r){ return r.json(); })
    .then(function(d){ cb.checked = !!d.subscribed; })
    .catch(function(){});
}

function usToggleNewsSubscription() {
  var cb = document.getElementById('us-news-subscribe');
  var status = document.getElementById('us-news-subscribe-status');
  var subscribe = cb.checked;
  var email = '{{ user_email }}';
  cb.disabled = true;
  if (status) { status.textContent = 'Saving…'; status.className = 'ml-auto text-xs text-gray-400 dark:text-gray-500'; }
  fetch(subscribe ? '/news/subscribe' : '/news/unsubscribe', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({email: email})
  }).then(function(r){ return r.json(); }).then(function(d){
    cb.disabled = false;
    if (d.error) {
      cb.checked = !subscribe;
      if (status) { status.textContent = 'Error'; status.className = 'ml-auto text-xs text-red-500'; }
      return;
    }
    // Keep the News page button state in sync if it has been initialised.
    _newsSubscribed = subscribe;
    if (typeof newsUpdateSubscribeBtn === 'function' && _newsInitialized) newsUpdateSubscribeBtn();
    if (status) {
      status.textContent = subscribe ? 'Subscribed' : 'Unsubscribed';
      status.className = 'ml-auto text-xs text-green-600 dark:text-green-400';
      setTimeout(function(){ if (status) status.textContent = ''; }, 2000);
    }
  }).catch(function(){
    cb.disabled = false;
    cb.checked = !subscribe;
    if (status) { status.textContent = 'Error'; status.className = 'ml-auto text-xs text-red-500'; }
  });
}

function usRenderThemeToggle() {
  var isDark = document.documentElement.classList.contains('dark');
  var toggle = document.getElementById('us-theme-toggle');
  var dot = document.getElementById('us-theme-dot');
  if (isDark) {
    toggle.classList.add('bg-togen-teal');
    toggle.classList.remove('bg-gray-300', 'dark:bg-gray-600');
    toggle.setAttribute('aria-checked', 'true');
    dot.classList.remove('translate-x-1');
    dot.classList.add('translate-x-6');
  } else {
    toggle.classList.remove('bg-togen-teal');
    toggle.classList.add('bg-gray-300', 'dark:bg-gray-600');
    toggle.setAttribute('aria-checked', 'false');
    dot.classList.remove('translate-x-6');
    dot.classList.add('translate-x-1');
  }
}

function usToggleTheme() {
  toggleTheme();
  usRenderThemeToggle();
}

function _usToolHasPermission(tid) {
  if (_isToGenAdmin) return true;
  if (tid === 'file-browser') return true;
  if (tid === 'project-builder' || tid === 'takeoff-bridge' || tid === 'sync-admin' || tid === 'single-carrier-sheet') return _isTakeoffWorkflow || _isToGenAdmin;
  if (tid === 'prod-run-merge') return _isProdRun || _isToGenAdmin;
  if (tid === 'mts-label') return _isMts || _isToGenAdmin;
  if (tid === 'scada-view') return _isScada || _isToGenAdmin;
  if (tid === 'vision') return _isTogenVision || _isToGenAdmin;
  if (tid === 'wor-admin') return _isWorAdmin;
  if (tid === 'auditor') return _isToGenAdmin;  // DVI-569: admin-only for now
  if (tid === 'togen-admin') return false;
  return true;
}

// Display label for a tool row, including the per-user-only Togen Admin entry
// which is intentionally absent from the admin-managed _toolVisibilityLabels map.
function _usToolLabel(tid) {
  if (tid === 'togen-admin') return 'Togen Admin';
  return _toolVisibilityLabels[tid] || tid;
}

// Shared drag-to-reorder behavior for both tool rows and separator rows.
function _usAttachRowDrag(row, container) {
  row.addEventListener('dragstart', function(e) {
    _usDragSrcRow = this;
    this.classList.add('opacity-50');
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData('text/plain', this.getAttribute('data-tool-id') || '');
  });
  row.addEventListener('dragend', function() {
    this.classList.remove('opacity-50');
    var rows = container.querySelectorAll('[data-tool-id]');
    for (var i = 0; i < rows.length; i++) rows[i].classList.remove('border-togen-teal');
  });
  row.addEventListener('dragover', function(e) {
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
    this.classList.add('border-togen-teal');
  });
  row.addEventListener('dragleave', function() {
    this.classList.remove('border-togen-teal');
  });
  row.addEventListener('drop', function(e) {
    e.preventDefault();
    this.classList.remove('border-togen-teal');
    if (_usDragSrcRow && _usDragSrcRow !== this) {
      var allRows = Array.prototype.slice.call(container.querySelectorAll('[data-tool-id]'));
      var fromIdx = allRows.indexOf(_usDragSrcRow);
      var toIdx = allRows.indexOf(this);
      if (fromIdx < toIdx) container.insertBefore(_usDragSrcRow, this.nextSibling);
      else container.insertBefore(_usDragSrcRow, this);
    }
  });
}

// A "separator" list row (DVI-524). Reorderable like a tool and removable; its
// saved position splits the home grid when tools sit on both sides of it.
function _usMakeSeparatorRow(container) {
  var row = document.createElement('div');
  row.setAttribute('draggable', 'true');
  row.setAttribute('data-tool-id', 'separator');
  row.className = 'flex items-center gap-3 p-3 rounded-xl bg-gray-100 dark:bg-gray-800/60 border-2 border-dashed border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors';
  row.innerHTML =
    '<div class="cursor-grab active:cursor-grabbing text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 shrink-0 select-none" title="Drag to reorder">' +
      '<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">' +
        '<path d="M7 2a2 2 0 10.001 4.001A2 2 0 007 2zm0 6a2 2 0 10.001 4.001A2 2 0 007 8zm0 6a2 2 0 10.001 4.001A2 2 0 007 14zm6-8a2 2 0 10-.001-4.001A2 2 0 0013 6zm0 2a2 2 0 10.001 4.001A2 2 0 0013 8zm0 6a2 2 0 10.001 4.001A2 2 0 0013 14z" />' +
      '</svg>' +
    '</div>' +
    '<div class="flex items-center gap-2 flex-1 min-w-0">' +
      '<span class="h-px flex-1 bg-gray-300 dark:bg-gray-600"></span>' +
      '<span class="text-xs font-medium uppercase tracking-wide text-gray-400 dark:text-gray-500 shrink-0">Separator</span>' +
      '<span class="h-px flex-1 bg-gray-300 dark:bg-gray-600"></span>' +
    '</div>' +
    '<button type="button" title="Remove separator" ' +
      'class="shrink-0 inline-flex items-center justify-center h-7 w-7 rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors cursor-pointer">' +
      '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>' +
    '</button>';
  var removeBtn = row.querySelector('button');
  removeBtn.addEventListener('click', function() {
    if (row.parentNode) row.parentNode.removeChild(row);
  });
  _usAttachRowDrag(row, container);
  return row;
}

// Tools-pane "+" menu (DVI-524).
function usToggleAddMenu(e) {
  if (e) e.stopPropagation();
  var menu = document.getElementById('us-tools-add-menu');
  var btn = document.getElementById('us-tools-add-btn');
  if (!menu) return;
  var nowHidden = menu.classList.toggle('hidden');
  if (btn) btn.setAttribute('aria-expanded', nowHidden ? 'false' : 'true');
}

function usCloseAddMenu() {
  var menu = document.getElementById('us-tools-add-menu');
  var btn = document.getElementById('us-tools-add-btn');
  if (menu && !menu.classList.contains('hidden')) {
    menu.classList.add('hidden');
    if (btn) btn.setAttribute('aria-expanded', 'false');
  }
}

document.addEventListener('click', function(e) {
  var btn = document.getElementById('us-tools-add-btn');
  var menu = document.getElementById('us-tools-add-menu');
  if (!menu || menu.classList.contains('hidden')) return;
  if (btn && btn.parentNode && btn.parentNode.contains(e.target)) return;
  usCloseAddMenu();
});

function usAddSeparator() {
  usCloseAddMenu();
  var container = document.getElementById('us-tools-list');
  if (!container) return;
  if (container.querySelector('[data-tool-id="separator"]')) {
    var status = document.getElementById('us-tools-save-status');
    if (status) {
      status.textContent = 'Only one separator can be added.';
      status.className = 'text-sm text-gray-500 dark:text-gray-400';
      setTimeout(function(){ status.textContent = ''; }, 3000);
    }
    return;
  }
  container.appendChild(_usMakeSeparatorRow(container));
}

function usRenderToolsList() {
  var container = document.getElementById('us-tools-list');
  if (!container) return;
  container.innerHTML = '';

  var userToolOrder = (_userSettings.tool_order && _userSettings.tool_order.length) ? _userSettings.tool_order : _toolOrder;
  var userToolVis = _userSettings.tool_visibility || {};

  var ids = userToolOrder.filter(function(tid){
    if (tid === 'separator') return true;             // DVI-524 separator marker
    if (tid === 'togen-admin') return _isToGenAdmin;  // DVI-524 admin tile toggle
    return !!_toolVisibilityLabels[tid];
  });
  var allKnown = Object.keys(_toolVisibilityLabels);
  allKnown.forEach(function(tid){ if (ids.indexOf(tid) === -1) ids.push(tid); });

  // Togen Admins may hide their Togen Admin tile here (DVI-524); the tool stays
  // reachable from the profile menu regardless. Append if not already ordered.
  if (_isToGenAdmin && ids.indexOf('togen-admin') === -1) ids.push('togen-admin');

  // Vision is access-restricted: show only to admins (always) or vision-group
  // users while it is enabled in production. Hide entirely otherwise.
  ids = ids.filter(function(tid){
    // Auditor is admin-only for now (DVI-569): hide entirely from non-admins.
    if (tid === 'auditor') return _isToGenAdmin;
    if (tid !== 'vision') return true;
    if (_isToGenAdmin) return true;
    return _isTogenVision && _toolVisibility['vision'] !== false;
  });

  ids.forEach(function(tid) {
    if (tid === 'separator') { container.appendChild(_usMakeSeparatorRow(container)); return; }
    var label = _usToolLabel(tid);
    var hasPermission = _usToolHasPermission(tid);
    var enabledInProd = _toolVisibility[tid] !== false;
    var userChecked;
    if (userToolVis.hasOwnProperty(tid)) {
      userChecked = userToolVis[tid];
    } else {
      userChecked = enabledInProd && hasPermission;
    }
    var canToggle = hasPermission && enabledInProd;

    var row = document.createElement('div');
    row.setAttribute('draggable', 'true');
    row.setAttribute('data-tool-id', tid);
    row.className = 'flex items-center gap-3 p-3 rounded-xl bg-gray-50 dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors';

    var checkboxHtml;
    if (canToggle) {
      checkboxHtml =
        '<input type="checkbox" data-tool-id="' + tid + '"' + (userChecked ? ' checked' : '') +
        ' class="h-5 w-5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer" />';
    } else {
      checkboxHtml =
        '<input type="checkbox" data-tool-id="' + tid + '" disabled' +
        ' class="h-5 w-5 rounded border-gray-300 dark:border-gray-600 text-gray-300 dark:text-gray-600 cursor-not-allowed opacity-50" />';
    }

    var actionHtml = '';
    if (!hasPermission && enabledInProd) {
      actionHtml =
        '<button onclick="usRequestAccess(\'' + tid + '\', \'' + esc(label).replace(/'/g, "\\'") + '\')" ' +
        'class="px-3 py-1.5 text-xs font-medium rounded-lg bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border border-yellow-300 dark:border-yellow-700 hover:bg-yellow-200 dark:hover:bg-yellow-900/50 transition-colors whitespace-nowrap cursor-pointer">' +
        'Request Access</button>';
    }

    var labelClass = canToggle
      ? 'text-sm font-medium text-gray-800 dark:text-gray-100'
      : 'text-sm font-medium text-gray-400 dark:text-gray-500';

    row.innerHTML =
      '<div class="cursor-grab active:cursor-grabbing text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 shrink-0 select-none" title="Drag to reorder">' +
        '<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">' +
          '<path d="M7 2a2 2 0 10.001 4.001A2 2 0 007 2zm0 6a2 2 0 10.001 4.001A2 2 0 007 8zm0 6a2 2 0 10.001 4.001A2 2 0 007 14zm6-8a2 2 0 10-.001-4.001A2 2 0 0013 6zm0 2a2 2 0 10.001 4.001A2 2 0 0013 8zm0 6a2 2 0 10.001 4.001A2 2 0 0013 14z" />' +
        '</svg>' +
      '</div>' +
      '<label class="flex items-center gap-3 flex-1 ' + (canToggle ? 'cursor-pointer' : 'cursor-default') + '">' +
        checkboxHtml +
        '<span class="' + labelClass + '">' + esc(label) + '</span>' +
      '</label>' +
      actionHtml;

    _usAttachRowDrag(row, container);

    container.appendChild(row);
  });
}

function usSaveSettings() {
  var btn = document.getElementById('us-tools-save-btn');
  var status = document.getElementById('us-tools-save-status');

  var checks = document.querySelectorAll('#us-tools-list input[type=checkbox]');
  var visPayload = {};
  for (var i = 0; i < checks.length; i++) {
    var tid = checks[i].getAttribute('data-tool-id');
    if (!checks[i].disabled) {
      visPayload[tid] = checks[i].checked;
    }
  }

  var rows = document.querySelectorAll('#us-tools-list [data-tool-id]');
  var orderPayload = [];
  for (var j = 0; j < rows.length; j++) {
    orderPayload.push(rows[j].getAttribute('data-tool-id'));
  }

  var isDark = document.documentElement.classList.contains('dark');

  btn.disabled = true;
  btn.classList.add('opacity-50', 'cursor-not-allowed');
  status.textContent = 'Saving...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';

  fetch('/user-settings', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      tool_visibility: visPayload,
      tool_order: orderPayload,
      theme: isDark ? 'dark' : 'light'
    })
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      if (d.error) {
        status.textContent = 'Error: ' + d.error;
        status.className = 'text-sm text-red-500';
      } else {
        _userSettings = d.settings;
        status.textContent = 'Saved';
        status.className = 'text-sm text-green-600 dark:text-green-400';
        renderTileGrid();
        setTimeout(function(){ status.textContent = ''; }, 3000);
      }
    })
    .catch(function(e){
      btn.disabled = false;
      btn.classList.remove('opacity-50', 'cursor-not-allowed');
      status.textContent = 'Error: ' + e.message;
      status.className = 'text-sm text-red-500';
    });
}

function usResetSettings() {
  var status = document.getElementById('us-tools-save-status');
  status.textContent = 'Resetting...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';

  fetch('/user-settings', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({tool_visibility: {}, tool_order: [], theme: 'system'})
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) {
        status.textContent = 'Error: ' + d.error;
        status.className = 'text-sm text-red-500';
      } else {
        _userSettings = d.settings;
        usRenderToolsList();
        renderTileGrid();
        status.textContent = 'Reset to defaults';
        status.className = 'text-sm text-green-600 dark:text-green-400';
        setTimeout(function(){ status.textContent = ''; }, 3000);
      }
    })
    .catch(function(e){
      status.textContent = 'Error: ' + e.message;
      status.className = 'text-sm text-red-500';
    });
}

function usRequestAccess(toolId, toolLabel) {
  document.getElementById('profile-menu').classList.add('hidden');
  var prevTool = 'user-settings';
  openTool('support-request', 'Support Request');
  if (!_srInitialized) _srInitialized = true;
  var f = document.getElementById('sr-form');
  if (f) {
    var ssoName = '{{ user_name }}';
    var ssoEmail = '{{ user_email }}';
    if (ssoName && ssoName !== 'User') f.elements['sr_name'].value = ssoName;
    if (ssoEmail) f.elements['sr_email'].value = ssoEmail;
    f.elements['sr_datetime'].value = new Date().toLocaleString();
    f.elements['sr_active_tool'].value = 'User Settings';
    f.elements['sr_description'].value = 'Access Request: I would like to request access to the "' + toolLabel + '" tool (ID: ' + toolId + ').';
    var state = {
      activeTool: prevTool,
      requestedTool: toolId,
      requestedToolLabel: toolLabel,
      requestType: 'tool_access_request',
      url: window.location.href,
      userAgent: navigator.userAgent,
      screenSize: window.innerWidth + 'x' + window.innerHeight,
      timestamp: new Date().toISOString()
    };
    f.elements['sr_app_state'].value = JSON.stringify(state, null, 2);
    f.elements['sr_send_to'].value = 'icast.voyagetech.com@email.autotask.net';
  }
  document.getElementById('sr-submit-status').textContent = '';
}

// ===== SIMPLE QR CREATOR (DVI-270) =====
var _sqrCurrentB64 = null;

function sqrGenerate() {
  var url = document.getElementById('sqr-url').value.trim();
  if (!url) { document.getElementById('sqr-status').textContent = 'Please enter a URL.'; return; }
  var statusEl = document.getElementById('sqr-status');
  statusEl.textContent = 'Generating...';
  document.getElementById('sqr-generate-btn').disabled = true;
  fetch('/simple-qr/generate', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({url: url})
  }).then(function(r){ return r.json(); }).then(function(data) {
    document.getElementById('sqr-generate-btn').disabled = false;
    if (data.error) { statusEl.textContent = 'Error: ' + data.error; return; }
    _sqrCurrentB64 = data.image;
    var imgEl = document.getElementById('sqr-preview-img');
    imgEl.src = 'data:image/png;base64,' + data.image;
    document.getElementById('sqr-preview-url').textContent = url;
    document.getElementById('sqr-preview-wrapper').classList.remove('hidden');
    statusEl.textContent = '';
  }).catch(function() {
    document.getElementById('sqr-generate-btn').disabled = false;
    statusEl.textContent = 'Failed to generate QR code.';
  });
}

function sqrCopyToClipboard() {
  if (!_sqrCurrentB64) return;
  var statusEl = document.getElementById('sqr-status');
  var byteStr = atob(_sqrCurrentB64);
  var bytes = new Uint8Array(byteStr.length);
  for (var i = 0; i < byteStr.length; i++) bytes[i] = byteStr.charCodeAt(i);
  var blob = new Blob([bytes], {type: 'image/png'});
  navigator.clipboard.write([new ClipboardItem({'image/png': blob})]).then(function() {
    statusEl.textContent = 'QR code copied to clipboard';
    setTimeout(function(){ statusEl.textContent = ''; }, 3000);
  }).catch(function() {
    statusEl.textContent = 'Copy failed — try right-click and copy the image.';
    setTimeout(function(){ statusEl.textContent = ''; }, 3000);
  });
}

function sqrEmail() {
  if (!_sqrCurrentB64) return;
  document.getElementById('sqr-email-status').textContent = '';
  document.getElementById('sqr-email-overlay').classList.remove('hidden');
  setTimeout(function(){ document.getElementById('sqr-email-input').focus(); document.getElementById('sqr-email-input').select(); }, 100);
}

function sqrEmailClose() {
  document.getElementById('sqr-email-overlay').classList.add('hidden');
}

function sqrEmailSend() {
  var email = document.getElementById('sqr-email-input').value.trim();
  if (!email) { document.getElementById('sqr-email-status').textContent = 'Enter an email address.'; return; }
  var url = document.getElementById('sqr-preview-url').textContent;
  var statusEl = document.getElementById('sqr-email-status');
  statusEl.textContent = 'Sending...';
  document.getElementById('sqr-email-send-btn').disabled = true;
  fetch('/simple-qr/email', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({email: email, image: _sqrCurrentB64, url: url})
  }).then(function(r){ return r.json(); }).then(function(data) {
    document.getElementById('sqr-email-send-btn').disabled = false;
    if (data.error) { statusEl.textContent = 'Error: ' + data.error; return; }
    statusEl.textContent = 'Sent!';
    setTimeout(function(){ sqrEmailClose(); }, 1500);
  }).catch(function() {
    document.getElementById('sqr-email-send-btn').disabled = false;
    statusEl.textContent = 'Failed to send.';
  });
}

function sqrOpenWithUrl(url) {
  openTool('simple-qr', 'Simple QR Creator');
  document.getElementById('sqr-url').value = url;
  sqrGenerate();
}

function fbOpenInQrCreator() {
  var url = _fbDocViewerUrl();
  if (!url) return;
  _fbCloseWorkflowMenu();
  _fbtCloseWorkflowMenu();
  sqrOpenWithUrl(url);
}

// ===== EQUIPMENT MANUALS (DVI-456) =====
var _emInitialized = false;
var _emManuals = [];
// Grid/List view toggle (DVI-464). Defaults to grid; persisted choice survives reloads.
var _emView = (function(){ try { return localStorage.getItem('emView') === 'list' ? 'list' : 'grid'; } catch(e){ return 'grid'; } })();

function emLoadList() {
  var container = document.getElementById('em-list');
  // Reset any active search so reopening the tool shows the full manuals list.
  _emSearchActive = false;
  var searchInput = document.getElementById('em-search-input');
  if (searchInput) searchInput.value = '';
  container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>';
  fetch('/equipment-manuals/list')
    .then(function(r){ return r.json(); })
    .then(function(d){
      _emManuals = d.manuals || [];
      emRenderList();
    })
    .catch(function(err){
      container.innerHTML = '<p class="text-sm text-red-500">Error loading manuals: ' + esc(err.message) + '</p>';
    });
}

// ===== Equipment Manuals: cross-manual OCR search (DVI-491) =====
var _emSearchTimer = null;
var _emSearchActive = false;

function emSearchDebounced() {
  if (_emSearchTimer) clearTimeout(_emSearchTimer);
  _emSearchTimer = setTimeout(emRunSearch, 300);
}

function emRunSearch() {
  if (_emSearchTimer) { clearTimeout(_emSearchTimer); _emSearchTimer = null; }
  var input = document.getElementById('em-search-input');
  var q = (input ? input.value : '').trim();
  var container = document.getElementById('em-list');
  if (q.length < 2) {
    // Cleared / too short: restore the normal manuals list.
    _emSearchActive = false;
    emRenderList();
    return;
  }
  _emSearchActive = true;
  container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Searching…</p>';
  fetch('/equipment-manuals/search?q=' + encodeURIComponent(q))
    .then(function(r){ return r.json(); })
    .then(function(d){
      // Ignore stale responses if the box changed since this request fired.
      var cur = (document.getElementById('em-search-input').value || '').trim();
      if (cur !== q) return;
      _emRenderSearchResults(container, d.results || [], q);
    })
    .catch(function(err){
      container.innerHTML = '<p class="text-sm text-red-500">Search error: ' + esc(err.message) + '</p>';
    });
}

function _emRenderSearchResults(container, results, q) {
  if (!results.length) {
    container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No manuals contain “' + esc(q) + '”. ' +
      'Newly uploaded manuals are indexed in the background and may take a moment.</p>';
    return;
  }
  var html = '<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">' + results.length +
    ' manual' + (results.length === 1 ? '' : 's') + ' match “' + esc(q) + '”</p>';
  results.forEach(function(m){
    var hits = m.match_count + ' hit' + (m.match_count === 1 ? '' : 's');
    html += '<div class="py-3 px-3 rounded-lg border border-gray-100 dark:border-gray-700 mb-2">' +
      '<div class="flex items-start justify-between gap-3">' +
        '<div class="flex-1 min-w-0">' +
          '<p class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate" title="' + esc(m.equipment) + '">' + esc(m.equipment || '(no equipment name)') + '</p>' +
          '<p class="text-xs text-gray-500 dark:text-gray-400 truncate" title="' + esc(m.original_name) + '">' + esc(m.original_name) + '</p>' +
        '</div>' +
        '<button onclick="window.open(\'' + encodeURI(m.url) + '\',\'_blank\')" class="px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shrink-0">Open</button>' +
      '</div>' +
      '<p class="text-xs text-gray-600 dark:text-gray-300 mt-2 italic">' + esc(m.snippet) + '</p>' +
      '<p class="text-[11px] text-gray-400 dark:text-gray-500 mt-1">First match on page ' + m.page + ' · ' + hits + '</p>' +
    '</div>';
  });
  container.innerHTML = html;
}

// View toggle: in list view the button offers "Grid view"; in grid view it offers "List view".
function emToggleView() {
  _emView = (_emView === 'grid') ? 'list' : 'grid';
  try { localStorage.setItem('emView', _emView); } catch(e) {}
  emRenderList();
}

var EM_GRID_ICON = '<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M5 3a2 2 0 00-2 2v1a2 2 0 002 2h1a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v1a2 2 0 002 2h1a2 2 0 002-2v-1a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h1a2 2 0 012 2v1a2 2 0 01-2 2h-1a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h1a2 2 0 012 2v1a2 2 0 01-2 2h-1a2 2 0 01-2-2v-1z"/></svg>';
var EM_LIST_ICON = '<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>';

function _emUpdateToggleButton() {
  var label = document.getElementById('em-view-toggle-label');
  var icon = document.getElementById('em-view-toggle-icon');
  if (label) label.textContent = (_emView === 'grid') ? 'List view' : 'Grid view';
  if (icon) icon.innerHTML = (_emView === 'grid') ? EM_LIST_ICON : EM_GRID_ICON;
}

function emRenderList() {
  _emUpdateToggleButton();
  var container = document.getElementById('em-list');
  if (!_emManuals.length) {
    container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No manuals uploaded yet. Use the Upload button to add one.</p>';
    return;
  }
  if (_emView === 'grid') { _emRenderGridView(container); }
  else { _emRenderListView(container); }
}

// The Workflow dropdown markup is shared between the list and grid layouts.
function _emWorkflowMenu(i) {
  return '<div class="relative">' +
      '<button onclick="emToggleWorkflowMenu(event,' + i + ')" class="px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center gap-1">' +
        '<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"/></svg>' +
        'Workflow</button>' +
      '<div id="em-wf-menu-' + i + '" class="hidden absolute right-0 top-full mt-2 w-72 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50">' +
        '<button onclick="emOpenInQrCreator(' + i + ')" class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">Open in Simple QR Creator</button>' +
        '<button onclick="emCreatePmWo(' + i + ')" class="w-full text-left px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-t border-gray-100 dark:border-gray-700">Create PM Work Order with this manual</button>' +
      '</div>' +
    '</div>';
}

function _emRenderListView(container) {
  var html = '';
  _emManuals.forEach(function(m, i){
    var who = m.updated_by ? (' by ' + esc(m.updated_by)) : '';
    html += '<div class="flex items-center justify-between py-3 px-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 border border-gray-100 dark:border-gray-700 mb-2">' +
      '<div class="flex-1 min-w-0">' +
        '<p class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate" title="' + esc(m.equipment) + '">' + esc(m.equipment || '(no equipment name)') + '</p>' +
        '<p class="text-xs text-gray-500 dark:text-gray-400 truncate" title="' + esc(m.original_name) + '">' + esc(m.original_name) + '</p>' +
        (m.updated_at ? '<p class="text-xs text-gray-400 dark:text-gray-500">' + fmtLocalDate(m.updated_at) + who + '</p>' : '') +
      '</div>' +
      '<div class="flex items-center gap-2 ml-3 shrink-0">' +
        '<button onclick="emOpen(' + i + ')" class="px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Open</button>' +
        '<button onclick="emOpenEditModal(' + i + ')" class="px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Edit</button>' +
        _emWorkflowMenu(i) +
      '</div>' +
    '</div>';
  });
  container.innerHTML = html;
}

function _emRenderGridView(container) {
  var html = '<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">';
  _emManuals.forEach(function(m, i){
    var who = m.updated_by ? (' by ' + esc(m.updated_by)) : '';
    html += '<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex flex-col">' +
      '<div id="em-thumb-' + i + '" onclick="emOpen(' + i + ')" title="Open ' + esc(m.original_name) + '" ' +
           'class="em-thumb rounded-t-xl overflow-hidden bg-gray-100 dark:bg-gray-900 aspect-[4/3] flex items-center justify-center cursor-pointer">' +
        '<span class="text-xs text-gray-400 dark:text-gray-500">Loading preview…</span>' +
      '</div>' +
      '<div class="px-3 pt-2 flex-1 min-w-0">' +
        '<p class="text-xs font-semibold text-gray-800 dark:text-gray-200 truncate" title="' + esc(m.equipment) + '">' + esc(m.equipment || '(no equipment name)') + '</p>' +
        '<p class="text-[11px] text-gray-500 dark:text-gray-400 truncate" title="' + esc(m.original_name) + '">' + esc(m.original_name) + '</p>' +
        (m.updated_at ? '<p class="text-[11px] text-gray-400 dark:text-gray-500 truncate">' + fmtLocalDate(m.updated_at) + who + '</p>' : '') +
      '</div>' +
      '<div class="px-3 py-2 flex items-center gap-1.5">' +
        '<button onclick="emOpenEditModal(' + i + ')" class="px-2.5 py-1 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">Edit</button>' +
        _emWorkflowMenu(i) +
      '</div>' +
    '</div>';
  });
  html += '</div>';
  container.innerHTML = html;
  _emManuals.forEach(function(m, i){ _emFillThumb(i, m); });
}

function _emExt(m) {
  var name = (m.original_name || m.filename || '');
  var dot = name.lastIndexOf('.');
  return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
}

function _emDocPlaceholder(ext) {
  return '<div class="flex flex-col items-center justify-center gap-1 text-gray-400 dark:text-gray-500">' +
    '<svg class="h-10 w-10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/></svg>' +
    (ext ? '<span class="text-[10px] font-semibold uppercase tracking-wide">' + esc(ext) + '</span>' : '') +
    '</div>';
}

var _emPdfJsPromise = null;
function _emLoadPdfJs() {
  if (window.pdfjsLib) return Promise.resolve(window.pdfjsLib);
  if (_emPdfJsPromise) return _emPdfJsPromise;
  _emPdfJsPromise = new Promise(function(resolve, reject){
    var s = document.createElement('script');
    s.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
    s.onload = function(){
      if (window.pdfjsLib) {
        window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
        resolve(window.pdfjsLib);
      } else { reject(new Error('pdf.js failed to load')); }
    };
    s.onerror = function(){ reject(new Error('pdf.js failed to load')); };
    document.head.appendChild(s);
  });
  return _emPdfJsPromise;
}

function _emFillThumb(i, m) {
  var el = document.getElementById('em-thumb-' + i);
  if (!el) return;
  var ext = _emExt(m);
  var isImg = ['png','jpg','jpeg','gif','webp','bmp'].indexOf(ext) >= 0;
  if (ext === 'pdf' || isImg) {
    // Prefer the pre-cached, server-rendered thumbnail (DVI-585): a tiny JPEG
    // loads far faster than downloading + rendering the full manual. If the
    // cache route can't produce one (unsupported type / renderer unavailable),
    // fall back to client-side rendering.
    var img = new Image();
    img.alt = m.original_name || '';
    img.className = 'w-full h-full object-contain';
    img.onload = function(){ el.innerHTML = ''; el.appendChild(img); };
    img.onerror = function(){ _emFillThumbFallback(el, m, ext, isImg); };
    img.src = '/equipment-manuals/thumb/' + encodeURIComponent(m.filename);
  } else {
    el.innerHTML = _emDocPlaceholder(ext);
  }
}

function _emFillThumbFallback(el, m, ext, isImg) {
  var fileUrl = '/equipment-manuals/file/' + encodeURIComponent(m.filename);
  if (isImg) {
    var img = new Image();
    img.alt = m.original_name || '';
    img.className = 'w-full h-full object-contain';
    img.onload = function(){ el.innerHTML = ''; el.appendChild(img); };
    img.onerror = function(){ el.innerHTML = _emDocPlaceholder(ext); };
    img.src = fileUrl;
  } else if (ext === 'pdf') {
    _emRenderPdfThumb(el, fileUrl);
  } else {
    el.innerHTML = _emDocPlaceholder(ext);
  }
}

function _emRenderPdfThumb(el, url) {
  _emLoadPdfJs()
    .then(function(pdfjsLib){ return pdfjsLib.getDocument(url).promise; })
    .then(function(pdf){ return pdf.getPage(1); })
    .then(function(page){
      var base = page.getViewport({ scale: 1 });
      var scale = Math.min(400 / base.width, 1.5);
      var viewport = page.getViewport({ scale: scale });
      var canvas = document.createElement('canvas');
      canvas.width = viewport.width;
      canvas.height = viewport.height;
      canvas.className = 'w-full h-full object-contain';
      el.innerHTML = '';
      el.appendChild(canvas);
      return page.render({ canvasContext: canvas.getContext('2d'), viewport: viewport }).promise;
    })
    .catch(function(){ el.innerHTML = _emDocPlaceholder('pdf'); });
}

function _emCloseWorkflowMenus() {
  var menus = document.querySelectorAll('[id^="em-wf-menu-"]');
  for (var i = 0; i < menus.length; i++) menus[i].classList.add('hidden');
}

function emToggleWorkflowMenu(e, i) {
  e.stopPropagation();
  var menu = document.getElementById('em-wf-menu-' + i);
  var wasHidden = menu.classList.contains('hidden');
  _emCloseWorkflowMenus();
  if (wasHidden) {
    menu.classList.remove('hidden');
    setTimeout(function(){ document.addEventListener('click', _emCloseWorkflowMenus, {once: true}); }, 0);
  }
}

function emOpen(i) {
  var m = _emManuals[i];
  if (m && m.url) window.open(m.url, '_blank');
}

function emOpenInQrCreator(i) {
  _emCloseWorkflowMenus();
  var m = _emManuals[i];
  if (m && m.url) sqrOpenWithUrl(m.url);
}

function emCreatePmWo(i) {
  _emCloseWorkflowMenus();
  var m = _emManuals[i];
  if (!m) return;
  openTool('work-order', 'Work Order Request');
  worSelectType('preventive');
  if (typeof pmClearForm === 'function') pmClearForm();
  var eq = document.querySelector('#pm-form [name="equipment"]');
  if (eq) eq.value = m.equipment || '';
  pmSetExistingManual(m.filename, m.original_name, m.url);
}

function emOpenUploadModal() {
  document.getElementById('em-upload-equipment').value = '';
  document.getElementById('em-upload-file').value = '';
  var st = document.getElementById('em-upload-status');
  st.classList.add('hidden');
  st.textContent = '';
  document.getElementById('em-upload-modal').classList.remove('hidden');
  document.getElementById('em-upload-equipment').focus();
}

function emCloseUploadModal() {
  document.getElementById('em-upload-modal').classList.add('hidden');
}

function emSubmitUpload() {
  var equipment = document.getElementById('em-upload-equipment').value.trim();
  var fileInput = document.getElementById('em-upload-file');
  var st = document.getElementById('em-upload-status');
  st.classList.remove('hidden');
  if (!equipment) {
    st.textContent = 'Equipment / Asset / System name is required.';
    st.className = 'text-sm text-red-500';
    return;
  }
  if (!fileInput.files || !fileInput.files.length) {
    st.textContent = 'Please choose a manual file.';
    st.className = 'text-sm text-red-500';
    return;
  }
  var btn = document.getElementById('em-upload-submit');
  btn.disabled = true;
  st.textContent = 'Uploading...';
  st.className = 'text-sm text-gray-500 dark:text-gray-400';
  var fd = new FormData();
  fd.append('equipment', equipment);
  fd.append('manual', fileInput.files[0]);
  fetch('/equipment-manuals/upload', {method: 'POST', body: fd})
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      btn.disabled = false;
      if (!res.ok || res.d.error) {
        st.textContent = 'Error: ' + (res.d.error || 'upload failed');
        st.className = 'text-sm text-red-500';
        return;
      }
      emCloseUploadModal();
      emLoadList();
    })
    .catch(function(err){
      btn.disabled = false;
      st.textContent = 'Error: ' + err.message;
      st.className = 'text-sm text-red-500';
    });
}

// ===== OCR Tool (DVI-490): upload PDF -> poll -> download searchable PDF =====
var _ocrPollTimer = null;

function _ocrReset() {
  if (_ocrPollTimer) { clearTimeout(_ocrPollTimer); _ocrPollTimer = null; }
  document.getElementById('ocr-progress').classList.add('hidden');
  document.getElementById('ocr-result').classList.add('hidden');
  document.getElementById('ocr-error').classList.add('hidden');
  document.getElementById('ocr-submit-btn').disabled = false;
}

function ocrSubmit() {
  var fileInput = document.getElementById('ocr-file');
  var errEl = document.getElementById('ocr-error');
  _ocrReset();
  if (!fileInput.files || !fileInput.files.length) {
    errEl.textContent = 'Please choose a PDF file.';
    errEl.classList.remove('hidden');
    return;
  }
  var btn = document.getElementById('ocr-submit-btn');
  btn.disabled = true;
  document.getElementById('ocr-progress').classList.remove('hidden');

  var fd = new FormData();
  fd.append('pdf', fileInput.files[0]);
  fd.append('mode', document.getElementById('ocr-mode').value);

  fetch('/ocr/submit', {method: 'POST', body: fd})
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok || res.d.error) {
        _ocrShowError(res.d.error || 'Upload failed');
        return;
      }
      _ocrPoll(res.d.job_id);
    })
    .catch(function(err){ _ocrShowError(err.message); });
}

function _ocrShowError(msg) {
  if (_ocrPollTimer) { clearTimeout(_ocrPollTimer); _ocrPollTimer = null; }
  document.getElementById('ocr-progress').classList.add('hidden');
  var errEl = document.getElementById('ocr-error');
  errEl.textContent = 'Error: ' + msg;
  errEl.classList.remove('hidden');
  document.getElementById('ocr-submit-btn').disabled = false;
}

function _ocrPoll(jobId) {
  fetch('/ocr/status/' + encodeURIComponent(jobId))
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.status === 'done') {
        document.getElementById('ocr-progress').classList.add('hidden');
        var link = document.getElementById('ocr-download-link');
        link.href = '/ocr/download/' + encodeURIComponent(jobId);
        document.getElementById('ocr-result').classList.remove('hidden');
        document.getElementById('ocr-submit-btn').disabled = false;
      } else if (d.status === 'error') {
        _ocrShowError(d.error || 'OCR failed');
      } else {
        _ocrPollTimer = setTimeout(function(){ _ocrPoll(jobId); }, 2000);
      }
    })
    .catch(function(err){ _ocrShowError(err.message); });
}

// ===== OCR Tool tabs + backend selector (DVI-497) =====
function ocrSwitchTab(tab) {
  var tabs = ['upload', 'projects', 'regions', 'settings'];
  var activeCls = ['border-togen-teal', 'text-togen-teal'];
  var idleCls = ['border-transparent', 'text-gray-500', 'dark:text-gray-400', 'hover:text-gray-700', 'dark:hover:text-gray-300'];
  // Projects is gated server-side; fall back to Upload if it isn't rendered.
  if (!document.getElementById('ocr-tab-btn-' + tab)) tab = 'upload';
  tabs.forEach(function(t){
    var btn = document.getElementById('ocr-tab-btn-' + t);
    var pane = document.getElementById('ocr-tab-' + t);
    var on = (t === tab);
    if (btn) {
      if (on) { btn.classList.add.apply(btn.classList, activeCls); btn.classList.remove.apply(btn.classList, idleCls); }
      else { btn.classList.remove.apply(btn.classList, activeCls); btn.classList.add.apply(btn.classList, idleCls); }
    }
    if (pane) { if (on) pane.classList.remove('hidden'); else pane.classList.add('hidden'); }
  });
  if (tab === 'settings') ocrLoadBackends();
  else if (tab === 'projects' && !_ocrProjLoaded) { _ocrProjLoaded = true; ocrProjNavigate(''); }
}

// ===== OCR Tool — Projects tab (DVI-646) =====
var _ocrProjLoaded = false;
var _ocrProjPath = '';
var _ocrProjSelected = null;      // {name, path}
var _ocrProjJobId = null;
var _ocrProjSourceDir = '';
var _ocrProjSourceName = '';
var _ocrProjPollTimer = null;
var _OCR_PROJ_EXTS = ['.pdf', '.doc', '.docx', '.odt', '.rtf', '.xls', '.xlsx',
  '.ods', '.ppt', '.pptx', '.odp', '.jpg', '.jpeg', '.png', '.tif', '.tiff',
  '.bmp', '.gif', '.webp'];

function _ocrProjProcessable(name) {
  var lower = (name || '').toLowerCase();
  for (var i = 0; i < _OCR_PROJ_EXTS.length; i++) {
    if (lower.slice(-_OCR_PROJ_EXTS[i].length) === _OCR_PROJ_EXTS[i]) return true;
  }
  return false;
}

function ocrProjNavigate(relPath) {
  _ocrProjSelected = null;
  _ocrProjPath = relPath;
  ocrProjUpdateProcessBtn();
  var listEl = document.getElementById('ocr-proj-list');
  listEl.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 px-4 py-3">Loading…</p>';
  fetch('/fb/browse?path=' + encodeURIComponent(relPath))
    .then(function(r){ return r.json(); })
    .then(function(data){
      if (data.error) { listEl.innerHTML = '<p class="text-sm text-red-500 px-4 py-3">' + esc(data.error) + '</p>'; return; }
      _ocrProjPath = data.path;
      ocrProjRenderBreadcrumb(data.path);
      ocrProjRenderList(data.entries);
      var backBtn = document.getElementById('ocr-proj-back-btn');
      if (data.path) backBtn.classList.remove('hidden'); else backBtn.classList.add('hidden');
    })
    .catch(function(){ listEl.innerHTML = '<p class="text-sm text-red-500 px-4 py-3">Failed to load directory.</p>'; });
}

function ocrProjRenderBreadcrumb(path) {
  var nav = document.getElementById('ocr-proj-breadcrumb');
  var html = '<span class="cursor-pointer hover:text-togen-teal" onclick="ocrProjNavigate(\'\')"> / </span>';
  if (path) {
    var parts = path.split('/'); var cum = '';
    for (var i = 0; i < parts.length; i++) {
      cum += (cum ? '/' : '') + parts[i];
      html += ' <span class="text-gray-300 dark:text-gray-600">/</span> ';
      if (i === parts.length - 1) html += '<span class="text-gray-700 dark:text-gray-200 font-medium">' + esc(parts[i]) + '</span>';
      else html += '<span class="cursor-pointer hover:text-togen-teal" onclick="ocrProjNavigate(\'' + esc(cum).replace(/'/g, "\\'") + '\')">' + esc(parts[i]) + '</span>';
    }
  }
  nav.innerHTML = html;
}

function ocrProjRenderList(entries) {
  var listEl = document.getElementById('ocr-proj-list');
  if (!entries || !entries.length) { listEl.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 px-4 py-8 text-center">This folder is empty.</p>'; return; }
  var html = '';
  for (var i = 0; i < entries.length; i++) {
    var e = entries[i];
    var fullPath = _ocrProjPath ? (_ocrProjPath + '/' + e.name) : e.name;
    var selectable = e.is_dir || _ocrProjProcessable(e.name);
    var icon = e.is_dir
      ? '<svg class="h-5 w-5 text-togen-teal flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/></svg>'
      : '<svg class="h-5 w-5 text-gray-400 dark:text-gray-500 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg>';
    html += '<div class="flex items-center gap-3 px-4 py-2.5 select-none transition-colors ' +
            (selectable ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : 'opacity-50') + '" ' +
            'data-path="' + esc(fullPath) + '" data-name="' + esc(e.name) + '" data-isdir="' + e.is_dir + '" ' +
            (selectable ? 'onclick="ocrProjClick(this)" ondblclick="ocrProjDblClick(this)"' : '') + '>' +
            icon +
            '<span class="flex-1 text-sm text-gray-700 dark:text-gray-200 truncate">' + esc(e.name) + '</span>' +
            '</div>';
  }
  listEl.innerHTML = html;
}

function ocrProjClick(el) {
  var isDir = el.getAttribute('data-isdir') === 'true';
  if (isDir) return; // single click selects files only; double-click opens folders
  var prev = document.querySelector('#ocr-proj-list .bg-togen-teal\\/10');
  if (prev) prev.classList.remove('bg-togen-teal/10', 'dark:bg-togen-teal/20', 'border-l-2', 'border-togen-teal');
  _ocrProjSelected = { name: el.getAttribute('data-name'), path: el.getAttribute('data-path') };
  el.classList.add('bg-togen-teal/10', 'dark:bg-togen-teal/20', 'border-l-2', 'border-togen-teal');
  var sel = document.getElementById('ocr-proj-selected');
  sel.textContent = 'Selected: ' + _ocrProjSelected.name;
  sel.classList.remove('hidden');
  ocrProjUpdateProcessBtn();
}

function ocrProjDblClick(el) {
  if (el.getAttribute('data-isdir') === 'true') ocrProjNavigate(el.getAttribute('data-path'));
}

function ocrProjGoBack() {
  if (!_ocrProjPath) return;
  var parts = _ocrProjPath.split('/'); parts.pop();
  ocrProjNavigate(parts.join('/'));
}

function ocrProjUpdateProcessBtn() {
  var btn = document.getElementById('ocr-proj-process-btn');
  if (btn) btn.disabled = !_ocrProjSelected;
}

function ocrProjProcess() {
  if (!_ocrProjSelected) return;
  if (_ocrProjPollTimer) { clearTimeout(_ocrProjPollTimer); _ocrProjPollTimer = null; }
  document.getElementById('ocr-proj-placeholder').classList.add('hidden');
  document.getElementById('ocr-proj-result').classList.add('hidden');
  document.getElementById('ocr-proj-error').classList.add('hidden');
  document.getElementById('ocr-proj-progress').classList.remove('hidden');
  document.getElementById('ocr-proj-process-btn').disabled = true;
  fetch('/ocr/process-project', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ path: _ocrProjSelected.path, mode: document.getElementById('ocr-proj-mode').value })
  })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok || res.d.error) { _ocrProjError(res.d.error || 'Could not start OCR.'); return; }
      _ocrProjJobId = res.d.job_id;
      _ocrProjSourceDir = res.d.source_dir || '';
      _ocrProjSourceName = res.d.source_name || '';
      var nameField = document.getElementById('ocr-proj-save-name');
      if (nameField) nameField.value = res.d.suggested_name || 'document-ocr_processed';
      _ocrProjPoll();
    })
    .catch(function(err){ _ocrProjError(err.message); });
}

function _ocrProjError(msg) {
  if (_ocrProjPollTimer) { clearTimeout(_ocrProjPollTimer); _ocrProjPollTimer = null; }
  document.getElementById('ocr-proj-progress').classList.add('hidden');
  var el = document.getElementById('ocr-proj-error');
  el.textContent = 'Error: ' + msg; el.classList.remove('hidden');
  ocrProjUpdateProcessBtn();
}

function _ocrProjPoll() {
  if (!_ocrProjJobId) return;
  fetch('/ocr/status/' + encodeURIComponent(_ocrProjJobId))
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.status === 'done') {
        document.getElementById('ocr-proj-progress').classList.add('hidden');
        document.getElementById('ocr-proj-result').classList.remove('hidden');
        document.getElementById('ocr-proj-frame').src = '/ocr/view/' + encodeURIComponent(_ocrProjJobId);
        document.getElementById('ocr-proj-save-status').textContent = '';
        ocrProjUpdateProcessBtn();
      } else if (d.status === 'error') {
        _ocrProjError(d.error || 'OCR failed.');
      } else {
        _ocrProjPollTimer = setTimeout(_ocrProjPoll, 2000);
      }
    })
    .catch(function(err){ _ocrProjError(err.message); });
}

function ocrProjToggleSearch() {
  var bar = document.getElementById('ocr-proj-searchbar');
  bar.classList.toggle('hidden');
  if (!bar.classList.contains('hidden')) document.getElementById('ocr-proj-search-input').focus();
}

function ocrProjSearch() {
  if (!_ocrProjJobId) return;
  var q = document.getElementById('ocr-proj-search-input').value.trim();
  var status = document.getElementById('ocr-proj-search-status');
  status.classList.remove('hidden');
  if (!q) { status.textContent = 'Enter text to search.'; return; }
  status.textContent = 'Searching…';
  fetch('/ocr/search/' + encodeURIComponent(_ocrProjJobId) + '?q=' + encodeURIComponent(q))
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.count > 0) {
        status.textContent = d.count + ' match' + (d.count === 1 ? '' : 'es') + ' — jumping to page ' + d.first_page + '.';
        document.getElementById('ocr-proj-frame').src = '/ocr/view/' + encodeURIComponent(_ocrProjJobId) + '#page=' + d.first_page;
      } else {
        status.textContent = 'No matches found.';
      }
    })
    .catch(function(){ status.textContent = 'Search failed.'; });
}

function ocrProjOpenViewer() {
  // Open the searchable PDF in the external Document Viewer. The viewer host can
  // only resolve files that live in the project tree, so if the processed file
  // hasn't been saved yet we save it first, then open the saved copy. A blank tab
  // is opened synchronously during the click to avoid popup blocking.
  if (!_ocrProjJobId && !_ocrProjLastSavedPath) {
    var st = document.getElementById('ocr-proj-save-status');
    if (st) st.textContent = 'Process a document first.';
    return;
  }
  if (_ocrProjLastSavedPath) {
    window.open(_sharedUrlPrefix + '/shared/' + encodeURI(_ocrProjLastSavedPath), '_blank');
    return;
  }
  var win = window.open('', '_blank');
  ocrProjSave(false, function(path){
    if (path && win) win.location = _sharedUrlPrefix + '/shared/' + encodeURI(path);
    else if (win) win.close();
  });
}

var _ocrProjLastSavedPath = '';

function ocrProjSave(overwrite, onSuccess) {
  if (!_ocrProjJobId) return;
  var name = (document.getElementById('ocr-proj-save-name').value || '').trim();
  var status = document.getElementById('ocr-proj-save-status');
  if (!name) { status.textContent = 'Enter a filename.'; return; }
  status.textContent = 'Saving…';
  fetch('/ocr/save-project', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ job_id: _ocrProjJobId, dir: _ocrProjSourceDir, filename: name, overwrite: !!overwrite })
  })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (res.d && res.d.needs_confirm) {
        if (window.confirm('"' + res.d.filename + '" already exists in this folder. Overwrite it?')) ocrProjSave(true, onSuccess);
        else status.textContent = 'Save cancelled.';
        return;
      }
      if (!res.ok || res.d.error) { status.textContent = 'Error: ' + (res.d.error || 'save failed'); return; }
      _ocrProjLastSavedPath = res.d.path || '';
      status.textContent = 'Saved as ' + res.d.filename + '.';
      if (typeof onSuccess === 'function') onSuccess(_ocrProjLastSavedPath);
    })
    .catch(function(err){ status.textContent = 'Error: ' + err.message; });
}

function ocrProjReplace() {
  if (!_ocrProjJobId || !_ocrProjSourceName) return;
  if (!window.confirm('Replace the original file "' + _ocrProjSourceName + '" with the searchable version?')) return;
  // Save under the source filename (extension forced to .pdf server-side),
  // overwriting the original after this confirmation.
  var stem = _ocrProjSourceName.replace(/\.[^.]+$/, '');
  document.getElementById('ocr-proj-save-name').value = stem;
  ocrProjSave(true);
}

var _ocrBackends = [];

function ocrLoadBackends() {
  fetch('/ocr/backend')
    .then(function(r){ return r.json(); })
    .then(function(d){
      _ocrBackends = d.backends || [];
      ['tool', 'admin'].forEach(function(sfx){ _ocrFillBackendPane(sfx, d.backend); });
    })
    .catch(function(){ /* leave panes empty on error */ });
}

function _ocrFillBackendPane(sfx, current) {
  var sel = document.getElementById('ocr-backend-select-' + sfx);
  if (!sel) return;
  sel.innerHTML = '';
  _ocrBackends.forEach(function(b){
    var o = document.createElement('option');
    o.value = b.id;
    o.textContent = b.label;
    if (b.id === current) o.selected = true;
    sel.appendChild(o);
  });
  _ocrUpdateBackendDesc(sfx);
  // Setting the backend is admin-gated; reflect that in the reusable pane.
  var btn = document.getElementById('ocr-backend-save-' + sfx);
  var note = document.getElementById('ocr-backend-admin-note-' + sfx);
  if (!_isToGenAdmin) {
    sel.disabled = true;
    if (btn) { btn.disabled = true; btn.classList.add('opacity-50', 'cursor-not-allowed'); }
    if (note) note.classList.remove('hidden');
  }
}

function _ocrUpdateBackendDesc(sfx) {
  var sel = document.getElementById('ocr-backend-select-' + sfx);
  var desc = document.getElementById('ocr-backend-desc-' + sfx);
  if (!sel || !desc) return;
  var match = _ocrBackends.filter(function(b){ return b.id === sel.value; })[0];
  desc.textContent = match ? (match.description || '') : '';
}

function ocrSaveBackend(sfx) {
  var sel = document.getElementById('ocr-backend-select-' + sfx);
  var status = document.getElementById('ocr-backend-status-' + sfx);
  if (!sel) return;
  status.textContent = 'Saving...';
  status.className = 'text-sm text-gray-500 dark:text-gray-400';
  fetch('/ocr/backend', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({backend: sel.value})
  })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok || res.d.error) {
        status.textContent = 'Error: ' + (res.d.error || 'Save failed');
        status.className = 'text-sm text-red-500';
        return;
      }
      status.textContent = 'Saved';
      status.className = 'text-sm text-green-600 dark:text-green-400';
      // Keep the other copy of the pane in sync.
      ['tool', 'admin'].forEach(function(other){
        if (other === sfx) return;
        var os = document.getElementById('ocr-backend-select-' + other);
        if (os) { os.value = res.d.backend; _ocrUpdateBackendDesc(other); }
      });
    })
    .catch(function(e){ status.textContent = 'Error: ' + e.message; status.className = 'text-sm text-red-500'; });
}

// ===== Equipment Manuals: edit Equipment/Asset/System name (DVI-462) =====
var _emEditFilename = '';

function emOpenEditModal(i) {
  _emCloseWorkflowMenus();
  var m = _emManuals[i];
  if (!m) return;
  _emEditFilename = m.filename;
  document.getElementById('em-edit-file-label').textContent = m.original_name || m.filename;
  var inp = document.getElementById('em-edit-equipment');
  inp.value = m.equipment || '';
  var st = document.getElementById('em-edit-status');
  st.classList.add('hidden'); st.textContent = '';
  document.getElementById('em-edit-modal').classList.remove('hidden');
  inp.focus();
}

function emCloseEditModal() {
  document.getElementById('em-edit-modal').classList.add('hidden');
  _emEditFilename = '';
}

function emSubmitEdit() {
  var equipment = document.getElementById('em-edit-equipment').value.trim();
  var st = document.getElementById('em-edit-status');
  st.classList.remove('hidden');
  if (!equipment) {
    st.textContent = 'Equipment / Asset / System name is required.';
    st.className = 'text-sm text-red-500';
    return;
  }
  var btn = document.getElementById('em-edit-submit');
  btn.disabled = true;
  st.textContent = 'Saving...';
  st.className = 'text-sm text-gray-500 dark:text-gray-400';
  fetch('/equipment-manuals/rename', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({filename: _emEditFilename, equipment: equipment})
  })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      btn.disabled = false;
      if (!res.ok || res.d.error) {
        st.textContent = 'Error: ' + (res.d.error || 'rename failed');
        st.className = 'text-sm text-red-500';
        return;
      }
      emCloseEditModal();
      emLoadList();
    })
    .catch(function(err){
      btn.disabled = false;
      st.textContent = 'Error: ' + err.message;
      st.className = 'text-sm text-red-500';
    });
}

// ===== Reusable Equipment Manual picker (DVI-462) =====
// manualPickerOpen(cb): cb receives one of
//   {mode:'existing', filename, original_name, equipment, url}
//   {mode:'upload', file:<File>, equipment}
// The caller decides what to do (attach to an existing WO via /wor-attach-manual,
// or stage the manual into an in-progress WOR/MM form).
var _manualPickerCb = null;
var _manualPickerManuals = [];
var _manualPickerFile = null;

function manualPickerOpen(cb) {
  _manualPickerCb = cb;
  _manualPickerFile = null;
  document.getElementById('manual-picker-file').value = '';
  manualPickerCancelUpload();
  manualPickerSetStatus('', '');
  document.getElementById('manual-picker-modal').classList.remove('hidden');
  manualPickerLoad();
}

function manualPickerClose() {
  document.getElementById('manual-picker-modal').classList.add('hidden');
  _manualPickerCb = null;
  _manualPickerFile = null;
}

function manualPickerSetStatus(msg, kind) {
  var st = document.getElementById('manual-picker-status');
  if (!msg) { st.classList.add('hidden'); st.textContent = ''; return; }
  st.classList.remove('hidden');
  st.textContent = msg;
  st.className = 'text-sm mt-2 ' + (kind === 'error' ? 'text-red-500' : 'text-gray-500 dark:text-gray-400');
}

function manualPickerLoad() {
  var list = document.getElementById('manual-picker-list');
  list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>';
  fetch('/equipment-manuals/list')
    .then(function(r){ return r.json(); })
    .then(function(d){
      _manualPickerManuals = d.manuals || [];
      manualPickerRenderList();
    })
    .catch(function(err){
      list.innerHTML = '<p class="text-sm text-red-500">Error loading manuals: ' + esc(err.message) + '</p>';
    });
}

function manualPickerRenderList() {
  var list = document.getElementById('manual-picker-list');
  if (!_manualPickerManuals.length) {
    list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No manuals yet. Upload one above.</p>';
    return;
  }
  var html = '';
  _manualPickerManuals.forEach(function(m, i){
    html += '<div class="flex items-center justify-between gap-3 py-2 px-3 rounded-lg border border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 mb-2">' +
      '<div class="min-w-0">' +
        '<p class="text-sm font-medium text-gray-800 dark:text-gray-200 truncate" title="' + esc(m.equipment) + '">' + esc(m.equipment || '(no equipment name)') + '</p>' +
        '<p class="text-xs text-gray-500 dark:text-gray-400 truncate" title="' + esc(m.original_name) + '">' + esc(m.original_name) + '</p>' +
      '</div>' +
      '<button onclick="manualPickerPick(' + i + ')" class="shrink-0 px-3 py-1 text-xs font-semibold rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors">Attach</button>' +
    '</div>';
  });
  list.innerHTML = html;
}

function manualPickerPick(i) {
  var m = _manualPickerManuals[i];
  if (!m || !_manualPickerCb) return;
  _manualPickerCb({mode: 'existing', filename: m.filename, original_name: m.original_name, equipment: m.equipment, url: m.url});
}

function manualPickerChooseFile() {
  document.getElementById('manual-picker-file').click();
}

function manualPickerFileChosen() {
  var input = document.getElementById('manual-picker-file');
  if (!input.files || !input.files.length) return;
  manualPickerStageFile(input.files[0]);
}

function manualPickerStageFile(file) {
  _manualPickerFile = file;
  document.getElementById('manual-picker-upload-name').textContent = file.name;
  document.getElementById('manual-picker-upload-eq').value = '';
  document.getElementById('manual-picker-upload-step').classList.remove('hidden');
  manualPickerSetStatus('', '');
  document.getElementById('manual-picker-upload-eq').focus();
}

function manualPickerCancelUpload() {
  _manualPickerFile = null;
  document.getElementById('manual-picker-file').value = '';
  var step = document.getElementById('manual-picker-upload-step');
  if (step) step.classList.add('hidden');
}

function manualPickerConfirmUpload() {
  var eq = document.getElementById('manual-picker-upload-eq').value.trim();
  if (!eq) { manualPickerSetStatus('Equipment / Asset / System name is required.', 'error'); return; }
  if (!_manualPickerFile || !_manualPickerCb) return;
  _manualPickerCb({mode: 'upload', file: _manualPickerFile, equipment: eq});
}

(function(){
  var drop = document.getElementById('manual-picker-drop');
  if (!drop) return;
  ['dragenter','dragover'].forEach(function(ev){
    drop.addEventListener(ev, function(e){ e.preventDefault(); e.stopPropagation(); drop.classList.add('border-togen-teal'); });
  });
  ['dragleave','drop'].forEach(function(ev){
    drop.addEventListener(ev, function(e){ e.preventDefault(); e.stopPropagation(); drop.classList.remove('border-togen-teal'); });
  });
  drop.addEventListener('drop', function(e){
    var files = e.dataTransfer && e.dataTransfer.files;
    if (files && files.length) manualPickerStageFile(files[0]);
  });
})();

document.getElementById('sqr-url').addEventListener('keydown', function(e) {
  if (e.key === 'Enter') { e.preventDefault(); sqrGenerate(); }
});

// Suggest a previously-provided manual when the PM equipment field is filled (DVI-452).
(function(){
  var pmEq = document.querySelector('#pm-form [name="equipment"]');
  if (pmEq) {
    pmEq.addEventListener('blur', pmManualLookup);
    pmEq.addEventListener('change', pmManualLookup);
    // Close the EAS type-ahead dropdown on blur (delayed so option clicks register).
    pmEq.addEventListener('blur', function(){ setTimeout(pmEasHideDropdown, 150); });
  }
})();

document.getElementById('sqr-email-input').addEventListener('keydown', function(e) {
  if (e.key === 'Enter') { e.preventDefault(); sqrEmailSend(); }
});

document.getElementById('fb-email-input').addEventListener('keydown', function(e) {
  if (e.key === 'Enter') { e.preventDefault(); fbEmailSend(); }
});

// ===== MTS LABEL CREATOR (DVI-283) =====
var _mtsInitialized = false;
var _mtsLookupTimer = null;
var _mtsDrawingFile = null;
var _mtsLastLabelFilename = null;

function mtsInit() {
  var today = new Date();
  var yyyy = today.getFullYear();
  var mm = String(today.getMonth() + 1).padStart(2, '0');
  var dd = String(today.getDate()).padStart(2, '0');
  document.getElementById('mts-date').value = yyyy + '-' + mm + '-' + dd;
  mtsCheckForm();
}

function mtsOnProductCodeInput() {
  clearTimeout(_mtsLookupTimer);
  var code = document.getElementById('mts-product-code').value.trim();
  if (!code) {
    document.getElementById('mts-product-status').classList.add('hidden');
    mtsCheckForm();
    return;
  }
  _mtsLookupTimer = setTimeout(function() { mtsLookupProduct(code); }, 400);
  mtsCheckForm();
}

function mtsLookupProduct(code) {
  var statusEl = document.getElementById('mts-product-status');
  statusEl.textContent = 'Looking up product...';
  statusEl.classList.remove('hidden');
  statusEl.className = 'text-xs mt-1 text-gray-400 dark:text-gray-500';
  fetch('/mts/product/' + encodeURIComponent(code))
    .then(function(r) { return r.json(); })
    .then(function(data) {
      if (data.found) {
        document.getElementById('mts-description').value = data.description || '';
        document.getElementById('mts-weight').value = data.weight || '';
        statusEl.textContent = 'Product found — fields populated.';
        statusEl.className = 'text-xs mt-1 text-green-600 dark:text-green-400';
        if (data.has_drawing) {
          document.getElementById('mts-drawing-existing').classList.remove('hidden');
          document.getElementById('mts-drawing-link').href = _sharedUrlPrefix + '/stockproducts/drawings/' + encodeURIComponent(code) + '.pdf';
        } else {
          document.getElementById('mts-drawing-existing').classList.add('hidden');
        }
      } else {
        statusEl.textContent = 'New product — enter details below.';
        statusEl.className = 'text-xs mt-1 text-blue-600 dark:text-blue-400';
        document.getElementById('mts-drawing-existing').classList.add('hidden');
      }
      mtsCheckForm();
    })
    .catch(function() {
      statusEl.textContent = 'Lookup failed.';
      statusEl.className = 'text-xs mt-1 text-red-500 dark:text-red-400';
    });
}

function mtsCheckForm() {
  var code = document.getElementById('mts-product-code').value.trim();
  var desc = document.getElementById('mts-description').value.trim();
  var weight = document.getElementById('mts-weight').value.trim();
  var dt = document.getElementById('mts-date').value.trim();
  document.getElementById('mts-submit-btn').disabled = !(code && desc && weight && dt);
}

function mtsOnDrawingSelected(input) {
  if (input.files && input.files[0]) {
    _mtsDrawingFile = input.files[0];
    document.getElementById('mts-drawing-name').textContent = _mtsDrawingFile.name;
    document.getElementById('mts-drawing-info').classList.remove('hidden');
  }
}

function mtsOnDrawingDrop(e) {
  var files = e.dataTransfer.files;
  if (files.length && files[0].name.toLowerCase().endsWith('.pdf')) {
    _mtsDrawingFile = files[0];
    document.getElementById('mts-drawing-name').textContent = _mtsDrawingFile.name;
    document.getElementById('mts-drawing-info').classList.remove('hidden');
  }
}

function mtsRemoveDrawing() {
  _mtsDrawingFile = null;
  document.getElementById('mts-drawing-input').value = '';
  document.getElementById('mts-drawing-info').classList.add('hidden');
  document.getElementById('mts-drawing-name').textContent = '';
}

function mtsShowStatus(type, msg) {
  var el = document.getElementById('mts-status');
  el.classList.remove('hidden', 'border-gray-200', 'dark:border-gray-700', 'border-green-300', 'dark:border-green-700', 'border-red-300', 'dark:border-red-700');
  document.getElementById('mts-spinner').classList.add('hidden');
  document.getElementById('mts-check').classList.add('hidden');
  document.getElementById('mts-x').classList.add('hidden');
  if (type === 'loading') {
    document.getElementById('mts-spinner').classList.remove('hidden');
    el.classList.add('border-gray-200', 'dark:border-gray-700');
  } else if (type === 'success') {
    document.getElementById('mts-check').classList.remove('hidden');
    el.classList.add('border-green-300', 'dark:border-green-700');
  } else {
    document.getElementById('mts-x').classList.remove('hidden');
    el.classList.add('border-red-300', 'dark:border-red-700');
  }
  var msgEl = document.getElementById('mts-msg');
  msgEl.className = 'text-sm font-medium ' + (
    type === 'success' ? 'text-green-700 dark:text-green-400' :
    type === 'error'   ? 'text-red-700 dark:text-red-400' :
                         'text-togen-dark dark:text-togen-teal'
  );
  msgEl.textContent = msg;
}

function mtsSubmit() {
  var code = document.getElementById('mts-product-code').value.trim();
  var desc = document.getElementById('mts-description').value.trim();
  var weight = document.getElementById('mts-weight').value.trim();
  var dt = document.getElementById('mts-date').value.trim();
  if (!code || !desc || !weight || !dt) return;

  document.getElementById('mts-submit-btn').disabled = true;
  mtsShowStatus('loading', 'Generating label...');

  var drawingUpload = Promise.resolve();
  if (_mtsDrawingFile) {
    var fd = new FormData();
    fd.append('drawing', _mtsDrawingFile);
    drawingUpload = fetch('/mts/drawing/' + encodeURIComponent(code), {method: 'POST', body: fd})
      .then(function(r) { return r.json(); })
      .then(function(data) {
        if (data.error) throw new Error(data.error);
      });
  }

  drawingUpload.then(function() {
    return fetch('/mts/label', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({product_code: code, description: desc, weight: weight, date: dt})
    });
  })
  .then(function(r) { return r.json(); })
  .then(function(data) {
    if (data.error) {
      mtsShowStatus('error', data.error);
      document.getElementById('mts-submit-btn').disabled = false;
      return;
    }
    _mtsLastLabelFilename = data.filename;
    mtsShowStatus('success', 'Label created: ' + data.filename);
    document.getElementById('mts-form').classList.add('hidden');
    document.getElementById('mts-preview-placeholder').classList.add('hidden');
    document.getElementById('mts-preview-container').classList.remove('hidden');
    var url = '/mts/label/preview/' + encodeURIComponent(data.filename);
    document.getElementById('mts-preview-embed').src = url;
    document.getElementById('mts-download-btn').href = url;
    document.getElementById('mts-download-btn').download = data.filename;
  })
  .catch(function(err) {
    mtsShowStatus('error', 'Error: ' + err.message);
    document.getElementById('mts-submit-btn').disabled = false;
  });
}

function mtsReset() {
  document.getElementById('mts-form').classList.remove('hidden');
  document.getElementById('mts-form').reset();
  document.getElementById('mts-preview-container').classList.add('hidden');
  document.getElementById('mts-preview-placeholder').classList.remove('hidden');
  document.getElementById('mts-status').classList.add('hidden');
  document.getElementById('mts-product-status').classList.add('hidden');
  document.getElementById('mts-drawing-info').classList.add('hidden');
  document.getElementById('mts-drawing-existing').classList.add('hidden');
  _mtsDrawingFile = null;
  _mtsLastLabelFilename = null;
  mtsInit();
}

function mtsToggleShareMenu(e) {
  e.stopPropagation();
  document.getElementById('mts-workflow-menu').classList.add('hidden');
  var menu = document.getElementById('mts-share-menu');
  menu.classList.toggle('hidden');
  if (!menu.classList.contains('hidden')) {
    setTimeout(function() {
      document.addEventListener('click', _mtsCloseShareMenu, {once: true});
    }, 0);
  }
}

function _mtsCloseShareMenu() {
  document.getElementById('mts-share-menu').classList.add('hidden');
}

function mtsToggleWorkflowMenu(e) {
  e.stopPropagation();
  document.getElementById('mts-share-menu').classList.add('hidden');
  var menu = document.getElementById('mts-workflow-menu');
  menu.classList.toggle('hidden');
  if (!menu.classList.contains('hidden')) {
    setTimeout(function() {
      document.addEventListener('click', _mtsCloseWorkflowMenu, {once: true});
    }, 0);
  }
}

function _mtsCloseWorkflowMenu() {
  document.getElementById('mts-workflow-menu').classList.add('hidden');
}

function mtsEmailLabel() {
  _mtsCloseShareMenu();
  if (!_mtsLastLabelFilename) return;
  document.getElementById('mts-email-status').textContent = '';
  document.getElementById('mts-email-overlay').classList.remove('hidden');
  setTimeout(function(){ document.getElementById('mts-email-input').focus(); document.getElementById('mts-email-input').select(); }, 100);
}

function mtsEmailClose() {
  document.getElementById('mts-email-overlay').classList.add('hidden');
}

function mtsEmailSend() {
  var email = document.getElementById('mts-email-input').value.trim();
  if (!email) { document.getElementById('mts-email-status').textContent = 'Enter an email address.'; return; }
  if (!_mtsLastLabelFilename) return;
  var statusEl = document.getElementById('mts-email-status');
  statusEl.textContent = 'Sending...';
  document.getElementById('mts-email-send-btn').disabled = true;
  fetch('/mts/email', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({email: email, filename: _mtsLastLabelFilename})
  }).then(function(r){ return r.json(); }).then(function(data) {
    document.getElementById('mts-email-send-btn').disabled = false;
    if (data.error) { statusEl.textContent = 'Error: ' + data.error; return; }
    statusEl.textContent = 'Sent!';
    setTimeout(function(){ mtsEmailClose(); }, 1500);
  }).catch(function() {
    document.getElementById('mts-email-send-btn').disabled = false;
    statusEl.textContent = 'Failed to send.';
  });
}

function mtsOpenInQrCreator() {
  _mtsCloseWorkflowMenu();
  if (!_mtsLastLabelFilename) return;
  var url = _sharedUrlPrefix + '/mtslabels/' + encodeURIComponent(_mtsLastLabelFilename);
  sqrOpenWithUrl(url);
}

function mtsPrintLabel() {
  if (!_mtsLastLabelFilename) { alert('Generate a label first.'); return; }
  togenOpenPrintPopup({source: 'mtslabels', fileRef: _mtsLastLabelFilename,
                       kind: 'label', title: 'Print Label'});
}

document.getElementById('mts-email-input').addEventListener('keydown', function(e) {
  if (e.key === 'Enter') { e.preventDefault(); mtsEmailSend(); }
});

var _mtsProductsCache = null;

function mtsOpenProductLookup() {
  document.getElementById('mts-lookup-overlay').classList.remove('hidden');
  document.getElementById('mts-lookup-filter').value = '';
  document.getElementById('mts-lookup-list').innerHTML =
    '<p class="text-sm text-gray-400 dark:text-gray-500 px-5 py-4 text-center">Loading products...</p>';
  document.getElementById('mts-lookup-filter').focus();
  fetch('/mts/products')
    .then(function(r) { return r.json(); })
    .then(function(data) {
      _mtsProductsCache = data.products || [];
      mtsRenderProductList(_mtsProductsCache);
    })
    .catch(function() {
      document.getElementById('mts-lookup-list').innerHTML =
        '<p class="text-sm text-red-500 dark:text-red-400 px-5 py-4 text-center">Failed to load products.</p>';
    });
}

function mtsCloseProductLookup() {
  document.getElementById('mts-lookup-overlay').classList.add('hidden');
}

function mtsFilterProducts() {
  if (!_mtsProductsCache) return;
  var q = document.getElementById('mts-lookup-filter').value.trim().toLowerCase();
  if (!q) { mtsRenderProductList(_mtsProductsCache); return; }
  var filtered = _mtsProductsCache.filter(function(p) {
    return p.product_code.toLowerCase().indexOf(q) !== -1 ||
           (p.description || '').toLowerCase().indexOf(q) !== -1;
  });
  mtsRenderProductList(filtered);
}

function mtsRenderProductList(products) {
  var container = document.getElementById('mts-lookup-list');
  if (!products.length) {
    container.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 px-5 py-4 text-center">No products found.</p>';
    return;
  }
  var html = '';
  products.forEach(function(p) {
    var desc = p.description || '';
    if (desc.length > 60) desc = desc.substring(0, 57) + '...';
    html += '<button onclick="mtsSelectProduct(\'' + esc(p.product_code).replace(/'/g, "\\'") + '\')" ' +
            'class="w-full text-left px-5 py-3 text-sm hover:bg-gray-50 dark:hover:bg-gray-800 ' +
            'transition-colors border-b border-gray-100 dark:border-gray-800 flex items-center gap-2">' +
            '<span class="font-medium text-gray-800 dark:text-gray-100">' + esc(p.product_code) + '</span>' +
            '<span class="text-gray-400 dark:text-gray-500">:</span> ' +
            '<span class="text-gray-500 dark:text-gray-400 truncate">' + esc(desc) + '</span>' +
            '</button>';
  });
  container.innerHTML = html;
}

function mtsSelectProduct(code) {
  mtsCloseProductLookup();
  document.getElementById('mts-product-code').value = code;
  mtsLookupProduct(code);
  document.getElementById('mts-form').classList.remove('hidden');
  document.getElementById('mts-preview-container').classList.add('hidden');
  document.getElementById('mts-preview-placeholder').classList.remove('hidden');
  document.getElementById('mts-status').classList.add('hidden');
}

// ===== SCADA VIEW (DVI-300) =====
// ===== VISION TOOL =====
var _visionInitialized = false;
var _visionCollectorLoaded = false;
var _visionActivityLoaded = false;
var _visionMachines = [];        // last-known machine list (for Activity dropdown + commands)
var _visionMockActive = false;   // true while showing mock collectors (no real commands)
var _visionAlertsExpanded = false;
var _visionRecordingsLoaded = false;
var _visionLogsLoaded = false;   // DVI-742: Logs tab lazy-load guard
var _visionPolicy = null;        // {default:{...}, machines:{...}} loaded from server
var _visionPolicyDefaults = null;
var _visionPolicyLoaded = false;
var _visionEnrollLoaded = false;
var _visionSettingsLoaded = false;
// DVI-717: Collector view mode — 'large' (default) | 'compact' | 'list'. Persisted per-browser.
var _visionCollectorView = (function(){ try { return localStorage.getItem('visionCollectorView') || 'large'; } catch (e) { return 'large'; } })();
var _VISION_VIEW_LABELS = { large: 'Large Tiles', medium: 'Medium Tiles', compact: 'Compact', list: 'List' };
// DVI-742: Collector sort mode — 'activity' (default, server order) | 'alpha' | 'status' | 'outdated'. Persisted per-browser.
var _visionCollectorSort = (function(){ try { return localStorage.getItem('visionCollectorSort') || 'activity'; } catch (e) { return 'activity'; } })();
var _VISION_SORT_LABELS = { activity: 'Activity', alpha: 'Alphabetically', status: 'Status', outdated: 'Out of date' };

// Home-tile live status (DVI-399): online/offline counts shown as chips on the
// Vision tile, modeled on the Work Order Admin Live Tile.
var _visionTileData = null;
var _visionTileLoading = false;
function _visionFetchTileData(cb) {
  if (_visionTileLoading) return;
  _visionTileLoading = true;
  fetch('/vision/collectors')
    .then(function(r) { return r.ok ? r.json() : {}; })
    .then(function(d) {
      if (d && !d.error) {
        var machines = d.collectors || [];
        var online = machines.filter(function(m){ return m.online; }).length;
        _visionTileData = { online: online, offline: machines.length - online, total: machines.length, mock: !!d.mock_data };
      }
    })
    .catch(function() {})
    .then(function() { _visionTileLoading = false; if (cb) cb(); });
}

function visionSwitchTab(tab) {
  var tabs = document.querySelectorAll('[id^="vision-tab-"]');
  var panels = document.querySelectorAll('[id^="vision-panel-"]');
  for (var i = 0; i < tabs.length; i++) {
    tabs[i].classList.remove('border-togen-teal', 'text-togen-teal');
    tabs[i].classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400', 'hover:text-gray-700', 'dark:hover:text-gray-300');
  }
  for (var i = 0; i < panels.length; i++) panels[i].classList.add('hidden');
  var activeTab = document.getElementById('vision-tab-' + tab);
  var activePanel = document.getElementById('vision-panel-' + tab);
  if (activeTab) {
    activeTab.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400', 'hover:text-gray-700', 'dark:hover:text-gray-300');
    activeTab.classList.add('border-togen-teal', 'text-togen-teal');
  }
  if (activePanel) activePanel.classList.remove('hidden');
  if (tab === 'collector' && !_visionCollectorLoaded) {
    _visionCollectorLoaded = true;
    visionLoadCollectors();
  }
  if (tab === 'activity') {
    visionPopulateActivityMachines();
    if (!_visionActivityLoaded) { _visionActivityLoaded = true; visionLoadActivity(); }
  }
  if (tab === 'recordings') {
    visionPopulateRecordingMachines();
    if (!_visionRecordingsLoaded) { _visionRecordingsLoaded = true; visionLoadRecordings(); }
  }
  if (tab === 'logs' && !_visionLogsLoaded) {
    _visionLogsLoaded = true;
    visionLoadLogs();
  }
  if (tab === 'policy' && !_visionPolicyLoaded) {
    _visionPolicyLoaded = true;
    visionPolicyLoad();
  }
  if (tab === 'enroll' && !_visionEnrollLoaded) {
    _visionEnrollLoaded = true;
    visionEnrollLoad();
  }
  if (tab === 'settings' && !_visionSettingsLoaded) {
    _visionSettingsLoaded = true;
    visionSettingsLoad();
    visionAgentVersionLoad();
  }
}

// DVI-593: Vision → Settings → Assistant model selector. Lists System Providers
// (org models from Togen Admin → Assistant) and User Models (the user's own
// connection) as <optgroup>s. Option values are namespaced "source:modelId" so
// the same model id appearing under both groups stays distinct.
function visionSettingsLoad() {
  var sel = document.getElementById('vision-settings-model');
  var empty = document.getElementById('vision-settings-empty');
  fetch('/vision/assistant-config')
    .then(function(r) { return r.ok ? r.json() : null; })
    .then(function(d) {
      sel.innerHTML = '';
      if (!d) return;
      var count = 0;
      function addGroup(label, models) {
        if (!models || !models.length) return;
        var og = document.createElement('optgroup');
        og.label = label;
        models.forEach(function(m) {
          var o = document.createElement('option');
          o.value = m.value;
          o.textContent = m.label;
          og.appendChild(o);
          count++;
        });
        sel.appendChild(og);
      }
      addGroup('System Providers', d.system_providers || []);
      addGroup('User Models', d.user_models || []);
      sel.classList.toggle('hidden', count === 0);
      empty.classList.toggle('hidden', count !== 0);
      document.getElementById('vision-settings-save-btn').disabled = count === 0;
      if (d.selected) sel.value = d.selected;
    })
    .catch(function() {});
}

// DVI-712: Vision → Settings → Vision Agent pane. Shows the latest available
// agent version and lists enrolled agents on an older version, each with an
// "Update Agent" button that enqueues the existing update_agent command.
function visionAgentVersionLoad() {
  var verEl = document.getElementById('vision-agent-latest-version');
  var loading = document.getElementById('vision-agent-version-loading');
  var empty = document.getElementById('vision-agent-version-empty');
  var error = document.getElementById('vision-agent-version-error');
  var list = document.getElementById('vision-agent-version-list');
  loading.classList.remove('hidden');
  empty.classList.add('hidden');
  error.classList.add('hidden');
  list.classList.add('hidden');
  list.innerHTML = '';
  fetch('/vision/collectors')
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      loading.classList.add('hidden');
      if (!res.ok || res.d.error) {
        error.textContent = res.d.error || 'Failed to load agents.';
        error.classList.remove('hidden');
        return;
      }
      var latestVer = res.d.latest_agent_version || null;
      verEl.textContent = latestVer || '—';
      var machines = res.d.collectors || [];
      machines.forEach(function(m){ if (latestVer && !m.latest_agent_version) m.latest_agent_version = latestVer; });
      var outdated = machines.filter(_visionVersionOutdated);
      if (!outdated.length) {
        empty.classList.remove('hidden');
        return;
      }
      list.innerHTML = outdated.map(function(m){
        var name = m.display_name || m.hostname || m.machine_id;
        return '<li class="flex items-center justify-between gap-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50/40 dark:bg-amber-900/10 px-3 py-2">' +
            '<div class="min-w-0">' +
              '<div class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + esc(name) + '</div>' +
              '<div class="text-xs text-gray-500 dark:text-gray-400">' +
                'Version <span class="font-mono text-amber-600 dark:text-amber-400">' + esc(m.agent_version || '—') + '</span>' +
                ' &rarr; <span class="font-mono">' + esc(m.latest_agent_version) + '</span></div>' +
              '<div id="vision-agentver-cmd-state-' + esc(m.machine_id) + '" class="mt-1 text-xs text-gray-500 dark:text-gray-400"></div>' +
            '</div>' +
            '<button onclick="visionSendCommand(\'' + esc(m.machine_id) + '\',\'update_agent\',false)" ' +
              'class="shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors cursor-pointer">Update Agent</button>' +
          '</li>';
      }).join('');
      list.classList.remove('hidden');
    })
    .catch(function(e){
      loading.classList.add('hidden');
      error.textContent = 'Failed to load agents: ' + e.message;
      error.classList.remove('hidden');
    });
}

// DVI-742: Staged (rolling) update — queue update_agent on every out-of-date
// agent one at a time with a delay between each so they don't all restart at
// once. Confirms first; reports progress in the status line.
var _VISION_STAGED_UPDATE_DELAY_MS = 8000;
var _visionStagedUpdateRunning = false;
function visionStagedUpdate() {
  if (_visionStagedUpdateRunning) return;
  var btn = document.getElementById('vision-staged-update-btn');
  var status = document.getElementById('vision-staged-update-status');
  if (status) { status.classList.remove('hidden'); status.textContent = 'Checking for out-of-date agents…'; }
  fetch('/vision/collectors')
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok || res.d.error) { throw new Error(res.d.error || 'Failed to load agents.'); }
      var latestVer = res.d.latest_agent_version || null;
      var machines = res.d.collectors || [];
      machines.forEach(function(m){ if (latestVer && !m.latest_agent_version) m.latest_agent_version = latestVer; });
      var outdated = machines.filter(_visionVersionOutdated);
      if (!outdated.length) { if (status) status.textContent = 'All enrolled agents are already up to date.'; return; }
      if (!confirm('Queue a rolling update on ' + outdated.length + ' out-of-date agent' + (outdated.length === 1 ? '' : 's') + '? Each will be updated one at a time.')) {
        if (status) status.textContent = 'Staged update cancelled.';
        return;
      }
      _visionStagedUpdateRunning = true;
      if (btn) btn.disabled = true;
      _visionStagedUpdateStep(outdated, 0, btn, status);
    })
    .catch(function(e){ if (status) status.textContent = 'Staged update failed: ' + e.message; });
}
function _visionStagedUpdateStep(agents, idx, btn, status) {
  if (idx >= agents.length) {
    _visionStagedUpdateRunning = false;
    if (btn) btn.disabled = false;
    if (status) status.textContent = 'Staged update complete — queued ' + agents.length + ' agent' + (agents.length === 1 ? '' : 's') + '. Agents apply the update on their next check-in.';
    visionAgentVersionLoad();
    return;
  }
  var m = agents[idx];
  var name = m.display_name || m.hostname || m.machine_id;
  if (status) status.textContent = 'Updating ' + (idx + 1) + ' of ' + agents.length + ': ' + name + '…';
  fetch('/vision/commands', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ machine_id: m.machine_id, action: 'update_agent', params: null })
  })
    .then(function(){})
    .catch(function(){})
    .then(function(){
      setTimeout(function(){ _visionStagedUpdateStep(agents, idx + 1, btn, status); }, _VISION_STAGED_UPDATE_DELAY_MS);
    });
}

function visionSaveAssistantModel() {
  var sel = document.getElementById('vision-settings-model');
  var status = document.getElementById('vision-settings-save-status');
  status.textContent = 'Saving...';
  fetch('/vision/assistant-config', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ model: sel.value })
  })
    .then(function(r) { return r.json(); })
    .then(function(d) { status.textContent = (d && d.ok) ? 'Saved' : ((d && d.error) || 'Save failed'); })
    .catch(function(e) { status.textContent = 'Save failed: ' + e.message; });
}

function visionLoadCollectors() {
  var loading = document.getElementById('vision-collector-loading');
  var empty = document.getElementById('vision-collector-empty');
  var error = document.getElementById('vision-collector-error');
  var grid = document.getElementById('vision-collector-grid');
  loading.classList.remove('hidden');
  empty.classList.add('hidden');
  error.classList.add('hidden');
  grid.classList.add('hidden');
  fetch('/vision/collectors')
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      loading.classList.add('hidden');
      if (!res.ok || res.d.error) {
        document.getElementById('vision-collector-error-msg').textContent = res.d.error || 'Failed to load collectors.';
        error.classList.remove('hidden');
        return;
      }
      var badge = document.getElementById('vision-mock-badge');
      if (res.d.mock_data) badge.classList.remove('hidden'); else badge.classList.add('hidden');
      document.getElementById('vision-collector-time').textContent = 'Updated ' + new Date().toLocaleTimeString();
      var machines = res.d.collectors || [];
      // latest_agent_version is reported once at the response level (latest ZIP on
      // the server); stamp it onto each machine so tile/popup can flag stale agents.
      var latestVer = res.d.latest_agent_version || null;
      machines.forEach(function(m){ if (latestVer && !m.latest_agent_version) m.latest_agent_version = latestVer; });
      _visionMachines = machines;
      _visionMockActive = !!res.d.mock_data;
      visionPopulateActivityMachines();
      if (!machines.length) { empty.classList.remove('hidden'); return; }
      // render manages grid/empty visibility (filters may reduce the set to zero).
      visionRenderCollectorGrid(machines);
    })
    .catch(function(e){
      loading.classList.add('hidden');
      document.getElementById('vision-collector-error-msg').textContent = e.message;
      error.classList.remove('hidden');
    });
}

// DVI-742: Logs tab — list every uploaded/requested agent log; click opens the
// shared log viewer modal. Mirrors the SCADA View > Logs tab layout.
var _visionLogs = [];
function visionLoadLogs() {
  var loading = document.getElementById('vision-logs-loading');
  var empty = document.getElementById('vision-logs-empty');
  var error = document.getElementById('vision-logs-error');
  var tableWrap = document.getElementById('vision-logs-table-wrap');
  var status = document.getElementById('vision-logs-status');
  if (!loading) return;
  loading.classList.remove('hidden');
  empty.classList.add('hidden');
  error.classList.add('hidden');
  tableWrap.classList.add('hidden');
  fetch('/vision/agent-logs')
    .then(function(r){ if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
    .then(function(data){
      loading.classList.add('hidden');
      _visionLogs = data.logs || [];
      if (status) status.textContent = 'Last refreshed: ' + new Date().toLocaleTimeString();
      if (!_visionLogs.length) { empty.classList.remove('hidden'); return; }
      tableWrap.classList.remove('hidden');
      var tbody = document.getElementById('vision-logs-tbody');
      tbody.innerHTML = '';
      _visionLogs.forEach(function(log){
        var when = log.received_at ? new Date(log.received_at * 1000).toLocaleString() : '';
        var agent = log.display_name || log.hostname || log.machine_id || '—';
        var tr = document.createElement('tr');
        tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer transition-colors';
        tr.onclick = function(){ visionOpenLogViewer(log.machine_id, log.file); };
        tr.innerHTML =
          '<td class="py-2.5 pr-4 text-gray-600 dark:text-gray-400 whitespace-nowrap">' + esc(when) + '</td>' +
          '<td class="py-2.5 pr-4 text-gray-800 dark:text-gray-200 font-medium truncate max-w-xs">' + esc(agent) + '</td>' +
          '<td class="py-2.5 text-togen-teal truncate max-w-xs">' + esc(log.file || '') + '</td>';
        tbody.appendChild(tr);
      });
    })
    .catch(function(err){
      loading.classList.add('hidden');
      error.classList.remove('hidden');
      document.getElementById('vision-logs-error-msg').textContent = 'Failed to load logs: ' + err.message;
    });
}

// DVI-409: status pill color map (active/idle/paused/error).
var _VISION_STATUS_STYLES = {
  active: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
  idle:   'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300',
  paused: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
  error:  'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
};

// "Idle 2h 5m" / "Idle 14 min" / "Idle 45s". DVI-742: roll idle past 60 min into
// hours + minutes. Only call when > 2 min.
function _visionFmtIdle(seconds) {
  if (seconds >= 3600) {
    var h = Math.floor(seconds / 3600);
    var m = Math.round((seconds % 3600) / 60);
    if (m === 60) { h += 1; m = 0; }
    return 'Idle ' + h + 'h' + (m ? ' ' + m + 'm' : '');
  }
  if (seconds >= 60) {
    return 'Idle ' + Math.round(seconds / 60) + ' min';
  }
  return 'Idle ' + Math.round(seconds) + 's';
}

// "3d 4h" / "4h 12m" / "12m" — compact agent uptime.
function _visionFmtUptime(seconds) {
  if (typeof seconds !== 'number' || seconds < 0) return '—';
  var d = Math.floor(seconds / 86400);
  var h = Math.floor((seconds % 86400) / 3600);
  var m = Math.floor((seconds % 3600) / 60);
  if (d > 0) return d + 'd' + (h ? ' ' + h + 'h' : '');
  if (h > 0) return h + 'h' + (m ? ' ' + m + 'm' : '');
  return m + 'm';
}

// Inline online/offline sparkline as bars: filled teal = online bucket, faint = offline.
function _visionSparkSvg(spark, w, h) {
  if (!spark || !spark.length) return '';
  w = w || 96; h = h || 18;
  var n = spark.length;
  var bw = w / n;
  var bars = '';
  for (var i = 0; i < n; i++) {
    var on = spark[i] === 1;
    var bh = on ? h : Math.max(2, h * 0.25);
    var y = h - bh;
    var fill = on ? '#0d9488' : '#cbd5e1';
    bars += '<rect x="' + (i * bw + 0.5) + '" y="' + y + '" width="' + (bw - 1) +
            '" height="' + bh + '" rx="1" fill="' + fill + '"></rect>';
  }
  // DVI-744: render responsive so the bar never overflows narrow (medium) tiles.
  // viewBox keeps the bar geometry; width:100% + max-width caps it at the
  // requested size on wider (large-tile) layouts.
  return '<svg viewBox="0 0 ' + w + ' ' + h + '" height="' + h +
         '" preserveAspectRatio="none" class="block" style="width:100%;max-width:' + w + 'px">' +
         bars + '</svg>';
}

// DVI-717: toggle the view-options dropdown. Closes on outside click.
function visionToggleViewMenu(ev) {
  if (ev) ev.stopPropagation();
  var menu = document.getElementById('vision-view-menu');
  if (!menu) return;
  var willOpen = menu.classList.contains('hidden');
  menu.classList.toggle('hidden');
  if (willOpen) {
    setTimeout(function(){ document.addEventListener('click', _visionViewMenuOutside); }, 0);
  } else {
    document.removeEventListener('click', _visionViewMenuOutside);
  }
}
function _visionViewMenuOutside(ev) {
  var wrap = document.getElementById('vision-view-btn');
  var menu = document.getElementById('vision-view-menu');
  if (!menu || (wrap && wrap.contains(ev.target)) || menu.contains(ev.target)) return;
  menu.classList.add('hidden');
  document.removeEventListener('click', _visionViewMenuOutside);
}

// DVI-717: select a collector view mode, persist it, and re-render.
function visionSetView(view) {
  if (!_VISION_VIEW_LABELS[view]) view = 'large';
  _visionCollectorView = view;
  try { localStorage.setItem('visionCollectorView', view); } catch (e) {}
  var label = document.getElementById('vision-view-label');
  if (label) label.textContent = _VISION_VIEW_LABELS[view];
  var menu = document.getElementById('vision-view-menu');
  if (menu) {
    menu.querySelectorAll('.vision-view-item').forEach(function(btn){
      var check = btn.querySelector('.vision-view-check');
      if (check) check.classList.toggle('hidden', btn.getAttribute('data-view') !== view);
    });
    menu.classList.add('hidden');
    document.removeEventListener('click', _visionViewMenuOutside);
  }
  if (_visionMachines && _visionMachines.length) visionRenderCollectorGrid(_visionMachines);
}

function visionRenderCollectorGrid(machines) {
  var grid = document.getElementById('vision-collector-grid');
  grid.innerHTML = '';
  // Keep the view/sort menus in sync with the active selections.
  visionSyncViewMenu();
  visionSyncSortMenu();
  // DVI-742: apply filters + sort before rendering (non-destructive copy).
  machines = _visionSortMachines(_visionFilterMachines(machines));
  var empty = document.getElementById('vision-collector-empty');
  if (empty) empty.classList.toggle('hidden', machines.length > 0);
  grid.classList.toggle('hidden', machines.length === 0);
  if (_visionCollectorView === 'compact') {
    grid.className = 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2';
    machines.forEach(function(m){ grid.appendChild(_visionBuildCompactTile(m)); });
    return;
  }
  if (_visionCollectorView === 'list') {
    grid.className = 'flex flex-col gap-2';
    machines.forEach(function(m){ grid.appendChild(_visionBuildListTile(m)); });
    return;
  }
  if (_visionCollectorView === 'medium') {
    grid.className = 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3';
    machines.forEach(function(m){ grid.appendChild(_visionBuildMediumTile(m)); });
    return;
  }
  grid.className = 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3';
  machines.forEach(function(m){
    var card = _visionBuildLargeTile(m);
    grid.appendChild(card);
    if (card._visionLoadCmd) visionLoadCommands(m.machine_id);
  });
}

// DVI-742: Medium tile — screenshot + name + status + user, with the Last 6h
// sparkline, command palette, and recent command output collapsed behind an
// expand arrow at the bottom. Commands load lazily on first expand.
function _visionBuildMediumTile(m) {
  var online = !!m.online;
  var dispName = m.display_name || m.hostname || 'Unknown host';
  var seenMs = m.last_seen ? m.last_seen * 1000 : 0;
  var ago = _syncFmtAgo(seenMs) || 'never';
  var badgeCls = online
    ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
    : 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400';

  var idleHtml = '';
  if (typeof m.idle_seconds === 'number' && m.idle_seconds > 120) {
    idleHtml = '<span class="inline-flex items-center text-xs text-amber-600 dark:text-amber-400">' +
      esc(_visionFmtIdle(m.idle_seconds)) + '</span>';
  }

  var thumbHtml = m.thumb_url
    ? '<button type="button" onclick="visionLightboxOpen(\'' + esc(m.full_url || m.thumb_url) + '\',\'' + esc(m.hostname || m.machine_id || '') + '\')" ' +
        'class="block w-full mb-2 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 cursor-zoom-in group/thumb" title="Click to view full screenshot">' +
        '<img src="' + esc(m.thumb_url) + '" alt="Latest screenshot" loading="lazy" class="w-full h-24 object-cover bg-gray-100 dark:bg-gray-800 group-hover/thumb:opacity-90 transition-opacity"></button>'
    : '';

  // Collapsible body: sparkline + command palette + recent command output.
  var sparkHtml = '';
  if (m.sparkline && m.sparkline.length) {
    sparkHtml =
      '<div class="mt-2 pt-2 border-t border-gray-100 dark:border-gray-700">' +
        '<div class="flex items-center justify-between mb-1">' +
          '<span class="text-xs text-gray-500 dark:text-gray-400">Last 6h</span>' +
          '<span class="text-[10px] text-gray-400 dark:text-gray-500">online/offline</span>' +
        '</div>' + _visionSparkSvg(m.sparkline, 220, 18) +
      '</div>';
  }
  var cmdHtml = '';
  var loadCmd = false;
  if (_isToGenAdmin && !_visionMockActive && m.machine_id) {
    cmdHtml = _visionCommandPanelHtml(m.machine_id, 'vision-cmd-state');
    loadCmd = true;
  }

  var hasExtras = !!(sparkHtml || cmdHtml);
  var bodyId = 'vision-medium-body-' + esc(m.machine_id || '');
  var expandHtml = '';
  if (hasExtras) {
    expandHtml =
      '<div class="mt-2 pt-2 border-t border-gray-100 dark:border-gray-700">' +
        '<button type="button" onclick="visionToggleMediumBody(\'' + esc(m.machine_id || '') + '\')" ' +
          'class="w-full flex items-center justify-center gap-1 text-xs text-togen-teal hover:underline cursor-pointer">' +
          '<span>Details &amp; commands</span>' +
          '<svg id="' + bodyId + '-chevron" class="w-3.5 h-3.5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>' +
        '</button>' +
        '<div id="' + bodyId + '" class="hidden">' + sparkHtml + cmdHtml + '</div>' +
      '</div>';
  }

  var card = document.createElement('div');
  card.className = 'togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-3 cursor-pointer hover:border-togen-teal dark:hover:border-togen-teal transition-colors';
  card.onclick = function(ev){
    if (ev.target.closest('button, a, input, select, textarea, [onclick]')) return;
    visionAgentPopupOpen(m.machine_id);
  };
  card.innerHTML =
    thumbHtml +
    '<div class="flex items-center justify-between gap-2 mb-1">' +
      '<span class="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate" title="' + esc(dispName) + '">' + esc(dispName) + '</span>' +
      '<span class="flex items-center gap-1.5 shrink-0">' + _visionModeBadge(m) +
        '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ' + badgeCls + '">' + (online ? 'Online' : 'Offline') + '</span>' +
      '</span>' +
    '</div>' +
    (idleHtml ? '<div class="mb-1">' + idleHtml + '</div>' : '') +
    '<dl class="text-xs text-gray-500 dark:text-gray-400 space-y-0.5">' +
      '<div class="flex justify-between gap-2"><dt>User</dt><dd class="text-gray-700 dark:text-gray-300 truncate">' + esc(m.logged_in_user || '—') + '</dd></div>' +
      '<div class="flex justify-between gap-2"><dt>Last check-in</dt><dd class="text-gray-700 dark:text-gray-300">' + esc(ago) + '</dd></div>' +
      '<div class="flex justify-between gap-2"><dt>Agent</dt><dd class="' +
        (_visionVersionOutdated(m) ? 'text-amber-600 dark:text-amber-400 font-medium' : 'text-gray-700 dark:text-gray-300') + '">' +
        esc(m.agent_version || '—') + (_visionVersionOutdated(m) ? ' ⚠' : '') + '</dd></div>' +
    '</dl>' +
    expandHtml;
  card._visionLoadCmd = loadCmd;
  return card;
}

// Expand/collapse a medium tile's details, lazily loading command state once.
function visionToggleMediumBody(machineId) {
  var body = document.getElementById('vision-medium-body-' + machineId);
  if (!body) return;
  var chevron = document.getElementById('vision-medium-body-' + machineId + '-chevron');
  var willOpen = body.classList.contains('hidden');
  body.classList.toggle('hidden');
  if (chevron) chevron.classList.toggle('rotate-180', willOpen);
  if (willOpen && !body._visionCmdLoaded) {
    body._visionCmdLoaded = true;
    visionLoadCommands(machineId);
  }
}

// Reflect the current view in the dropdown label + check marks (no re-render).
function visionSyncViewMenu() {
  var label = document.getElementById('vision-view-label');
  if (label) label.textContent = _VISION_VIEW_LABELS[_visionCollectorView] || 'Large Tiles';
  var menu = document.getElementById('vision-view-menu');
  if (!menu) return;
  menu.querySelectorAll('.vision-view-item').forEach(function(btn){
    var check = btn.querySelector('.vision-view-check');
    if (check) check.classList.toggle('hidden', btn.getAttribute('data-view') !== _visionCollectorView);
  });
}

// DVI-742: Sort dropdown — mirrors the view dropdown behavior.
function visionToggleSortMenu(ev) {
  if (ev) ev.stopPropagation();
  var menu = document.getElementById('vision-sort-menu');
  if (!menu) return;
  var willOpen = menu.classList.contains('hidden');
  menu.classList.toggle('hidden');
  if (willOpen) {
    setTimeout(function(){ document.addEventListener('click', _visionSortMenuOutside); }, 0);
  } else {
    document.removeEventListener('click', _visionSortMenuOutside);
  }
}
function _visionSortMenuOutside(ev) {
  var btn = document.getElementById('vision-sort-btn');
  var menu = document.getElementById('vision-sort-menu');
  if (!menu || (btn && btn.contains(ev.target)) || menu.contains(ev.target)) return;
  menu.classList.add('hidden');
  document.removeEventListener('click', _visionSortMenuOutside);
}
function visionSetSort(sort) {
  if (!_VISION_SORT_LABELS[sort]) sort = 'activity';
  _visionCollectorSort = sort;
  try { localStorage.setItem('visionCollectorSort', sort); } catch (e) {}
  var menu = document.getElementById('vision-sort-menu');
  if (menu) { menu.classList.add('hidden'); document.removeEventListener('click', _visionSortMenuOutside); }
  visionSyncSortMenu();
  if (_visionMachines && _visionMachines.length) visionRenderCollectorGrid(_visionMachines);
}
function visionSyncSortMenu() {
  var label = document.getElementById('vision-sort-label');
  if (label) label.textContent = _VISION_SORT_LABELS[_visionCollectorSort] || 'Activity';
  var menu = document.getElementById('vision-sort-menu');
  if (!menu) return;
  menu.querySelectorAll('.vision-sort-item').forEach(function(btn){
    var check = btn.querySelector('.vision-sort-check');
    if (check) check.classList.toggle('hidden', btn.getAttribute('data-sort') !== _visionCollectorSort);
  });
}

// DVI-742: return a sorted copy of machines per the active sort mode. 'activity'
// keeps the server order (most recently active first).
function _visionSortMachines(machines) {
  var arr = machines.slice();
  if (_visionCollectorSort === 'alpha') {
    arr.sort(function(a, b){
      var an = (a.display_name || a.hostname || a.machine_id || '').toLowerCase();
      var bn = (b.display_name || b.hostname || b.machine_id || '').toLowerCase();
      return an < bn ? -1 : (an > bn ? 1 : 0);
    });
  } else if (_visionCollectorSort === 'status') {
    arr.sort(function(a, b){ return (b.online ? 1 : 0) - (a.online ? 1 : 0); });
  } else if (_visionCollectorSort === 'outdated') {
    arr.sort(function(a, b){ return (_visionVersionOutdated(b) ? 1 : 0) - (_visionVersionOutdated(a) ? 1 : 0); });
  }
  return arr;
}

// DVI-742: filter machines per the Filters bar (online / out of date / backend).
function _visionFilterMachines(machines) {
  var onlineEl = document.getElementById('vision-filter-online');
  var outdatedEl = document.getElementById('vision-filter-outdated');
  var backendEl = document.getElementById('vision-filter-backend');
  var onlineOnly = onlineEl && onlineEl.checked;
  var outdatedOnly = outdatedEl && outdatedEl.checked;
  var backend = backendEl ? backendEl.value : 'all';
  return machines.filter(function(m){
    if (onlineOnly && !m.online) return false;
    if (outdatedOnly && !_visionVersionOutdated(m)) return false;
    if (backend === 'video' && !_visionIsVideo(m)) return false;
    if (backend === 'screenshot' && _visionIsVideo(m)) return false;
    return true;
  });
}
function visionApplyFilters() {
  if (_visionMachines && _visionMachines.length) visionRenderCollectorGrid(_visionMachines);
}
function visionClearFilters() {
  var onlineEl = document.getElementById('vision-filter-online');
  var outdatedEl = document.getElementById('vision-filter-outdated');
  var backendEl = document.getElementById('vision-filter-backend');
  if (onlineEl) onlineEl.checked = false;
  if (outdatedEl) outdatedEl.checked = false;
  if (backendEl) backendEl.value = 'all';
  visionApplyFilters();
}

// DVI-717: Compact tile — ~1/3 the large tile, screenshot as the background with
// name + online indicator overlaid. Click opens the agent detail popup.
function _visionBuildCompactTile(m) {
  var online = !!m.online;
  var dispName = m.display_name || m.hostname || 'Unknown host';
  var dotCls = online ? 'bg-green-400' : 'bg-gray-400';
  var card = document.createElement('div');
  card.className = 'relative rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 cursor-pointer hover:border-togen-teal dark:hover:border-togen-teal transition-colors aspect-[4/3]';
  card.title = dispName;
  card.onclick = function(){ visionAgentPopupOpen(m.machine_id); };
  var bg = m.thumb_url
    ? '<img src="' + esc(m.thumb_url) + '" alt="Latest screenshot" loading="lazy" class="absolute inset-0 w-full h-full object-cover">'
    : '<div class="absolute inset-0 flex items-center justify-center text-gray-300 dark:text-gray-600">' +
        '<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2" stroke-width="1.5"/></svg></div>';
  card.innerHTML =
    bg +
    '<div class="absolute top-1 right-1">' + _visionModeBadge(m) + '</div>' +
    '<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-2 py-1.5">' +
      '<div class="flex items-center gap-1.5">' +
        '<span class="inline-block w-2 h-2 rounded-full ' + dotCls + ' shrink-0" title="' + (online ? 'Online' : 'Offline') + '"></span>' +
        '<span class="text-xs font-medium text-white truncate">' + esc(dispName) + '</span>' +
      '</div>' +
    '</div>';
  return card;
}

// DVI-717: List row — wide horizontal tile, screenshot as a left-side icon plus a
// few lines of text. Click opens the agent detail popup.
function _visionBuildListTile(m) {
  var online = !!m.online;
  var dispName = m.display_name || m.hostname || 'Unknown host';
  var seenMs = m.last_seen ? m.last_seen * 1000 : 0;
  var ago = _syncFmtAgo(seenMs) || 'never';
  var badgeCls = online
    ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
    : 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400';
  var icon = m.thumb_url
    ? '<img src="' + esc(m.thumb_url) + '" alt="Latest screenshot" loading="lazy" class="w-24 h-16 object-cover rounded-lg border border-gray-200 dark:border-gray-700 shrink-0 bg-gray-100 dark:bg-gray-800">'
    : '<div class="w-24 h-16 rounded-lg border border-gray-200 dark:border-gray-700 shrink-0 bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-gray-300 dark:text-gray-600">' +
        '<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2" stroke-width="1.5"/></svg></div>';
  var card = document.createElement('div');
  card.className = 'togen-pane flex items-center gap-3 bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-3 cursor-pointer hover:border-togen-teal dark:hover:border-togen-teal transition-colors';
  card.onclick = function(ev){
    if (ev.target.closest('button, a, input, select, textarea, [onclick]')) return;
    visionAgentPopupOpen(m.machine_id);
  };
  card.innerHTML =
    icon +
    '<div class="min-w-0 flex-1">' +
      '<div class="flex items-center gap-2 mb-1">' +
        '<span class="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate" title="' + esc(dispName) + '">' + esc(dispName) + '</span>' +
        '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ' + badgeCls + '">' + (online ? 'Online' : 'Offline') + '</span>' +
        _visionModeBadge(m) +
        (_visionVersionOutdated(m) ? '<span class="text-xs text-amber-600 dark:text-amber-400" title="Update available — latest is ' + esc(m.latest_agent_version) + '">&#9888; ' + esc(m.agent_version || '') + '</span>' : '') +
      '</div>' +
      '<div class="text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-x-4 gap-y-0.5">' +
        '<span>User: <span class="text-gray-700 dark:text-gray-300">' + esc(m.logged_in_user || '—') + '</span></span>' +
        '<span>Last check-in: <span class="text-gray-700 dark:text-gray-300">' + esc(ago) + '</span></span>' +
        '<span>Agent: <span class="text-gray-700 dark:text-gray-300">' + esc(m.agent_version || '—') + '</span></span>' +
      '</div>' +
    '</div>';
  return card;
}

function _visionBuildLargeTile(m) {
    var online = !!m.online;
    var seenMs = m.last_seen ? m.last_seen * 1000 : 0;
    var ago = _syncFmtAgo(seenMs) || 'never';
    var badgeCls = online
      ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
      : 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400';

    // Status pill — only when a recognized status is present.
    var statusHtml = '';
    var statusCls = m.status ? _VISION_STATUS_STYLES[m.status] : null;
    if (statusCls) {
      statusHtml =
        '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ' + statusCls + '">' +
          esc(m.status) +
        '</span>';
    }

    // Idle indicator — only when idle > 2 min.
    var idleHtml = '';
    if (typeof m.idle_seconds === 'number' && m.idle_seconds > 120) {
      idleHtml =
        '<span class="inline-flex items-center text-xs text-amber-600 dark:text-amber-400">' +
          esc(_visionFmtIdle(m.idle_seconds)) +
        '</span>';
    }

    // Capture-enabled padlock — locked = capturing on, unlocked = off.
    var captureHtml = '';
    if (m.capture_enabled === true || m.capture_enabled === false) {
      var locked = m.capture_enabled === true;
      var lockTitle = locked ? 'Capture on' : 'Capture off';
      var lockCls = locked ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500';
      var lockPath = locked
        ? 'M5 11V7a5 5 0 0110 0v4M5 11h10a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2z'
        : 'M5 11V7a5 5 0 019.9-1M5 11h10a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2z';
      captureHtml =
        '<span class="inline-flex items-center ' + lockCls + '" title="' + lockTitle + '" aria-label="' + lockTitle + '">' +
          '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">' +
            '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="' + lockPath + '"></path>' +
          '</svg>' +
        '</span>';
    }

    // Header second row: status pill + idle + padlock (only rendered when present).
    var subRowHtml = '';
    if (statusHtml || idleHtml || captureHtml) {
      subRowHtml =
        '<div class="flex items-center gap-2 mb-2">' + statusHtml + idleHtml +
          (captureHtml ? '<span class="ml-auto">' + captureHtml + '</span>' : '') +
        '</div>';
    }

    // Foreground app — hidden when null (locked / no session).
    var fgHtml = '';
    if (m.foreground_app) {
      fgHtml =
        '<div class="flex justify-between gap-2"><dt>Foreground</dt>' +
          '<dd class="text-gray-700 dark:text-gray-300 truncate" title="' + esc(m.foreground_app) + '">' + esc(m.foreground_app) + '</dd></div>';
    }

    // CPU % — small horizontal bar with numeric label.
    var cpuHtml = '';
    if (typeof m.cpu_pct === 'number') {
      var pct = Math.max(0, Math.min(100, m.cpu_pct));
      cpuHtml =
        '<div class="flex justify-between items-center gap-2"><dt>CPU</dt>' +
          '<dd class="flex items-center gap-2 text-gray-700 dark:text-gray-300">' +
            '<span class="inline-block w-16 h-1.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">' +
              '<span class="block h-full rounded-full bg-indigo-500" style="width:' + pct + '%"></span>' +
            '</span>' +
            '<span>' + Math.round(m.cpu_pct) + '%</span>' +
          '</dd></div>';
    }

    // Thumbnail (DVI-427, D2) — low-res frame; click opens the full screenshot.
    var thumbHtml = '';
    if (m.thumb_url) {
      thumbHtml =
        '<button type="button" onclick="visionLightboxOpen(\'' + esc(m.full_url || m.thumb_url) + '\',\'' + esc(m.hostname || m.machine_id || '') + '\')" ' +
          'class="block w-full mb-3 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 cursor-zoom-in group/thumb" ' +
          'title="Click to view full screenshot">' +
          '<img src="' + esc(m.thumb_url) + '" alt="Latest screenshot" loading="lazy" ' +
            'class="w-full h-28 object-cover bg-gray-100 dark:bg-gray-800 group-hover/thumb:opacity-90 transition-opacity">' +
        '</button>';
    }

    // Phase-3 telemetry (DVI-427): uptime/restart indicator + online/offline sparkline.
    var uptimeHtml = '';
    if (typeof m.agent_uptime_s === 'number') {
      var restartTxt = (typeof m.restarts === 'number' && m.restarts > 0)
        ? ' · ' + m.restarts + (m.restarts === 1 ? ' restart' : ' restarts') : '';
      uptimeHtml =
        '<div class="flex justify-between gap-2"><dt>Uptime</dt>' +
          '<dd class="text-gray-700 dark:text-gray-300">' + esc(_visionFmtUptime(m.agent_uptime_s)) +
          '<span class="text-gray-400 dark:text-gray-500">' + esc(restartTxt) + '</span></dd></div>';
    }
    var sparkHtml = '';
    if (m.sparkline && m.sparkline.length) {
      sparkHtml =
        '<div class="mt-2 pt-2 border-t border-gray-100 dark:border-gray-700">' +
          '<div class="flex items-center justify-between mb-1">' +
            '<span class="text-xs text-gray-500 dark:text-gray-400">Last 6h</span>' +
            '<span class="text-[10px] text-gray-400 dark:text-gray-500">online/offline</span>' +
          '</div>' + _visionSparkSvg(m.sparkline, 220, 18) +
        '</div>';
    }

    // Commands operator UI (DVI-424) — admin-only, per-machine, real agents only.
    var cmdHtml = '';
    if (_isToGenAdmin && !_visionMockActive && m.machine_id) {
      cmdHtml = _visionCommandPanelHtml(m.machine_id, 'vision-cmd-state');
    }

    var dispName = m.display_name || m.hostname || 'Unknown host';
    var card = document.createElement('div');
    // DVI-682: tile hover now just highlights the border (matches the home grid);
    // a click anywhere outside a button opens the agent detail popup.
    card.className = 'togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 cursor-pointer hover:border-togen-teal dark:hover:border-togen-teal transition-colors';
    card.onclick = function(ev) {
      if (ev.target.closest('button, a, input, select, textarea, [onclick]')) return;
      visionAgentPopupOpen(m.machine_id);
    };
    card.innerHTML =
      thumbHtml +
      '<div class="flex items-center justify-between mb-2">' +
        '<span class="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate" title="' + esc(dispName) + '">' + esc(dispName) + '</span>' +
        '<span class="flex items-center gap-1.5 shrink-0">' + _visionModeBadge(m) +
          '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ' + badgeCls + '">' + (online ? 'Online' : 'Offline') + '</span>' +
        '</span>' +
      '</div>' +
      subRowHtml +
      '<dl class="text-xs text-gray-500 dark:text-gray-400 space-y-1">' +
        '<div class="flex justify-between gap-2"><dt>User</dt><dd class="text-gray-700 dark:text-gray-300 truncate">' + esc(m.logged_in_user || '—') + '</dd></div>' +
        '<div class="flex justify-between gap-2"><dt>Last check-in</dt><dd class="text-gray-700 dark:text-gray-300">' + esc(ago) + '</dd></div>' +
        '<div class="flex justify-between gap-2"><dt>Agent</dt><dd class="' +
          (_visionVersionOutdated(m) ? 'text-amber-600 dark:text-amber-400 font-medium' : 'text-gray-700 dark:text-gray-300') + '"' +
          (_visionVersionOutdated(m) ? ' title="Update available — latest is ' + esc(m.latest_agent_version) + '"' : '') + '>' +
          esc(m.agent_version || '—') + (_visionVersionOutdated(m) ? ' ⚠' : '') + '</dd></div>' +
        fgHtml +
        cpuHtml +
        uptimeHtml +
      '</dl>' +
      sparkHtml +
      cmdHtml;
  card._visionLoadCmd = !!cmdHtml;
  return card;
}

// Numeric dotted-version compare ("3.2.12" vs "3.2.9"). Returns -1/0/1.
function _visionVerCmp(a, b) {
  var pa = String(a).split('.'), pb = String(b).split('.');
  var n = Math.max(pa.length, pb.length);
  for (var i = 0; i < n; i++) {
    var x = parseInt(pa[i], 10) || 0, y = parseInt(pb[i], 10) || 0;
    if (x < y) return -1;
    if (x > y) return 1;
  }
  return 0;
}

// A machine is in video mode when its effective policy says so or its newest
// captured frame is a video segment (DVI-727 / DVI-731).
function _visionIsVideo(m) {
  return !!m && (m.capture_mode === 'video' || m.latest_kind === 'video');
}

// Small video-vs-screenshot mode pill for Collector tiles / detail popup.
function _visionModeBadge(m) {
  if (_visionIsVideo(m)) {
    return '<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300" title="Video backend">' +
      '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 6h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2z"/></svg>Video</span>';
  }
  return '<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300" title="Screenshot backend">' +
    '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="14" rx="2" stroke-width="2"/></svg>Shots</span>';
}

// True when the agent's reported version is older than the latest deployed ZIP.
function _visionVersionOutdated(m) {
  if (!m || !m.agent_version || !m.latest_agent_version) return false;
  return _visionVerCmp(m.agent_version, m.latest_agent_version) < 0;
}

// Shared command panel for a machine — rendered on both the collector tile and
// the agent detail popup. statePrefix keeps the state element id unique per
// surface ('vision-cmd-state' on the tile, 'vision-popup-cmd-state' in the popup).
function _visionCommandPanelHtml(machineId, statePrefix) {
  var mid = machineId;
  return '<div class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700">' +
      '<div class="flex items-center justify-between mb-2">' +
        '<span class="text-xs font-medium text-gray-600 dark:text-gray-300">Commands</span>' +
        '<button onclick="visionLoadCommands(\'' + esc(mid) + '\')" class="text-xs text-togen-teal hover:underline cursor-pointer">Refresh state</button>' +
      '</div>' +
      '<div class="flex flex-wrap gap-1.5">' +
        visionCmdBtn(mid, 'pause_capture', 'Pause', true) +
        visionCmdBtn(mid, 'resume_capture', 'Resume', false) +
        visionCmdBtn(mid, 'collect_screenshot', 'Screenshot', false) +
        visionCmdBtn(mid, 'set_interval', 'Set interval', false) +
        visionCmdBtn(mid, 'start_capture_task', 'Start Capture Task', false) +
        visionCmdBtn(mid, 'update_agent', 'Update Agent', false) +
        visionCmdBtn(mid, 'upload_log', 'Upload log', false) +
        visionCmdBtn(mid, 'restart_agent', 'Restart', true) +
        visionCmdBtn(mid, 'uninstall', 'Uninstall', true) +
      '</div>' +
      '<div id="' + statePrefix + '-' + esc(mid) + '" class="mt-2 text-xs text-gray-500 dark:text-gray-400"></div>' +
    '</div>';
}

// One command button. Destructive actions (restart/pause) flagged for confirmation.
function visionCmdBtn(machineId, action, label, destructive) {
  var cls = destructive
    ? 'px-2 py-1 text-xs rounded-md border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer'
    : 'px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer';
  return '<button class="' + cls + '" onclick="visionSendCommand(\'' + esc(machineId) + '\',\'' + action + '\',' + (destructive ? 'true' : 'false') + ')">' + esc(label) + '</button>';
}

// POST /vision/commands for a single machine. Confirms destructive actions and
// prompts for the interval on set_interval. Strictly per-machine (no bulk).
function visionSendCommand(machineId, action, destructive) {
  var params = null;
  if (action === 'set_interval') {
    var v = prompt('Capture interval in seconds for ' + machineId + ':', '60');
    if (v === null) return;
    var n = parseInt(v, 10);
    if (isNaN(n) || n <= 0) { alert('Interval must be a positive number of seconds.'); return; }
    params = { interval_s: n };
  }
  if (destructive) {
    var verb = action === 'restart_agent' ? 'restart the agent on'
             : action === 'uninstall'   ? 'uninstall and permanently remove the agent from'
             : 'pause capture on';
    if (!confirm('Are you sure you want to ' + verb + ' ' + machineId + '?')) return;
  }
  // Command buttons render on both the collector tile and the detail popup,
  // so update every matching state element (ids are tile + popup variants).
  var stateEls = _visionCmdStateEls(machineId);
  stateEls.forEach(function(el){ el.textContent = 'Queuing ' + action + '...'; });
  fetch('/vision/commands', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ machine_id: machineId, action: action, params: params })
  })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok) { stateEls.forEach(function(el){ el.textContent = 'Error: ' + (res.d.error || 'failed'); }); return; }
      visionLoadCommands(machineId);
    })
    .catch(function(e){ stateEls.forEach(function(el){ el.textContent = 'Error: ' + e.message; }); });
}

// Collect all command-state elements for a machine (collector tile + detail popup).
function _visionCmdStateEls(machineId) {
  var els = [];
  var tile = document.getElementById('vision-cmd-state-' + machineId);
  if (tile) els.push(tile);
  var popup = document.getElementById('vision-popup-cmd-state-' + machineId);
  if (popup) els.push(popup);
  var agentver = document.getElementById('vision-agentver-cmd-state-' + machineId);
  if (agentver) els.push(agentver);
  return els;
}

// Render recent command state (queued / delivered / acked) for a machine.
function visionLoadCommands(machineId) {
  var stateEls = _visionCmdStateEls(machineId);
  if (!stateEls.length) return;
  fetch('/vision/commands?machine_id=' + encodeURIComponent(machineId))
    .then(function(r){ return r.ok ? r.json() : {}; })
    .then(function(d){
      var cmds = (d && d.commands) || [];
      if (!cmds.length) { stateEls.forEach(function(el){ el.textContent = 'No recent commands.'; }); return; }
      var stateCls = { queued: 'text-gray-500 dark:text-gray-400', delivered: 'text-blue-600 dark:text-blue-400', acked: 'text-green-600 dark:text-green-400' };
      var html = cmds.slice(0, 3).map(function(c){
        var label = c.state === 'acked' && c.acked_status === 'error' ? 'acked (error)' : c.state;
        return '<div class="flex justify-between gap-2"><span>' + esc(c.action) + '</span>' +
               '<span class="' + (stateCls[c.state] || '') + '">' + esc(label) + '</span></div>';
      }).join('');
      stateEls.forEach(function(el){ el.innerHTML = html; });
    })
    .catch(function(){ stateEls.forEach(function(el){ el.textContent = ''; }); });
}

// ----- Activity tab (DVI-424): per-machine telemetry timeline -----

// Gaps larger than this between consecutive check-ins are rendered as
// offline/no-data spans rather than continuous activity.
var _VISION_GAP_S = 300;

function visionPopulateActivityMachines() {
  var sel = document.getElementById('vision-activity-machine');
  if (!sel) return;
  var prev = sel.value;
  sel.innerHTML = '';
  if (!_visionMachines.length) {
    var o = document.createElement('option');
    o.value = ''; o.textContent = 'No machines';
    sel.appendChild(o);
    return;
  }
  _visionMachines.forEach(function(m){
    var o = document.createElement('option');
    o.value = m.machine_id;
    o.textContent = _visionDispName(m);
    sel.appendChild(o);
  });
  if (prev && _visionMachines.some(function(m){ return m.machine_id === prev; })) sel.value = prev;
}

// Friendly display label for a machine: admin-assigned name, else hostname, else id.
function _visionDispName(m) {
  if (!m) return '';
  return m.display_name || m.hostname || m.machine_id || '';
}

function _visionFmtDur(seconds) {
  seconds = Math.round(seconds);
  if (seconds < 60) return seconds + 's';
  if (seconds < 3600) return Math.round(seconds / 60) + ' min';
  var h = Math.floor(seconds / 3600), m = Math.round((seconds % 3600) / 60);
  return h + 'h' + (m ? ' ' + m + 'm' : '');
}

function visionLoadActivity() {
  var sel = document.getElementById('vision-activity-machine');
  var loading = document.getElementById('vision-activity-loading');
  var empty = document.getElementById('vision-activity-empty');
  var error = document.getElementById('vision-activity-error');
  var content = document.getElementById('vision-activity-content');
  loading.classList.add('hidden'); empty.classList.add('hidden');
  error.classList.add('hidden'); content.classList.add('hidden');
  var machineId = sel ? sel.value : '';
  if (!machineId) { empty.classList.remove('hidden'); return; }
  loading.classList.remove('hidden');
  fetch('/vision/checkins/' + encodeURIComponent(machineId))
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      loading.classList.add('hidden');
      if (!res.ok || res.d.error) {
        document.getElementById('vision-activity-error-msg').textContent = res.d.error || 'Failed to load activity.';
        error.classList.remove('hidden');
        return;
      }
      document.getElementById('vision-activity-time').textContent = 'Updated ' + new Date().toLocaleTimeString();
      var checkins = res.d.checkins || [];
      if (!checkins.length) { empty.classList.remove('hidden'); return; }
      visionRenderActivity(checkins);
      content.classList.remove('hidden');
    })
    .catch(function(e){
      loading.classList.add('hidden');
      document.getElementById('vision-activity-error-msg').textContent = e.message;
      error.classList.remove('hidden');
    });
}

// Derive spans + foreground-app durations from raw ascending check-in history.
function visionRenderActivity(checkins) {
  var content = document.getElementById('vision-activity-content');
  var nowS = Date.now() / 1000;
  var spans = [];
  var appDur = {};      // foreground app -> seconds
  var activeS = 0, idleS = 0, onlineS = 0;

  for (var i = 0; i < checkins.length; i++) {
    var c = checkins[i];
    var start = c.received_at;
    var end = (i + 1 < checkins.length) ? checkins[i + 1].received_at : nowS;
    var dur = end - start;
    if (dur <= 0) continue;
    if (dur > _VISION_GAP_S) {
      // Reporting gap — count only the expected beat as online, rest is no-data.
      spans.push({ start: start, end: end, status: 'offline', app: null, gap: true });
      continue;
    }
    spans.push({ start: start, end: end, status: c.status || 'unknown', app: c.foreground_app, gap: false });
    onlineS += dur;
    if (c.status === 'active') activeS += dur;
    else if (c.status === 'idle') idleS += dur;
    if (c.foreground_app) appDur[c.foreground_app] = (appDur[c.foreground_app] || 0) + dur;
  }

  // Summary cards.
  var apps = Object.keys(appDur).map(function(k){ return { app: k, dur: appDur[k] }; })
    .sort(function(a, b){ return b.dur - a.dur; }).slice(0, 5);
  var summary =
    '<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">' +
      _visionStatCard('Online (tracked)', _visionFmtDur(onlineS)) +
      _visionStatCard('Active', _visionFmtDur(activeS)) +
      _visionStatCard('Idle', _visionFmtDur(idleS)) +
    '</div>';

  var appsHtml = '';
  if (apps.length) {
    appsHtml =
      '<div><h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">Foreground apps</h4>' +
        '<div class="space-y-1">' + apps.map(function(a){
          var pct = onlineS > 0 ? Math.round(a.dur / onlineS * 100) : 0;
          return '<div class="flex items-center gap-2 text-xs">' +
            '<span class="w-32 truncate text-gray-700 dark:text-gray-300" title="' + esc(a.app) + '">' + esc(a.app) + '</span>' +
            '<span class="flex-1 h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden"><span class="block h-full rounded-full bg-togen-teal" style="width:' + pct + '%"></span></span>' +
            '<span class="w-16 text-right text-gray-500 dark:text-gray-400">' + esc(_visionFmtDur(a.dur)) + '</span></div>';
        }).join('') + '</div></div>';
  }

  // Timeline (newest first), recent spans only.
  var recent = spans.slice(-40).reverse();
  var timeline =
    '<div><h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">Timeline</h4>' +
      '<ul class="space-y-1.5">' + recent.map(function(s){
        var t = new Date(s.start * 1000).toLocaleString();
        var dot, label;
        if (s.gap) { dot = 'bg-gray-300 dark:bg-gray-600'; label = 'Offline / no data'; }
        else if (s.status === 'active') { dot = 'bg-green-500'; label = 'Active'; }
        else if (s.status === 'idle') { dot = 'bg-amber-500'; label = 'Idle'; }
        else if (s.status === 'paused') { dot = 'bg-blue-500'; label = 'Paused'; }
        else if (s.status === 'error') { dot = 'bg-red-500'; label = 'Error'; }
        else { dot = 'bg-gray-400'; label = esc(s.status); }
        var appTxt = s.app ? ' · ' + esc(s.app) : '';
        return '<li class="flex items-center gap-2 text-xs">' +
          '<span class="inline-block w-2 h-2 rounded-full ' + dot + '"></span>' +
          '<span class="text-gray-500 dark:text-gray-400 w-40 shrink-0">' + esc(t) + '</span>' +
          '<span class="text-gray-700 dark:text-gray-300">' + label + appTxt + '</span>' +
          '<span class="ml-auto text-gray-400 dark:text-gray-500">' + esc(_visionFmtDur(s.end - s.start)) + '</span></li>';
      }).join('') + '</ul></div>';

  content.innerHTML = summary + appsHtml + timeline;
}

function _visionStatCard(label, value) {
  return '<div class="rounded-xl border border-gray-200 dark:border-gray-700 p-3">' +
    '<div class="text-xs text-gray-500 dark:text-gray-400">' + esc(label) + '</div>' +
    '<div class="text-lg font-semibold text-gray-800 dark:text-gray-100">' + esc(value) + '</div></div>';
}

// ----- Alerts (DVI-424) -----
function visionLoadAlerts() {
  fetch('/vision/alerts')
    .then(function(r){ return r.ok ? r.json() : {}; })
    .then(function(d){
      var alerts = (d && d.alerts) || [];
      var banner = document.getElementById('vision-alerts-banner');
      var countEl = document.getElementById('vision-alerts-count');
      var list = document.getElementById('vision-alerts-list');
      var dismissAll = document.getElementById('vision-alerts-dismiss-all');
      if (!banner) return;
      if (!alerts.length) { banner.classList.add('hidden'); return; }
      banner.classList.remove('hidden');
      if (dismissAll) dismissAll.classList.toggle('hidden', !_visionAlertsExpanded);
      countEl.textContent = alerts.length + (alerts.length === 1 ? ' alert' : ' alerts');
      list.innerHTML = alerts.slice(0, 20).map(function(a){
        var when = a.created_at ? new Date(a.created_at).toLocaleString() : '';
        var xBtn = a.key
          ? '<button onclick="visionDismissAlert(\'' + esc(a.key) + '\')" title="Dismiss" ' +
              'class="shrink-0 -my-0.5 p-0.5 rounded text-amber-600/70 dark:text-amber-300/60 hover:text-amber-800 dark:hover:text-amber-200 hover:bg-amber-100 dark:hover:bg-amber-900/40 cursor-pointer">' +
              '<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">' +
                '<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg></button>'
          : '';
        return '<li class="flex items-center justify-between gap-2"><span>' + esc(a.message || a.type) + '</span>' +
               '<span class="flex items-center gap-2 shrink-0">' +
                 '<span class="text-amber-600/70 dark:text-amber-300/60">' + esc(when) + '</span>' + xBtn +
               '</span></li>';
      }).join('');
    })
    .catch(function(){});
}

function visionToggleAlerts() {
  _visionAlertsExpanded = !_visionAlertsExpanded;
  var list = document.getElementById('vision-alerts-list');
  var toggle = document.getElementById('vision-alerts-toggle');
  var dismissAll = document.getElementById('vision-alerts-dismiss-all');
  if (_visionAlertsExpanded) { list.classList.remove('hidden'); toggle.textContent = 'Hide'; if (dismissAll) dismissAll.classList.remove('hidden'); }
  else { list.classList.add('hidden'); toggle.textContent = 'Show'; if (dismissAll) dismissAll.classList.add('hidden'); }
}

function visionDismissAlert(key) {
  fetch('/vision/alerts/dismiss', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ key: key })
  }).then(function(){ visionLoadAlerts(); }).catch(function(){});
}

function visionDismissAllAlerts() {
  fetch('/vision/alerts/dismiss', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ all: true })
  }).then(function(){ visionLoadAlerts(); }).catch(function(){});
}

// ----- Screenshot lightbox (DVI-427, D2) -----
function visionLightboxOpen(url, caption) {
  if (!url) return;
  document.getElementById('vision-lightbox-img').src = url;
  document.getElementById('vision-lightbox-caption').textContent = caption || '';
  document.getElementById('vision-lightbox-overlay').classList.remove('hidden');
}
function visionLightboxClose() {
  document.getElementById('vision-lightbox-overlay').classList.add('hidden');
  document.getElementById('vision-lightbox-img').src = '';
}

// ----- Agent detail popup (DVI-682) -----
var _visionPopupMachineId = null;

function _visionFindMachine(machineId) {
  for (var i = 0; i < _visionMachines.length; i++) {
    if (_visionMachines[i].machine_id === machineId) return _visionMachines[i];
  }
  return null;
}

function visionAgentPopupOpen(machineId) {
  var m = _visionFindMachine(machineId);
  if (!m) return;
  _visionPopupMachineId = machineId;
  var dispName = m.display_name || m.hostname || 'Unknown host';
  document.getElementById('vision-agent-popup-title').textContent = dispName;
  visionRenderAgentDetail(m);
  visionRenderAgentSettings(m);
  // Always start on the detail pane.
  document.getElementById('vision-agent-popup-detail').classList.remove('hidden');
  document.getElementById('vision-agent-popup-settings').classList.add('hidden');
  document.getElementById('vision-agent-popup-overlay').classList.remove('hidden');
}

function visionAgentPopupClose() {
  document.getElementById('vision-agent-popup-overlay').classList.add('hidden');
  _visionPopupMachineId = null;
}

// Gear icon flips between the detail pane and the settings pane.
function visionAgentPopupToggle() {
  var detail = document.getElementById('vision-agent-popup-detail');
  var settings = document.getElementById('vision-agent-popup-settings');
  detail.classList.toggle('hidden');
  settings.classList.toggle('hidden');
}

function _visionDetailRow(label, value) {
  return '<div class="flex justify-between gap-3 py-1 border-b border-gray-100 dark:border-gray-800">' +
           '<dt class="text-gray-500 dark:text-gray-400">' + esc(label) + '</dt>' +
           '<dd class="text-gray-800 dark:text-gray-200 text-right truncate" title="' + esc(value) + '">' + esc(value) + '</dd>' +
         '</div>';
}

function visionRenderAgentDetail(m) {
  var online = !!m.online;
  var seenMs = m.last_seen ? m.last_seen * 1000 : 0;
  var ago = _syncFmtAgo(seenMs) || 'never';
  var shotHtml = '';
  if (m.thumb_url) {
    shotHtml =
      '<button type="button" onclick="visionLightboxOpen(\'' + esc(m.full_url || m.thumb_url) + '\',\'' + esc(m.display_name || m.hostname || m.machine_id || '') + '\')" ' +
        'class="block w-full mb-4 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 cursor-zoom-in" title="Click to view full screenshot">' +
        '<img src="' + esc(m.thumb_url) + '" alt="Latest screenshot" class="w-full max-h-56 object-cover bg-gray-100 dark:bg-gray-800"></button>';
  } else {
    shotHtml = '<div class="mb-4 rounded-lg border border-dashed border-gray-200 dark:border-gray-700 py-8 text-center text-xs text-gray-400 dark:text-gray-500">No screenshot available</div>';
  }
  var rows = '';
  rows += _visionDetailRow('Status', online ? 'Online' : 'Offline');
  if (m.status) rows += _visionDetailRow('Agent status', m.status);
  rows += _visionDetailRow('Hostname', m.hostname || '—');
  rows += _visionDetailRow('User', m.logged_in_user || '—');
  rows += _visionDetailRow('Last check-in', ago);
  if (_visionVersionOutdated(m)) {
    rows += '<div class="flex justify-between gap-3 py-1 border-b border-gray-100 dark:border-gray-800">' +
              '<dt class="text-gray-500 dark:text-gray-400">Agent version</dt>' +
              '<dd class="text-amber-600 dark:text-amber-400 font-medium text-right" title="Latest deployed is ' + esc(m.latest_agent_version) + '">' +
                esc(m.agent_version || '—') + ' ⚠ <span class="text-xs font-normal">(update available)</span></dd>' +
            '</div>';
  } else {
    rows += _visionDetailRow('Agent version', m.agent_version || '—');
  }
  if (typeof m.agent_uptime_s === 'number') rows += _visionDetailRow('Uptime', _visionFmtUptime(m.agent_uptime_s));
  if (typeof m.restarts === 'number') rows += _visionDetailRow('Restarts (6h)', String(m.restarts));
  if (m.foreground_app) rows += _visionDetailRow('Foreground', m.foreground_app);
  if (typeof m.cpu_pct === 'number') rows += _visionDetailRow('CPU', Math.round(m.cpu_pct) + '%');
  if (typeof m.idle_seconds === 'number') rows += _visionDetailRow('Idle', Math.round(m.idle_seconds) + 's');
  if (m.capture_enabled === true || m.capture_enabled === false) rows += _visionDetailRow('Capture', m.capture_enabled ? 'On' : 'Off');
  var modeTxt = _visionIsVideo(m) ? 'Video' : 'Screenshot';
  if (typeof m.capture_fps === 'number') modeTxt += ' · ' + m.capture_fps + ' fps';
  rows += _visionDetailRow('Capture mode', modeTxt);
  rows += _visionDetailRow('Machine ID', m.machine_id || '—');
  var cmdHtml = '';
  var logHtml = '';
  if (_isToGenAdmin && !_visionMockActive && m.machine_id) {
    cmdHtml = _visionCommandPanelHtml(m.machine_id, 'vision-popup-cmd-state');
    logHtml =
      '<div class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700">' +
        '<div class="flex items-center justify-between mb-2">' +
          '<span class="text-xs font-medium text-gray-600 dark:text-gray-300">Logs</span>' +
          '<button onclick="visionLoadAgentLogs(\'' + esc(m.machine_id) + '\')" class="text-xs text-togen-teal hover:underline cursor-pointer">Refresh</button>' +
        '</div>' +
        '<div id="vision-popup-logs-' + esc(m.machine_id) + '" class="text-xs text-gray-500 dark:text-gray-400">Loading…</div>' +
      '</div>';
  }
  document.getElementById('vision-agent-popup-detail').innerHTML =
    shotHtml + '<dl class="text-sm space-y-0.5">' + rows + '</dl>' + cmdHtml + logHtml;
  if (cmdHtml) visionLoadCommands(m.machine_id);
  if (logHtml) visionLoadAgentLogs(m.machine_id);
}

// List uploaded log files for a machine in the detail popup. Each file opens a
// scrollable viewer; "Upload log" (in Commands) asks the agent to push fresh logs.
function visionLoadAgentLogs(machineId) {
  var box = document.getElementById('vision-popup-logs-' + machineId);
  if (!box) return;
  box.textContent = 'Loading…';
  fetch('/vision/agent-log?machine_id=' + encodeURIComponent(machineId))
    .then(function(r){ return r.ok ? r.json() : {}; })
    .then(function(d){
      var files = (d && d.files) || [];
      if (!files.length) {
        box.innerHTML = 'No logs uploaded yet. Use <span class="font-medium">Upload log</span> above to request the latest from the agent.';
        return;
      }
      box.innerHTML = files.map(function(f){
        var when = f.received_at ? new Date(f.received_at * 1000).toLocaleString() : '';
        return '<button onclick="visionOpenLogViewer(\'' + esc(machineId) + '\',\'' + esc(f.file) + '\')" ' +
          'class="flex items-center justify-between w-full gap-2 px-2 py-1 rounded-md border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer mb-1">' +
          '<span class="text-togen-teal truncate">' + esc(f.file) + '</span>' +
          '<span class="text-[10px] text-gray-400">' + esc(when) + '</span></button>';
      }).join('');
    })
    .catch(function(){ box.textContent = 'Failed to load logs.'; });
}

// Scrollable modal showing a single agent log file's content (DVI-705 #2).
function visionOpenLogViewer(machineId, file) {
  var overlay = document.getElementById('vision-log-viewer-overlay');
  if (!overlay) {
    overlay = document.createElement('div');
    overlay.id = 'vision-log-viewer-overlay';
    overlay.className = 'fixed inset-0 z-[60] hidden items-center justify-center bg-black/50 p-4';
    overlay.onclick = function(ev){ if (ev.target === overlay) visionLogViewerClose(); };
    overlay.innerHTML =
      '<div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl w-full max-w-3xl max-h-[80vh] flex flex-col overflow-hidden">' +
        '<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">' +
          '<span id="vision-log-viewer-title" class="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate"></span>' +
          '<button onclick="visionLogViewerClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-lg leading-none">×</button>' +
        '</div>' +
        '<pre id="vision-log-viewer-body" class="flex-1 overflow-auto p-4 text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words font-mono"></pre>' +
      '</div>';
    document.body.appendChild(overlay);
  }
  document.getElementById('vision-log-viewer-title').textContent = file;
  var bodyEl = document.getElementById('vision-log-viewer-body');
  bodyEl.textContent = 'Loading…';
  overlay.classList.remove('hidden');
  overlay.classList.add('flex');
  fetch('/vision/agent-log?machine_id=' + encodeURIComponent(machineId) + '&file=' + encodeURIComponent(file))
    .then(function(r){ return r.ok ? r.json() : {}; })
    .then(function(d){
      bodyEl.textContent = (d && d.content) ? d.content : '(empty — no content uploaded for this file yet)';
    })
    .catch(function(){ bodyEl.textContent = 'Failed to load log content.'; });
}

function visionLogViewerClose() {
  var overlay = document.getElementById('vision-log-viewer-overlay');
  if (overlay) { overlay.classList.add('hidden'); overlay.classList.remove('flex'); }
}

function visionRenderAgentSettings(m) {
  var el = document.getElementById('vision-agent-popup-settings');
  if (!_isToGenAdmin || _visionMockActive || !m.machine_id) {
    el.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Settings are available to Togen administrators for enrolled agents.</p>';
    return;
  }
  el.innerHTML =
    '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Agent name</label>' +
    '<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">A friendly name shown on the tile instead of the hostname (' + esc(m.hostname || '—') + ').</p>' +
    '<div class="flex items-center gap-2">' +
      '<input id="vision-agent-name-input" type="text" maxlength="80" value="' + esc(m.display_name || '') + '" placeholder="' + esc(m.hostname || '') + '" ' +
        'class="flex-1 px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:border-togen-teal focus:outline-none">' +
      '<button onclick="visionSaveAgentName()" class="px-3 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">Save</button>' +
    '</div>' +
    '<p id="vision-agent-name-status" class="mt-2 text-xs text-gray-500 dark:text-gray-400"></p>';
}

function visionSaveAgentName() {
  var mid = _visionPopupMachineId;
  if (!mid) return;
  var input = document.getElementById('vision-agent-name-input');
  var statusEl = document.getElementById('vision-agent-name-status');
  var name = (input.value || '').trim();
  if (statusEl) statusEl.textContent = 'Saving...';
  fetch('/vision/agent-name', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ machine_id: mid, name: name })
  })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok) { if (statusEl) statusEl.textContent = 'Error: ' + (res.d.error || 'failed'); return; }
      if (statusEl) statusEl.textContent = name ? 'Saved.' : 'Cleared — using hostname.';
      var m = _visionFindMachine(mid);
      if (m) {
        m.display_name = res.d.name || null;
        var dispName = m.display_name || m.hostname || 'Unknown host';
        document.getElementById('vision-agent-popup-title').textContent = dispName;
      }
      visionRenderCollectorGrid(_visionMachines);
    })
    .catch(function(e){ if (statusEl) statusEl.textContent = 'Error: ' + e.message; });
}

// ----- Recordings tab (DVI-427): searchable screenshot gallery -----
function visionPopulateRecordingMachines() {
  var sel = document.getElementById('vision-rec-machine');
  if (!sel) return;
  var prev = sel.value;
  sel.innerHTML = '';
  if (!_visionMachines.length) {
    var o = document.createElement('option');
    o.value = ''; o.textContent = 'No machines';
    sel.appendChild(o);
    return;
  }
  _visionMachines.forEach(function(m){
    var o = document.createElement('option');
    o.value = m.machine_id;
    o.textContent = _visionDispName(m);
    sel.appendChild(o);
  });
  if (prev && _visionMachines.some(function(m){ return m.machine_id === prev; })) sel.value = prev;
}

// datetime-local value -> epoch seconds (local). Empty -> null.
function _visionDtToEpoch(val) {
  if (!val) return null;
  var ms = new Date(val).getTime();
  return isNaN(ms) ? null : Math.floor(ms / 1000);
}

function visionRecordingsClear() {
  document.getElementById('vision-rec-from').value = '';
  document.getElementById('vision-rec-to').value = '';
  visionLoadRecordings();
}

function visionLoadRecordings() {
  var sel = document.getElementById('vision-rec-machine');
  var loading = document.getElementById('vision-rec-loading');
  var empty = document.getElementById('vision-rec-empty');
  var error = document.getElementById('vision-rec-error');
  var grid = document.getElementById('vision-rec-grid');
  loading.classList.add('hidden'); empty.classList.add('hidden');
  error.classList.add('hidden'); grid.classList.add('hidden');
  var machineId = sel ? sel.value : '';
  if (!machineId) { empty.classList.remove('hidden'); return; }
  var qs = [];
  var frm = _visionDtToEpoch(document.getElementById('vision-rec-from').value);
  var to = _visionDtToEpoch(document.getElementById('vision-rec-to').value);
  if (frm !== null) qs.push('from=' + frm);
  if (to !== null) qs.push('to=' + to);
  loading.classList.remove('hidden');
  fetch('/vision/recordings/' + encodeURIComponent(machineId) + (qs.length ? '?' + qs.join('&') : ''))
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      loading.classList.add('hidden');
      if (!res.ok || res.d.error) {
        document.getElementById('vision-rec-error-msg').textContent = res.d.error || 'Failed to load recordings.';
        error.classList.remove('hidden');
        return;
      }
      document.getElementById('vision-rec-time').textContent = 'Updated ' + new Date().toLocaleTimeString();
      var frames = res.d.frames || [];
      if (!frames.length) { empty.classList.remove('hidden'); return; }
      visionRenderRecordings(frames);
      grid.classList.remove('hidden');
    })
    .catch(function(e){
      loading.classList.add('hidden');
      document.getElementById('vision-rec-error-msg').textContent = e.message;
      error.classList.remove('hidden');
    });
}

// Build one recordings-grid card. Video segments (kind === 'video') get an inline
// HTML5 <video> player (range-served by /vision/segments/...) with the poster frame;
// screenshots stay click-to-zoom thumbnails. Shared by the Vision tool + File Browser.
function _visionRecordingCardHtml(f) {
  var when = f.ts ? new Date(f.ts * 1000).toLocaleString() : f.frame_id;
  if (f.kind === 'video' && f.video_url) {
    return '<div class="block rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">' +
      '<video controls preload="none" playsinline poster="' + esc(f.thumb_url || '') + '" ' +
        'class="w-full h-28 object-cover bg-black">' +
        '<source src="' + esc(f.video_url) + '" type="video/mp4">' +
      '</video>' +
      '<div class="px-2 py-1.5 text-[11px] text-gray-600 dark:text-gray-300 truncate flex items-center gap-1" title="' + esc(when) + '">' +
        '<svg class="w-3 h-3 shrink-0 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 6h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2z"/></svg>' +
        '<span class="truncate">' + esc(when) + '</span></div>' +
    '</div>';
  }
  return '<button type="button" onclick="visionLightboxOpen(\'' + esc(f.full_url) + '\',\'' + esc(when) + '\')" ' +
    'class="block text-left rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 cursor-zoom-in hover:shadow-md transition-shadow">' +
    '<img src="' + esc(f.thumb_url) + '" alt="Screenshot" loading="lazy" class="w-full h-28 object-cover bg-gray-100 dark:bg-gray-800">' +
    '<div class="px-2 py-1.5 text-[11px] text-gray-600 dark:text-gray-300 truncate" title="' + esc(when) + '">' + esc(when) + '</div>' +
  '</button>';
}

function visionRenderRecordings(frames) {
  var grid = document.getElementById('vision-rec-grid');
  grid.innerHTML = frames.map(_visionRecordingCardHtml).join('');
}

// ----- Policy tab (DVI-427): per-machine capture policy -----
function visionPolicyLoad() {
  var status = document.getElementById('vision-policy-status');
  if (status) status.textContent = 'Loading...';
  fetch('/togen-admin/vision-policy')
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok || res.d.error) { if (status) status.textContent = res.d.error || 'Failed to load policy.'; return; }
      _visionPolicy = res.d.policy || { default: {}, machines: {} };
      if (!_visionPolicy.machines) _visionPolicy.machines = {};
      _visionPolicyDefaults = res.d.defaults || {};
      if (status) status.textContent = '';
      visionPolicyPopulateTargets();
      visionPolicyRenderForm();
    })
    .catch(function(e){ if (status) status.textContent = 'Error: ' + e.message; });
}

function visionPolicyPopulateTargets() {
  var sel = document.getElementById('vision-policy-target');
  if (!sel) return;
  var prev = sel.value;
  sel.innerHTML = '';
  var optD = document.createElement('option');
  optD.value = '__default__'; optD.textContent = 'Default (all machines)';
  sel.appendChild(optD);
  _visionMachines.forEach(function(m){
    if (!m.machine_id || String(m.machine_id).indexOf('mock-') === 0) return;
    var o = document.createElement('option');
    var hasOverride = _visionPolicy.machines && _visionPolicy.machines.hasOwnProperty(m.machine_id);
    o.value = m.machine_id;
    o.textContent = _visionDispName(m) + (hasOverride ? '  (override)' : '');
    sel.appendChild(o);
  });
  if (prev) sel.value = prev;
}

// Resolve the policy object being edited for the selected scope, falling back to
// the default values so a new per-machine override starts from the effective policy.
function _visionPolicyForTarget(target) {
  var base = Object.assign({}, _visionPolicyDefaults, _visionPolicy.default || {});
  if (target === '__default__') return Object.assign({}, base);
  var ov = (_visionPolicy.machines && _visionPolicy.machines[target]) || {};
  return Object.assign(base, ov);
}

function visionPolicyRenderForm() {
  var sel = document.getElementById('vision-policy-target');
  var target = sel ? sel.value : '__default__';
  var p = _visionPolicyForTarget(target);
  var sched = p.schedule || {};
  var days = sched.days || [];
  var dayLabels = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
  var dayBoxes = dayLabels.map(function(lbl, i){
    return '<label class="inline-flex items-center gap-1 text-xs cursor-pointer text-gray-700 dark:text-gray-300">' +
      '<input type="checkbox" class="vision-policy-day h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal" ' +
      'value="' + i + '"' + (days.indexOf(i) !== -1 ? ' checked' : '') + '>' + lbl + '</label>';
  }).join('');
  var resetBtn = document.getElementById('vision-policy-reset-btn');
  if (resetBtn) {
    if (target !== '__default__' && _visionPolicy.machines && _visionPolicy.machines.hasOwnProperty(target)) {
      resetBtn.classList.remove('hidden');
    } else { resetBtn.classList.add('hidden'); }
  }
  // Effective fps: prefer an explicit capture_fps, else derive from capture_rate_s.
  var fps = (typeof p.capture_fps === 'number' && p.capture_fps > 0)
    ? p.capture_fps
    : (p.capture_rate_s ? Math.round((1 / p.capture_rate_s) * 1000) / 1000 : 1);
  var form = document.getElementById('vision-policy-form');
  form.innerHTML =
    '<label class="flex items-center gap-3 cursor-pointer">' +
      '<input type="checkbox" id="vision-policy-capture" class="h-5 w-5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"' + (p.capture_enabled ? ' checked' : '') + '>' +
      '<span class="text-sm font-medium text-gray-800 dark:text-gray-100">Capture enabled</span>' +
    '</label>' +
    '<div class="rounded-lg border border-gray-200 dark:border-gray-700 p-3 space-y-2">' +
      '<div class="flex items-end gap-3 flex-wrap">' +
        '<div class="w-40">' +
          '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Capture rate (fps)</label>' +
          '<input type="number" min="0.1" max="30" step="0.1" id="vision-policy-fps" value="' + esc(fps) + '" oninput="visionPolicyModeUpdate()" ' +
            'class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">' +
        '</div>' +
        '<div id="vision-policy-mode-badge" class="pb-2"></div>' +
      '</div>' +
      '<p class="text-xs text-gray-500 dark:text-gray-400">Screenshots cap at <strong>0.1 fps</strong> (1 frame / 10s). Any faster rate switches the machine to the H.264 <strong>video backend</strong>.</p>' +
      '<div id="vision-policy-capacity"></div>' +
    '</div>' +
    '<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">' +
      _visionPolicyNum('vision-policy-idle', 'Idle threshold (s)', p.idle_threshold_s) +
      _visionPolicyNum('vision-policy-retention', 'Retention (days)', p.retention_days) +
    '</div>' +
    '<div>' +
      '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Exclusion list (apps / URL substrings, one per line)</label>' +
      '<textarea id="vision-policy-exclusions" rows="4" placeholder="1Password\nchase.com\nWorkday" ' +
        'class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal font-mono">' +
        esc((p.exclusions || []).join('\n')) + '</textarea>' +
      '<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Sensitive apps/sites the agent must never capture (enforced capture-side).</p>' +
    '</div>' +
    '<div>' +
      '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Capture schedule</label>' +
      '<div class="flex flex-wrap gap-3 mb-2">' + dayBoxes + '</div>' +
      '<div class="flex items-center gap-3">' +
        '<label class="text-sm text-gray-600 dark:text-gray-300">Start <input type="time" id="vision-policy-start" value="' + esc(sched.start || '08:00') + '" class="ml-1 px-2 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"></label>' +
        '<label class="text-sm text-gray-600 dark:text-gray-300">End <input type="time" id="vision-policy-end" value="' + esc(sched.end || '17:00') + '" class="ml-1 px-2 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"></label>' +
      '</div>' +
    '</div>';
  visionPolicyModeUpdate();
}

// Screenshot backend ceiling (DVI-727 D4): 1 frame / 10s. Faster => video backend.
var VISION_SCREENSHOT_MAX_FPS = 0.1;

// Rough planning bitrate estimate (Mbps) for a capture rate. Screenshots are full
// ~240KB JPEGs per frame; the H.264 video backend is ~10x denser-efficient.
function _visionEstBitrateMbps(fps, mode) {
  if (!(fps > 0)) return 0;
  if (mode === 'video') return Math.max(0.3, 0.2 * fps);
  return 1.92 * fps; // 240KB * 8 bits / 1000 * fps
}

// Find the collector record for the policy target (null for Default / no telemetry).
function _visionPolicyTargetMachine() {
  var sel = document.getElementById('vision-policy-target');
  var target = sel ? sel.value : '__default__';
  if (target === '__default__') return null;
  return (_visionMachines || []).find(function(m){ return m.machine_id === target; }) || null;
}

// Recompute the mode badge + capability/bandwidth panel from the current fps input.
function visionPolicyModeUpdate() {
  var inp = document.getElementById('vision-policy-fps');
  var badge = document.getElementById('vision-policy-mode-badge');
  var cap = document.getElementById('vision-policy-capacity');
  if (!inp || !badge || !cap) return;
  var fps = parseFloat(inp.value);
  if (isNaN(fps) || fps <= 0) { badge.innerHTML = ''; cap.innerHTML = ''; return; }
  var mode = fps > VISION_SCREENSHOT_MAX_FPS ? 'video' : 'screenshot';
  if (mode === 'video') {
    badge.innerHTML = '<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300">' +
      '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 6h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2z"/></svg>' +
      'Video backend active</span>';
  } else {
    badge.innerHTML = '<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">' +
      '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="14" rx="2" stroke-width="2"/></svg>' +
      'Screenshot backend</span>';
  }
  var est = _visionEstBitrateMbps(fps, mode);
  var estTxt = est >= 1 ? est.toFixed(1) + ' Mbps' : Math.round(est * 1000) + ' kbps';
  var rows = '<div class="text-xs text-gray-600 dark:text-gray-300">Estimated uplink: <strong>~' + estTxt + '</strong> per machine</div>';
  var m = _visionPolicyTargetMachine();
  if (mode === 'video' && m && m.video_capable === false) {
    rows += '<div class="mt-1 text-xs text-amber-600 dark:text-amber-400">&#9888; This machine reports no video-backend capability; it will fall back to screenshots.</div>';
  }
  if (m && typeof m.uplink_mbps === 'number' && m.uplink_mbps > 0) {
    rows += '<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">Machine capability: ' + esc(m.uplink_mbps) + ' Mbps uplink' + (m.video_capable === false ? ', no video encoder' : '') + '</div>';
    if (est > 0.7 * m.uplink_mbps) {
      rows += '<div class="mt-1 text-xs text-red-600 dark:text-red-400">&#9888; Estimated bandwidth exceeds 70% of the machine uplink — capacity may be insufficient. Lower the fps or use screenshot mode.</div>';
    }
  } else if (m) {
    rows += '<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">Capability/bandwidth not yet reported by this agent.</div>';
  } else if (mode === 'video' && est >= 2) {
    rows += '<div class="mt-1 text-xs text-amber-600 dark:text-amber-400">&#9888; High default bitrate — confirm each machine uplink can sustain it.</div>';
  }
  cap.innerHTML = rows;
}

function _visionPolicyNum(id, label, val) {
  return '<div><label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">' + esc(label) + '</label>' +
    '<input type="number" min="0" id="' + id + '" value="' + (val == null ? '' : esc(val)) + '" ' +
    'class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal"></div>';
}

function _visionPolicyCollectForm() {
  var days = [];
  document.querySelectorAll('.vision-policy-day').forEach(function(cb){ if (cb.checked) days.push(parseInt(cb.value, 10)); });
  function num(id) { var v = parseInt(document.getElementById(id).value, 10); return isNaN(v) ? undefined : Math.max(0, v); }
  var excl = document.getElementById('vision-policy-exclusions').value.split('\n')
    .map(function(s){ return s.trim(); }).filter(function(s){ return s; });
  var fps = parseFloat(document.getElementById('vision-policy-fps').value);
  if (isNaN(fps) || fps <= 0) fps = 1;
  fps = Math.min(30, Math.max(0.1, fps));
  var mode = fps > VISION_SCREENSHOT_MAX_FPS ? 'video' : 'screenshot';
  // Keep capture_rate_s coherent for the screenshot backend (>=10s floor).
  var rateS = Math.max(10, Math.round(1 / fps));
  return {
    capture_enabled: document.getElementById('vision-policy-capture').checked,
    capture_mode: mode,
    capture_fps: Math.round(fps * 1000) / 1000,
    capture_rate_s: rateS,
    idle_threshold_s: num('vision-policy-idle'),
    retention_days: num('vision-policy-retention'),
    exclusions: excl,
    schedule: { days: days, start: document.getElementById('vision-policy-start').value, end: document.getElementById('vision-policy-end').value }
  };
}

function visionPolicySave() {
  var sel = document.getElementById('vision-policy-target');
  var target = sel ? sel.value : '__default__';
  var status = document.getElementById('vision-policy-save-status');
  var btn = document.getElementById('vision-policy-save-btn');
  var payload = _visionPolicyCollectForm();
  var body = (target === '__default__')
    ? { default: payload }
    : { machines: (function(){ var o = {}; o[target] = payload; return o; })() };
  btn.disabled = true; btn.classList.add('opacity-50', 'cursor-not-allowed');
  status.textContent = 'Saving...'; status.className = 'text-sm text-gray-500 dark:text-gray-400';
  fetch('/togen-admin/vision-policy', {
    method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
  })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed');
      if (!res.ok || res.d.error) { status.textContent = 'Error: ' + (res.d.error || 'save failed'); status.className = 'text-sm text-red-500'; return; }
      _visionPolicy = res.d.policy;
      if (!_visionPolicy.machines) _visionPolicy.machines = {};
      status.textContent = 'Saved'; status.className = 'text-sm text-green-600 dark:text-green-400';
      visionPolicyPopulateTargets();
      sel.value = target;
      visionPolicyRenderForm();
      setTimeout(function(){ status.textContent = ''; }, 2000);
    })
    .catch(function(e){
      btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed');
      status.textContent = 'Error: ' + e.message; status.className = 'text-sm text-red-500';
    });
}

function visionPolicyResetOverride() {
  var sel = document.getElementById('vision-policy-target');
  var target = sel ? sel.value : '';
  if (target === '__default__' || !target) return;
  if (!confirm('Remove the per-machine policy override for ' + target + '? It will fall back to the Default policy.')) return;
  var body = { machines: (function(){ var o = {}; o[target] = null; return o; })() };
  fetch('/togen-admin/vision-policy', {
    method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
  })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok || res.d.error) { alert('Error: ' + (res.d.error || 'failed')); return; }
      _visionPolicy = res.d.policy;
      if (!_visionPolicy.machines) _visionPolicy.machines = {};
      visionPolicyPopulateTargets();
      sel.value = target;
      visionPolicyRenderForm();
    })
    .catch(function(e){ alert('Error: ' + e.message); });
}

var _scadaInitialized = false;
var _scadaLogs = [];
var _scadaReportLoaded = false;
var _scadaReportSensors = [];
var _scadaReportSelected = {};
var _scadaReportEarliest = null;
var _scadaSchedLoaded = false;
var _scadaSchedules = [];
var _scadaSchedEditId = null;
var _scadaConfig = { ranges: {}, enabled: {}, aliases: {}, reporting: {} };
var _scadaAllReadings = [];
var _scadaHistory = {};
var _scadaHistoryLoaded = false;
var _scadaHistoryLoading = false;
var _scadaLastTab = 'status';
var _scadaTileTime = '';
var _scadaTileConfigLoaded = false;
var _scadaTileLoading = false;

// Fetches config (once), history (once), and fresh status for the Home tile.
// Reuses shared _scadaConfig/_scadaHistory/_scadaAllReadings so the SCADA tool
// benefits from any data already loaded by the tile, and vice-versa.
function _scadaFetchTileData(cb) {
  if (_scadaTileLoading) return;
  _scadaTileLoading = true;
  var fetches = [];
  if (!_scadaTileConfigLoaded) {
    fetches.push(
      fetch('/scada/config')
        .then(function(r) { return r.ok ? r.json() : {}; })
        .then(function(data) {
          _scadaConfig = data.config || { ranges: {}, enabled: {}, aliases: {}, reporting: {} };
          if (!_scadaConfig.aliases) _scadaConfig.aliases = {};
          if (!_scadaConfig.reporting) _scadaConfig.reporting = {};
          _scadaTileConfigLoaded = true;
        })
        .catch(function() {})
    );
  }
  if (!_scadaHistoryLoaded && !_scadaHistoryLoading) {
    _scadaHistoryLoading = true;
    fetches.push(
      fetch('/scada/history')
        .then(function(r) { return r.ok ? r.json() : {}; })
        .then(function(data) {
          _scadaHistory = data.series || {};
          _scadaHistoryLoaded = true;
          _scadaHistoryLoading = false;
        })
        .catch(function() { _scadaHistoryLoading = false; })
    );
  }
  fetches.push(
    fetch('/scada/status')
      .then(function(r) { return r.ok ? r.json() : {}; })
      .then(function(data) {
        _scadaAllReadings = data.readings || [];
        _scadaTileTime = data.time || '';
      })
      .catch(function() {})
  );
  Promise.all(fetches).then(function() {
    _scadaTileLoading = false;
    if (cb) cb();
  }).catch(function() {
    _scadaTileLoading = false;
    if (cb) cb();
  });
}

// DVI-381: fetch WO Admin activity once on home load so the tile can render its
// submission backdrop, due-soon badge, and status chips before the tool is opened.
var _woradminTileLoading = false;
function _woradminFetchTileData(cb) {
  if (_woradminTileLoading) return;
  _woradminTileLoading = true;
  fetch('/wor-admin-activity')
    .then(function(r) { return r.ok ? r.json() : {}; })
    .then(function(d) { if (d && !d.error) _woradminActivity = d; })
    .catch(function() {})
    .then(function() { _woradminTileLoading = false; if (cb) cb(); });
}

function scadaLoadConfig(cb) {
  fetch('/scada/config')
    .then(function(r) { return r.ok ? r.json() : {config:{ranges:{},enabled:{},aliases:{},reporting:{}}}; })
    .then(function(data) {
      _scadaConfig = data.config || { ranges: {}, enabled: {}, aliases: {}, reporting: {}, notify: {} };
      if (!_scadaConfig.aliases) _scadaConfig.aliases = {};
      if (!_scadaConfig.reporting) _scadaConfig.reporting = {};
      if (!_scadaConfig.notify) _scadaConfig.notify = {};
      if (cb) cb();
    })
    .catch(function() { if (cb) cb(); });
}

function scadaDisplayName(label) {
  var alias = _scadaConfig.aliases ? _scadaConfig.aliases[label] : '';
  return (alias && String(alias).trim()) ? alias : label;
}

function scadaSaveConfig() {
  fetch('/scada/config', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(_scadaConfig)
  }).then(function(r) {
    if (!r.ok) throw new Error('HTTP ' + r.status);
    var rs = document.getElementById('scada-range-save-status');
    var es = document.getElementById('scada-enabled-save-status');
    if (rs) rs.textContent = 'Saved.';
    if (es) es.textContent = 'Saved.';
    setTimeout(function(){ if (rs) rs.textContent=''; if (es) es.textContent=''; }, 2000);
    scadaRenderStatusGrid();
  }).catch(function(err) {
    var rs = document.getElementById('scada-range-save-status');
    if (rs) rs.textContent = 'Save failed: ' + err.message;
  });
}

function _scadaIsOutOfRange(label, value) {
  var range = _scadaConfig.ranges[label];
  if (!range) return false;
  var num = parseFloat(value);
  if (isNaN(num)) return false;
  if (range.low !== '' && range.low !== null && range.low !== undefined && num < parseFloat(range.low)) return true;
  if (range.high !== '' && range.high !== null && range.high !== undefined && num > parseFloat(range.high)) return true;
  return false;
}

function _scadaIsEnabled(label) {
  if (!_scadaConfig.enabled || Object.keys(_scadaConfig.enabled).length === 0) return true;
  return _scadaConfig.enabled[label] !== false;
}

// DVI-403: reporting can be disabled per sensor from the Range tab. Disabled
// sensors keep their Status tile (marked "Reporting disabled") but are excluded
// from the Home Live Tile summary.
function _scadaReportingDisabled(label) {
  return !!(_scadaConfig.reporting && _scadaConfig.reporting[label] === false);
}

function _scadaIsSensorLabel(label) {
  return label.indexOf('Temp') !== -1 || label.indexOf('RH') !== -1;
}

// Shared SVG graph builder used by tiles (background), History list (spark),
// and the sensor detail view (detail). Out-of-range points drawn in red.
// mode: 'spark' | 'background' | 'detail'
function scadaBuildGraph(points, range, opts) {
  opts = opts || {};
  var mode = opts.mode || 'spark';
  var nums = [];
  (points || []).forEach(function(p) {
    var v = parseFloat(p.value);
    if (!isNaN(v)) nums.push({ t: p.time, v: v });
  });
  if (nums.length === 0) return '';

  var gLow = (range && range.low !== '' && range.low !== null && range.low !== undefined) ? parseFloat(range.low) : null;
  var gHigh = (range && range.high !== '' && range.high !== null && range.high !== undefined) ? parseFloat(range.high) : null;
  if (gLow !== null && isNaN(gLow)) gLow = null;
  if (gHigh !== null && isNaN(gHigh)) gHigh = null;

  var w, h, padL, padR, padT, padB, stroke;
  stroke = opts.stroke || '#0d9488';
  if (mode === 'detail') { w = 720; h = 260; padL = 44; padR = 12; padT = 14; padB = 26; }
  else if (mode === 'background') { w = 120; h = 48; padL = 0; padR = 0; padT = 2; padB = 2; }
  else { w = 120; h = 32; padL = 2; padR = 2; padT = 3; padB = 3; }

  var lo = nums[0].v, hi = nums[0].v;
  nums.forEach(function(p) { if (p.v < lo) lo = p.v; if (p.v > hi) hi = p.v; });
  if (mode === 'detail') {
    if (gLow !== null) { lo = Math.min(lo, gLow); hi = Math.max(hi, gLow); }
    if (gHigh !== null) { lo = Math.min(lo, gHigh); hi = Math.max(hi, gHigh); }
  }
  if (hi === lo) { hi = lo + 1; lo = lo - 1; }
  var span = hi - lo;
  var plotW = w - padL - padR, plotH = h - padT - padB;
  function X(i) { return nums.length === 1 ? (padL + plotW / 2) : (padL + (i / (nums.length - 1)) * plotW); }
  function Y(v) { return padT + (1 - (v - lo) / span) * plotH; }
  function oor(v) { return (gLow !== null && v < gLow) || (gHigh !== null && v > gHigh); }

  var linePts = nums.map(function(p, i) { return X(i).toFixed(1) + ',' + Y(p.v).toFixed(1); }).join(' ');

  var svgOpen;
  if (mode === 'detail') {
    svgOpen = '<svg viewBox="0 0 ' + w + ' ' + h + '" style="width:100%;height:auto" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">';
  } else if (mode === 'background') {
    svgOpen = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none" style="position:absolute;inset:0;width:100%;height:100%" xmlns="http://www.w3.org/2000/svg">';
  } else {
    svgOpen = '<svg viewBox="0 0 ' + w + ' ' + h + '" width="' + w + '" height="' + h + '" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">';
  }
  var svg = svgOpen;

  if (mode === 'detail') {
    if (gLow !== null) svg += '<line x1="' + padL + '" y1="' + Y(gLow).toFixed(1) + '" x2="' + (w - padR) + '" y2="' + Y(gLow).toFixed(1) + '" stroke="#f59e0b" stroke-width="1" stroke-dasharray="4 3" opacity="0.75"/>';
    if (gHigh !== null) svg += '<line x1="' + padL + '" y1="' + Y(gHigh).toFixed(1) + '" x2="' + (w - padR) + '" y2="' + Y(gHigh).toFixed(1) + '" stroke="#f59e0b" stroke-width="1" stroke-dasharray="4 3" opacity="0.75"/>';
  }

  if (mode === 'background') {
    var area = X(0).toFixed(1) + ',' + (h - padB) + ' ' + linePts + ' ' + X(nums.length - 1).toFixed(1) + ',' + (h - padB);
    svg += '<polygon points="' + area + '" fill="' + stroke + '" opacity="0.12"/>';
  }

  svg += '<polyline points="' + linePts + '" fill="none" stroke="' + stroke + '" stroke-width="' + (mode === 'detail' ? 2 : 1.5) + '" stroke-linejoin="round" stroke-linecap="round" opacity="' + (mode === 'background' ? 0.5 : 1) + '"/>';

  nums.forEach(function(p, i) {
    if (oor(p.v)) {
      svg += '<circle cx="' + X(i).toFixed(1) + '" cy="' + Y(p.v).toFixed(1) + '" r="' + (mode === 'detail' ? 3 : 2) + '" fill="#dc2626"/>';
    } else if (mode === 'detail') {
      svg += '<circle cx="' + X(i).toFixed(1) + '" cy="' + Y(p.v).toFixed(1) + '" r="2.5" fill="' + stroke + '"/>';
    }
  });

  if (mode === 'detail') {
    svg += '<text x="' + (padL - 4) + '" y="' + (padT + 4) + '" font-size="11" fill="#9ca3af" text-anchor="end">' + hi.toFixed(1) + '</text>';
    svg += '<text x="' + (padL - 4) + '" y="' + (h - padB) + '" font-size="11" fill="#9ca3af" text-anchor="end">' + lo.toFixed(1) + '</text>';
    svg += '<text x="' + padL + '" y="' + (h - 6) + '" font-size="10" fill="#9ca3af">' + esc(String(nums[0].t || '')) + '</text>';
    if (nums.length > 1) svg += '<text x="' + (w - padR) + '" y="' + (h - 6) + '" font-size="10" fill="#9ca3af" text-anchor="end">' + esc(String(nums[nums.length - 1].t || '')) + '</text>';
  }

  svg += '</svg>';
  return svg;
}

function scadaLoadHistory(cb) {
  if (_scadaHistoryLoaded) { if (cb) cb(); return; }
  if (_scadaHistoryLoading) { return; }
  _scadaHistoryLoading = true;
  fetch('/scada/history')
    .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
    .then(function(data) {
      _scadaHistory = data.series || {};
      _scadaHistoryLoaded = true;
      _scadaHistoryLoading = false;
      if (cb) cb();
    })
    .catch(function() {
      _scadaHistoryLoading = false;
      if (cb) cb();
    });
}

function scadaRenderStatusGrid() {
  var grid = document.getElementById('scada-status-grid');
  var empty = document.getElementById('scada-status-empty');
  if (!_scadaAllReadings.length) { empty.classList.remove('hidden'); grid.classList.add('hidden'); return; }
  var filtered = _scadaAllReadings.filter(function(r) { return _scadaIsEnabled(r.label); });
  if (!filtered.length) { empty.classList.remove('hidden'); grid.classList.add('hidden'); return; }
  empty.classList.add('hidden');
  grid.classList.remove('hidden');
  grid.innerHTML = '';
  filtered.forEach(function(r) {
    var tile = document.createElement('div');
    tile.className = 'togen-pane relative bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 flex flex-col items-center justify-center text-center overflow-hidden cursor-pointer hover:border-togen-teal/50 transition-colors';
    tile.onclick = (function(label) { return function() { scadaShowSensor(label); }; })(r.label);
    var reportingOff = _scadaReportingDisabled(r.label);
    var isOutOfRange = !reportingOff && _scadaIsOutOfRange(r.label, r.value);
    var valueClass = 'font-bold text-gray-800 dark:text-gray-100';
    if (reportingOff) {
      valueClass = 'font-bold text-gray-400 dark:text-gray-500';
    } else if (r.label.indexOf('Temp') !== -1) {
      valueClass = isOutOfRange ? 'font-bold text-red-600 dark:text-red-400' : 'font-bold text-togen-teal';
    } else if (r.label.indexOf('RH') !== -1) {
      valueClass = isOutOfRange ? 'font-bold text-red-600 dark:text-red-400' : 'font-bold text-blue-600 dark:text-blue-400';
    } else if (r.value === 'On') {
      valueClass = 'font-bold text-green-600 dark:text-green-400';
    } else if (r.value === 'Off') {
      valueClass = 'font-bold text-gray-400 dark:text-gray-500';
    }
    if (reportingOff) { tile.className += ' opacity-60'; }
    var displayVal = r.value;
    var sizeClass = 'text-xl';
    if (displayVal.length > 10) { sizeClass = 'text-sm'; }
    if (displayVal.length > 20) { displayVal = displayVal.substring(0, 18) + '…'; }
    var bg = '';
    if (!reportingOff && _scadaIsSensorLabel(r.label) && _scadaHistory[r.label] && _scadaHistory[r.label].length) {
      var sv = scadaBuildGraph(_scadaHistory[r.label], _scadaConfig.ranges[r.label] || null, { mode: 'background' });
      if (sv) bg = '<div class="absolute inset-0 pointer-events-none" style="z-index:0;opacity:0.6">' + sv + '</div>';
    }
    var statusHtml = reportingOff
      ? '<span class="mt-1 inline-block rounded-full bg-gray-200 dark:bg-gray-700 px-2 py-0.5 text-[10px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Reporting disabled</span>'
      : '<span class="' + sizeClass + ' ' + valueClass + ' truncate w-full" title="' + esc(r.value) + '">' + esc(displayVal) + '</span>';
    tile.innerHTML = bg +
      '<div class="relative flex flex-col items-center justify-center w-full" style="z-index:1">' +
        '<span class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1 truncate w-full">' + esc(scadaDisplayName(r.label)) + '</span>' +
        statusHtml +
      '</div>';
    grid.appendChild(tile);
  });
}

function scadaLoadStatus() {
  var loading = document.getElementById('scada-status-loading');
  var empty = document.getElementById('scada-status-empty');
  var error = document.getElementById('scada-status-error');
  var grid = document.getElementById('scada-status-grid');
  var timeEl = document.getElementById('scada-status-time');
  loading.classList.remove('hidden');
  empty.classList.add('hidden');
  error.classList.add('hidden');
  grid.classList.add('hidden');

  fetch('/scada/status')
    .then(function(r) {
      if (!r.ok) throw new Error('HTTP ' + r.status);
      return r.json();
    })
    .then(function(data) {
      loading.classList.add('hidden');
      _scadaAllReadings = data.readings || [];
      if (data.time) {
        timeEl.textContent = 'Last updated: ' + data.time;
      } else {
        timeEl.textContent = '';
      }
      scadaRenderStatusGrid();
      scadaRenderSensorsList();
      scadaRenderRelaysList();
    })
    .catch(function(err) {
      loading.classList.add('hidden');
      error.classList.remove('hidden');
      document.getElementById('scada-status-error-msg').textContent = 'Failed to load status: ' + err.message;
    });
}

// DVI-465: pencil icon used by the Sensors/Relays config rows.
var _scadaPencilSvg = '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /></svg>';

// Build one config row (used by both the Sensors and Relays tabs): enabled
// checkbox + display name + edit icon. Enabled toggles auto-save.
function scadaBuildConfigRow(label, isSensor) {
  var row = document.createElement('div');
  row.className = 'flex items-center gap-3 p-3 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700';
  var enabled = _scadaIsEnabled(label);
  var dn = scadaDisplayName(label);
  var sub = (dn !== label) ? '<span class="block text-xs text-gray-400 dark:text-gray-500 truncate">' + esc(label) + '</span>' : '';
  row.innerHTML =
    '<input type="checkbox" data-enabled-label="' + esc(label) + '"' + (enabled ? ' checked' : '') +
    ' class="h-5 w-5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer shrink-0" />' +
    '<div class="min-w-0 flex-1">' +
      '<span class="block text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + esc(dn) + '</span>' + sub +
    '</div>' +
    '<button type="button" class="scada-edit-btn shrink-0 p-2 rounded-lg text-gray-400 hover:text-togen-teal hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer" title="Edit">' + _scadaPencilSvg + '</button>';
  row.querySelector('input[type="checkbox"]').addEventListener('change', function() {
    _scadaConfig.enabled[label] = this.checked;
    scadaSaveConfig();
  });
  row.querySelector('.scada-edit-btn').addEventListener('click', function() { scadaOpenEdit(label, isSensor); });
  return row;
}

function scadaRenderSensorsList() {
  var container = document.getElementById('scada-sensors-list');
  if (!container) return;
  if (!_scadaAllReadings.length) {
    container.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-4">Open the Status tab first to discover sensors.</p>';
    return;
  }
  var sensors = _scadaAllReadings.filter(function(r) { return _scadaIsSensorLabel(r.label); });
  if (!sensors.length) { container.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-4">No sensors found.</p>'; return; }
  container.innerHTML = '';
  sensors.forEach(function(r) { container.appendChild(scadaBuildConfigRow(r.label, true)); });
}

function scadaRenderRelaysList() {
  var container = document.getElementById('scada-relays-list');
  if (!container) return;
  if (!_scadaAllReadings.length) {
    container.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-4">Open the Status tab first to discover relays.</p>';
    return;
  }
  var relays = _scadaAllReadings.filter(function(r) { return !_scadaIsSensorLabel(r.label); });
  if (!relays.length) { container.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-4">No relays found.</p>'; return; }
  container.innerHTML = '';
  relays.forEach(function(r) { container.appendChild(scadaBuildConfigRow(r.label, false)); });
}

// ===== SCADA edit dialog (DVI-465) =====
var _scadaEditLabel = null;
var _scadaEditIsSensor = false;

function scadaOpenEdit(label, isSensor) {
  _scadaEditLabel = label;
  _scadaEditIsSensor = isSensor;
  document.getElementById('scada-edit-title').textContent = isSensor ? 'Edit Sensor' : 'Edit Relay';
  document.getElementById('scada-edit-label').textContent = label;
  var nameInput = document.getElementById('scada-edit-name');
  nameInput.value = (_scadaConfig.aliases && _scadaConfig.aliases[label]) || '';
  nameInput.placeholder = label;
  var rangeBlock = document.getElementById('scada-edit-range-block');
  if (isSensor) {
    rangeBlock.classList.remove('hidden');
    var unit = label.indexOf('Temp') !== -1 ? '(°F)' : '(%RH)';
    document.getElementById('scada-edit-unit-low').textContent = unit;
    document.getElementById('scada-edit-unit-high').textContent = unit;
    var range = _scadaConfig.ranges[label] || { low: '', high: '' };
    document.getElementById('scada-edit-low').value = (range.low != null) ? range.low : '';
    document.getElementById('scada-edit-high').value = (range.high != null) ? range.high : '';
    document.getElementById('scada-edit-reporting').checked = _scadaReportingDisabled(label);
  } else {
    rangeBlock.classList.add('hidden');
  }
  document.getElementById('scada-edit-modal').classList.remove('hidden');
}

function scadaCloseEdit() {
  document.getElementById('scada-edit-modal').classList.add('hidden');
  _scadaEditLabel = null;
}

function scadaSaveEdit() {
  var label = _scadaEditLabel;
  if (!label) { scadaCloseEdit(); return; }
  if (!_scadaConfig.aliases) _scadaConfig.aliases = {};
  var name = document.getElementById('scada-edit-name').value.trim();
  if (name) { _scadaConfig.aliases[label] = name; } else { delete _scadaConfig.aliases[label]; }
  if (_scadaEditIsSensor) {
    if (!_scadaConfig.ranges[label]) _scadaConfig.ranges[label] = { low: '', high: '' };
    _scadaConfig.ranges[label].low = document.getElementById('scada-edit-low').value;
    _scadaConfig.ranges[label].high = document.getElementById('scada-edit-high').value;
    if (!_scadaConfig.reporting) _scadaConfig.reporting = {};
    if (document.getElementById('scada-edit-reporting').checked) { _scadaConfig.reporting[label] = false; }
    else { delete _scadaConfig.reporting[label]; }
  }
  scadaSaveConfig();
  scadaCloseEdit();
  scadaRenderSensorsList();
  scadaRenderRelaysList();
}

// ===== SCADA bulk-edit dialog (DVI-483) =====
// Pre-fills the temp/humidity range inputs from the first sensor of each type so
// the operator sees the current settings before overwriting all sensors.
function scadaOpenBulkEdit() {
  var sensors = _scadaAllReadings.filter(function(r) { return _scadaIsSensorLabel(r.label); });
  var ranges = _scadaConfig.ranges || {};
  var notify = _scadaConfig.notify || {};
  var firstTemp = null, firstHum = null, anyNotify = false;
  sensors.forEach(function(r) {
    if (firstTemp === null && r.label.indexOf('Temp') !== -1) firstTemp = ranges[r.label];
    if (firstHum === null && r.label.indexOf('RH') !== -1) firstHum = ranges[r.label];
    if (notify[r.label] !== false) anyNotify = true;
  });
  var setVal = function(id, v) { document.getElementById(id).value = (v != null && v !== '') ? v : ''; };
  setVal('scada-bulk-temp-low', firstTemp && firstTemp.low);
  setVal('scada-bulk-temp-high', firstTemp && firstTemp.high);
  setVal('scada-bulk-hum-low', firstHum && firstHum.low);
  setVal('scada-bulk-hum-high', firstHum && firstHum.high);
  document.getElementById('scada-bulk-notify').checked = sensors.length ? anyNotify : true;
  document.getElementById('scada-bulk-modal').classList.remove('hidden');
}

function scadaCloseBulkEdit() {
  document.getElementById('scada-bulk-modal').classList.add('hidden');
}

function scadaSaveBulkEdit() {
  var labels = _scadaAllReadings
    .filter(function(r) { return _scadaIsSensorLabel(r.label); })
    .map(function(r) { return r.label; });
  var notifyEnabled = document.getElementById('scada-bulk-notify').checked;
  var temp = { low: document.getElementById('scada-bulk-temp-low').value, high: document.getElementById('scada-bulk-temp-high').value };
  var humidity = { low: document.getElementById('scada-bulk-hum-low').value, high: document.getElementById('scada-bulk-hum-high').value };
  // Mirror the server-side override locally so the grid reflects it immediately.
  if (!_scadaConfig.ranges) _scadaConfig.ranges = {};
  if (!_scadaConfig.notify) _scadaConfig.notify = {};
  labels.forEach(function(label) {
    _scadaConfig.ranges[label] = (label.indexOf('Temp') !== -1)
      ? { low: temp.low, high: temp.high }
      : { low: humidity.low, high: humidity.high };
    _scadaConfig.notify[label] = notifyEnabled;
  });
  fetch('/scada/config', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ bulk_override: true, labels: labels, temp: temp, humidity: humidity, notify_enabled: notifyEnabled })
  })
    .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
    .then(function() {
      scadaCloseBulkEdit();
      scadaRenderSensorsList();
      scadaRenderStatusGrid();
    })
    .catch(function(err) { alert('Bulk save failed: ' + err.message); });
}

// ===== SCADA Settings tab (DVI-465) =====
// Per-user notification prefs live in _userSettings.scada_notifications and are
// mirrored between this tab and the User Settings Notifications pane.
function _scadaNotifPrefs() {
  var p = (typeof _userSettings !== 'undefined' && _userSettings && _userSettings.scada_notifications) || {};
  return { stale_data: !!p.stale_data, out_of_range: !!p.out_of_range };
}

function scadaSyncNotifUI() {
  var p = _scadaNotifPrefs();
  [['scada-notify-stale', 'stale_data'], ['scada-notify-oor', 'out_of_range'],
   ['us-scada-notify-stale', 'stale_data'], ['us-scada-notify-oor', 'out_of_range']].forEach(function(pair) {
    var el = document.getElementById(pair[0]);
    if (el) el.checked = p[pair[1]];
  });
}

function scadaSaveNotif(key, checked, statusId) {
  var p = _scadaNotifPrefs();
  p[key] = checked;
  if (typeof _userSettings === 'undefined' || !_userSettings) _userSettings = {};
  _userSettings.scada_notifications = p;
  scadaSyncNotifUI();
  var st = statusId && document.getElementById(statusId);
  if (st) { st.textContent = 'Saving…'; st.className = 'text-xs text-gray-500 dark:text-gray-400 mt-3 block'; }
  fetch('/user-settings', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ scada_notifications: p })
  })
    .then(function(r) { return r.json(); })
    .then(function(d) {
      if (d.error) throw new Error(d.error);
      if (d.settings) { _userSettings = d.settings; }
      scadaSyncNotifUI();
      if (st) { st.textContent = 'Saved.'; setTimeout(function() { if (st) st.textContent = ''; }, 2000); }
    })
    .catch(function(err) {
      if (st) { st.textContent = 'Save failed: ' + err.message; st.className = 'text-xs text-red-500 mt-3 block'; }
    });
}

function scadaRenderSettings() {
  scadaSyncNotifUI();
  var collector = (_scadaConfig && _scadaConfig.collector) || {};
  var iv = document.getElementById('scada-collector-interval');
  var stale = document.getElementById('scada-collector-stale');
  if (iv) iv.value = (collector.check_interval_minutes != null) ? collector.check_interval_minutes : 15;
  if (stale) stale.value = (collector.stale_after_minutes != null) ? collector.stale_after_minutes : 60;
  scadaLoadReportNotif();
}

// ===== REPORT NOTIFICATIONS (DVI-487) =====
var _scadaRNRecipients = { enforced: [], external: [] };

function scadaRNSetStatus(msg, kind) {
  var s = document.getElementById('scada-rn-status');
  if (!s) return;
  s.textContent = msg || '';
  s.className = 'text-xs mt-3 block ' + (
    kind === 'error' ? 'text-red-500' :
    kind === 'ok' ? 'text-green-600 dark:text-green-400' :
    'text-gray-500 dark:text-gray-400');
  if (kind === 'ok') setTimeout(function() { if (s.textContent === msg) s.textContent = ''; }, 2500);
}

function scadaLoadReportNotif() {
  fetch('/scada/report-schedules')
    .then(function(r) { return r.ok ? r.json() : { schedules: [] }; })
    .then(function(d) { _scadaSchedules = d.schedules || []; scadaRenderReportNotifGrid(); })
    .catch(function() { scadaRenderReportNotifGrid(); });
  ['enforced', 'external'].forEach(function(name) {
    fetch('/scada/report-recipients/' + name)
      .then(function(r) { return r.ok ? r.json() : { recipients: [] }; })
      .then(function(d) { _scadaRNRecipients[name] = d.recipients || []; scadaRenderRecipients(name); })
      .catch(function() { scadaRenderRecipients(name); });
  });
}

function scadaRenderReportNotifGrid() {
  var empty = document.getElementById('scada-rn-sched-empty');
  var wrap = document.getElementById('scada-rn-sched-wrap');
  var tbody = document.getElementById('scada-rn-sched-tbody');
  if (!tbody) return;
  if (!_scadaSchedules.length) {
    empty.classList.remove('hidden');
    wrap.classList.add('hidden');
    return;
  }
  empty.classList.add('hidden');
  wrap.classList.remove('hidden');
  tbody.innerHTML = '';
  _scadaSchedules.forEach(function(s) {
    var tr = document.createElement('tr');
    tr.className = 'border-b border-gray-100 dark:border-gray-800';
    var nameTd = document.createElement('td');
    nameTd.className = 'py-2 pr-4 text-gray-800 dark:text-gray-100 font-medium';
    nameTd.textContent = s.name;
    var notifyTd = document.createElement('td');
    notifyTd.className = 'py-2 pr-4';
    var notifyCb = document.createElement('input');
    notifyCb.type = 'checkbox';
    notifyCb.className = 'h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer';
    notifyCb.checked = !!s.notify;
    notifyCb.onchange = function() { scadaToggleScheduleField(s.id, 'notify', notifyCb.checked); };
    notifyTd.appendChild(notifyCb);
    var extTd = document.createElement('td');
    extTd.className = 'py-2';
    var extCb = document.createElement('input');
    extCb.type = 'checkbox';
    extCb.className = 'h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer';
    extCb.checked = !!s.external;
    extCb.onchange = function() { scadaToggleScheduleField(s.id, 'external', extCb.checked); };
    extTd.appendChild(extCb);
    tr.appendChild(nameTd);
    tr.appendChild(notifyTd);
    tr.appendChild(extTd);
    tbody.appendChild(tr);
  });
}

function scadaToggleScheduleField(id, field, checked) {
  var sched = _scadaSchedules.filter(function(s) { return s.id === id; })[0];
  if (!sched) return;
  var payload = {
    name: sched.name,
    cadence: sched.cadence,
    sensors: sched.sensors || [],
    notify: !!sched.notify,
    external: !!sched.external
  };
  if (sched.cadence === 'custom') payload.custom_days = sched.custom_days;
  payload[field] = checked;
  scadaRNSetStatus('Saving…');
  fetch('/scada/report-schedules/' + id, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload)
  })
    .then(function(r) { return r.json().then(function(d) { if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); return d; }); })
    .then(function(d) {
      sched.notify = d.schedule.notify;
      sched.external = d.schedule.external;
      scadaRNSetStatus('Saved', 'ok');
    })
    .catch(function(err) {
      scadaRNSetStatus('Save failed: ' + err.message, 'error');
      scadaRenderReportNotifGrid();
    });
}

function scadaRenderRecipients(name) {
  var ul = document.getElementById('scada-rn-' + name + '-list');
  if (!ul) return;
  ul.innerHTML = '';
  var emails = _scadaRNRecipients[name] || [];
  if (!emails.length) {
    var li = document.createElement('li');
    li.className = 'py-1 text-sm text-gray-400 dark:text-gray-500 italic';
    li.textContent = 'No recipients.';
    ul.appendChild(li);
    return;
  }
  emails.forEach(function(em) {
    var li = document.createElement('li');
    li.className = 'flex items-center gap-2 py-1';
    var span = document.createElement('span');
    span.className = 'flex-1 min-w-0 truncate text-sm text-gray-800 dark:text-gray-100';
    span.textContent = em;
    var btn = document.createElement('button');
    btn.type = 'button';
    btn.title = 'Remove';
    btn.className = 'shrink-0 inline-flex items-center justify-center h-7 w-7 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-500 hover:text-red-600 hover:border-red-400 dark:hover:text-red-400 transition-colors cursor-pointer';
    btn.innerHTML = '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 10a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>';
    btn.onclick = function() { scadaRecipientMutate(name, 'remove', em); };
    li.appendChild(span);
    li.appendChild(btn);
    ul.appendChild(li);
  });
}

function scadaRecipientAdd(name) {
  var input = document.getElementById('scada-rn-' + name + '-input');
  if (!input) return;
  if (input.classList.contains('hidden')) {
    input.classList.remove('hidden');
    input.focus();
    return;
  }
  var email = (input.value || '').trim();
  if (!email) { input.focus(); return; }
  scadaRecipientMutate(name, 'add', email);
}

function scadaRecipientMutate(name, action, email) {
  scadaRNSetStatus('Saving…');
  fetch('/scada/report-recipients/' + name, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ action: action, email: email })
  })
    .then(function(r) { return r.json().then(function(d) { if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); return d; }); })
    .then(function(d) {
      _scadaRNRecipients[name] = d.recipients || [];
      var input = document.getElementById('scada-rn-' + name + '-input');
      if (input) { input.value = ''; input.classList.add('hidden'); }
      scadaRenderRecipients(name);
      scadaRNSetStatus(action === 'add' ? 'Added' : 'Removed', 'ok');
    })
    .catch(function(err) { scadaRNSetStatus('Error: ' + err.message, 'error'); });
}

function scadaSaveCollector() {
  var iv = parseInt(document.getElementById('scada-collector-interval').value, 10);
  var stale = parseInt(document.getElementById('scada-collector-stale').value, 10);
  if (isNaN(iv) || iv < 1) iv = 1;
  if (isNaN(stale) || stale < 1) stale = 1;
  var collector = { check_interval_minutes: iv, stale_after_minutes: stale };
  _scadaConfig.collector = collector;
  var st = document.getElementById('scada-collector-status');
  if (st) { st.textContent = 'Saving…'; st.className = 'text-xs text-gray-500 dark:text-gray-400'; }
  fetch('/scada/config', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ collector: collector })
  })
    .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
    .then(function() { if (st) { st.textContent = 'Saved.'; setTimeout(function() { if (st) st.textContent = ''; }, 2000); } })
    .catch(function(err) { if (st) { st.textContent = 'Save failed: ' + err.message; st.className = 'text-xs text-red-500'; } });
}

function scadaRenderHistoryList() {
  var list = document.getElementById('scada-history-list');
  var loading = document.getElementById('scada-history-loading');
  var empty = document.getElementById('scada-history-empty');
  if (!list) return;
  if (loading) loading.classList.add('hidden');
  var labels = [];
  if (_scadaAllReadings.length) {
    _scadaAllReadings.forEach(function(r) {
      if (_scadaIsSensorLabel(r.label) && _scadaIsEnabled(r.label)) labels.push(r.label);
    });
  } else {
    Object.keys(_scadaHistory).forEach(function(l) {
      if (_scadaIsSensorLabel(l) && _scadaIsEnabled(l)) labels.push(l);
    });
  }
  if (!labels.length) {
    if (empty) empty.classList.remove('hidden');
    list.innerHTML = '';
    return;
  }
  if (empty) empty.classList.add('hidden');
  list.innerHTML = '';
  labels.forEach(function(label) {
    var points = _scadaHistory[label] || [];
    var range = _scadaConfig.ranges[label] || null;
    var spark = scadaBuildGraph(points, range, { mode: 'spark' });
    var latest = points.length ? points[points.length - 1].value : '—';
    var row = document.createElement('div');
    row.className = 'flex items-center justify-between py-3 px-4 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 border border-gray-200 dark:border-gray-700 cursor-pointer';
    row.onclick = function() { scadaShowSensor(label); };
    row.innerHTML =
      '<div class="min-w-0">' +
        '<div class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + esc(scadaDisplayName(label)) + '</div>' +
        '<div class="text-xs text-gray-400 dark:text-gray-500">' + points.length + ' readings · latest ' + esc(String(latest)) + '</div>' +
      '</div>' +
      '<div class="shrink-0 ml-4">' + (spark || '<span class="text-xs text-gray-400 dark:text-gray-500">no data</span>') + '</div>';
    list.appendChild(row);
  });
}

function scadaBuildRelayTimeline(points) {
  if (!points || !points.length) return '<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8">No state history.</p>';
  var w = 720, h = 44, n = points.length, seg = w / n;
  var svg = '<svg viewBox="0 0 ' + w + ' ' + h + '" style="width:100%;height:auto" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">';
  points.forEach(function(p, i) {
    var on = String(p.value).toLowerCase() === 'on';
    var color = on ? '#16a34a' : '#9ca3af';
    svg += '<rect x="' + (i * seg).toFixed(1) + '" y="8" width="' + (seg + 0.5).toFixed(1) + '" height="20" fill="' + color + '" opacity="0.85"/>';
  });
  svg += '</svg>';
  return '<div class="mb-1">' + svg + '</div>' +
    '<div class="flex justify-between text-[10px] text-gray-400 dark:text-gray-500"><span>' + esc(String(points[0].time || '')) + '</span><span>' + esc(String(points[points.length - 1].time || '')) + '</span></div>' +
    '<div class="flex gap-4 mt-2 text-xs text-gray-500 dark:text-gray-400">' +
      '<span class="inline-flex items-center gap-1"><span class="inline-block w-3 h-3 rounded-sm" style="background:#16a34a"></span>On</span>' +
      '<span class="inline-flex items-center gap-1"><span class="inline-block w-3 h-3 rounded-sm" style="background:#9ca3af"></span>Off</span>' +
    '</div>';
}

function scadaShowSensor(label) {
  scadaLoadHistory(function() {
    document.getElementById('scada-tabbar').classList.add('hidden');
    var panels = document.querySelectorAll('[id^="scada-panel-"]');
    for (var i = 0; i < panels.length; i++) panels[i].classList.add('hidden');
    document.getElementById('scada-sensor-detail').classList.remove('hidden');
    document.getElementById('scada-sensor-name').textContent = scadaDisplayName(label);

    var points = _scadaHistory[label] || [];
    var range = _scadaConfig.ranges[label] || null;
    var graphEl = document.getElementById('scada-sensor-graph');
    var eventsEl = document.getElementById('scada-sensor-events');
    var isSensor = _scadaIsSensorLabel(label);

    if (isSensor) {
      var g = scadaBuildGraph(points, range, { mode: 'detail' });
      graphEl.innerHTML = g || '<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8">No numeric history to graph.</p>';
    } else {
      graphEl.innerHTML = scadaBuildRelayTimeline(points);
    }

    if (!points.length) {
      eventsEl.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 py-4">No recorded events.</p>';
    } else {
      var rows = '';
      for (var j = points.length - 1; j >= 0; j--) {
        var p = points[j];
        var isOor = isSensor && _scadaIsOutOfRange(label, p.value);
        var valClass = isOor ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-800 dark:text-gray-200';
        rows += '<tr class="border-b border-gray-100 dark:border-gray-800">' +
          '<td class="py-2 pr-4 text-gray-500 dark:text-gray-400 whitespace-nowrap">' + esc(String(p.time || '')) + '</td>' +
          '<td class="py-2 ' + valClass + '">' + esc(String(p.value)) + '</td>' +
        '</tr>';
      }
      eventsEl.innerHTML = '<table class="w-full text-sm"><thead><tr class="border-b border-gray-200 dark:border-gray-700 text-left">' +
        '<th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Time</th>' +
        '<th class="pb-2 font-semibold text-gray-600 dark:text-gray-300">Value</th>' +
        '</tr></thead><tbody>' + rows + '</tbody></table>';
    }
  });
}

function scadaCloseSensor() {
  document.getElementById('scada-sensor-detail').classList.add('hidden');
  document.getElementById('scada-tabbar').classList.remove('hidden');
  scadaSwitchTab(_scadaLastTab || 'status');
}

function scadaSwitchTab(tab) {
  var tabs = document.querySelectorAll('[id^="scada-tab-"]');
  var panels = document.querySelectorAll('[id^="scada-panel-"]');
  for (var i = 0; i < tabs.length; i++) {
    tabs[i].classList.remove('border-togen-teal', 'text-togen-teal');
    tabs[i].classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400', 'hover:text-gray-700', 'dark:hover:text-gray-300');
  }
  for (var i = 0; i < panels.length; i++) panels[i].classList.add('hidden');
  var activeTab = document.getElementById('scada-tab-' + tab);
  var activePanel = document.getElementById('scada-panel-' + tab);
  if (activeTab) {
    activeTab.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400', 'hover:text-gray-700', 'dark:hover:text-gray-300');
    activeTab.classList.add('border-togen-teal', 'text-togen-teal');
  }
  if (activePanel) activePanel.classList.remove('hidden');
  _scadaLastTab = tab;
  if (tab === 'history') {
    if (!_scadaHistoryLoaded) {
      var loading = document.getElementById('scada-history-loading');
      if (loading) loading.classList.remove('hidden');
      scadaLoadHistory(scadaRenderHistoryList);
    } else {
      scadaRenderHistoryList();
    }
  } else if (tab === 'sensors') {
    scadaRenderSensorsList();
  } else if (tab === 'relays') {
    scadaRenderRelaysList();
  } else if (tab === 'reporting') {
    if (!_scadaReportLoaded) scadaLoadReport();
  } else if (tab === 'schedules') {
    if (!_scadaSchedLoaded) scadaLoadSchedules();
  } else if (tab === 'settings') {
    scadaRenderSettings();
  }
}

function scadaLoadLogs() {
  var loading = document.getElementById('scada-logs-loading');
  var empty = document.getElementById('scada-logs-empty');
  var error = document.getElementById('scada-logs-error');
  var tableWrap = document.getElementById('scada-logs-table-wrap');
  var detail = document.getElementById('scada-log-detail');
  var status = document.getElementById('scada-logs-status');
  loading.classList.remove('hidden');
  empty.classList.add('hidden');
  error.classList.add('hidden');
  tableWrap.classList.add('hidden');
  detail.classList.add('hidden');

  fetch('/scada/logs')
    .then(function(r) {
      if (!r.ok) throw new Error('HTTP ' + r.status);
      return r.json();
    })
    .then(function(data) {
      loading.classList.add('hidden');
      _scadaLogs = data.logs || [];
      if (status) status.textContent = 'Last refreshed: ' + new Date().toLocaleTimeString();
      if (_scadaLogs.length === 0) {
        empty.classList.remove('hidden');
        return;
      }
      tableWrap.classList.remove('hidden');
      var tbody = document.getElementById('scada-logs-tbody');
      tbody.innerHTML = '';
      _scadaLogs.forEach(function(log, idx) {
        var tr = document.createElement('tr');
        tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer transition-colors';
        tr.onclick = function() { scadaShowDetail(idx); };
        var preview = (log.body || '').substring(0, 120);
        if ((log.body || '').length > 120) preview += '...';
        tr.innerHTML =
          '<td class="py-2.5 pr-4 text-gray-600 dark:text-gray-400 whitespace-nowrap">' + esc(log.date || '') + '</td>' +
          '<td class="py-2.5 pr-4 text-gray-800 dark:text-gray-200 font-medium">' + esc(log.subject || '') + '</td>' +
          '<td class="py-2.5 text-gray-500 dark:text-gray-400 truncate max-w-xs">' + esc(preview) + '</td>';
        tbody.appendChild(tr);
      });
    })
    .catch(function(err) {
      loading.classList.add('hidden');
      error.classList.remove('hidden');
      document.getElementById('scada-logs-error-msg').textContent = 'Failed to load logs: ' + err.message;
    });
}

function scadaShowDetail(idx) {
  var log = _scadaLogs[idx];
  if (!log) return;
  document.getElementById('scada-logs-table-wrap').classList.add('hidden');
  document.getElementById('scada-log-detail').classList.remove('hidden');
  document.getElementById('scada-detail-subject').textContent = (log.date || '') + ' — ' + (log.subject || '');
  document.getElementById('scada-detail-body').textContent = log.body || '(no content)';
}

function scadaCloseDetail() {
  document.getElementById('scada-log-detail').classList.add('hidden');
  document.getElementById('scada-logs-table-wrap').classList.remove('hidden');
}

// ===== REPORTING TAB =====
function scadaReportQuery() {
  var params = new URLSearchParams();
  var from = document.getElementById('scada-report-from').value;
  var to = document.getElementById('scada-report-to').value;
  if (from) params.set('date_from', from);
  if (to) params.set('date_to', to);
  Object.keys(_scadaReportSelected).forEach(function(s) {
    if (_scadaReportSelected[s]) params.append('sensors', s);
  });
  return params.toString();
}

function scadaUpdateSensorLabel() {
  var sel = Object.keys(_scadaReportSelected).filter(function(s) { return _scadaReportSelected[s]; });
  var label = document.getElementById('scada-report-sensors-label');
  if (!sel.length) label.textContent = 'All sensors';
  else if (sel.length === 1) label.textContent = sel[0];
  else label.textContent = sel.length + ' sensors';
}

function scadaToggleSensorMenu() {
  document.getElementById('scada-report-sensors-menu').classList.toggle('hidden');
}

function scadaBuildSensorMenu() {
  var menu = document.getElementById('scada-report-sensors-menu');
  menu.innerHTML = '';
  if (!_scadaReportSensors.length) {
    menu.innerHTML = '<p class="text-xs text-gray-400 px-1 py-1">No sensors available.</p>';
    return;
  }
  _scadaReportSensors.forEach(function(s) {
    var id = 'scada-rs-' + s.replace(/[^a-zA-Z0-9]/g, '_');
    var lbl = document.createElement('label');
    lbl.className = 'flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer px-1 py-0.5';
    lbl.innerHTML = '<input type="checkbox" class="rounded" id="' + id + '">' +
      '<span>' + esc(s) + '</span>';
    var cb = lbl.querySelector('input');
    cb.checked = !!_scadaReportSelected[s];
    cb.onchange = function() {
      _scadaReportSelected[s] = cb.checked;
      scadaUpdateSensorLabel();
    };
    menu.appendChild(lbl);
  });
}

function scadaLoadReport() {
  var loading = document.getElementById('scada-report-loading');
  var empty = document.getElementById('scada-report-empty');
  var error = document.getElementById('scada-report-error');
  var grid = document.getElementById('scada-report-grid');
  document.getElementById('scada-report-sensors-menu').classList.add('hidden');
  loading.classList.remove('hidden');
  empty.classList.add('hidden');
  error.classList.add('hidden');
  grid.classList.add('hidden');

  fetch('/scada/report?' + scadaReportQuery())
    .then(function(r) {
      return r.json().then(function(data) {
        if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
        return data;
      });
    })
    .then(function(data) {
      loading.classList.add('hidden');
      _scadaReportLoaded = true;
      _scadaReportEarliest = data.earliest_date || null;
      if (data.available_sensors && data.available_sensors.length) {
        _scadaReportSensors = data.available_sensors;
        scadaBuildSensorMenu();
      }
      var earliestEl = document.getElementById('scada-report-earliest');
      earliestEl.textContent = _scadaReportEarliest
        ? 'Earliest available: ' + _scadaReportEarliest : '';
      if (!data.days || !data.days.length) {
        empty.classList.remove('hidden');
        return;
      }
      grid.innerHTML = '';
      data.days.forEach(function(day) {
        var wrap = document.createElement('div');
        var head = '<div class="flex items-baseline gap-2 mb-2">' +
          '<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100">X-400 - Log</h4>' +
          '<span class="text-xs text-gray-500 dark:text-gray-400">' + esc(day.date) + '</span></div>';
        var thead = '<tr class="border-b border-gray-200 dark:border-gray-700 text-left">' +
          day.columns.map(function(c) {
            return '<th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300 whitespace-nowrap">' + esc(c) + '</th>';
          }).join('') + '</tr>';
        var tbody = day.rows.map(function(row) {
          return '<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
            row.map(function(cell) {
              return '<td class="py-1.5 pr-4 text-gray-700 dark:text-gray-300 whitespace-nowrap">' + esc(String(cell == null ? '' : cell)) + '</td>';
            }).join('') + '</tr>';
        }).join('');
        wrap.innerHTML = head +
          '<div class="overflow-x-auto"><table class="w-full text-sm"><thead>' +
          thead + '</thead><tbody class="divide-y divide-gray-100 dark:divide-gray-800">' +
          tbody + '</tbody></table></div>';
        grid.appendChild(wrap);
      });
      grid.classList.remove('hidden');
    })
    .catch(function(err) {
      loading.classList.add('hidden');
      error.classList.remove('hidden');
      document.getElementById('scada-report-error-msg').textContent = 'Failed to load report: ' + err.message;
    });
}

function scadaOpenReportDownload() {
  document.getElementById('scada-report-dl-from').value = document.getElementById('scada-report-from').value;
  document.getElementById('scada-report-dl-to').value = document.getElementById('scada-report-to').value;
  var box = document.getElementById('scada-report-dl-sensors');
  box.innerHTML = '';
  if (!_scadaReportSensors.length) {
    box.innerHTML = '<p class="text-xs text-gray-400 px-1">No sensors available.</p>';
  } else {
    _scadaReportSensors.forEach(function(s) {
      var lbl = document.createElement('label');
      lbl.className = 'flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer';
      lbl.innerHTML = '<input type="checkbox" class="scada-dl-sensor rounded" value="' + esc(s) + '">' +
        '<span>' + esc(s) + '</span>';
      lbl.querySelector('input').checked = !!_scadaReportSelected[s];
      box.appendChild(lbl);
    });
  }
  document.getElementById('scada-report-dl-earliest').textContent =
    _scadaReportEarliest ? 'Earliest available: ' + _scadaReportEarliest : '';
  document.getElementById('scada-report-dl-overlay').classList.remove('hidden');
}

function scadaCloseReportDownload() {
  document.getElementById('scada-report-dl-overlay').classList.add('hidden');
}

function scadaDoReportDownload() {
  var params = new URLSearchParams();
  var from = document.getElementById('scada-report-dl-from').value;
  var to = document.getElementById('scada-report-dl-to').value;
  if (from) params.set('date_from', from);
  if (to) params.set('date_to', to);
  var boxes = document.querySelectorAll('#scada-report-dl-sensors .scada-dl-sensor:checked');
  boxes.forEach(function(cb) { params.append('sensors', cb.value); });
  window.location.href = '/scada/report/download?' + params.toString();
  scadaCloseReportDownload();
}

// ===== SCADA REPORT SCHEDULES (DVI-484) =====
var SCADA_CADENCE_LABELS = { last_week: 'Last week', last_month: 'Last month', custom: 'Custom' };

function scadaCadenceText(s) {
  if (s.cadence === 'custom') return 'Last ' + (s.custom_days || '?') + ' days';
  return SCADA_CADENCE_LABELS[s.cadence] || s.cadence;
}

function scadaLoadSchedules() {
  var loading = document.getElementById('scada-sched-loading');
  var empty = document.getElementById('scada-sched-empty');
  var error = document.getElementById('scada-sched-error');
  var wrap = document.getElementById('scada-sched-table-wrap');
  loading.classList.remove('hidden');
  empty.classList.add('hidden');
  error.classList.add('hidden');
  wrap.classList.add('hidden');
  fetch('/scada/report-schedules')
    .then(function(r) {
      return r.json().then(function(data) {
        if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
        return data;
      });
    })
    .then(function(data) {
      loading.classList.add('hidden');
      _scadaSchedLoaded = true;
      _scadaSchedules = data.schedules || [];
      scadaRenderSchedules();
    })
    .catch(function(err) {
      loading.classList.add('hidden');
      error.classList.remove('hidden');
      document.getElementById('scada-sched-error-msg').textContent = 'Failed to load schedules: ' + err.message;
    });
}

function scadaRenderSchedules() {
  var empty = document.getElementById('scada-sched-empty');
  var wrap = document.getElementById('scada-sched-table-wrap');
  var tbody = document.getElementById('scada-sched-tbody');
  if (!_scadaSchedules.length) {
    empty.classList.remove('hidden');
    wrap.classList.add('hidden');
    return;
  }
  empty.classList.add('hidden');
  wrap.classList.remove('hidden');
  tbody.innerHTML = '';
  _scadaSchedules.forEach(function(s) {
    var sensorTxt = (s.sensors && s.sensors.length) ? s.sensors.join(', ') : 'All sensors';
    var tr = document.createElement('tr');
    tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
    tr.innerHTML =
      '<td class="py-2 pr-4 text-gray-800 dark:text-gray-100 font-medium">' + esc(s.name) + '</td>' +
      '<td class="py-2 pr-4 text-gray-700 dark:text-gray-300 whitespace-nowrap">' + esc(scadaCadenceText(s)) + '</td>' +
      '<td class="py-2 pr-4 text-gray-700 dark:text-gray-300">' + esc(sensorTxt) + '</td>' +
      '<td class="py-2 pr-4 text-gray-700 dark:text-gray-300">' + (s.notify ? 'Yes' : 'No') + '</td>' +
      '<td class="py-2 pr-4 text-right whitespace-nowrap"></td>';
    var actions = tr.lastChild;
    var editBtn = document.createElement('button');
    editBtn.className = 'px-2 py-1 text-xs font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors cursor-pointer mr-2';
    editBtn.textContent = 'Edit';
    editBtn.onclick = function() { scadaOpenSchedule(s.id); };
    var delBtn = document.createElement('button');
    delBtn.className = 'px-2 py-1 text-xs font-medium rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-900/40 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors cursor-pointer';
    delBtn.textContent = 'Delete';
    delBtn.onclick = function() { scadaDeleteSchedule(s.id); };
    actions.appendChild(editBtn);
    actions.appendChild(delBtn);
    tbody.appendChild(tr);
  });
}

function scadaSchedBuildSensors(selected) {
  var box = document.getElementById('scada-sched-sensors');
  box.innerHTML = '';
  if (!_scadaReportSensors.length) {
    box.innerHTML = '<p class="text-xs text-gray-400 px-1">No sensors discovered yet.</p>';
    return;
  }
  var sel = {};
  (selected || []).forEach(function(s) { sel[s] = true; });
  _scadaReportSensors.forEach(function(s) {
    var lbl = document.createElement('label');
    lbl.className = 'flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer';
    lbl.innerHTML = '<input type="checkbox" class="scada-sched-sensor rounded" value="' + esc(s) + '">' +
      '<span>' + esc(s) + '</span>';
    lbl.querySelector('input').checked = !!sel[s];
    box.appendChild(lbl);
  });
}

function scadaSchedCadenceChanged() {
  var cadence = document.getElementById('scada-sched-cadence').value;
  document.getElementById('scada-sched-custom-wrap').classList.toggle('hidden', cadence !== 'custom');
}

function scadaOpenSchedule(id) {
  _scadaSchedEditId = id || null;
  var sched = id ? _scadaSchedules.filter(function(s) { return s.id === id; })[0] : null;
  document.getElementById('scada-sched-title').textContent = sched ? 'Edit Schedule' : 'Add Schedule';
  document.getElementById('scada-sched-name').value = sched ? sched.name : '';
  document.getElementById('scada-sched-cadence').value = sched ? sched.cadence : 'last_week';
  document.getElementById('scada-sched-custom-days').value = (sched && sched.custom_days) ? sched.custom_days : 7;
  document.getElementById('scada-sched-notify').checked = sched ? !!sched.notify : false;
  document.getElementById('scada-sched-external').checked = sched ? !!sched.external : false;
  document.getElementById('scada-sched-form-error').classList.add('hidden');
  scadaSchedCadenceChanged();
  document.getElementById('scada-sched-overlay').classList.remove('hidden');
  var selected = sched ? sched.sensors : [];
  // Sensors come from the report endpoint; fetch once if not loaded yet.
  if (!_scadaReportSensors.length) {
    fetch('/scada/report')
      .then(function(r) { return r.ok ? r.json() : {}; })
      .then(function(data) {
        if (data.available_sensors && data.available_sensors.length) _scadaReportSensors = data.available_sensors;
        scadaSchedBuildSensors(selected);
      })
      .catch(function() { scadaSchedBuildSensors(selected); });
  } else {
    scadaSchedBuildSensors(selected);
  }
}

function scadaCloseSchedule() {
  document.getElementById('scada-sched-overlay').classList.add('hidden');
  _scadaSchedEditId = null;
}

function scadaSaveSchedule() {
  var errEl = document.getElementById('scada-sched-form-error');
  errEl.classList.add('hidden');
  var name = document.getElementById('scada-sched-name').value.trim();
  if (!name) {
    errEl.textContent = 'Name is required.';
    errEl.classList.remove('hidden');
    return;
  }
  var cadence = document.getElementById('scada-sched-cadence').value;
  var sensors = [];
  document.querySelectorAll('#scada-sched-sensors .scada-sched-sensor:checked').forEach(function(cb) {
    sensors.push(cb.value);
  });
  var payload = {
    name: name,
    cadence: cadence,
    sensors: sensors,
    notify: document.getElementById('scada-sched-notify').checked,
    external: document.getElementById('scada-sched-external').checked
  };
  if (cadence === 'custom') {
    payload.custom_days = parseInt(document.getElementById('scada-sched-custom-days').value, 10);
  }
  var url = _scadaSchedEditId ? '/scada/report-schedules/' + _scadaSchedEditId : '/scada/report-schedules';
  var method = _scadaSchedEditId ? 'PUT' : 'POST';
  fetch(url, {
    method: method,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload)
  })
    .then(function(r) {
      return r.json().then(function(data) {
        if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
        return data;
      });
    })
    .then(function() {
      scadaCloseSchedule();
      scadaLoadSchedules();
    })
    .catch(function(err) {
      errEl.textContent = err.message;
      errEl.classList.remove('hidden');
    });
}

function scadaDeleteSchedule(id) {
  var sched = _scadaSchedules.filter(function(s) { return s.id === id; })[0];
  if (!confirm('Delete schedule "' + (sched ? sched.name : '') + '"?')) return;
  fetch('/scada/report-schedules/' + id, { method: 'DELETE' })
    .then(function(r) {
      if (!r.ok) return r.json().then(function(d) { throw new Error(d.error || ('HTTP ' + r.status)); });
    })
    .then(function() { scadaLoadSchedules(); })
    .catch(function(err) {
      var error = document.getElementById('scada-sched-error');
      error.classList.remove('hidden');
      document.getElementById('scada-sched-error-msg').textContent = 'Delete failed: ' + err.message;
    });
}

// ===== KEYBOARD SHORTCUTS =====
function openKeyboardShortcuts() {
  document.getElementById('help-menu').classList.add('hidden');
  document.getElementById('kb-shortcuts-overlay').classList.remove('hidden');
}

function closeKeyboardShortcuts() {
  document.getElementById('kb-shortcuts-overlay').classList.add('hidden');
}

document.getElementById('kb-shortcuts-backdrop').addEventListener('click', closeKeyboardShortcuts);

document.addEventListener('keydown', function(e) {
  var overlay = document.getElementById('kb-shortcuts-overlay');
  var overlayOpen = !overlay.classList.contains('hidden');

  if (e.key === 'Escape') {
    if (overlayOpen) { closeKeyboardShortcuts(); return; }
    var aboutOvl = document.getElementById('about-overlay');
    if (aboutOvl && !aboutOvl.classList.contains('hidden')) { closeAbout(); return; }
    var subOvl = document.getElementById('subscribe-overlay');
    if (subOvl && !subOvl.classList.contains('hidden')) { closeSubscribe(); return; }
    var fbEmailOvl = document.getElementById('fb-email-overlay');
    if (fbEmailOvl && !fbEmailOvl.classList.contains('hidden')) { fbEmailClose(); return; }
    var sqrEmailOvl = document.getElementById('sqr-email-overlay');
    if (sqrEmailOvl && !sqrEmailOvl.classList.contains('hidden')) { sqrEmailClose(); return; }
    var wfpOvl = document.getElementById('workflow-picker-overlay');
    if (wfpOvl && !wfpOvl.classList.contains('hidden')) { closeWorkflowPicker(); return; }
    var spEditOvl = document.getElementById('sp-edit-modal');
    if (spEditOvl && !spEditOvl.classList.contains('hidden')) { spCloseEditDialog(); return; }
    var rptDlOvl = document.getElementById('scada-report-dl-overlay');
    if (rptDlOvl && !rptDlOvl.classList.contains('hidden')) { scadaCloseReportDownload(); return; }
  }

  var tag = (e.target.tagName || '').toLowerCase();
  if (tag === 'input' || tag === 'textarea' || tag === 'select' || e.target.isContentEditable) return;
  if (e.ctrlKey || e.metaKey || e.altKey) return;

  if (e.key === '?') {
    e.preventDefault();
    if (overlayOpen) closeKeyboardShortcuts();
    else openKeyboardShortcuts();
    return;
  }

  if (overlayOpen) return;
  if (_activeTool) return;

  var key = e.key.toUpperCase();
  var shortcuts = {
    'W': ['work-order', 'Work Order Request'],
    'Q': ['simple-qr', 'Simple QR Creator'],
    'B': ['file-browser', 'File Browser'],
  };
  if (_isTakeoffWorkflow || _isToGenAdmin) {
    shortcuts['P'] = ['project-builder', 'Project Builder'];
    shortcuts['T'] = ['takeoff-bridge', 'Take-off Bridge'];
    shortcuts['S'] = ['sync-admin', 'Sync Admin'];
  }
  if (_isProdRun || _isToGenAdmin) {
    shortcuts['R'] = ['prod-run-merge', 'Production Run'];
  }

  if (shortcuts[key]) {
    e.preventDefault();
    openTool(shortcuts[key][0], shortcuts[key][1]);
    return;
  }

  if (key === 'Z') {
    e.preventDefault();
    location.reload();
    return;
  }
});

// Render tile grid on page init (shared folders load lazily when tool is opened)
history.replaceState({page:'home'}, '');
_applyToolOverrides();
renderTileGrid();
// Lazy-load SCADA data for entitled users so the tile shows live status on first view
if (_isScada || _isToGenAdmin) {
  _scadaFetchTileData(function() { renderTileGrid(); });
}
if (_isWorAdmin) {
  _woradminFetchTileData(function() { renderTileGrid(); });
}
if (_isTogenVision || _isToGenAdmin) {
  _visionFetchTileData(function() { renderTileGrid(); });
}
// Email tile unread badge (DVI-470): everyone has a mailbox.
_emailFetchTileData(function() { renderTileGrid(); });
worRenderTypeSelector();
woradminRenderTabs();

// Handle email deep links: ?open_news=1 or ?news_unsubscribe=1
(function() {
  var params = new URLSearchParams(window.location.search);
  if (params.get('open_news') === '1') {
    window.history.replaceState({}, '', window.location.pathname);
    openNewsPage();
  }
  var fbRemove = params.get('fb_remove_watcher');
  if (fbRemove) {
    window.history.replaceState({}, '', window.location.pathname);
    if (typeof fbWatcherRemoveFromLink === 'function') fbWatcherRemoveFromLink(fbRemove);
  }
  if (params.get('open_fb_notifications') === '1') {
    window.history.replaceState({}, '', window.location.pathname);
    if (typeof openUserSettings === 'function') openUserSettings();
  }
  if (params.get('news_unsubscribe') === '1') {
    // Clean URL
    window.history.replaceState({}, '', window.location.pathname);
    // Open news page and trigger unsubscribe
    openNewsPage();
    var userEmail = '{{ user_email }}';
    if (userEmail) {
      fetch('/news/unsubscribe', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({email: userEmail})
      })
        .then(function(r){ return r.json(); })
        .then(function(d){
          if (!d.error) {
            _newsSubscribed = false;
            newsUpdateSubscribeBtn();
            var feed = document.getElementById('news-feed');
            var notice = document.createElement('div');
            notice.className = 'bg-green-50 dark:bg-green-900/20 rounded-xl p-4 border border-green-300 dark:border-green-700 mb-4';
            notice.innerHTML = '<p class="text-sm text-green-700 dark:text-green-300 font-medium">You have been unsubscribed from Togen News email notifications.</p>';
            feed.parentNode.insertBefore(notice, feed);
            setTimeout(function(){ notice.remove(); }, 8000);
          }
        })
        .catch(function(){});
    }
  }
})();
</script>
</body>
</html>"""



# ---------------------------------------------------------------------------
# Authentication helpers — Azure AD SSO (DVI-194)
# ---------------------------------------------------------------------------

def _is_json_request():
    return request.is_json or "application/json" in (request.headers.get("Accept") or "")


def login_required(f):
    """Redirect unauthenticated requests to /login (return JSON 401 for API calls)."""
    @wraps(f)
    def _wrap(*args, **kwargs):
        if not session.get("user"):
            if _is_json_request():
                return jsonify(error="Session expired. Please reload the page and log in again."), 401
            session["next"] = request.url
            return redirect(url_for("login"))
        return f(*args, **kwargs)
    return _wrap


def wor_admin_required(f):
    """Require login + WOR Admin group membership."""
    @wraps(f)
    def _wrap(*args, **kwargs):
        if not session.get("user"):
            if _is_json_request():
                return jsonify(error="Session expired. Please reload the page and log in again."), 401
            session["next"] = request.url
            return redirect(url_for("login"))
        if not session["user"].get("wor_admin"):
            return jsonify(error="Access denied: Work Order Admin group membership required."), 403
        return f(*args, **kwargs)
    return _wrap


def togen_admin_required(f):
    """Require login + Togen Admin group membership."""
    @wraps(f)
    def _wrap(*args, **kwargs):
        if not session.get("user"):
            if _is_json_request():
                return jsonify(error="Session expired. Please reload the page and log in again."), 401
            session["next"] = request.url
            return redirect(url_for("login"))
        if not session["user"].get("togen_admin"):
            return jsonify(error="Access denied: Togen Admin group membership required."), 403
        return f(*args, **kwargs)
    return _wrap


def takeoff_workflow_required(f):
    """Require login + Togen Take-off Workflow or Togen Admin group membership."""
    @wraps(f)
    def _wrap(*args, **kwargs):
        if not session.get("user"):
            if _is_json_request():
                return jsonify(error="Session expired. Please reload the page and log in again."), 401
            session["next"] = request.url
            return redirect(url_for("login"))
        u = session["user"]
        if not (u.get("takeoff_workflow") or u.get("togen_admin")):
            return jsonify(error="Access denied: Togen Take-off Workflow group membership required."), 403
        return f(*args, **kwargs)
    return _wrap


def prod_run_required(f):
    """Require login + Togen Production Run or Togen Admin group membership."""
    @wraps(f)
    def _wrap(*args, **kwargs):
        if not session.get("user"):
            if _is_json_request():
                return jsonify(error="Session expired. Please reload the page and log in again."), 401
            session["next"] = request.url
            return redirect(url_for("login"))
        u = session["user"]
        if not (u.get("prod_run") or u.get("togen_admin")):
            return jsonify(error="Access denied: Togen Production Run group membership required."), 403
        return f(*args, **kwargs)
    return _wrap


def mts_label_required(f):
    """Require login + Togen MTS or Togen Admin group membership."""
    @wraps(f)
    def _wrap(*args, **kwargs):
        if not session.get("user"):
            if _is_json_request():
                return jsonify(error="Session expired. Please reload the page and log in again."), 401
            session["next"] = request.url
            return redirect(url_for("login"))
        u = session["user"]
        if not (u.get("togen_mts") or u.get("togen_admin")):
            return jsonify(error="Access denied: Togen MTS group membership required."), 403
        return f(*args, **kwargs)
    return _wrap


def scada_required(f):
    """Require login + Togen SCADA or Togen Admin group membership."""
    @wraps(f)
    def _wrap(*args, **kwargs):
        if not session.get("user"):
            if _is_json_request():
                return jsonify(error="Session expired. Please reload the page and log in again."), 401
            session["next"] = request.url
            return redirect(url_for("login"))
        u = session["user"]
        if not (u.get("togen_scada") or u.get("togen_admin")):
            return jsonify(error="Access denied: Togen SCADA group membership required."), 403
        return f(*args, **kwargs)
    return _wrap


def vision_access_required(f):
    """Require login + Vision access: togen_admin always, OR togen_vision only
    while the Vision tool is enabled in Tools Enabled (admin-disabled => 403)."""
    @wraps(f)
    def _wrap(*args, **kwargs):
        if not session.get("user"):
            if _is_json_request():
                return jsonify(error="Session expired. Please reload the page and log in again."), 401
            session["next"] = request.url
            return redirect(url_for("login"))
        u = session["user"]
        if u.get("togen_admin"):
            return f(*args, **kwargs)
        if u.get("togen_vision") and _load_tool_visibility().get("vision", False):
            return f(*args, **kwargs)
        return jsonify(error="Access denied: Vision access required."), 403
    return _wrap


def rpm_required(f):
    """Require Repair Parts Manager access (DVI-596): togen_admin always, OR
    wor_admin only while the tool is enabled in Tools Enabled (admin-disabled => 403)."""
    @wraps(f)
    def _wrap(*args, **kwargs):
        if not session.get("user"):
            if _is_json_request():
                return jsonify(error="Session expired. Please reload the page and log in again."), 401
            session["next"] = request.url
            return redirect(url_for("login"))
        u = session["user"]
        if u.get("togen_admin"):
            return f(*args, **kwargs)
        if u.get("wor_admin") and _load_tool_visibility().get("repair-parts-manager", False):
            return f(*args, **kwargs)
        return jsonify(error="Access denied: Repair Parts Manager access required."), 403
    return _wrap


def _wor_type_dirs(wor_type: str | None = None) -> list[Path]:
    """Return the ordered list of directories to search for a given WOR type."""
    if wor_type == "mobilemachine":
        return [WOR_MOBILEMACHINE_DIR, WOR_MM_INPROGRESS_DIR, WOR_MM_COMPLETED_DIR,
                WOR_MM_CANCELLED_DIR]
    if wor_type == "safety":
        return [WOR_SAFETY_DIR, WOR_SAFETY_INPROGRESS_DIR, WOR_SAFETY_COMPLETED_DIR,
                WOR_SAFETY_CANCELLED_DIR]
    if wor_type == "preventive":
        return [WOR_PREVENTIVE_DIR, WOR_PREVENTIVE_INPROGRESS_DIR,
                WOR_PREVENTIVE_COMPLETED_DIR, WOR_PREVENTIVE_CANCELLED_DIR]
    if wor_type == "maintenance":
        return [WOR_MAINTENANCE_DIR, WOR_MAINT_INPROGRESS_DIR, WOR_MAINT_COMPLETED_DIR,
                WOR_MAINT_CANCELLED_DIR]
    return [
        WOR_MAINTENANCE_DIR, WOR_MAINT_INPROGRESS_DIR, WOR_MAINT_COMPLETED_DIR,
        WOR_MAINT_CANCELLED_DIR,
        WOR_MOBILEMACHINE_DIR, WOR_MM_INPROGRESS_DIR, WOR_MM_COMPLETED_DIR,
        WOR_MM_CANCELLED_DIR,
        WOR_SAFETY_DIR, WOR_SAFETY_INPROGRESS_DIR, WOR_SAFETY_COMPLETED_DIR,
        WOR_SAFETY_CANCELLED_DIR,
        WOR_PREVENTIVE_DIR, WOR_PREVENTIVE_INPROGRESS_DIR,
        WOR_PREVENTIVE_COMPLETED_DIR, WOR_PREVENTIVE_CANCELLED_DIR,
    ]


def _resolve_wor_file(filename: str, wor_type: str | None = None) -> Path | None:
    """Find a WOR file across new / in_progress / completed directories."""
    if not filename or ".." in filename or "/" in filename:
        return None
    for d in _wor_type_dirs(wor_type):
        candidate = (d / filename).resolve()
        if not str(candidate).startswith(str(d.resolve())):
            return None
        if candidate.is_file():
            return candidate
    return None


@app.route("/login")
def login():
    """Initiate Microsoft OAuth 2.0 Authorization Code flow."""
    cl = msal.ConfidentialClientApplication(
        _AZ_CLIENT_ID, authority=_AZ_AUTHORITY, client_credential=_AZ_SECRET)
    auth_url = cl.get_authorization_request_url(
        _AZ_SCOPES,
        redirect_uri=_AZ_REDIRECT,
        state=session.get("next", "/"),
    )
    return redirect(auth_url)


@app.route("/auth/callback")
def auth_callback():
    """Handle the redirect from Microsoft after login or admin consent."""
    # Admin-consent redirects don't carry an authorization code.
    if request.args.get("admin_consent", "").lower() == "true":
        tenant = request.args.get("tenant", "unknown")
        return (
            f"Admin consent granted successfully for tenant {tenant}. "
            "You may close this window."
        ), 200

    code = request.args.get("code")
    if not code:
        return "Authentication failed: no code returned.", 401

    cl = msal.ConfidentialClientApplication(
        _AZ_CLIENT_ID, authority=_AZ_AUTHORITY, client_credential=_AZ_SECRET)
    result = cl.acquire_token_by_authorization_code(
        code, scopes=_AZ_SCOPES, redirect_uri=_AZ_REDIRECT)

    if "error" in result:
        return f"Login error: {result.get('error_description', result['error'])}", 401

    claims = result["id_token_claims"]
    token = result["access_token"]
    user_tenant = claims.get("tid", "")
    username = claims.get("preferred_username", "unknown")

    # Validate the user's tenant against the allowlist.
    tenant_cfg = _AZ_TENANT_CONFIG.get(user_tenant)
    if not tenant_cfg:
        print(
            f"[AUTH] Rejected login from unknown tenant {user_tenant} "
            f"(user={username})",
            flush=True,
        )
        return (
            "Access denied: your organization is not authorized to use Togen. "
            "Contact your IT administrator to request access.",
            403,
        )

    tenant_name = tenant_cfg["name"]
    base_group = tenant_cfg["base_group"]
    tenant_groups = tenant_cfg.get("groups", {})

    print(
        f"[AUTH] Login from tenant {tenant_name} ({user_tenant}), "
        f"user={username}",
        flush=True,
    )

    # Verify the user is a member of the tenant's base access group.
    # base_group=None means any user from the tenant is allowed (DVI-280).
    if base_group is not None:
        graph_resp = _http_requests.get(
            f"https://graph.microsoft.com/v1.0/me/memberOf/{base_group}",
            headers={"Authorization": f"Bearer {token}"},
            timeout=10,
        )
        if graph_resp.status_code != 200:
            print(
                f"[AUTH] Base group check failed for {username}: "
                f"group={base_group}, status={graph_resp.status_code}",
                flush=True,
            )
            return (
                "Access denied: your account is not a member of the required "
                f"Togen group in {tenant_name}. "
                "Contact your IT administrator to request access.",
                403,
            )

    # Check role-specific group memberships based on tenant config.
    role_flags = {}
    for role_key in ("wor_admin", "togen_admin", "takeoff_workflow", "prod_run", "togen_mts", "togen_scada", "togen_vision"):
        group_id = tenant_groups.get(role_key)
        if not group_id:
            role_flags[role_key] = False
            continue
        try:
            resp = _http_requests.get(
                f"https://graph.microsoft.com/v1.0/me/memberOf/{group_id}",
                headers={"Authorization": f"Bearer {token}"},
                timeout=10,
            )
            role_flags[role_key] = resp.status_code == 200
            print(
                f"[{role_key.upper()}] Group check for {username}: "
                f"status={resp.status_code}, granted={role_flags[role_key]}",
                flush=True,
            )
        except Exception as exc:
            role_flags[role_key] = False
            print(f"[{role_key.upper()}] Group check failed: {exc}", flush=True)

    session["user"] = {
        "name":  claims.get("name"),
        "email": username,
        "tenant": tenant_name,
        "wor_admin": role_flags["wor_admin"],
        "togen_admin": role_flags["togen_admin"],
        "takeoff_workflow": role_flags["takeoff_workflow"],
        "prod_run": role_flags["prod_run"],
        "togen_mts": role_flags["togen_mts"],
        "togen_scada": role_flags["togen_scada"],
        "togen_vision": role_flags["togen_vision"],
    }
    next_url = request.args.get("state", "/")
    return redirect(next_url)


@app.route("/logout")
def logout():
    """Clear local session and sign out from Microsoft."""
    session.clear()
    post_logout = "https://togen.icastinc.com"
    return redirect(
        f"{_AZ_AUTHORITY}/oauth2/v2.0/logout"
        f"?post_logout_redirect_uri={post_logout}"
    )

# ---------------------------------------------------------------------------
# Routes — multi-item CSV workflow
# ---------------------------------------------------------------------------

@app.route("/")
@login_required
def index():
    from assistant import is_assistant_connected
    user = session.get("user", {})
    user_email = user.get("email", "")
    _assistant_available = is_assistant_connected(
        user_email, user.get("togen_admin", False), _load_user_settings(user_email)
    )
    _is_admin = user.get("togen_admin", False)
    _toolbar_vis = _load_toolbar_visibility()
    return render_template_string(
        _HTML,
        assistant_available=_assistant_available,
        toolbar_visibility=_toolbar_vis,
        show_toolbar_search=_toolbar_enabled_for("search", _is_admin, _toolbar_vis),
        show_toolbar_assistant=_toolbar_enabled_for("assistant", _is_admin, _toolbar_vis),
        show_toolbar_calendar=_toolbar_enabled_for("calendar", _is_admin, _toolbar_vis),
        user_name=user.get("name", "User"),
        user_email=user_email,
        user_tenant=user.get("tenant", ""),
        is_wor_admin=user.get("wor_admin", False),
        is_togen_admin=user.get("togen_admin", False),
        is_takeoff_workflow=user.get("takeoff_workflow", False),
        is_prod_run=user.get("prod_run", False),
        is_togen_mts=user.get("togen_mts", False),
        is_togen_scada=user.get("togen_scada", False),
        is_togen_vision=user.get("togen_vision", False),
        tool_visibility=_load_tool_visibility(),
        tool_order=_load_tool_order(),
        tool_overrides=_load_tool_overrides(),
        wor_type_visibility=_load_wor_type_visibility(),
        wor_type_order=_load_wor_type_order(),
        public_version=_load_public_version(),
        about_html=_render_news_markdown(_load_about_content()),
        site_title=_load_site_text()["title"],
        site_tagline=_load_site_text()["tagline"],
        shared_url_prefix=_load_shared_url_prefix(),
        user_settings=_user_settings_with_grid(user_email),
        wor_primary_enforced=_wor_primary_enforced_for(user_email),
        branding_settings=_load_branding_settings(),
        branding_has_logo=CORPORATE_LOGO_FILE.is_file(),
        print_can_use=_user_can_print(user),
    )


@app.route("/upload-csv", methods=["POST"])
@takeoff_workflow_required
def upload_csv():
    if "csv" not in request.files:
        return jsonify({"error": "No CSV file provided"}), 400
    f = request.files["csv"]
    if not f.filename or not f.filename.lower().endswith(".csv"):
        return jsonify({"error": "File must be a .csv"}), 400

    safe_name = secure_filename(f.filename)
    stem = Path(safe_name).stem
    folder = WATCH_DIR / stem
    folder.mkdir(parents=True, exist_ok=True)

    csv_path = folder / safe_name
    f.save(str(csv_path))

    # Skip header row; use column C (index 2) as item name, column H (index 7) as part reference
    items: list[str] = []
    item_refs: list[str] = []
    with open(csv_path, newline="", encoding="utf-8-sig") as fh:
        reader = csv.reader(fh)
        next(reader, None)  # header
        for row in reader:
            if len(row) >= 3 and row[2].strip():
                items.append(row[2].strip())
                item_refs.append(row[7].strip() if len(row) >= 8 else "")

    if not items:
        return jsonify({"error": "No items found — expected data in column C from row 2 onward"}), 400

    state: dict = {
        "csv_filename": safe_name,
        "stem": stem,
        "items": items,
        "item_refs": item_refs,
        "files": {str(i): {} for i in range(len(items))},
        "results": {},
    }
    _save_state(folder, state)
    return jsonify({"folder": stem, "items": items, "item_refs": item_refs})


@app.route("/upload-zip", methods=["POST"])
@takeoff_workflow_required
def upload_zip():
    if "zip" not in request.files:
        return jsonify({"error": "No zip file provided"}), 400
    f = request.files["zip"]
    if not f.filename or not f.filename.lower().endswith(".zip"):
        return jsonify({"error": "File must be a .zip"}), 400

    safe_name = secure_filename(f.filename)
    stem = Path(safe_name).stem
    folder = WATCH_DIR / stem
    folder.mkdir(parents=True, exist_ok=True)

    # Extract zip, rejecting any entries that would escape the target folder
    with zipfile.ZipFile(f.stream) as zf:
        for member in zf.namelist():
            dest = (folder / member).resolve()
            if not str(dest).startswith(str(folder.resolve())):
                return jsonify({"error": f"Unsafe path in zip entry: {member}"}), 400
        zf.extractall(str(folder))

    # Find the CSV file (search root first, then subdirectories)
    csv_files = sorted(folder.glob("*.csv")) or sorted(folder.glob("**/*.csv"))
    if not csv_files:
        return jsonify({"error": "No CSV file found in zip"}), 400
    csv_path = csv_files[0]

    # Parse same as upload_csv: skip header, col A = identifier, col C = item name, col H = ref
    items: list[str] = []
    item_refs: list[str] = []
    item_cols_a: list[str] = []
    with open(csv_path, newline="", encoding="utf-8-sig") as fh:
        reader = csv.reader(fh)
        next(reader, None)
        for row in reader:
            if len(row) >= 3 and row[2].strip():
                items.append(row[2].strip())
                item_refs.append(row[7].strip() if len(row) >= 8 else "")
                item_cols_a.append(row[0].strip() if row else "")

    if not items:
        return jsonify({"error": "CSV in zip has no items (expected data in column C from row 2 onward)"}), 400

    # Auto-match extracted PDF files to CSV rows
    auto_files = _match_files_to_items(folder, items, item_refs, item_cols_a)

    # Pre-populate state["files"] with matched filenames
    files_state: dict = {}
    for i, matched in enumerate(auto_files):
        files_state[str(i)] = dict(matched)  # copy; user uploads will override

    state: dict = {
        "csv_filename": str(csv_path.relative_to(folder)),
        "stem": stem,
        "items": items,
        "item_refs": item_refs,
        "item_cols_a": item_cols_a,
        "files": files_state,
        "results": {},
    }
    _save_state(folder, state)
    return jsonify({"folder": stem, "items": items, "item_refs": item_refs, "auto_files": auto_files})


@app.route("/upload-file", methods=["POST"])
@takeoff_workflow_required
def upload_file():
    if "file" not in request.files:
        return jsonify({"error": "No file provided"}), 400
    f = request.files["file"]
    if not f.filename or not f.filename.lower().endswith(".pdf"):
        return jsonify({"error": "Only PDF files are accepted"}), 400

    folder_name = request.form.get("folder", "")
    item_index  = request.form.get("item_index", "")
    file_type   = request.form.get("file_type", "")

    if file_type not in ("shop", "submittal", "carrier"):
        return jsonify({"error": "Invalid file_type"}), 400
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify({"error": "Invalid folder"}), 400

    folder = WATCH_DIR / folder_name
    if not folder.exists():
        return jsonify({"error": "Session not found — please re-upload CSV"}), 400

    save_name = secure_filename(f.filename)
    f.save(str(folder / save_name))

    state = _load_state(folder)
    state.setdefault("files", {}).setdefault(item_index, {})[file_type] = save_name
    _save_state(folder, state)
    return jsonify({"ok": True})


@app.route("/detach-file", methods=["POST"])
@takeoff_workflow_required
def detach_file():
    """Disconnect a detected/uploaded file from an item without deleting it.

    Removes only the state.json association so /submit treats the slot as
    empty (e.g. detaching a carrier re-enables the Create path). Reopening
    the folder re-runs auto-matching, so a detach lasts for the session.
    """
    data = request.get_json(silent=True) or {}
    folder_name = data.get("folder", "")
    item_index  = str(data.get("item_index", ""))
    file_type   = data.get("file_type", "")

    if file_type not in ("shop", "submittal", "carrier"):
        return jsonify({"error": "Invalid file_type"}), 400
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify({"error": "Invalid folder"}), 400

    folder = WATCH_DIR / folder_name
    if not folder.exists():
        return jsonify({"error": "Session not found — please re-upload CSV"}), 400

    state = _load_state(folder)
    item_files = state.get("files", {}).get(item_index)
    if isinstance(item_files, dict):
        item_files.pop(file_type, None)
        if file_type == "carrier":
            item_files.pop("created", None)
        if not item_files:
            state["files"].pop(item_index, None)
        _save_state(folder, state)
    return jsonify({"ok": True})


# Project-metadata keys rendered onto a generated carrier sheet (vendor_project_code
# is saved in project_metadata.json but has no field on the carrier template).
_CARRIER_META_KEYS = (
    "job_number", "production_date", "spec_type", "station",
    "customer_name", "project_name",
)


def _build_carrier_fields(row: list, metadata: dict, override: dict) -> dict:
    """Fields dict for qr_merge.create_carrier_sheet from a CSV row + project metadata.

    CSV columns: B=structure_id, C=product_code, F=weight, G=structure_size, H=part_ref.
    Per-item override values win over project-level metadata.
    """
    def col(idx: int) -> str:
        return row[idx].strip() if len(row) > idx else ""

    fields = {
        "structure_id":   col(1),
        "product_code":   col(2),
        "weight":         col(5),
        "structure_size": col(6),
        "part_ref":       col(7),
    }
    for key in _CARRIER_META_KEYS:
        fields[key] = str(override.get(key, metadata.get(key, "")) or "").strip()
    return fields


@app.route("/submit", methods=["POST"])
@takeoff_workflow_required
def submit():
    data = request.get_json(silent=True) or {}
    folder_name = data.get("folder", "")
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify({"error": "Invalid folder"}), 400

    folder = WATCH_DIR / folder_name
    state  = _load_state(folder)
    items  = state.get("items", [])
    if not items:
        return jsonify({"error": "No items in session — please re-upload CSV"}), 400

    raw_flags = data.get("create_flags")
    create_flags = (
        {str(k): bool(v) for k, v in raw_flags.items()}
        if isinstance(raw_flags, dict) else {}
    )

    # Project metadata for generated sheets: payload first, project_metadata.json fallback
    metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else None
    if metadata is None and any(create_flags.values()):
        meta_path = folder / "project_metadata.json"
        if meta_path.is_file():
            try:
                loaded = json.loads(meta_path.read_text(encoding="utf-8"))
                if isinstance(loaded, dict):
                    metadata = loaded
            except (json.JSONDecodeError, OSError):
                pass
    metadata = metadata or {}
    item_overrides = metadata.get("item_overrides")
    if not isinstance(item_overrides, dict):
        item_overrides = {}

    _create_carrier_sheet = None
    _create_import_error = ""
    if any(create_flags.values()):
        try:
            from qr_merge import create_carrier_sheet as _create_carrier_sheet  # noqa: E402
        except ImportError:
            _create_import_error = (
                "Carrier sheet generation is unavailable on this host "
                "(qr_merge.create_carrier_sheet not found)"
            )

    # Load CSV rows to append URL columns
    csv_path = folder / state["csv_filename"]
    with open(csv_path, newline="", encoding="utf-8-sig") as fh:
        rows = list(csv.reader(fh))

    # Map item index → CSV row index (skip header; only count populated col-C rows)
    item_row_map: dict[int, int] = {}
    item_count = 0
    for row_idx, row in enumerate(rows):
        if row_idx == 0:
            continue
        if len(row) >= 3 and row[2].strip():
            item_row_map[item_count] = row_idx
            item_count += 1

    results: list[dict] = []
    for i, item_name in enumerate(items):
        files = state.get("files", {}).get(str(i), {})
        shop_name      = files.get("shop")
        carrier_name   = files.get("carrier")
        submittal_name = files.get("submittal")

        # No shop drawing → skip this line (no merge, no CSV URLs written)
        if not shop_name:
            results.append({"skipped": True})
            continue

        # No carrier sheet: generate one when the item is create-flagged,
        # otherwise keep the existing validation error.
        created_now = False
        if not carrier_name:
            if not create_flags.get(str(i)):
                results.append({"error": f'Shop Drawing uploaded for "{item_name}" but QR Carrier Sheet is required'})
                continue
            if _create_import_error:
                results.append({"error": _create_import_error})
                continue
            row = rows[item_row_map[i]] if i in item_row_map else []
            fields = _build_carrier_fields(row, metadata, item_overrides.get(str(i), {}))
            if not fields["customer_name"] or not fields["project_name"]:
                results.append({"error": f'Project metadata (customer and project name) is required to create a carrier sheet for "{item_name}"'})
                continue
            try:
                gen_path = _create_carrier_sheet(fields=fields, output_folder=folder)
                # The intermediate is named "... CarrierQR blank.pdf"; the merged
                # result is final, so name it like SCS output ("CarrierQR generated").
                final_path = folder / gen_path.name.replace("CarrierQR blank", "CarrierQR generated")
                if final_path != gen_path:
                    gen_path.replace(final_path)
                carrier_name = final_path.name
            except Exception as exc:
                results.append({"error": f'Carrier sheet generation failed for "{item_name}": {exc}'})
                continue
            created_now = True
            item_files = state.setdefault("files", {}).setdefault(str(i), {})
            item_files["carrier"] = carrier_name
            item_files["created"] = True

        shop_path     = folder / shop_name
        carrier_path  = folder / carrier_name
        submittal_path = (folder / submittal_name) if submittal_name else None

        try:
            output_path = _merge_item(carrier_path, shop_path, folder, submittal_path)

            # shop_name / carrier_name / submittal_name are relative paths from folder
            # (either just "file.pdf" for manual uploads or "subdir/file.pdf" for zip auto-matches).
            # Use safe='/' so subdirectory separators survive URL encoding.
            _qr_base      = _get_qr_base_url()
            folder_url    = f"{_qr_base.rstrip('/')}/{quote(folder_name, safe='')}"
            shop_url      = f"{folder_url}/{quote(shop_name, safe='/')}"
            carrier_url   = f"{folder_url}/{quote(carrier_name, safe='/')}"
            submittal_url = f"{folder_url}/{quote(submittal_name, safe='/')}" if submittal_name else ""

            # Write URLs into columns J (9), K (10), L (11)
            if i in item_row_map:
                row = rows[item_row_map[i]]
                while len(row) < 12:
                    row.append("")
                row[9]  = shop_url
                row[10] = submittal_url
                row[11] = carrier_url

            state.setdefault("results", {})[str(i)] = {"output": carrier_name}
            result = {
                "ok": True,
                "download_url": f"download-file?folder={quote(folder_name, safe='')}&file={quote(carrier_name, safe='/')}",
                "preview_url":  f"preview-file?folder={quote(folder_name, safe='')}&file={quote(carrier_name, safe='/')}",
            }
            if created_now:
                result["created"] = True
                result["carrier"] = carrier_name
            results.append(result)
        except Exception as exc:
            results.append({"error": str(exc)})

    # Overwrite the original CSV with the updated rows (URLs now in columns J/K/L)
    with open(csv_path, "w", newline="", encoding="utf-8") as fh:
        csv.writer(fh).writerows(rows)

    if create_flags:
        state["create_flags"] = create_flags
    _save_state(folder, state)
    return jsonify({"results": results})


@app.route("/download-file")
@takeoff_workflow_required
def download_file():
    folder_name = request.args.get("folder", "")
    filename    = request.args.get("file", "")
    if not folder_name or not filename:
        return "Missing parameters", 400
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return "Invalid folder", 400
    # Use path resolution for safety instead of secure_filename, which mangles spaces.
    folder    = (WATCH_DIR / folder_name).resolve()
    file_path = (folder / filename).resolve()
    if not str(file_path).startswith(str(folder) + os.sep) and file_path != folder:
        return "Invalid path", 400
    if not file_path.exists():
        return "File not found", 404
    return send_file(str(file_path), as_attachment=True)


@app.route("/download-csv")
@takeoff_workflow_required
def download_csv():
    folder_name = request.args.get("folder", "")
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return "Invalid folder", 400
    folder = WATCH_DIR / folder_name
    state  = _load_state(folder)
    csv_path = folder / state.get("csv_filename", "")
    if not csv_path.exists():
        return "CSV not found", 404
    return send_file(str(csv_path), as_attachment=True)


@app.route("/list-shared-folders")
@takeoff_workflow_required
def list_shared_folders():
    """Return sorted list of subdirectory names in SHARED_DIR."""
    if not SHARED_DIR.is_dir():
        return jsonify({"folders": []})
    folders = sorted(
        (f.name for f in SHARED_DIR.iterdir() if f.is_dir() and not f.name.startswith(".")),
        reverse=True,
    )
    return jsonify({"folders": folders})


@app.route("/select-shared-folder", methods=["POST"])
@takeoff_workflow_required
def select_shared_folder():
    """Load a shared folder: copy to WATCH_DIR if needed, parse CSV, auto-match files."""
    data = request.get_json(silent=True) or {}
    folder_name = data.get("folder", "")
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify({"error": "Invalid folder name"}), 400

    src = (SHARED_DIR / folder_name).resolve()
    if not str(src).startswith(str(SHARED_DIR.resolve())) or not src.is_dir():
        return jsonify({"error": "Folder not found in shared directory"}), 404

    # Use or create a working copy in WATCH_DIR
    dst = WATCH_DIR / folder_name
    if not dst.exists():
        shutil.copytree(str(src), str(dst))

    # Find CSV (root first, then subdirectories)
    csv_files = sorted(dst.glob("*.csv")) or sorted(dst.glob("**/*.csv"))
    if not csv_files:
        return jsonify({"error": "No CSV file found in folder"}), 400
    csv_path = csv_files[0]

    # Parse same as upload-zip: col A=identifier, col C=item name, col H=ref
    items: list[str] = []
    item_refs: list[str] = []
    item_cols_a: list[str] = []
    with open(csv_path, newline="", encoding="utf-8-sig") as fh:
        reader = csv.reader(fh)
        next(reader, None)
        for row in reader:
            if len(row) >= 3 and row[2].strip():
                items.append(row[2].strip())
                item_refs.append(row[7].strip() if len(row) >= 8 else "")
                item_cols_a.append(row[0].strip() if row else "")

    if not items:
        return jsonify({"error": "CSV has no items (expected data in column C from row 2 onward)"}), 400

    auto_files = _match_files_to_items(dst, items, item_refs, item_cols_a)
    files_state = {str(i): dict(m) for i, m in enumerate(auto_files)}

    # Carry over carriers generated by a previous Submit All (state.json records
    # created=True + filename); their names don't follow the auto-match suffix
    # convention, so re-discovery relies on the prior state.
    try:
        prev_files = _load_state(dst).get("files", {})
    except Exception:
        prev_files = {}
    if isinstance(prev_files, dict):
        for i in range(len(items)):
            pf = prev_files.get(str(i), {})
            if (isinstance(pf, dict) and pf.get("created") and pf.get("carrier")
                    and (dst / pf["carrier"]).is_file()):
                files_state[str(i)]["carrier"] = pf["carrier"]
                files_state[str(i)]["created"] = True
                auto_files[i]["carrier"] = pf["carrier"]

    state: dict = {
        "csv_filename": str(csv_path.relative_to(dst)),
        "stem": folder_name,
        "items": items,
        "item_refs": item_refs,
        "item_cols_a": item_cols_a,
        "files": files_state,
        "results": {},
    }
    _save_state(dst, state)

    meta_path = dst / "project_metadata.json"
    has_metadata = meta_path.is_file()
    metadata = None
    if has_metadata:
        try:
            metadata = json.loads(meta_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            has_metadata = False

    return jsonify({
        "folder": folder_name,
        "items": items,
        "item_refs": item_refs,
        "auto_files": auto_files,
        "has_metadata": has_metadata,
        "metadata": metadata,
    })


_META_OPTIONAL_KEYS = ("job_number", "production_date", "spec_type", "station")
_META_OVERRIDE_KEYS = frozenset(
    ("customer_name", "project_name", "vendor_project_code") + _META_OPTIONAL_KEYS
)


@app.route("/save-project-metadata", methods=["POST"])
@takeoff_workflow_required
def save_project_metadata():
    """Save project_metadata.json into the working folder in WATCH_DIR.

    Merges into any existing metadata file so callers that omit the optional
    keys (job_number, production_date, spec_type, station, item_overrides)
    leave previously saved values untouched. item_overrides, when present,
    replaces the stored per-item override map wholesale.
    """
    data = request.get_json(silent=True) or {}
    folder_name = data.get("folder", "")

    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify({"error": "Invalid folder name"}), 400

    folder_path = (WATCH_DIR / folder_name).resolve()
    if not str(folder_path).startswith(str(WATCH_DIR.resolve())) or not folder_path.is_dir():
        return jsonify({"error": "Folder not found"}), 404

    meta_path = folder_path / "project_metadata.json"
    metadata: dict = {}
    if meta_path.is_file():
        try:
            existing = json.loads(meta_path.read_text(encoding="utf-8"))
            if isinstance(existing, dict):
                metadata = existing
        except (json.JSONDecodeError, OSError):
            metadata = {}

    metadata.update({
        "customer_name": str(data.get("customer_name") or "").strip(),
        "project_name": str(data.get("project_name") or "").strip(),
        "vendor_project_code": str(data.get("vendor_project_code") or "").strip(),
        "folder_name": folder_name,
    })
    for key in _META_OPTIONAL_KEYS:
        if key in data:
            metadata[key] = str(data.get(key) or "").strip()

    item_overrides = data.get("item_overrides")
    if isinstance(item_overrides, dict):
        metadata["item_overrides"] = {
            str(idx): {k: str(v).strip() for k, v in fields.items() if k in _META_OVERRIDE_KEYS}
            for idx, fields in item_overrides.items()
            if isinstance(fields, dict)
        }

    metadata.setdefault("created_at", _datetime_type.now().isoformat())
    metadata["updated_at"] = _datetime_type.now().isoformat()

    try:
        with open(meta_path, "w", encoding="utf-8") as fh:
            json.dump(metadata, fh, indent=2)
    except OSError as exc:
        return jsonify({"error": f"Failed to save metadata: {exc}"}), 500

    return jsonify({"ok": True, "metadata": metadata})


@app.route("/preview-file")
@takeoff_workflow_required
def preview_file():
    """Serve a job file inline (no download prompt) for PDF preview."""
    folder_name = request.args.get("folder", "")
    filename    = request.args.get("file", "")
    if not folder_name or not filename:
        return "Missing parameters", 400
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return "Invalid folder", 400
    folder    = (WATCH_DIR / folder_name).resolve()
    file_path = (folder / filename).resolve()
    if not str(file_path).startswith(str(folder) + os.sep) and file_path != folder:
        return "Invalid path", 400
    if not file_path.exists():
        return "File not found", 404
    return send_file(str(file_path), mimetype="application/pdf")  # inline, no as_attachment


@app.route("/takeoff/project-files")
@takeoff_workflow_required
def takeoff_project_files():
    """List the files in a Take-off working folder for the Project Information
    popup's file browser/preview panels (DVI-641). Returns a flat, recursive
    listing so every project document is reachable in one list."""
    folder_name = request.args.get("folder", "")
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify(error="Invalid folder"), 400
    folder = (WATCH_DIR / folder_name).resolve()
    if not folder.is_dir():
        return jsonify(error="Folder not found"), 404
    skip_names = {"state.json"}
    files = []
    for p in sorted(folder.rglob("*"), key=lambda x: str(x).lower()):
        if not p.is_file() or p.name.startswith(".") or p.name in skip_names:
            continue
        rel = p.relative_to(folder)
        parent = str(rel.parent).replace(os.sep, "/")
        files.append({
            "name": p.name,
            "rel": str(rel).replace(os.sep, "/"),
            "dir": "" if parent == "." else parent,
            "ext": p.suffix.lower().lstrip("."),
            "size": p.stat().st_size,
        })
    return jsonify(folder=folder_name, files=files)


@app.route("/takeoff/file-preview")
@takeoff_workflow_required
def takeoff_file_preview():
    """Serve a Take-off working-folder file inline for the Project Information
    popup preview pane (DVI-641). Unlike /preview-file (PDF-only), the mimetype is
    auto-detected so images and text files render inline too."""
    folder_name = request.args.get("folder", "")
    rel = request.args.get("file", "")
    if not folder_name or not rel:
        return "Missing parameters", 400
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name or ".." in rel:
        return "Invalid path", 400
    folder = (WATCH_DIR / folder_name).resolve()
    file_path = (folder / rel).resolve()
    if not str(file_path).startswith(str(folder) + os.sep):
        return "Invalid path", 400
    if not file_path.is_file():
        return "File not found", 404
    return send_file(str(file_path), download_name=file_path.name)  # inline, auto mimetype


@app.route("/download-job-zip")
@takeoff_workflow_required
def download_job_zip():
    """Zip the entire job folder (excluding state.json) and stream it as a download."""
    folder_name = request.args.get("folder", "")
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return "Invalid folder", 400
    folder = (WATCH_DIR / folder_name).resolve()
    if not folder.is_dir():
        return "Folder not found", 404
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
        for f in sorted(folder.rglob("*")):
            if f.is_file() and f.name != "state.json":
                zf.write(f, f.relative_to(folder))
    buf.seek(0)
    return send_file(
        buf,
        as_attachment=True,
        download_name=f"{folder_name}.zip",
        mimetype="application/zip",
    )


@app.route("/publish", methods=["POST"])
@takeoff_workflow_required
def publish():
    """Copy job folder to SHARED_DIR. Errors if destination already exists."""
    data = request.get_json(silent=True) or {}
    folder_name = data.get("folder", "")
    overwrite = bool(data.get("overwrite", False))
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify({"error": "Invalid folder"}), 400
    src = (WATCH_DIR / folder_name).resolve()
    if not src.is_dir():
        return jsonify({"error": "Source folder not found"}), 400
    SHARED_DIR.mkdir(parents=True, exist_ok=True)
    dst = (SHARED_DIR / folder_name).resolve()
    if not str(dst).startswith(str(SHARED_DIR.resolve())):
        return jsonify({"error": "Invalid destination path"}), 400
    if dst.exists():
        if not overwrite:
            return jsonify({"error": f'A folder named "{folder_name}" already exists in the shared directory', "exists": True}), 409
        shutil.rmtree(str(dst))
    shutil.copytree(str(src), str(dst))
    return jsonify({"ok": True})


@app.route("/duplicate-folder", methods=["POST"])
@takeoff_workflow_required
def duplicate_folder():
    """Copy a job folder (documents, CSV, metadata, and all changes) to a new
    folder in WATCH_DIR. The copy is made as-is; no processing is applied."""
    data = request.get_json(silent=True) or {}
    folder_name = data.get("folder", "")
    new_name = (data.get("new_folder", "") or "").strip()
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify({"error": "Invalid source folder"}), 400
    if not _SAFE_JOB_RE.match(new_name) or ".." in new_name:
        return jsonify({"error": "Invalid folder name. Use letters, numbers, spaces, dashes, underscores or dots."}), 400
    src = (WATCH_DIR / folder_name).resolve()
    if not src.is_dir():
        return jsonify({"error": "Source folder not found"}), 400
    dst = (WATCH_DIR / new_name).resolve()
    if not str(dst).startswith(str(WATCH_DIR.resolve())):
        return jsonify({"error": "Invalid destination path"}), 400
    if dst.exists():
        return jsonify({"error": f'A folder named "{new_name}" already exists.'}), 409
    shutil.copytree(str(src), str(dst))
    return jsonify({"ok": True, "folder": new_name})


# ---------------------------------------------------------------------------
# Routes — Production Run (DVI-215)
# ---------------------------------------------------------------------------

PRODRUN_DIR = Path(os.environ.get("PRODRUN_DIR", "/var/www/html/prodrunmerge"))

_SP_HOSTNAME = "icastinc.sharepoint.com"
_SP_SITE_PATH = "/sites/StackIT"
_SP_SYNC_PREFIX = "/sites/StackIT/Shared Documents/StackIT-to-Netsuite/"
_SP_DRIVE_SYNC_ROOT = "StackIT-to-Netsuite"


def _extract_pdf_url(viewer_url: str) -> str:
    """Extract direct PDF URL from Google Docs viewer wrapper URLs.

    Input:  https://docs.google.com/viewer?url=http://fileshare.icastinc.com/shared/...
    Output: http://fileshare.icastinc.com/shared/...  (with double-encoding resolved)
    Non-viewer URLs are returned unchanged.
    """
    from urllib.parse import urlparse, parse_qs
    if "docs.google.com/viewer" in viewer_url:
        parsed = urlparse(viewer_url)
        qs = parse_qs(parsed.query)
        inner = qs.get("url", [""])[0]
        if inner:
            return inner  # parse_qs already decodes %2520 → %20
    return viewer_url


def _resolve_sharepoint_url(url: str) -> dict | None:
    """Recognise SharePoint URLs from icastinc.sharepoint.com.

    Returns a typed dict:
    - AllItems.aspx?id= URLs → {type:"path", relative_path, fileshare_url, sp_server_path}
    - Sharing links (/:b:/s/…) → {type:"sharing_link", url}
    - Other SP URLs           → {type:"sharing_link", url}  (best-effort)
    - Non-SharePoint URLs     → None
    """
    from urllib.parse import urlparse, parse_qs, quote
    parsed = urlparse(url)
    if parsed.hostname != _SP_HOSTNAME:
        return None

    qs = parse_qs(parsed.query)
    id_param = qs.get("id", [""])[0]
    if id_param and id_param.startswith(_SP_SYNC_PREFIX):
        relative_path = id_param[len(_SP_SYNC_PREFIX):]
        if relative_path:
            _url_prefix = _load_shared_url_prefix()
            fileshare_url = f"{_url_prefix}/shared/{quote(relative_path, safe='/')}"
            return {
                "type": "path",
                "relative_path": relative_path,
                "fileshare_url": fileshare_url,
                "sp_server_path": id_param,
            }

    return {"type": "sharing_link", "url": url}


def _download_from_sharepoint(sp_server_path: str, dest_dir: Path, index: int) -> Path | None:
    """Download a file directly from SharePoint using app-only credentials."""
    try:
        from urllib.parse import quote as _q
        app_client = msal.ConfidentialClientApplication(
            _AZ_CLIENT_ID, authority=_AZ_AUTHORITY_HOME, client_credential=_AZ_SECRET)
        token_result = app_client.acquire_token_for_client(
            scopes=[f"https://{_SP_HOSTNAME}/.default"])
        if "access_token" not in token_result:
            print(
                f"[PRODRUN-SP] Failed to acquire SharePoint token: "
                f"{token_result.get('error_description', 'unknown')}",
                flush=True,
            )
            return None
        encoded_path = _q(sp_server_path, safe="/")
        api_url = (
            f"https://{_SP_HOSTNAME}{_SP_SITE_PATH}/_api/web/"
            f"GetFileByServerRelativeUrl('{encoded_path}')/$value"
        )
        resp = _http_requests.get(
            api_url,
            headers={
                "Authorization": f"Bearer {token_result['access_token']}",
                "Accept": "application/octet-stream",
            },
            timeout=120,
            stream=True,
        )
        resp.raise_for_status()
        fname = Path(sp_server_path).name
        if not fname or not fname.lower().endswith(".pdf"):
            fname = f"file_{index:04d}.pdf"
        fname = secure_filename(fname)
        dest = dest_dir / fname
        counter = 1
        while dest.exists():
            stem = dest.stem
            dest = dest_dir / f"{stem}_{counter}.pdf"
            counter += 1
        with open(dest, "wb") as f:
            for chunk in resp.iter_content(8192):
                f.write(chunk)
        print(f"[PRODRUN-SP] Downloaded from SharePoint API: {dest.name}", flush=True)
        return dest
    except Exception as exc:
        print(f"[PRODRUN-SP] SharePoint API download failed: {exc}", flush=True)
        return None


def _get_rclone_sp_token() -> str | None:
    """Get a Graph-compatible access token from rclone's OneDrive/SharePoint config.

    The rclone remote iCast_StackIT has delegated tokens with Files.Read.All
    and Sites.Read.All — sufficient for resolving sharing links.
    """
    import subprocess
    from datetime import datetime, timezone
    try:
        output = subprocess.check_output(
            ["rclone", "config", "show", "iCast_StackIT"],
            text=True, timeout=10)
    except Exception as exc:
        print(f"[PRODRUN-SP] rclone config read failed: {exc}", flush=True)
        return None
    token_data = None
    for line in output.splitlines():
        line = line.strip()
        if line.startswith("token ="):
            token_data = json.loads(line.split("=", 1)[1].strip())
            break
    if not token_data or not token_data.get("access_token"):
        return None
    expiry = token_data.get("expiry", "")
    if expiry:
        try:
            exp_str = expiry.rstrip("Z") + "+00:00" if "+" not in expiry else expiry
            exp_dt = datetime.fromisoformat(exp_str)
            if exp_dt <= datetime.now(timezone.utc):
                import subprocess as _sp
                print("[PRODRUN-SP] rclone token expired, refreshing…", flush=True)
                _sp.run(["rclone", "lsf", "iCast_StackIT:", "--max-depth", "0"],
                        timeout=30, capture_output=True)
                out2 = _sp.check_output(
                    ["rclone", "config", "show", "iCast_StackIT"],
                    text=True, timeout=10)
                for l2 in out2.splitlines():
                    l2 = l2.strip()
                    if l2.startswith("token ="):
                        token_data = json.loads(l2.split("=", 1)[1].strip())
                        break
        except Exception as exc:
            print(f"[PRODRUN-SP] rclone token refresh failed: {exc}", flush=True)
    return token_data.get("access_token")


def _resolve_sp_sharing_link(url: str, dest_dir: Path, index: int) -> Path | None:
    """Resolve a SharePoint sharing link via Graph API.

    1. GET /shares/{token}/driveItem  → file name + parent path → local SHARED_DIR lookup
    2. Fall back to fileshare URL download, then Graph direct download.
    Token sources: app credentials first, then rclone delegated token.
    """
    sharing_token = "u!" + base64.urlsafe_b64encode(url.encode()).rstrip(b"=").decode()

    def _try_graph_token() -> str | None:
        app_client = msal.ConfidentialClientApplication(
            _AZ_CLIENT_ID, authority=_AZ_AUTHORITY_HOME, client_credential=_AZ_SECRET)
        result = app_client.acquire_token_for_client(
            scopes=["https://graph.microsoft.com/.default"])
        return result.get("access_token")

    access_token = _try_graph_token()
    headers = {"Authorization": f"Bearer {access_token}"} if access_token else {}

    meta_resp = None
    if access_token:
        meta_resp = _http_requests.get(
            f"https://graph.microsoft.com/v1.0/shares/{sharing_token}/driveItem",
            headers=headers,
            params={"$select": "name,parentReference,size"},
            timeout=30,
        )
        if meta_resp.status_code in (401, 403):
            print("[PRODRUN-SP] App credentials lack Graph file permissions, trying rclone token", flush=True)
            meta_resp = None

    if meta_resp is None or meta_resp.status_code in (401, 403):
        rclone_token = _get_rclone_sp_token()
        if rclone_token:
            access_token = rclone_token
            headers = {"Authorization": f"Bearer {access_token}"}
            meta_resp = _http_requests.get(
                f"https://graph.microsoft.com/v1.0/shares/{sharing_token}/driveItem",
                headers=headers,
                params={"$select": "name,parentReference,size"},
                timeout=30,
            )
        else:
            print("[PRODRUN-SP] No rclone token available either", flush=True)
            return None

    try:
        fname = ""
        if meta_resp and meta_resp.status_code == 200:
            meta = meta_resp.json()
            fname = meta.get("name", "")
            parent_path = meta.get("parentReference", {}).get("path", "")
            print(f"[PRODRUN-SP] Sharing link resolved: name={fname!r} parent={parent_path!r}", flush=True)

            marker = f"{_SP_DRIVE_SYNC_ROOT}/"
            idx = parent_path.find(marker)
            if idx >= 0 and fname:
                rel_parent = parent_path[idx + len(marker):]
                local_path = SHARED_DIR / rel_parent / fname
                if local_path.exists():
                    print(f"[PRODRUN-SP] Sharing link → local file: {local_path}", flush=True)
                    return local_path
                print(f"[PRODRUN-SP] Local not found: {local_path}", flush=True)

                from urllib.parse import quote
                _url_prefix = _load_shared_url_prefix()
                fileshare_url = f"{_url_prefix}/shared/{quote(rel_parent + '/' + fname, safe='/')}"
                print(f"[PRODRUN-SP] Trying fileshare URL: {fileshare_url}", flush=True)
                try:
                    fs_resp = _http_requests.get(fileshare_url, timeout=120, stream=True)
                    fs_resp.raise_for_status()
                    dl_fname = secure_filename(fname) if fname else f"file_{index:04d}.pdf"
                    dest = dest_dir / dl_fname
                    counter = 1
                    while dest.exists():
                        dest = dest_dir / f"{dest.stem}_{counter}.pdf"
                        counter += 1
                    with open(dest, "wb") as f:
                        for chunk in fs_resp.iter_content(8192):
                            f.write(chunk)
                    print(f"[PRODRUN-SP] Downloaded via fileshare: {dest.name}", flush=True)
                    return dest
                except Exception:
                    print("[PRODRUN-SP] Fileshare download failed, trying Graph download", flush=True)
        else:
            status = meta_resp.status_code if meta_resp else "no response"
            body = meta_resp.text[:200] if meta_resp else ""
            print(f"[PRODRUN-SP] Graph driveItem lookup: {status} {body}", flush=True)

        # Download directly via Graph sharing API
        dl_resp = _http_requests.get(
            f"https://graph.microsoft.com/v1.0/shares/{sharing_token}/driveItem/content",
            headers=headers,
            timeout=120,
            stream=True,
        )
        dl_resp.raise_for_status()

        if not fname:
            cd = dl_resp.headers.get("Content-Disposition", "")
            if "filename=" in cd:
                fname = cd.split("filename=")[-1].strip('" ')
        if not fname or not fname.lower().endswith(".pdf"):
            fname = f"file_{index:04d}.pdf"
        fname = secure_filename(fname)
        dest = dest_dir / fname
        counter = 1
        while dest.exists():
            dest = dest_dir / f"{dest.stem}_{counter}.pdf"
            counter += 1
        with open(dest, "wb") as f:
            for chunk in dl_resp.iter_content(8192):
                f.write(chunk)
        print(f"[PRODRUN-SP] Downloaded via Graph sharing API: {dest.name}", flush=True)
        return dest

    except Exception as exc:
        print(f"[PRODRUN-SP] Sharing link resolution failed: {exc}", flush=True)
        return None


def _prodrun_merge_worker(job_id: str) -> None:
    """Background worker: download PDFs from saved state and merge each set."""
    from datetime import date
    from urllib.parse import urlparse

    job_dir = PRODRUN_DIR / job_id
    state = _prodrun_load_state(job_dir)
    if not state:
        _prodrun_write_status(job_dir, {"status": "error", "error": "No state found"})
        return

    date_str = date.today().strftime("%Y-%m-%d")
    date_dir = job_dir / date_str
    drawings_dir = date_dir / "drawings"
    labels_dir = date_dir / "labels"

    for d in (drawings_dir, labels_dir):
        d.mkdir(parents=True, exist_ok=True)

    _prodrun_write_status(job_dir, {"status": "processing", "message": "Preparing downloads…"})

    items = state.get("items", [])
    overrides = state.get("overrides", {})
    quantities = state.get("quantities", {})

    # Build per-item source info, tracking item index for failure reporting
    drawing_sources: list[dict] = []  # {url|file, item_idx, doc_num, original_url}
    label_sources: list[dict] = []
    skipped = 0

    for i, item in enumerate(items):
        ov = overrides.get(str(i), {})
        doc_num = item.get("doc_num", f"Row {i + 2}")

        # Drawing source
        draw_ov = ov.get("drawing")
        draw_src: dict | None = None
        if draw_ov and draw_ov.get("type") == "removed":
            pass
        elif draw_ov and draw_ov.get("type") == "file":
            draw_src = {"file": draw_ov["value"], "job_dir": str(job_dir)}
        elif draw_ov and draw_ov.get("type") == "url" and draw_ov.get("value"):
            draw_src = {"url": _extract_pdf_url(draw_ov["value"]), "original_url": draw_ov["value"]}
        elif item.get("drawing_url"):
            draw_src = {"url": _extract_pdf_url(item["drawing_url"]), "original_url": item["drawing_url"]}
        if draw_src:
            draw_src["item_idx"] = i
            draw_src["doc_num"] = doc_num
            draw_src["row_num"] = item.get("row_num", i + 2)
            draw_src["qty"] = int(quantities.get(str(i), 1))
            drawing_sources.append(draw_src)

        # Label source
        lbl_ov = ov.get("label")
        lbl_src: dict | None = None
        if lbl_ov and lbl_ov.get("type") == "removed":
            pass
        elif lbl_ov and lbl_ov.get("type") == "file":
            lbl_src = {"file": lbl_ov["value"], "job_dir": str(job_dir)}
        elif lbl_ov and lbl_ov.get("type") == "url" and lbl_ov.get("value"):
            lbl_src = {"url": _extract_pdf_url(lbl_ov["value"]), "original_url": lbl_ov["value"]}
        elif item.get("label_url"):
            lbl_src = {"url": _extract_pdf_url(item["label_url"]), "original_url": item["label_url"]}
        if lbl_src:
            lbl_src["item_idx"] = i
            lbl_src["doc_num"] = doc_num
            lbl_src["row_num"] = item.get("row_num", i + 2)
            lbl_src["qty"] = int(quantities.get(str(i), 1))
            label_sources.append(lbl_src)

        if not item.get("drawing_url") and not item.get("label_url") and str(i) not in overrides:
            skipped += 1

    _prodrun_write_status(job_dir, {"status": "processing",
                    "message": f"Downloading {len(drawing_sources)} drawings and {len(label_sources)} labels…"})

    def _download_pdf(url: str, dest_dir: Path, index: int) -> Path | None:
        try:
            resp = _http_requests.get(url, timeout=120, stream=True)
            resp.raise_for_status()
            parsed = urlparse(url)
            fname = Path(parsed.path).name if parsed.path else ""
            if not fname or not fname.lower().endswith(".pdf"):
                fname = f"file_{index:04d}.pdf"
            fname = secure_filename(fname)
            dest = dest_dir / fname
            counter = 1
            while dest.exists():
                stem = dest.stem
                dest = dest_dir / f"{stem}_{counter}.pdf"
                counter += 1
            with open(dest, "wb") as f:
                for chunk in resp.iter_content(8192):
                    f.write(chunk)
            return dest
        except Exception:
            return None

    def _resolve_source(src: dict, dest_dir: Path, index: int) -> Path | None:
        if "file" in src:
            p = Path(src["job_dir"]) / src["file"]
            if p.exists() and p.suffix.lower() == ".pdf":
                return p
            return None

        url = src["url"]
        sp_info = _resolve_sharepoint_url(url)
        if sp_info:
            if sp_info["type"] == "sharing_link":
                return _resolve_sp_sharing_link(sp_info["url"], dest_dir, index)

            local_path = SHARED_DIR / sp_info["relative_path"]
            if local_path.exists() and local_path.suffix.lower() == ".pdf":
                print(f"[PRODRUN-SP] Using local synced file: {local_path}", flush=True)
                return local_path
            print(f"[PRODRUN-SP] Local not found, trying fileshare URL", flush=True)
            result = _download_pdf(sp_info["fileshare_url"], dest_dir, index)
            if result:
                return result
            print(f"[PRODRUN-SP] Fileshare failed, trying SharePoint API", flush=True)
            return _download_from_sharepoint(sp_info["sp_server_path"], dest_dir, index)

        return _download_pdf(url, dest_dir, index)

    drawing_files: list[tuple[Path, int]] = []  # (path, quantity)
    failed_items: list[dict] = []  # [{item_idx, doc_num, type, url}]
    for i, src in enumerate(drawing_sources):
        p = _resolve_source(src, drawings_dir, i)
        if p:
            drawing_files.append((p, src.get("qty", 1)))
        else:
            failed_items.append({
                "item_idx": src["item_idx"],
                "doc_num": src["doc_num"],
                "row_num": src.get("row_num"),
                "type": "drawing",
                "url": src.get("original_url", src.get("url", src.get("file", ""))),
            })

    label_files: list[tuple[Path, int]] = []  # (path, quantity)
    for i, src in enumerate(label_sources):
        p = _resolve_source(src, labels_dir, i)
        if p:
            label_files.append((p, src.get("qty", 1)))
        else:
            failed_items.append({
                "item_idx": src["item_idx"],
                "doc_num": src["doc_num"],
                "row_num": src.get("row_num"),
                "type": "label",
                "url": src.get("original_url", src.get("url", src.get("file", ""))),
            })

    _prodrun_write_status(job_dir, {"status": "processing",
                    "message": f"Merging {len(drawing_files)} drawings and {len(label_files)} labels…"})

    from pypdf import PdfWriter

    result: dict = {
        "status": "done",
        "skipped_rows": skipped,
        "failed_downloads": len(failed_items),
        "failed_items": failed_items,
    }

    if drawing_files:
        try:
            writer = PdfWriter()
            for pdf_path, qty in drawing_files:
                for _ in range(qty):
                    writer.append(str(pdf_path))
            merged_name = f"{date_str} Drawing Merge.pdf"
            merged_path = date_dir / merged_name
            with open(merged_path, "wb") as f:
                writer.write(f)
            result["drawing_merge"] = {
                "filename": merged_name,
                "job_id": job_id,
                "page_count": len(writer.pages),
                "preview_url": f"prodrun-preview?job_id={quote(job_id, safe='')}&file={quote(merged_name, safe='')}",
                "download_url": f"prodrun-download?job_id={quote(job_id, safe='')}&file={quote(merged_name, safe='')}",
            }
        except Exception as exc:
            result["drawing_merge_error"] = str(exc)

    if label_files:
        try:
            writer = PdfWriter()
            for pdf_path, qty in label_files:
                for _ in range(qty):
                    writer.append(str(pdf_path))
            merged_name = f"{date_str} Label Merge.pdf"
            merged_path = date_dir / merged_name
            with open(merged_path, "wb") as f:
                writer.write(f)
            result["label_merge"] = {
                "filename": merged_name,
                "job_id": job_id,
                "page_count": len(writer.pages),
                "preview_url": f"prodrun-preview?job_id={quote(job_id, safe='')}&file={quote(merged_name, safe='')}",
                "download_url": f"prodrun-download?job_id={quote(job_id, safe='')}&file={quote(merged_name, safe='')}",
            }
        except Exception as exc:
            result["label_merge_error"] = str(exc)

    _prodrun_write_status(job_dir, result)


def _prodrun_find_file(job_id: str, filename: str) -> Path | None:
    """Locate a merged PDF for a job, regardless of which date subdir it sits in.

    Merged files are written under PRODRUN_DIR/<job_id>/<date>/<file>. Live runs
    use today's date, but History opens older runs, so we search all date subdirs
    instead of assuming date.today().
    """
    base = (PRODRUN_DIR / job_id).resolve()
    if not str(base).startswith(str(PRODRUN_DIR.resolve())):
        return None
    if not base.exists():
        return None
    for sub in base.iterdir():
        if sub.is_dir():
            cand = sub / filename
            if cand.exists():
                return cand
    return None


def _prodrun_state_path(job_dir: Path) -> Path:
    return job_dir / "prodrun_state.json"


def _prodrun_load_state(job_dir: Path) -> dict:
    p = _prodrun_state_path(job_dir)
    return json.loads(p.read_text()) if p.exists() else {}


def _prodrun_save_state(job_dir: Path, state: dict) -> None:
    _prodrun_state_path(job_dir).write_text(json.dumps(state, indent=2))


def _prodrun_write_status(job_dir: Path, status: dict) -> None:
    (job_dir / "status.json").write_text(json.dumps(status, indent=2))


def _parse_csv_prodrun(csv_path: Path) -> list[dict]:
    items: list[dict] = []
    with open(csv_path, newline="", encoding="utf-8-sig") as fh:
        reader = csv.reader(fh)
        next(reader, None)  # skip header
        for row_idx, row in enumerate(reader, start=2):
            doc_num = row[1].strip() if len(row) > 1 else ""
            customer = row[7].strip() if len(row) > 7 else ""
            so_num = row[8].strip() if len(row) > 8 else ""
            col_o = row[14].strip() if len(row) > 14 else ""
            col_p = row[15].strip() if len(row) > 15 else ""
            if not doc_num and not col_o and not col_p:
                continue
            items.append({
                "row_num": row_idx,
                "doc_num": doc_num,
                "customer": customer,
                "so_num": so_num,
                "drawing_url": col_o,
                "label_url": col_p,
            })
    return items


def _parse_xlsx_prodrun(xlsx_path: Path) -> list[dict]:
    wb = load_workbook(xlsx_path, read_only=True, data_only=True)
    # Use the second worksheet which contains the raw data
    ws = wb.worksheets[1] if len(wb.worksheets) > 1 else wb.worksheets[0]
    items: list[dict] = []
    for row_idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
        if not row or len(row) < 2:
            continue
        doc_num = str(row[1]).strip() if row[1] is not None else ""
        customer = str(row[7]).strip() if len(row) > 7 and row[7] is not None else ""
        so_num = str(row[8]).strip() if len(row) > 8 and row[8] is not None else ""
        col_o = str(row[14]).strip() if len(row) > 14 and row[14] is not None else ""
        col_p = str(row[15]).strip() if len(row) > 15 and row[15] is not None else ""
        if not doc_num and not col_o and not col_p:
            continue
        items.append({
            "row_num": row_idx,
            "doc_num": doc_num,
            "customer": customer,
            "so_num": so_num,
            "drawing_url": col_o,
            "label_url": col_p,
        })
    wb.close()
    return items


@app.route("/prodrun-upload", methods=["POST"])
@prod_run_required
def prodrun_upload():
    """Accept a CSV or XLSX, parse items with Document Numbers and URLs, return for review."""
    csv_file = request.files.get("csv")
    if not csv_file or not csv_file.filename:
        return jsonify({"error": "No file provided"}), 400
    fname_lower = csv_file.filename.lower()
    if not fname_lower.endswith(".csv") and not fname_lower.endswith(".xlsx"):
        return jsonify({"error": "File must be a .csv or .xlsx"}), 400

    job_id = str(uuid.uuid4())[:8]
    job_dir = PRODRUN_DIR / job_id
    job_dir.mkdir(parents=True, exist_ok=True)

    saved_path = job_dir / secure_filename(csv_file.filename)
    csv_file.save(saved_path)

    # Parse rows: col B = Document Number, col H = Customer, col I = SO#,
    #             col O = Drawing URL, col P = Label URL
    items: list[dict] = []
    if fname_lower.endswith(".xlsx"):
        items = _parse_xlsx_prodrun(saved_path)
    else:
        items = _parse_csv_prodrun(saved_path)

    if not items:
        shutil.rmtree(str(job_dir), ignore_errors=True)
        return jsonify({"error": "No items found in file"}), 400

    _prodrun_save_state(job_dir, {
        "csv_filename": secure_filename(csv_file.filename),
        "items": items,
        "overrides": {},
    })

    return jsonify({"folder": job_id, "items": items})


@app.route("/prodrun-upload-file", methods=["POST"])
@prod_run_required
def prodrun_upload_file():
    """Accept a per-item file override for production run merge."""
    f = request.files.get("file")
    folder = request.form.get("folder", "")
    item_index = request.form.get("item_index", "")
    file_type = request.form.get("file_type", "")

    if not f or not f.filename or not folder or not file_type:
        return jsonify({"error": "Missing parameters"}), 400
    if ".." in folder:
        return jsonify({"error": "Invalid folder"}), 400
    if not f.filename.lower().endswith(".pdf"):
        return jsonify({"error": "File must be a PDF"}), 400

    job_dir = (PRODRUN_DIR / folder).resolve()
    if not str(job_dir).startswith(str(PRODRUN_DIR.resolve())):
        return jsonify({"error": "Invalid folder"}), 400
    job_dir.mkdir(parents=True, exist_ok=True)

    fname = secure_filename(f.filename)
    f.save(job_dir / fname)
    return jsonify({"filename": fname})


@app.route("/prodrun-submit", methods=["POST"])
@prod_run_required
def prodrun_submit():
    """Accept reviewed items with overrides and start the merge."""
    data = request.get_json(silent=True) or {}
    folder = data.get("folder", "")
    overrides = data.get("overrides", {})
    quantities = data.get("quantities", {})

    if not folder or ".." in folder:
        return jsonify({"error": "Invalid folder"}), 400

    job_dir = (PRODRUN_DIR / folder).resolve()
    if not str(job_dir).startswith(str(PRODRUN_DIR.resolve())):
        return jsonify({"error": "Invalid folder"}), 400

    state = _prodrun_load_state(job_dir)
    if not state:
        return jsonify({"error": "No upload state found — upload CSV first"}), 400

    state["overrides"] = overrides
    state["quantities"] = quantities
    _prodrun_save_state(job_dir, state)

    t = threading.Thread(target=_prodrun_merge_worker, args=(folder,), daemon=True)
    t.start()
    return jsonify({"job_id": folder})


@app.route("/prodrun-status")
@prod_run_required
def prodrun_status():
    """Poll for production run merge status."""
    job_id = request.args.get("job_id", "")
    if not job_id or ".." in job_id:
        return jsonify({"error": "Invalid job_id"}), 400
    status_path = PRODRUN_DIR / job_id / "status.json"
    if not status_path.exists():
        return jsonify({"status": "pending", "message": "Initializing…"})
    try:
        return jsonify(json.loads(status_path.read_text()))
    except Exception:
        return jsonify({"status": "pending", "message": "Reading status…"})


@app.route("/prodrun-preview")
@prod_run_required
def prodrun_preview():
    """Serve a merged production run PDF inline for preview."""
    job_id = request.args.get("job_id", "")
    filename = request.args.get("file", "")
    if not job_id or not filename or ".." in job_id or ".." in filename:
        return "Invalid parameters", 400
    file_path = _prodrun_find_file(job_id, filename)
    if not file_path:
        return "File not found", 404
    return send_file(str(file_path), mimetype="application/pdf")


@app.route("/prodrun-download")
@prod_run_required
def prodrun_download():
    """Download a merged production run PDF."""
    job_id = request.args.get("job_id", "")
    filename = request.args.get("file", "")
    if not job_id or not filename or ".." in job_id or ".." in filename:
        return "Invalid parameters", 400
    file_path = _prodrun_find_file(job_id, filename)
    if not file_path:
        return "File not found", 404
    return send_file(str(file_path), as_attachment=True, download_name=filename)


@app.route("/prodrun-history")
@prod_run_required
def prodrun_history():
    """List completed production runs, latest first (DVI-528).

    Each entry summarizes a finished merge job; the client reuses
    /prodrun-status?job_id=<id> to load full detail (merged-file preview /
    download URLs) when an entry is opened.
    """
    runs: list[dict] = []
    if PRODRUN_DIR.exists():
        for job_dir in PRODRUN_DIR.iterdir():
            if not job_dir.is_dir():
                continue
            status_path = job_dir / "status.json"
            if not status_path.exists():
                continue
            try:
                status = json.loads(status_path.read_text())
            except Exception:
                continue
            if status.get("status") != "done":
                continue
            state = _prodrun_load_state(job_dir)
            items = state.get("items", [])
            customer = ""
            so_num = ""
            for it in items:
                if not customer and it.get("customer"):
                    customer = it["customer"]
                if not so_num and it.get("so_num"):
                    so_num = it["so_num"]
                if customer and so_num:
                    break
            dm = status.get("drawing_merge") or {}
            lm = status.get("label_merge") or {}
            try:
                mtime = status_path.stat().st_mtime
            except OSError:
                mtime = 0
            runs.append({
                "job_id": job_dir.name,
                "timestamp_ms": int(mtime * 1000),
                "csv_filename": state.get("csv_filename", ""),
                "item_count": len(items),
                "customer": customer,
                "so_num": so_num,
                "drawing_pages": dm.get("page_count"),
                "label_pages": lm.get("page_count"),
                "failed_downloads": status.get("failed_downloads", 0),
            })
    runs.sort(key=lambda r: r["timestamp_ms"], reverse=True)
    return jsonify({"runs": runs})


# ---------------------------------------------------------------------------
# Routes — Work Order Request (DVI-225)
# ---------------------------------------------------------------------------

def _generate_wor_pdf(data: dict, maintenance: dict | None = None) -> bytes:
    """Generate a Work Order Request PDF matching the ICAST template layout.

    *maintenance* — optional dict with keys ``accepted_by``, ``date``,
    ``work_order_number`` to fill the "For use by maintenance department" block.
    """
    pdf = FPDF(orientation="P", unit="mm", format="letter")
    pdf.set_auto_page_break(auto=False)
    pdf.add_page()

    pw = pdf.w - 20  # page width minus margins (10mm each side)
    teal = (0, 159, 175)
    dark_gray = (80, 80, 80)
    white = (255, 255, 255)
    light_bg = (240, 250, 252)
    black = (0, 0, 0)

    # Title
    pdf.set_font("Helvetica", "B", 22)
    pdf.set_text_color(*black)
    pdf.cell(50, 12, "ICAST", ln=0)
    pdf.cell(0, 12, "Maintenance Work Order", ln=1)
    pdf.ln(4)

    def section_header(title):
        pdf.set_fill_color(*teal)
        pdf.set_text_color(*white)
        pdf.set_font("Helvetica", "B", 10)
        pdf.cell(pw, 7, "  " + title, fill=True, ln=1)
        pdf.set_text_color(*black)

    def label_value_row(pairs, row_h=7):
        col_w = pw / len(pairs)
        pdf.set_font("Helvetica", "B", 7)
        for label, _ in pairs:
            pdf.cell(col_w, 4, label)
        pdf.ln(4)
        pdf.set_font("Helvetica", "", 9)
        for _, val in pairs:
            pdf.cell(col_w, row_h, str(val or ""), border="B")
        pdf.ln(row_h)

    # Requestor Information
    section_header("Requestor information")
    label_value_row([
        ("Requestor name", data.get("requestor_name", "")),
        ("Phone #", data.get("phone", "")),
        ("Email", data.get("email", "")),
    ])
    pdf.ln(3)

    # Request Details
    section_header("Request details")
    label_value_row([
        ("Date of request", data.get("date_of_request", "")),
        ("Location", data.get("location", "")),
    ])
    label_value_row([
        ("Building", data.get("building", "")),
        ("Man Hole", data.get("man_hole", "")),
        ("Equipment/asset/system affected", data.get("equipment", "")),
    ])
    _manual = data.get("manual") or {}
    if _manual.get("original_name"):
        pdf.set_font("Helvetica", "B", 7)
        pdf.cell(pw, 4, "Attached repair / operator manual")
        pdf.ln(4)
        pdf.set_font("Helvetica", "", 9)
        pdf.cell(pw, 6, str(_manual.get("original_name", "")), border="B", ln=1)
    pdf.ln(3)

    # Request Description
    pdf.set_fill_color(*light_bg)
    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(pw, 5, "Request description", ln=1)
    pdf.set_font("Helvetica", "", 9)
    desc = data.get("description", "")
    pdf.set_draw_color(180, 180, 180)
    x0, y0 = pdf.get_x(), pdf.get_y()
    pdf.rect(x0, y0, pw, 40)
    pdf.set_xy(x0 + 2, y0 + 1)
    pdf.multi_cell(pw - 4, 4.5, desc)
    pdf.set_y(y0 + 41)

    # Type of Issue
    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(pw, 5, "Type of issue (check all that apply)", ln=1)
    issue_types = data.get("issue_type", [])
    if isinstance(issue_types, str):
        issue_types = [issue_types]
    other_text = data.get("issue_type_other", "")
    all_types = [
        "Automotive", "Grounds", "Plumbing",
        "Custodial", "Cranes", "Concrete Forms",
        "Telehandler/Forklift", "Machine", "Electrical",
        "Tool Repair", "Safety",
    ]
    col_w = pw / 3
    for row_start in range(0, len(all_types), 3):
        row_items = all_types[row_start:row_start + 3]
        for item in row_items:
            checked = item in issue_types
            pdf.set_font("ZapfDingbats", "", 9)
            pdf.cell(5, 5, "4" if checked else "", border=1)
            pdf.set_font("Helvetica", "", 8)
            pdf.cell(col_w - 5, 5, "  " + item)
        pdf.ln(5.5)
    # Other row
    other_checked = "Other" in issue_types
    pdf.set_font("ZapfDingbats", "", 9)
    pdf.cell(5, 5, "4" if other_checked else "", border=1)
    pdf.set_font("Helvetica", "", 8)
    other_display = "Other: " + other_text if other_checked and other_text else "Other:"
    pdf.cell(col_w - 5, 5, "  " + other_display)
    pdf.ln(6)

    # Best times / Urgency / Desired completion (two-column layout)
    y_section = pdf.get_y()
    half = pw / 2
    lm = pdf.l_margin  # left margin x

    # -- Left column: best times + desired completion --
    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(half, 5, "Best times for service", ln=1)
    pdf.set_font("Helvetica", "", 9)
    pdf.cell(half, 6, data.get("best_times", ""), border="B", ln=1)
    pdf.ln(2)
    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(half, 5, "Desired completion date", ln=1)
    pdf.set_font("Helvetica", "", 9)
    pdf.cell(half, 6, data.get("desired_completion_date", ""), border="B", ln=1)
    left_end_y = pdf.get_y()

    # -- Right column: urgency checkboxes --
    urgency = data.get("urgency", "")
    urg_x = lm + half
    pdf.set_xy(urg_x, y_section)
    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(half, 5, "Urgency (check one)", ln=1)
    pdf.set_x(urg_x)
    for level in ["Low", "Medium", "High"]:
        checked = urgency == level
        pdf.set_font("ZapfDingbats", "", 9)
        pdf.cell(5, 5.5, "4" if checked else "", border=1)
        pdf.set_font("Helvetica", "", 8)
        pdf.cell(half - 5, 5.5, "  " + level, ln=1)
        pdf.set_x(urg_x)
    right_end_y = pdf.get_y()

    pdf.set_y(max(left_end_y, right_end_y) + 4)
    pdf.set_fill_color(*dark_gray)
    pdf.set_text_color(*white)
    pdf.set_font("Helvetica", "B", 9)
    pdf.cell(pw, 7, "  For use by maintenance department", fill=True, ln=1)
    pdf.set_text_color(*black)
    m = maintenance or {}
    wo_num = m.get("work_order_number", "") or (f"{data['_wor_number']:05d}" if data.get("_wor_number") else "")
    label_value_row([
        ("Accepted by", m.get("accepted_by", "")),
        ("Date", m.get("date", "")),
        ("Assigned work order #", wo_num),
    ], row_h=10)

    return pdf.output()


def _generate_preventive_wor_pdf(data: dict, maintenance: dict | None = None) -> bytes:
    """Generate a Preventative Maintenance Work Order PDF.

    Mirrors the standard maintenance template plus a Parts table (matching the
    Mobile Machine layout). The "Completed / In progress" status selections are
    intentionally omitted — status is tracked by Work Order Admin processing.
    """
    pdf = FPDF(orientation="P", unit="mm", format="letter")
    pdf.set_auto_page_break(auto=False)
    pdf.add_page()

    pw = pdf.w - 20
    teal = (0, 159, 175)
    dark_gray = (80, 80, 80)
    white = (255, 255, 255)
    light_bg = (240, 250, 252)
    black = (0, 0, 0)

    # Title
    pdf.set_font("Helvetica", "B", 22)
    pdf.set_text_color(*black)
    pdf.cell(50, 12, "ICAST", ln=0)
    pdf.set_font("Helvetica", "B", 16)
    pdf.cell(0, 12, "Preventative Maintenance Work Order", ln=1)
    pdf.ln(4)

    def section_header(title):
        pdf.set_fill_color(*teal)
        pdf.set_text_color(*white)
        pdf.set_font("Helvetica", "B", 10)
        pdf.cell(pw, 7, "  " + title, fill=True, ln=1)
        pdf.set_text_color(*black)

    def label_value_row(pairs, row_h=7):
        col_w = pw / len(pairs)
        pdf.set_font("Helvetica", "B", 7)
        for label, _ in pairs:
            pdf.cell(col_w, 4, label)
        pdf.ln(4)
        pdf.set_font("Helvetica", "", 9)
        for _, val in pairs:
            pdf.cell(col_w, row_h, str(val or ""), border="B")
        pdf.ln(row_h)

    # Requestor Information
    section_header("Requestor information")
    label_value_row([
        ("Requestor name", data.get("requestor_name", "")),
        ("Phone #", data.get("phone", "")),
        ("Email", data.get("email", "")),
    ])
    pdf.ln(3)

    # Request Details
    section_header("Request details")
    label_value_row([
        ("Date of request", data.get("date_of_request", "")),
        ("Location", data.get("location", "")),
    ])
    label_value_row([
        ("Building", data.get("building", "")),
        ("Man Hole", data.get("man_hole", "")),
        ("Equipment/asset/system affected", data.get("equipment", "")),
    ])
    _manual = data.get("manual") or {}
    if _manual.get("original_name"):
        pdf.set_font("Helvetica", "B", 7)
        pdf.cell(pw, 4, "Attached repair / operator manual")
        pdf.ln(4)
        pdf.set_font("Helvetica", "", 9)
        pdf.cell(pw, 6, str(_manual.get("original_name", "")), border="B", ln=1)
    pdf.ln(3)

    # Request Description
    pdf.set_fill_color(*light_bg)
    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(pw, 5, "Request description", ln=1)
    pdf.set_font("Helvetica", "", 9)
    desc = data.get("description", "")
    pdf.set_draw_color(180, 180, 180)
    x0, y0 = pdf.get_x(), pdf.get_y()
    pdf.rect(x0, y0, pw, 28)
    pdf.set_xy(x0 + 2, y0 + 1)
    pdf.multi_cell(pw - 4, 4.5, desc)
    pdf.set_y(y0 + 29)

    # Type of Issue
    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(pw, 5, "Type of issue (check all that apply)", ln=1)
    issue_types = data.get("issue_type", [])
    if isinstance(issue_types, str):
        issue_types = [issue_types]
    other_text = data.get("issue_type_other", "")
    all_types = [
        "Automotive", "Grounds", "Plumbing",
        "Custodial", "Cranes", "Concrete Forms",
        "Telehandler/Forklift", "Machine", "Electrical",
        "Tool Repair", "Safety",
    ]
    col_w = pw / 3
    for row_start in range(0, len(all_types), 3):
        row_items = all_types[row_start:row_start + 3]
        for item in row_items:
            checked = item in issue_types
            pdf.set_font("ZapfDingbats", "", 9)
            pdf.cell(5, 5, "4" if checked else "", border=1)
            pdf.set_font("Helvetica", "", 8)
            pdf.cell(col_w - 5, 5, "  " + item)
        pdf.ln(5.5)
    other_checked = "Other" in issue_types
    pdf.set_font("ZapfDingbats", "", 9)
    pdf.cell(5, 5, "4" if other_checked else "", border=1)
    pdf.set_font("Helvetica", "", 8)
    other_display = "Other: " + other_text if other_checked and other_text else "Other:"
    pdf.cell(col_w - 5, 5, "  " + other_display)
    pdf.ln(6)

    # Best times / Urgency / Desired completion (two-column layout)
    y_section = pdf.get_y()
    half = pw / 2
    lm = pdf.l_margin

    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(half, 5, "Best times for service", ln=1)
    pdf.set_font("Helvetica", "", 9)
    pdf.cell(half, 6, data.get("best_times", ""), border="B", ln=1)
    pdf.ln(2)
    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(half, 5, "Desired completion date", ln=1)
    pdf.set_font("Helvetica", "", 9)
    pdf.cell(half, 6, data.get("desired_completion_date", ""), border="B", ln=1)
    left_end_y = pdf.get_y()

    urgency = data.get("urgency", "")
    urg_x = lm + half
    pdf.set_xy(urg_x, y_section)
    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(half, 5, "Urgency (check one)", ln=1)
    pdf.set_x(urg_x)
    for level in ["Low", "Medium", "High"]:
        checked = urgency == level
        pdf.set_font("ZapfDingbats", "", 9)
        pdf.cell(5, 5.5, "4" if checked else "", border=1)
        pdf.set_font("Helvetica", "", 8)
        pdf.cell(half - 5, 5.5, "  " + level, ln=1)
        pdf.set_x(urg_x)
    right_end_y = pdf.get_y()

    pdf.set_y(max(left_end_y, right_end_y) + 4)

    # Parts table
    section_header("Parts")
    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(pw * 0.2, 5, "Part Number")
    pdf.cell(pw * 0.4, 5, "Part Name")
    pdf.cell(pw * 0.2, 5, "Quantity")
    pdf.cell(pw * 0.2, 5, "Amount")
    pdf.ln(5)
    parts = data.get("parts", [])
    pdf.set_font("Helvetica", "", 8)
    for part in parts[:8]:
        pdf.cell(pw * 0.2, 6, str(part.get("number", "")), border="B")
        pdf.cell(pw * 0.4, 6, str(part.get("name", "")), border="B")
        pdf.cell(pw * 0.2, 6, str(part.get("qty", "")), border="B")
        pdf.cell(pw * 0.2, 6, str(part.get("amount", "")), border="B")
        pdf.ln(6)
    if not parts:
        for _ in range(3):
            pdf.cell(pw * 0.2, 6, "", border="B")
            pdf.cell(pw * 0.4, 6, "", border="B")
            pdf.cell(pw * 0.2, 6, "", border="B")
            pdf.cell(pw * 0.2, 6, "", border="B")
            pdf.ln(6)
    pdf.ln(1)
    pdf.set_font("Helvetica", "", 8)
    pdf.cell(pw, 5, "Parts Total: " + str(data.get("parts_total", "")), align="R")
    pdf.ln(7)

    # For use by maintenance department
    pdf.set_fill_color(*dark_gray)
    pdf.set_text_color(*white)
    pdf.set_font("Helvetica", "B", 9)
    pdf.cell(pw, 7, "  For use by maintenance department", fill=True, ln=1)
    pdf.set_text_color(*black)
    m = maintenance or {}
    wo_num = m.get("work_order_number", "") or (f"{data['_wor_number']:05d}" if data.get("_wor_number") else "")
    label_value_row([
        ("Accepted by", m.get("accepted_by", "")),
        ("Date", m.get("date", "")),
        ("Assigned work order #", wo_num),
    ], row_h=10)

    return pdf.output()


def _generate_mm_wor_pdf(data: dict) -> bytes:
    """Generate a Mobile Machine Work Order PDF matching the ICAST template."""
    pdf = FPDF(orientation="P", unit="mm", format="letter")
    pdf.set_auto_page_break(auto=False)
    pdf.add_page()

    pw = pdf.w - 20
    teal = (0, 159, 175)
    dark_gray = (80, 80, 80)
    white = (255, 255, 255)
    black = (0, 0, 0)
    light_bg = (240, 250, 252)

    # Title
    pdf.set_font("Helvetica", "B", 22)
    pdf.set_text_color(*black)
    pdf.cell(50, 12, "ICAST", ln=0)
    pdf.cell(0, 12, "Mobile Machine WORK ORDER", ln=1)
    pdf.ln(4)

    def section_header(title):
        pdf.set_fill_color(*teal)
        pdf.set_text_color(*white)
        pdf.set_font("Helvetica", "B", 10)
        pdf.cell(pw, 7, "  " + title, fill=True, ln=1)
        pdf.set_text_color(*black)

    def label_value_row(pairs, row_h=7):
        col_w = pw / len(pairs)
        pdf.set_font("Helvetica", "B", 7)
        for label, _ in pairs:
            pdf.cell(col_w, 4, label)
        pdf.ln(4)
        pdf.set_font("Helvetica", "", 9)
        for _, val in pairs:
            pdf.cell(col_w, row_h, str(val or ""), border="B")
        pdf.ln(row_h)

    # Machine Information
    section_header("Machine Information")
    label_value_row([
        ("Machine", data.get("machine", "")),
        ("Order Number", data.get("_order_number", "") or (f"{data['_wor_number']:05d}" if data.get("_wor_number") else "")),
    ])
    label_value_row([
        ("Asset #", data.get("asset_number", "")),
        ("Order Received By", data.get("order_received_by", "")),
    ])
    label_value_row([
        ("Order Date & Time", data.get("order_date_time", "")),
        ("Date Promised", data.get("date_promised", "")),
        ("Date Delivered", data.get("date_delivered", "")),
    ])
    pdf.ln(2)

    # Vehicle Information
    section_header("Vehicle Information")
    label_value_row([("VIN", data.get("vin", "")), ("Make and Model", data.get("make_model", ""))])
    label_value_row([
        ("Odometer Reading", data.get("odometer", "")),
        ("Motor #", data.get("motor_number", "")),
    ])
    label_value_row([("License # and State", data.get("license_state", ""))])
    pdf.ln(2)

    # Service Type checkboxes
    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(pw, 5, "Service Type", ln=1)
    service_types = data.get("service_type", [])
    if isinstance(service_types, str):
        service_types = [service_types]
    all_services = [
        "Lubricate", "Wiper Replacement", "Air Filter Replacement",
        "Change Oil", "Boom Repair", "Spark Plug Replacement",
        "Transmission Service", "Fuel System", "Exhaust System Check",
        "Battery Replacement / Check", "Tire Replacement", "Electronics",
        "Differential Service", "Brake Inspection / Replacement", "Suspension and Steering Check",
        "Flat Tire Repair", "Coolant System Flush", "Hydraulic repair / fill",
    ]
    col_w = pw / 3
    for row_start in range(0, len(all_services), 3):
        row_items = all_services[row_start:row_start + 3]
        for item in row_items:
            checked = item in service_types
            pdf.set_font("ZapfDingbats", "", 9)
            pdf.cell(5, 5, "4" if checked else "", border=1)
            pdf.set_font("Helvetica", "", 7)
            pdf.cell(col_w - 5, 5, "  " + item)
        pdf.ln(5.5)
    pdf.ln(2)

    # Labor Description
    pdf.set_fill_color(*light_bg)
    pdf.set_font("Helvetica", "B", 7)
    labor_header_y = pdf.get_y()
    pdf.cell(pw - 30, 5, "Labor Description", ln=0)
    pdf.cell(30, 5, "Time", ln=1)
    pdf.set_font("Helvetica", "", 9)
    desc = data.get("labor_description", "")
    x0, y0 = pdf.get_x(), pdf.get_y()
    pdf.set_draw_color(180, 180, 180)
    pdf.rect(x0, y0, pw, 25)
    pdf.set_xy(x0 + 2, y0 + 1)
    pdf.multi_cell(pw - 4, 4.5, desc)
    pdf.set_y(y0 + 26)

    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(pw / 2, 5, "", ln=0)
    pdf.cell(pw / 4, 5, "Time: " + str(data.get("labor_time", "")), ln=0)
    pdf.cell(pw / 4, 5, "Labor Total: " + str(data.get("labor_total", "")), ln=1)
    pdf.ln(2)

    # Parts table
    section_header("Parts")
    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(pw * 0.2, 5, "Part Number")
    pdf.cell(pw * 0.4, 5, "Part Name")
    pdf.cell(pw * 0.2, 5, "Quantity")
    pdf.cell(pw * 0.2, 5, "Amount")
    pdf.ln(5)

    parts = data.get("parts", [])
    pdf.set_font("Helvetica", "", 8)
    for part in parts[:8]:
        pdf.cell(pw * 0.2, 6, str(part.get("number", "")), border="B")
        pdf.cell(pw * 0.4, 6, str(part.get("name", "")), border="B")
        pdf.cell(pw * 0.2, 6, str(part.get("qty", "")), border="B")
        pdf.cell(pw * 0.2, 6, str(part.get("amount", "")), border="B")
        pdf.ln(6)
    if not parts:
        for _ in range(3):
            pdf.cell(pw * 0.2, 6, "", border="B")
            pdf.cell(pw * 0.4, 6, "", border="B")
            pdf.cell(pw * 0.2, 6, "", border="B")
            pdf.cell(pw * 0.2, 6, "", border="B")
            pdf.ln(6)

    pdf.ln(1)
    pdf.set_font("Helvetica", "", 8)
    pdf.cell(pw, 5, "Parts Total: " + str(data.get("parts_total", "")), align="R")
    pdf.ln(7)

    # Authorization
    pdf.set_fill_color(*dark_gray)
    pdf.set_text_color(*white)
    pdf.set_font("Helvetica", "B", 9)
    pdf.cell(pw, 7, "  Authorization", fill=True, ln=1)
    pdf.set_text_color(*black)
    label_value_row([
        ("Work Order Compiled By", data.get("compiled_by", "")),
        ("Date", data.get("compiled_date", "")),
    ])
    label_value_row([
        ("Work Authorized By", data.get("authorized_by", "")),
        ("Authorization Date", data.get("authorization_date", "")),
    ])

    return pdf.output()


def _generate_safety_wor_pdf(data: dict, safety: dict | None = None) -> bytes:
    """Generate a Safety Concerns Work Order PDF matching the ICAST template layout.

    *safety* — optional dict with keys ``accepted_by``, ``date``,
    ``work_order_number`` to fill the "For use by Safety department" block.
    Status checkboxes from the paper template (Completed, In progress/parts
    ordered) are intentionally omitted — those states are tracked by the
    Work Order Admin workflow.
    """
    pdf = FPDF(orientation="P", unit="mm", format="letter")
    pdf.set_auto_page_break(auto=False)
    pdf.add_page()

    pw = pdf.w - 20  # page width minus margins (10mm each side)
    teal = (0, 159, 175)
    dark_gray = (80, 80, 80)
    white = (255, 255, 255)
    light_bg = (240, 250, 252)
    black = (0, 0, 0)

    # Title
    pdf.set_font("Helvetica", "B", 22)
    pdf.set_text_color(*black)
    pdf.cell(50, 12, "ICAST", ln=0)
    pdf.cell(0, 12, "Safety Concerns", ln=1)
    pdf.ln(4)

    def section_header(title):
        pdf.set_fill_color(*teal)
        pdf.set_text_color(*white)
        pdf.set_font("Helvetica", "B", 10)
        pdf.cell(pw, 7, "  " + title, fill=True, ln=1)
        pdf.set_text_color(*black)

    def label_value_row(pairs, row_h=7):
        col_w = pw / len(pairs)
        pdf.set_font("Helvetica", "B", 7)
        for label, _ in pairs:
            pdf.cell(col_w, 4, label)
        pdf.ln(4)
        pdf.set_font("Helvetica", "", 9)
        for _, val in pairs:
            pdf.cell(col_w, row_h, str(val or ""), border="B")
        pdf.ln(row_h)

    # Requestor Information
    section_header("Requestor information")
    label_value_row([
        ("Requestor name", data.get("requestor_name", "")),
        ("Phone #", data.get("phone", "")),
        ("Email", data.get("email", "")),
    ])
    pdf.ln(3)

    # Request Details
    section_header("Request details")
    label_value_row([
        ("Date of request", data.get("date_of_request", "")),
        ("Location", data.get("location", "")),
    ])
    label_value_row([
        ("Building", data.get("building", "")),
        ("Man Hole", data.get("man_hole", "")),
        ("Equipment/asset/system affected", data.get("equipment", "")),
    ])
    _manual = data.get("manual") or {}
    if _manual.get("original_name"):
        pdf.set_font("Helvetica", "B", 7)
        pdf.cell(pw, 4, "Attached repair / operator manual")
        pdf.ln(4)
        pdf.set_font("Helvetica", "", 9)
        pdf.cell(pw, 6, str(_manual.get("original_name", "")), border="B", ln=1)
    pdf.ln(3)

    # Request Description
    pdf.set_fill_color(*light_bg)
    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(pw, 5, "Request description", ln=1)
    pdf.set_font("Helvetica", "", 9)
    desc = data.get("description", "")
    pdf.set_draw_color(180, 180, 180)
    x0, y0 = pdf.get_x(), pdf.get_y()
    pdf.rect(x0, y0, pw, 40)
    pdf.set_xy(x0 + 2, y0 + 1)
    pdf.multi_cell(pw - 4, 4.5, desc)
    pdf.set_y(y0 + 41)

    # Type of Safety Issue
    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(pw, 5, "Type of Safety issue (check all that apply)", ln=1)
    issue_types = data.get("issue_type", [])
    if isinstance(issue_types, str):
        issue_types = [issue_types]
    other_text = data.get("issue_type_other", "")
    all_types = [
        "Slip/Trip/Fall", "Isleway or Egress", "General Safety",
        "Lighting", "Near Miss", "Concrete Forms",
        "Mobile equipment", "Machine", "Electrical",
        "Tool/Cords", "Building",
    ]
    col_w = pw / 3
    for row_start in range(0, len(all_types), 3):
        row_items = all_types[row_start:row_start + 3]
        for item in row_items:
            checked = item in issue_types
            pdf.set_font("ZapfDingbats", "", 9)
            pdf.cell(5, 5, "4" if checked else "", border=1)
            pdf.set_font("Helvetica", "", 8)
            pdf.cell(col_w - 5, 5, "  " + item)
        pdf.ln(5.5)
    # Other row
    other_checked = "Other" in issue_types
    pdf.set_font("ZapfDingbats", "", 9)
    pdf.cell(5, 5, "4" if other_checked else "", border=1)
    pdf.set_font("Helvetica", "", 8)
    other_display = "Other: " + other_text if other_checked and other_text else "Other:"
    pdf.cell(col_w - 5, 5, "  " + other_display)
    pdf.ln(6)

    # Best times / Urgency / Desired completion (two-column layout)
    y_section = pdf.get_y()
    half = pw / 2
    lm = pdf.l_margin  # left margin x

    # -- Left column: best times + desired completion --
    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(half, 5, "Best times for service", ln=1)
    pdf.set_font("Helvetica", "", 9)
    pdf.cell(half, 6, data.get("best_times", ""), border="B", ln=1)
    pdf.ln(2)
    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(half, 5, "Desired completion date", ln=1)
    pdf.set_font("Helvetica", "", 9)
    pdf.cell(half, 6, data.get("desired_completion_date", ""), border="B", ln=1)
    left_end_y = pdf.get_y()

    # -- Right column: urgency checkboxes --
    urgency = data.get("urgency", "")
    urg_x = lm + half
    pdf.set_xy(urg_x, y_section)
    pdf.set_font("Helvetica", "B", 7)
    pdf.cell(half, 5, "Urgency (check one)", ln=1)
    pdf.set_x(urg_x)
    for level in ["Low", "Medium", "High"]:
        checked = urgency == level
        pdf.set_font("ZapfDingbats", "", 9)
        pdf.cell(5, 5.5, "4" if checked else "", border=1)
        pdf.set_font("Helvetica", "", 8)
        pdf.cell(half - 5, 5.5, "  " + level, ln=1)
        pdf.set_x(urg_x)
    right_end_y = pdf.get_y()

    pdf.set_y(max(left_end_y, right_end_y) + 4)
    pdf.set_fill_color(*dark_gray)
    pdf.set_text_color(*white)
    pdf.set_font("Helvetica", "B", 9)
    pdf.cell(pw, 7, "  For use by Safety department", fill=True, ln=1)
    pdf.set_text_color(*black)
    m = safety or {}
    wo_num = m.get("work_order_number", "") or (f"{data['_wor_number']:05d}" if data.get("_wor_number") else "")
    label_value_row([
        ("Accepted by", m.get("accepted_by", "")),
        ("Date", m.get("date", "")),
        ("Assigned work order #", wo_num),
    ], row_h=10)

    return pdf.output()


@app.route("/wor-submit", methods=["POST"])
@login_required
def wor_submit():
    """Accept work order form data, generate PDF, and store it."""
    if not _wor_type_enabled_for("maintenance", session["user"]):
        return jsonify(error="This work order type is not currently enabled."), 403
    data = {
        "requestor_name": request.form.get("requestor_name", "").strip(),
        "phone": request.form.get("phone", "").strip(),
        "email": request.form.get("email", "").strip(),
        "date_of_request": request.form.get("date_of_request", "").strip(),
        "location": request.form.get("location", "").strip(),
        "building": request.form.get("building", "").strip(),
        "man_hole": request.form.get("man_hole", "").strip(),
        "equipment": request.form.get("equipment", "").strip(),
        "description": request.form.get("description", "").strip(),
        "issue_type": request.form.getlist("issue_type"),
        "issue_type_other": request.form.get("issue_type_other", "").strip(),
        "best_times": request.form.get("best_times", "").strip(),
        "desired_completion_date": request.form.get("desired_completion_date", "").strip(),
        "urgency": request.form.get("urgency", "").strip(),
    }

    if not data["requestor_name"]:
        return jsonify(error="Requestor name is required"), 400
    if not data["description"]:
        return jsonify(error="Request description is required"), 400

    data["_wor_type"] = "maintenance"
    wor_num = _next_work_order_number()
    data["_wor_number"] = wor_num
    WOR_MAINTENANCE_DIR.mkdir(parents=True, exist_ok=True)

    # Per-WO notification opt-in (unchecked by default). The recipient email
    # comes from the signed-in user, not the (editable) form field, so opt-in
    # always notifies the actual submitter. ``email`` mirrors ``requestor_email``
    # because the notifications core resolves recipients by the ``email`` key.
    user = session.get("user") or {}
    notify_email = user.get("email", "")
    data["_notify"] = {
        "enabled": "notify_enabled" in request.form,
        "requestor_name": user.get("name") or data["requestor_name"],
        "requestor_email": notify_email,
        "email": notify_email,
        "disabled_by_admin": False,
    }

    pdf_bytes = _generate_wor_pdf(data)

    timestamp = _datetime_type.now().strftime("%Y-%m-%d_%H%M%S")
    safe_name = re.sub(r"[^\w\s\-]", "", data["requestor_name"])[:40].strip().replace(" ", "_")
    filename = f"WOR_{wor_num:05d}_{timestamp}_{safe_name}.pdf"
    out_path = WOR_MAINTENANCE_DIR / filename
    out_path.write_bytes(pdf_bytes)

    json_path = out_path.with_suffix(".json")
    json_path.write_text(json.dumps(data, indent=2))

    # Raise workorder.created — one branded email per opted-in recipient.
    notifications.notify(
        notifications.EVENT_WO_CREATED,
        _wor_event_context(filename, "maintenance", data),
    )

    _send_wor_admin_email_bg(
        f"New Maintenance Work Order #{wor_num:05d} — {data['requestor_name']}",
        f"A new Maintenance Work Order has been submitted.\n\n"
        f"Requestor: {data['requestor_name']}\n"
        f"Date: {data.get('date_of_request', '')}\n"
        f"Location: {data.get('location', '')}\n"
        f"Description: {data.get('description', '')[:200]}\n"
        f"Urgency: {data.get('urgency', '')}\n"
        f"Desired Completion: {data.get('desired_completion_date', 'Not set')}\n\n"
        f"Filename: {filename}\n\n"
        f"This notification was sent from the ICAST Togen application.",
    )

    if _is_history_capture_enabled("wor-admin"):
        record_history(
            "wor-admin", "created",
            actor=(session.get("user") or {}).get("name"),
            summary=f"Maintenance WO #{wor_num:05d} created — {data['requestor_name']}",
            ref=f"WO#{wor_num:05d}",
            detail={"wor_type": "maintenance", "filename": filename},
        )

    return jsonify(filename=filename)


@app.route("/safety-wor-submit", methods=["POST"])
@login_required
def safety_wor_submit():
    """Accept Safety Concerns work order form data, generate PDF, and store it."""
    if not _wor_type_enabled_for("safety", session["user"]):
        return jsonify(error="This work order type is not currently enabled."), 403
    data = {
        "requestor_name": request.form.get("requestor_name", "").strip(),
        "phone": request.form.get("phone", "").strip(),
        "email": request.form.get("email", "").strip(),
        "date_of_request": request.form.get("date_of_request", "").strip(),
        "location": request.form.get("location", "").strip(),
        "building": request.form.get("building", "").strip(),
        "man_hole": request.form.get("man_hole", "").strip(),
        "equipment": request.form.get("equipment", "").strip(),
        "description": request.form.get("description", "").strip(),
        "issue_type": request.form.getlist("issue_type"),
        "issue_type_other": request.form.get("issue_type_other", "").strip(),
        "best_times": request.form.get("best_times", "").strip(),
        "desired_completion_date": request.form.get("desired_completion_date", "").strip(),
        "urgency": request.form.get("urgency", "").strip(),
    }

    if not data["requestor_name"]:
        return jsonify(error="Requestor name is required"), 400
    if not data["description"]:
        return jsonify(error="Request description is required"), 400

    data["_wor_type"] = "safety"
    wor_num = _next_work_order_number()
    data["_wor_number"] = wor_num
    WOR_SAFETY_DIR.mkdir(parents=True, exist_ok=True)

    # Per-WO notification opt-in (unchecked by default). Recipient email comes
    # from the signed-in user, not the editable form field, so opt-in always
    # notifies the actual submitter. See wor_submit() for the shared shape.
    user = session.get("user") or {}
    notify_email = user.get("email", "")
    data["_notify"] = {
        "enabled": "notify_enabled" in request.form,
        "requestor_name": user.get("name") or data["requestor_name"],
        "requestor_email": notify_email,
        "email": notify_email,
        "disabled_by_admin": False,
    }

    pdf_bytes = _generate_safety_wor_pdf(data)

    timestamp = _datetime_type.now().strftime("%Y-%m-%d_%H%M%S")
    safe_name = re.sub(r"[^\w\s\-]", "", data["requestor_name"])[:40].strip().replace(" ", "_")
    filename = f"SWO_{wor_num:05d}_{timestamp}_{safe_name}.pdf"
    out_path = WOR_SAFETY_DIR / filename
    out_path.write_bytes(pdf_bytes)

    json_path = out_path.with_suffix(".json")
    json_path.write_text(json.dumps(data, indent=2))

    _send_wor_admin_email_bg(
        f"New Safety Work Order #{wor_num:05d} — {data['requestor_name']}",
        f"A new Safety Concerns Work Order has been submitted.\n\n"
        f"Requestor: {data['requestor_name']}\n"
        f"Date: {data.get('date_of_request', '')}\n"
        f"Location: {data.get('location', '')}\n"
        f"Description: {data.get('description', '')[:200]}\n"
        f"Urgency: {data.get('urgency', '')}\n"
        f"Desired Completion: {data.get('desired_completion_date', 'Not set')}\n\n"
        f"Filename: {filename}\n\n"
        f"This notification was sent from the ICAST Togen application.",
    )

    if _is_history_capture_enabled("wor-admin"):
        record_history(
            "wor-admin", "created",
            actor=(session.get("user") or {}).get("name"),
            summary=f"Safety WO #{wor_num:05d} created — {data['requestor_name']}",
            ref=f"WO#{wor_num:05d}",
            detail={"wor_type": "safety", "filename": filename},
        )

    return jsonify(filename=filename)


@app.route("/mm-wor-submit", methods=["POST"])
@login_required
def mm_wor_submit():
    """Accept Mobile Machine work order form data, generate PDF, and store it."""
    if not _wor_type_enabled_for("mobilemachine", session["user"]):
        return jsonify(error="This work order type is not currently enabled."), 403
    part_numbers = request.form.getlist("part_number[]")
    part_names = request.form.getlist("part_name[]")
    part_qtys = request.form.getlist("part_qty[]")
    part_amounts = request.form.getlist("part_amount[]")
    parts = []
    for i in range(len(part_numbers)):
        pn = part_numbers[i].strip() if i < len(part_numbers) else ""
        nm = part_names[i].strip() if i < len(part_names) else ""
        qt = part_qtys[i].strip() if i < len(part_qtys) else ""
        am = part_amounts[i].strip() if i < len(part_amounts) else ""
        if pn or nm or qt or am:
            parts.append({"number": pn, "name": nm, "qty": qt, "amount": am})
    parts = _rpm_register_submitted_parts(parts, "mobilemachine", session.get("user"))

    data = {
        "machine": request.form.get("machine", "").strip(),
        "asset_number": request.form.get("asset_number", "").strip(),
        "order_date_time": request.form.get("order_date_time", "").strip(),
        "date_promised": request.form.get("date_promised", "").strip(),
        "date_delivered": request.form.get("date_delivered", "").strip(),
        "order_received_by": request.form.get("order_received_by", "").strip(),
        "make_model": request.form.get("make_model", "").strip(),
        "vin": request.form.get("vin", "").strip(),
        "odometer": request.form.get("odometer", "").strip(),
        "license_state": request.form.get("license_state", "").strip(),
        "motor_number": request.form.get("motor_number", "").strip(),
        "service_type": request.form.getlist("service_type"),
        "labor_description": request.form.get("labor_description", "").strip(),
        "labor_time": request.form.get("labor_time", "").strip(),
        "labor_total": request.form.get("labor_total", "").strip(),
        "parts": parts,
        "awaiting_approval": "awaiting_approval" in request.form,
        "parts_ordered": "parts_ordered" in request.form,
        "parts_total": request.form.get("parts_total", "").strip(),
        "compiled_by": request.form.get("compiled_by", "").strip(),
        "compiled_date": request.form.get("compiled_date", "").strip(),
        "authorized_by": request.form.get("authorized_by", "").strip(),
        "authorization_date": request.form.get("authorization_date", "").strip(),
        "_wor_type": "mobilemachine",
    }

    if not data["machine"]:
        return jsonify(error="Machine name is required"), 400

    # Optional manual attachment (DVI-462), indexed under the machine name.
    manual_info, manual_err = _pm_process_manual(data["machine"], session.get("user") or {})
    if manual_err:
        return jsonify(error=manual_err), 400
    if manual_info:
        data["manual"] = manual_info

    wor_num = _next_work_order_number()
    data["_wor_number"] = wor_num
    WOR_MOBILEMACHINE_DIR.mkdir(parents=True, exist_ok=True)

    # Per-WO notification opt-in (unchecked by default). Recipient email comes
    # from the signed-in user, not a form field, so opt-in always notifies the
    # actual submitter. See wor_submit() for the shared shape.
    user = session.get("user") or {}
    notify_email = user.get("email", "")
    data["_notify"] = {
        "enabled": "notify_enabled" in request.form,
        "requestor_name": user.get("name") or data["machine"],
        "requestor_email": notify_email,
        "email": notify_email,
        "disabled_by_admin": False,
    }

    pdf_bytes = _generate_mm_wor_pdf(data)

    timestamp = _datetime_type.now().strftime("%Y-%m-%d_%H%M%S")
    safe_machine = re.sub(r"[^\w\s\-]", "", data["machine"])[:40].strip().replace(" ", "_")
    filename = f"MMWO_{wor_num:05d}_{timestamp}_{safe_machine}.pdf"
    out_path = WOR_MOBILEMACHINE_DIR / filename
    out_path.write_bytes(pdf_bytes)

    json_path = out_path.with_suffix(".json")
    json_path.write_text(json.dumps(data, indent=2))

    _send_wor_admin_email_bg(
        f"New Mobile Machine Work Order #{wor_num:05d} — {data['machine']}",
        f"A new Mobile Machine Work Order has been submitted.\n\n"
        f"Machine: {data['machine']}\n"
        f"Asset #: {data.get('asset_number', '')}\n"
        f"Order Date: {data.get('order_date_time', '')}\n"
        f"Date Promised: {data.get('date_promised', 'Not set')}\n"
        f"Make/Model: {data.get('make_model', '')}\n\n"
        f"Filename: {filename}\n\n"
        f"This notification was sent from the ICAST Togen application.",
    )

    if _is_history_capture_enabled("wor-admin"):
        record_history(
            "wor-admin", "created",
            actor=(session.get("user") or {}).get("name"),
            summary=f"Mobile Machine WO #{wor_num:05d} created — {data['machine']}",
            ref=f"WO#{wor_num:05d}",
            detail={"wor_type": "mobilemachine", "filename": filename},
        )

    return jsonify(filename=filename)


@app.route("/preventive-wor-submit", methods=["POST"])
@login_required
def preventive_wor_submit():
    """Accept Preventative Maintenance work order form data, generate PDF, and store it."""
    if not _wor_type_enabled_for("preventive", session["user"]):
        return jsonify(error="This work order type is not currently enabled."), 403

    part_numbers = request.form.getlist("part_number[]")
    part_names = request.form.getlist("part_name[]")
    part_qtys = request.form.getlist("part_qty[]")
    part_amounts = request.form.getlist("part_amount[]")
    parts = []
    for i in range(len(part_numbers)):
        pn = part_numbers[i].strip() if i < len(part_numbers) else ""
        nm = part_names[i].strip() if i < len(part_names) else ""
        qt = part_qtys[i].strip() if i < len(part_qtys) else ""
        am = part_amounts[i].strip() if i < len(part_amounts) else ""
        if pn or nm or qt or am:
            parts.append({"number": pn, "name": nm, "qty": qt, "amount": am})
    parts = _rpm_register_submitted_parts(parts, "preventive", session.get("user"))

    data = {
        "requestor_name": request.form.get("requestor_name", "").strip(),
        "phone": request.form.get("phone", "").strip(),
        "email": request.form.get("email", "").strip(),
        "date_of_request": request.form.get("date_of_request", "").strip(),
        "location": request.form.get("location", "").strip(),
        "building": request.form.get("building", "").strip(),
        "man_hole": request.form.get("man_hole", "").strip(),
        "equipment": request.form.get("equipment", "").strip(),
        "description": request.form.get("description", "").strip(),
        "issue_type": request.form.getlist("issue_type"),
        "issue_type_other": request.form.get("issue_type_other", "").strip(),
        "best_times": request.form.get("best_times", "").strip(),
        "desired_completion_date": request.form.get("desired_completion_date", "").strip(),
        "urgency": request.form.get("urgency", "").strip(),
        "parts": parts,
        "awaiting_approval": "awaiting_approval" in request.form,
        "parts_ordered": "parts_ordered" in request.form,
        "parts_total": request.form.get("parts_total", "").strip(),
    }

    if not data["requestor_name"]:
        return jsonify(error="Requestor name is required"), 400
    if not data["description"]:
        return jsonify(error="Request description is required"), 400

    manual_info, manual_err = _pm_process_manual(data["equipment"], session.get("user") or {})
    if manual_err:
        return jsonify(error=manual_err), 400
    if manual_info:
        data["manual"] = manual_info

    data["_wor_type"] = "preventive"
    wor_num = _next_work_order_number()
    data["_wor_number"] = wor_num
    WOR_PREVENTIVE_DIR.mkdir(parents=True, exist_ok=True)

    user = session.get("user") or {}
    notify_email = user.get("email", "")
    data["_notify"] = {
        "enabled": "notify_enabled" in request.form,
        "requestor_name": user.get("name") or data["requestor_name"],
        "requestor_email": notify_email,
        "email": notify_email,
        "disabled_by_admin": False,
    }

    pdf_bytes = _generate_preventive_wor_pdf(data)

    timestamp = _datetime_type.now().strftime("%Y-%m-%d_%H%M%S")
    safe_name = re.sub(r"[^\w\s\-]", "", data["requestor_name"])[:40].strip().replace(" ", "_")
    filename = f"PMWO_{wor_num:05d}_{timestamp}_{safe_name}.pdf"
    out_path = WOR_PREVENTIVE_DIR / filename
    out_path.write_bytes(pdf_bytes)

    json_path = out_path.with_suffix(".json")
    json_path.write_text(json.dumps(data, indent=2))

    notifications.notify(
        notifications.EVENT_WO_CREATED,
        _wor_event_context(filename, "preventive", data),
    )

    _send_wor_admin_email_bg(
        f"New Preventative Maintenance Work Order #{wor_num:05d} — {data['requestor_name']}",
        f"A new Preventative Maintenance Work Order has been submitted.\n\n"
        f"Requestor: {data['requestor_name']}\n"
        f"Date: {data.get('date_of_request', '')}\n"
        f"Location: {data.get('location', '')}\n"
        f"Description: {data.get('description', '')[:200]}\n"
        f"Urgency: {data.get('urgency', '')}\n"
        f"Desired Completion: {data.get('desired_completion_date', 'Not set')}\n\n"
        f"Filename: {filename}\n\n"
        f"This notification was sent from the ICAST Togen application.",
    )

    if _is_history_capture_enabled("wor-admin"):
        record_history(
            "wor-admin", "created",
            actor=(session.get("user") or {}).get("name"),
            summary=f"Preventive Maint. WO #{wor_num:05d} created — {data['requestor_name']}",
            ref=f"WO#{wor_num:05d}",
            detail={"wor_type": "preventive", "filename": filename},
        )

    return jsonify(filename=filename, manual=data.get("manual"))


@app.route("/wor-history")
@login_required
def wor_history():
    """List new and in-progress Work Order Request PDFs (excludes completed)."""
    wor_type = request.args.get("type", "maintenance")
    dirs = _wor_dest_dirs(wor_type)
    files = []
    for directory, status in [(dirs["new"], "new"), (dirs["in_progress"], "in_progress")]:
        if not directory.is_dir():
            continue
        for f in directory.glob("*.pdf"):
            mtime = _datetime_type.utcfromtimestamp(f.stat().st_mtime)
            sidecar = _resolve_wor_sidecar(f.name, wor_type)
            data = None
            if sidecar:
                try:
                    data = json.loads(sidecar.read_text())
                except (ValueError, OSError):
                    data = None
            files.append({
                "name": f.name,
                "title": _wor_display_title(f.name, wor_type, data),
                "date": mtime.strftime("%Y-%m-%dT%H:%M:%SZ"),
                "status": status,
            })
    files.sort(key=lambda x: x["date"], reverse=True)
    return jsonify(files=files)


@app.route("/wor-preview")
@login_required
def wor_preview():
    """Serve a Work Order PDF inline for preview."""
    filename = request.args.get("file", "")
    file_path = _resolve_wor_file(filename)
    if not file_path:
        return "File not found", 404
    return send_file(str(file_path), mimetype="application/pdf")


@app.route("/wor-download")
@login_required
def wor_download():
    """Download a Work Order Request PDF."""
    filename = request.args.get("file", "")
    file_path = _resolve_wor_file(filename)
    if not file_path:
        return "File not found", 404
    return send_file(str(file_path), as_attachment=True, download_name=filename)


@app.route("/wor-viewer-url")
@login_required
def wor_viewer_url():
    """Return the Document Viewer URL for a work order PDF."""
    filename = request.args.get("file", "")
    file_path = _resolve_wor_file(filename)
    if not file_path:
        return jsonify({"error": "not found"}), 404
    try:
        rel = file_path.relative_to(WOR_DIR.resolve())
    except ValueError:
        return jsonify({"error": "not found"}), 404
    prefix = _load_shared_url_prefix()
    url = f"{prefix}/wor/{rel}"
    return jsonify({"url": url})


@app.route("/pm-manual-lookup")
@login_required
def pm_manual_lookup():
    """Return any manual previously associated with an Equipment/Asset/System name."""
    entry = _manuals_index_lookup(request.args.get("equipment", ""))
    if not entry:
        return jsonify({"found": False})
    return jsonify({
        "found": True,
        "filename": entry["filename"],
        "original_name": entry.get("original_name", entry["filename"]),
        "url": entry.get("url", _manual_viewer_url(entry["filename"])),
        "equipment": entry.get("equipment", ""),
    })


@app.route("/pm-eas-options")
@login_required
def pm_eas_options():
    """List known Equipment/Asset/System names for the PM work order type-ahead (DVI-590)."""
    return jsonify({"options": _collect_pm_eas_options()})


@app.route("/pm-manual-url")
@login_required
def pm_manual_url():
    """Return the Document Viewer URL for a stored manual file."""
    filename = (request.args.get("file", "") or "").strip()
    if not filename or "/" in filename or "\\" in filename or ".." in filename:
        return jsonify({"error": "not found"}), 404
    if not (WOR_MANUALS_DIR / filename).is_file():
        return jsonify({"error": "not found"}), 404
    return jsonify({"url": _manual_viewer_url(filename)})


@app.route("/wor-manual-info")
@login_required
def wor_manual_info():
    """Return the manual attached to a given work order (from its JSON sidecar)."""
    sidecar = _resolve_wor_sidecar(request.args.get("file", ""))
    if not sidecar:
        return jsonify({"found": False})
    try:
        data = json.loads(sidecar.read_text())
    except (json.JSONDecodeError, OSError):
        return jsonify({"found": False})
    manual = data.get("manual") or {}
    stored = manual.get("filename")
    if not stored or not (WOR_MANUALS_DIR / stored).is_file():
        return jsonify({"found": False})
    return jsonify({
        "found": True,
        "filename": stored,
        "original_name": manual.get("original_name", stored),
        "url": _manual_viewer_url(stored),
    })


@app.route("/equipment-manuals/list")
@login_required
def equipment_manuals_list():
    """Return all uploaded/attached equipment manuals for the Equipment Manuals tool."""
    return jsonify({"manuals": _list_all_manuals()})


def _em_search_manuals(q: str, only_filename: str | None = None) -> list:
    """Full-text OCR search across stored manuals (DVI-491).

    Returns each matching manual with a context snippet, the 1-based page number
    of the first hit, total hit count, and a link to open it. When
    ``only_filename`` is given, the search is restricted to that single manual
    (DVI-626: WO-scoped "start with the attached manual" search)."""
    ql = (q or "").strip().lower()
    if len(ql) < 2:
        return []

    # Map stored filename -> association entry for equipment name / open URL.
    assoc = {}
    for entry in _load_manuals_index().values():
        fn = entry.get("filename")
        if fn:
            assoc[fn] = entry

    results = []
    for fn, entry in _load_manuals_text_index().items():
        if only_filename and fn != only_filename:
            continue
        if not (WOR_MANUALS_DIR / fn).is_file():
            continue
        text = entry.get("text", "") or ""
        tl = text.lower()
        first = tl.find(ql)
        if first < 0:
            continue
        count = tl.count(ql)
        # First-hit page number via the per-page offset table.
        page = 1
        for p in entry.get("pages", []):
            if p.get("start", 0) <= first < p.get("end", 0):
                page = p.get("number", 1)
                break
        start = max(0, first - 60)
        end = min(len(text), first + len(ql) + 60)
        snippet = text[start:end].replace("\f", " ").strip()
        if start > 0:
            snippet = "…" + snippet
        if end < len(text):
            snippet = snippet + "…"
        meta = assoc.get(fn, {})
        results.append({
            "filename": fn,
            "original_name": entry.get("original_name") or meta.get("original_name", fn),
            "equipment": meta.get("equipment", ""),
            "url": _manual_viewer_url(fn),
            "page": page,
            "match_count": count,
            "snippet": snippet,
        })

    results.sort(key=lambda r: r["match_count"], reverse=True)
    return results


@app.route("/equipment-manuals/search")
@login_required
def equipment_manuals_search():
    """Cross-manual full-text search over OCR'd manuals (DVI-491, D2).

    Within-document search is served separately by the searchable-PDF text
    layer (Ctrl-F)."""
    q = (request.args.get("q") or "").strip()
    if len(q) < 2:
        return jsonify(results=[], query=q)
    return jsonify(results=_em_search_manuals(q), query=q)


@app.route("/equipment-manuals/file/<stored_filename>")
@login_required
def equipment_manuals_file(stored_filename):
    """Serve a manual's raw bytes same-origin (DVI-464).

    The public ``url`` points at the external Document Viewer (docs.icastinc.com),
    which is cross-origin and therefore unusable as a PDF.js / <img> source for the
    Grid-view thumbnails. This route streams the same file from the local manuals
    store so the browser can render a first-page preview without CORS issues.
    """
    if "/" in stored_filename or "\\" in stored_filename or ".." in stored_filename:
        abort(404)
    path = WOR_MANUALS_DIR / stored_filename
    if not path.is_file():
        abort(404)
    return send_file(str(path))


@app.route("/equipment-manuals/thumb/<stored_filename>")
@login_required
def equipment_manuals_thumb(stored_filename):
    """Serve a small cached Grid-view thumbnail for a manual (DVI-585).

    Returns a pre-rendered JPEG (page 1 for PDFs, downscaled for images) so the
    grid loads quickly instead of downloading + rendering each full manual. A
    404 here is expected for unsupported types or when the renderer is missing;
    the client falls back to its own pdf.js/image rendering.
    """
    if "/" in stored_filename or "\\" in stored_filename or ".." in stored_filename:
        abort(404)
    thumb = _ensure_manual_thumb(stored_filename)
    if not thumb:
        abort(404)
    resp = send_file(str(thumb), mimetype="image/jpeg")
    resp.headers["Cache-Control"] = "private, max-age=86400"
    return resp


@app.route("/equipment-manuals/upload", methods=["POST"])
@login_required
def equipment_manuals_upload():
    """Upload a manual and associate it with an Equipment/Asset/System name.

    The equipment name becomes the manual's attribution key, so a later
    Preventative Maintenance work order naming the same equipment auto-suggests
    this manual (see /pm-manual-lookup).
    """
    equipment = (request.form.get("equipment", "") or "").strip()
    if not equipment:
        return jsonify(error="Equipment / Asset / System name is required"), 400
    f = request.files.get("manual")
    if not f or not f.filename:
        return jsonify(error="A manual file is required"), 400
    ext = os.path.splitext(f.filename)[1].lower()
    if ext not in ALLOWED_MANUAL_EXTS:
        allowed = ", ".join(sorted(ALLOWED_MANUAL_EXTS))
        return jsonify(error=f"Unsupported manual file type. Allowed: {allowed}"), 400
    WOR_MANUALS_DIR.mkdir(parents=True, exist_ok=True)
    base = secure_filename(f.filename) or ("manual" + ext)
    stored = f"{_datetime_type.now().strftime('%Y%m%d_%H%M%S')}_{base}"
    f.save(str(WOR_MANUALS_DIR / stored))
    _manuals_index_set(equipment, stored, f.filename, session.get("user") or {})
    # Background OCR (D3): make the PDF searchable in place + index its text so
    # it surfaces in cross-manual search. No-op for non-PDFs / missing engine.
    _enqueue_manual_ocr(stored, f.filename)
    return jsonify({
        "ok": True,
        "filename": stored,
        "original_name": f.filename,
        "equipment": equipment,
        "url": _manual_viewer_url(stored),
    })


@app.route("/equipment-manuals/rename", methods=["POST"])
@login_required
def equipment_manuals_rename():
    """Edit the Equipment/Asset/System name a manual is associated with (DVI-462).

    The index is keyed by normalized equipment name, so renaming re-keys the
    entry (moving it off the old key) while keeping the same stored file.
    """
    body = request.get_json(silent=True) or {}
    filename = (body.get("filename") or "").strip()
    new_eq = (body.get("equipment") or "").strip()
    if not filename or "/" in filename or "\\" in filename or ".." in filename:
        return jsonify(error="Invalid manual reference"), 400
    if not new_eq:
        return jsonify(error="Equipment / Asset / System name is required"), 400
    if not (WOR_MANUALS_DIR / filename).is_file():
        return jsonify(error="Manual file not found"), 404
    new_key = _normalize_equipment(new_eq)
    if not new_key:
        return jsonify(error="Equipment / Asset / System name is required"), 400
    index = _load_manuals_index()
    old_key, entry = None, None
    for k, v in index.items():
        if v.get("filename") == filename:
            old_key, entry = k, v
            break
    base = dict(entry) if entry else {"filename": filename, "original_name": filename}
    user = session.get("user") or {}
    base.update({
        "filename": filename,
        "equipment": new_eq,
        "url": _manual_viewer_url(filename),
        "updated_at": _datetime_type.now().strftime("%Y-%m-%dT%H:%M:%S"),
        "updated_by": user.get("name", ""),
    })
    if old_key is not None and old_key != new_key:
        index.pop(old_key, None)
    index[new_key] = base
    _save_manuals_index(index)
    return jsonify(ok=True, filename=filename, equipment=new_eq, url=base["url"])


@app.route("/wor-attach-manual", methods=["POST"])
@login_required
def wor_attach_manual():
    """Attach a manual to an existing work order's JSON sidecar (DVI-462).

    Accepts a multipart form: ``file`` is the target WO's PDF filename. The
    manual itself is resolved via the same logic as PM submission — a freshly
    uploaded ``manual`` file (with an ``equipment`` name to index it under) takes
    precedence, otherwise an existing manual referenced by ``manual_existing``.
    The 'Open Manual' controls in WOA/WOR read this sidecar ``manual`` key.
    """
    filename = (request.form.get("file") or "").strip()
    sidecar = _resolve_wor_sidecar(filename)
    if not sidecar:
        return jsonify(error="Work order not found"), 404
    equipment = (request.form.get("equipment") or "").strip()
    manual_info, err = _pm_process_manual(equipment, session.get("user") or {})
    if err:
        return jsonify(error=err), 400
    if not manual_info:
        return jsonify(error="No manual provided"), 400
    try:
        data = json.loads(sidecar.read_text())
    except (json.JSONDecodeError, OSError):
        return jsonify(error="Work order data could not be read"), 500
    data["manual"] = manual_info
    sidecar.write_text(json.dumps(data, indent=2))
    return jsonify(ok=True, filename=manual_info["filename"],
                   original_name=manual_info.get("original_name"),
                   url=manual_info.get("url"))


@app.route("/wor-email", methods=["POST"])
@login_required
def wor_email():
    """Email a Work Order Request PDF via Microsoft Graph API."""
    body = request.get_json(silent=True) or {}
    filename = body.get("filename", "")
    to_email = body.get("email", "").strip()

    if not filename:
        return jsonify(error="Invalid filename"), 400
    if not to_email:
        return jsonify(error="Email address is required"), 400

    file_path = _resolve_wor_file(filename)
    if not file_path:
        return jsonify(error="File not found"), 404

    if not AZURE_MAIL_FROM:
        return jsonify(error="Email sender is not configured. Set AZURE_MAIL_FROM."), 500

    # Acquire an application token via client credentials flow
    app_client = msal.ConfidentialClientApplication(
        _AZ_CLIENT_ID, authority=_AZ_AUTHORITY_HOME, client_credential=_AZ_SECRET)
    token_result = app_client.acquire_token_for_client(
        scopes=["https://graph.microsoft.com/.default"])

    if "access_token" not in token_result:
        err = token_result.get("error_description", token_result.get("error", "unknown"))
        return jsonify(error=f"Failed to acquire mail token: {err}"), 500

    pdf_bytes = file_path.read_bytes()
    wor_type = _detect_wor_type(file_path)
    if wor_type == "mobilemachine":
        subject_prefix = "ICAST Mobile Machine Work Order"
    elif wor_type == "safety":
        subject_prefix = "ICAST Safety Work Order"
    elif wor_type == "preventive":
        subject_prefix = "ICAST Preventative Maintenance Work Order"
    else:
        subject_prefix = "ICAST Maintenance Work Order"
    graph_body = {
        "message": {
            "subject": f"{subject_prefix} — {filename}",
            "body": {
                "contentType": "Text",
                "content": (
                    "Please find the attached Work Order PDF.\n\n"
                    "This email was sent from the ICAST Togen application."
                ),
            },
            "toRecipients": [
                {"emailAddress": {"address": to_email}},
            ],
            "attachments": [
                {
                    "@odata.type": "#microsoft.graph.fileAttachment",
                    "name": filename,
                    "contentType": "application/pdf",
                    "contentBytes": base64.b64encode(pdf_bytes).decode(),
                },
            ],
        },
        "saveToSentItems": "false",
    }

    resp = _http_requests.post(
        f"https://graph.microsoft.com/v1.0/users/{AZURE_MAIL_FROM}/sendMail",
        headers={
            "Authorization": f"Bearer {token_result['access_token']}",
            "Content-Type": "application/json",
        },
        json=graph_body,
        timeout=30,
    )

    if resp.status_code not in (200, 202):
        detail = resp.json().get("error", {}).get("message", resp.text[:200])
        return jsonify(error=f"Graph API error: {detail}"), 502

    return jsonify(ok=True)


# ---------------------------------------------------------------------------
# Routes — Work Order Admin (DVI-233)
# ---------------------------------------------------------------------------

@app.route("/wor-admin-list")
@wor_admin_required
def wor_admin_list():
    """List all WORs across new / in_progress / completed with status and type."""
    registry = _load_recurring_registry()
    files = []
    for wor_type in ("maintenance", "mobilemachine", "safety", "preventive"):
        dirs = _wor_dest_dirs(wor_type)
        for status_key, directory in dirs.items():
            if not directory.is_dir():
                continue
            for f in directory.glob("*.pdf"):
                mtime = _datetime_type.utcfromtimestamp(f.stat().st_mtime)
                entry = {
                    "name": f.name,
                    "date": mtime.strftime("%Y-%m-%dT%H:%M:%SZ"),
                    "status": status_key,
                    "wor_type": wor_type,
                    "mtime": f.stat().st_mtime,
                }
                rec = registry.get(f.name)
                if rec:
                    entry["recurring"] = {
                        "frequency_days": rec.get("frequency_days"),
                        "next_run": rec.get("next_run", ""),
                        "end_date": rec.get("end_date", ""),
                        "enabled": rec.get("enabled", True),
                    }
                sidecar = _resolve_wor_sidecar(f.name, wor_type)
                sidecar_data = None
                if sidecar:
                    try:
                        sidecar_data = json.loads(sidecar.read_text())
                    except (ValueError, OSError):
                        sidecar_data = None
                    notify = (sidecar_data or {}).get("_notify") or {}
                    if notify.get("enabled"):
                        entry["notify"] = {
                            "enabled": True,
                            "requestor_name": notify.get("requestor_name", ""),
                        }
                    entry["submitter_email"] = (sidecar_data or {}).get("email", "")
                    entry["submitter_name"] = (sidecar_data or {}).get("requestor_name", "")
                # Parts count drives the WO Admin Parts icon (DVI-604); only MM/PM
                # WOs carry repair parts. Read-only count — no sidecar write here.
                if wor_type in RPM_PART_WOR_TYPES:
                    parts_norm, _ = _rpm_normalize_parts((sidecar_data or {}).get("parts"))
                    entry["parts_count"] = len(parts_norm)
                entry["title"] = _wor_display_title(f.name, wor_type, sidecar_data)
                files.append(entry)
    files.sort(key=lambda x: x["mtime"], reverse=True)
    for f in files:
        del f["mtime"]
    return jsonify(files=files)


@app.route("/wor-admin-activity")
@wor_admin_required
def wor_admin_activity():
    """Activity metrics for the Work Order Admin dashboard (DVI-380).

    Aggregates from existing files only (no new storage): a 30-day zero-filled
    submission histogram, status counts across all three WOR types, and recurring
    schedules due today or tomorrow (from the recurring registry).
    """
    from datetime import timedelta

    today = _datetime_type.now().date()

    # 30-day zero-filled histogram anchored on the server's local "today".
    window_start = today - timedelta(days=29)
    day_counts = {
        (window_start + timedelta(days=i)).strftime("%Y-%m-%d"): 0
        for i in range(30)
    }

    status_counts = {"new": 0, "in_progress": 0, "completed": 0, "cancelled": 0}

    for wor_type in ("maintenance", "mobilemachine", "safety", "preventive"):
        for status_key, directory in _wor_dest_dirs(wor_type).items():
            if not directory.is_dir():
                continue
            for f in directory.glob("*.pdf"):
                status_counts[status_key] = status_counts.get(status_key, 0) + 1
                # Prefer the recorded submission date from the sidecar
                # (`date` on recurring-generated WOs, `date_of_request` on
                # form submissions); fall back to file mtime when absent.
                day = ""
                sidecar = f.with_suffix(".json")
                if sidecar.is_file():
                    try:
                        sd = json.loads(sidecar.read_text()) or {}
                        day = (sd.get("date") or sd.get("date_of_request") or "").strip()
                    except (ValueError, OSError):
                        day = ""
                if not day:
                    day = _datetime_type.fromtimestamp(
                        f.stat().st_mtime).strftime("%Y-%m-%d")
                if day in day_counts:
                    day_counts[day] += 1

    submissions_by_day = [
        {"date": d, "count": c} for d, c in sorted(day_counts.items())
    ]

    # Recurring schedules due today (days_until 0) or tomorrow (1). Overdue
    # stragglers (negative days_until) are surfaced too — they're past due.
    recurring_due_soon = []
    for filename, rec in _load_recurring_registry().items():
        if not rec.get("enabled", True):
            continue
        next_run = rec.get("next_run") or ""
        try:
            nr = _datetime_type.strptime(next_run, "%Y-%m-%d").date()
        except (ValueError, TypeError):
            continue
        days_until = (nr - today).days
        if days_until <= 1:
            recurring_due_soon.append({
                "name": filename,
                "next_run": next_run,
                "days_until": days_until,
                "wor_type": rec.get("wor_type", "maintenance"),
            })
    recurring_due_soon.sort(key=lambda r: r["days_until"])

    return jsonify(
        submissions_by_day=submissions_by_day,
        status_counts=status_counts,
        recurring_due_soon=recurring_due_soon,
    )


@app.route("/wor-admin-toggle-notify", methods=["POST"])
@wor_admin_required
def wor_admin_toggle_notify():
    """Admin disables per-WO notifications: sets _notify.enabled false and flags it as admin-disabled."""
    body = request.get_json(silent=True) or {}
    filename = body.get("filename", "")

    if not filename or ".." in filename or "/" in filename:
        return jsonify(error="Invalid filename"), 400

    sidecar = _resolve_wor_sidecar(filename)
    if not sidecar:
        return jsonify(error="File not found"), 404

    try:
        data = json.loads(sidecar.read_text()) or {}
    except (ValueError, OSError):
        return jsonify(error="Could not read work order data"), 500

    notify = data.get("_notify") or {}
    notify["enabled"] = False
    notify["disabled_by_admin"] = True
    data["_notify"] = notify
    sidecar.write_text(json.dumps(data, indent=2))

    return jsonify(ok=True)


# ----- Repair Parts Manager (RPM) backend — DVI-596 ---------------------------
# Option A data model: parts stay on each Work Order sidecar JSON (under "parts").
# RPM extends each part with a stable "id" and "approved"/"ordered" booleans, and
# keeps a separate Part Number -> Part Name catalog (RPM_KNOWN_PARTS_FILE) for
# autofill. Aggregation views scan the Mobile Machine + Preventative Maintenance
# WO directories, mirroring how wor_admin_list reads sidecars.


def _rpm_load_known_parts() -> dict:
    """Return the {part_number: part_name} autofill catalog."""
    if RPM_KNOWN_PARTS_FILE.is_file():
        try:
            d = json.loads(RPM_KNOWN_PARTS_FILE.read_text())
            if isinstance(d, dict):
                return d
        except (ValueError, OSError):
            pass
    return {}


def _rpm_save_known_parts(catalog: dict) -> None:
    RPM_KNOWN_PARTS_FILE.write_text(json.dumps(catalog, indent=2))


def _rpm_upsert_known_part(number: str, name: str) -> None:
    """Remember a Part Number -> Part Name mapping (latest edit wins)."""
    number = (number or "").strip()
    name = (name or "").strip()
    if not number or not name:
        return
    catalog = _rpm_load_known_parts()
    if catalog.get(number) != name:
        catalog[number] = name
        _rpm_save_known_parts(catalog)


# RPM Settings (DVI-607). Policy toggles that change part-processing behavior.
_RPM_SETTINGS_DEFAULTS = {
    "assume_stocked_on_complete": False,
    # DVI-609: auto-mark a part Stocked once its carrier tracking reports
    # delivered. The carrier polling that sets tracking_status is deferred, but
    # the policy applies the moment a part's status is "delivered".
    "mark_stocked_on_delivered": False,
}


def _rpm_load_settings() -> dict:
    settings = dict(_RPM_SETTINGS_DEFAULTS)
    if RPM_SETTINGS_FILE.is_file():
        try:
            d = json.loads(RPM_SETTINGS_FILE.read_text())
            if isinstance(d, dict):
                for k in _RPM_SETTINGS_DEFAULTS:
                    if k in d:
                        settings[k] = bool(d[k])
        except (ValueError, OSError):
            pass
    return settings


def _rpm_save_settings(settings: dict) -> None:
    RPM_SETTINGS_FILE.write_text(json.dumps(settings, indent=2))


# RPM Approval permission + Auto Approval policy (DVI-614).
#   approve_mode  — who may approve parts (Settings > Policy > Approval permission):
#       "all_admins" (any Work Order Admin), "primary" (only emails on the Primary
#       list that enforces notifications, per WO type), or "specific" (an explicit
#       email allowlist).
#   approve_users — allowlist used when approve_mode == "specific".
#   auto_approval — master ``enabled`` plus a per-WO-type block. Each type block
#       has ``mode`` ("approvers" = the approve_mode designation, "all_admins",
#       or "specific") and a ``users`` allowlist for the "specific" mode. When a
#       part is added/submitted by a user matching the type's rule, it is auto
#       approved.
_RPM_APPROVE_MODES = ("all_admins", "primary", "specific")
_RPM_AUTO_MODES = ("approvers", "all_admins", "specific")
_RPM_APPROVAL_DEFAULTS = {
    "approve_mode": "all_admins",
    "approve_users": [],
    "auto_approval": {
        "enabled": False,
        "mobilemachine": {"mode": "approvers", "users": []},
        "preventive": {"mode": "approvers", "users": []},
    },
}


def _rpm_norm_emails(values) -> list:
    """Lower-cased, de-duplicated, order-stable email list from raw input."""
    out, seen = [], set()
    for v in (values or []):
        e = str(v or "").strip().lower()
        if e and e not in seen:
            seen.add(e)
            out.append(e)
    return out


def _rpm_load_approval() -> dict:
    """Return the RPM approval/auto-approval policy, defaults filled in."""
    pol = {
        "approve_mode": "all_admins",
        "approve_users": [],
        "auto_approval": {
            "enabled": False,
            "mobilemachine": {"mode": "approvers", "users": []},
            "preventive": {"mode": "approvers", "users": []},
        },
        # DVI-614 follow-up: per-WO-type email allowlists. A user on a type's
        # list receives ALL RPM notifications for that type as a matter of
        # policy, regardless of their per-user notifications grid.
        "policy_notify": {wt: [] for wt in RPM_PART_WOR_TYPES},
    }
    if RPM_APPROVAL_FILE.is_file():
        try:
            d = json.loads(RPM_APPROVAL_FILE.read_text())
        except (ValueError, OSError):
            d = {}
        if isinstance(d, dict):
            if d.get("approve_mode") in _RPM_APPROVE_MODES:
                pol["approve_mode"] = d["approve_mode"]
            pol["approve_users"] = _rpm_norm_emails(d.get("approve_users"))
            auto = d.get("auto_approval")
            if isinstance(auto, dict):
                pol["auto_approval"]["enabled"] = bool(auto.get("enabled"))
                for wt in RPM_PART_WOR_TYPES:
                    blk = auto.get(wt)
                    if isinstance(blk, dict):
                        mode = blk.get("mode")
                        if mode in _RPM_AUTO_MODES:
                            pol["auto_approval"][wt]["mode"] = mode
                        pol["auto_approval"][wt]["users"] = _rpm_norm_emails(blk.get("users"))
            pn = d.get("policy_notify")
            if isinstance(pn, dict):
                for wt in RPM_PART_WOR_TYPES:
                    pol["policy_notify"][wt] = _rpm_norm_emails(pn.get(wt))
    return pol


def _rpm_save_approval(pol: dict) -> None:
    RPM_APPROVAL_FILE.write_text(json.dumps(pol, indent=2))


def _rpm_user_can_approve(user: dict, wor_type: str) -> bool:
    """Whether ``user`` (session dict) may approve parts for ``wor_type`` under
    the current Approval permission policy. Togen Admins always may."""
    if not isinstance(user, dict):
        return False
    if user.get("togen_admin"):
        return True
    pol = _rpm_load_approval()
    mode = pol.get("approve_mode", "all_admins")
    email = (user.get("email") or "").strip().lower()
    if mode == "primary":
        primary = _load_wor_primary()
        return email in [e.lower() for e in primary.get(wor_type, [])]
    if mode == "specific":
        return email in pol.get("approve_users", [])
    # "all_admins" (default)
    return bool(user.get("wor_admin"))


def _rpm_auto_approve_for(user: dict, wor_type: str) -> bool:
    """Whether a part added/submitted by ``user`` for ``wor_type`` should be auto
    approved under the current Auto Approval policy."""
    if not isinstance(user, dict):
        return False
    pol = _rpm_load_approval()
    auto = pol.get("auto_approval") or {}
    if not auto.get("enabled"):
        return False
    cfg = auto.get(wor_type) or {}
    mode = cfg.get("mode", "approvers")
    email = (user.get("email") or "").strip().lower()
    if mode == "all_admins":
        return bool(user.get("wor_admin") or user.get("togen_admin"))
    if mode == "specific":
        return email in _rpm_norm_emails(cfg.get("users"))
    # "approvers" — anyone who satisfies the approve permission designation.
    return _rpm_user_can_approve(user, wor_type)


def _rpm_notify_part(event, wor_type, filename, data, part) -> None:
    """Fire a Repair Parts Manager part-lifecycle email (DVI-614) to users who
    opted into (wor_type x event) in their RPM notifications grid. No-ops when no
    one is subscribed; runs on a background thread."""
    try:
        notifications.notify_rpm(event, {
            "wor_type": wor_type,
            "wo_label": _wor_number_label(filename, data),
            "wo_title": _wor_display_title(filename, wor_type, data),
            "part_name": (part or {}).get("name") or "",
            "part_number": (part or {}).get("number") or "",
        })
    except Exception:  # notifications must never break the request path
        pass


def _rpm_effective_stocked(part, status_key, assume_on_complete,
                           mark_on_delivered=False) -> bool:
    """A part counts as stocked when explicitly flagged, or (per the
    "assume stocked on complete" policy) when its WO is marked completed, or
    (per the "mark stocked on delivered" policy, DVI-609) when its carrier
    tracking status is delivered."""
    if bool(part.get("stocked")):
        return True
    if assume_on_complete and status_key == "completed":
        return True
    if mark_on_delivered and str(part.get("tracking_status") or "").lower() == "delivered":
        return True
    return False


def _rpm_normalize_parts(parts):
    """Return (normalized_parts, changed) — every part gets a stable id plus
    approved/ordered/stocked booleans, preserving number/name/qty/amount."""
    normalized, changed, seen = [], False, set()
    for p in (parts or []):
        if not isinstance(p, dict):
            changed = True
            continue
        pid = str(p.get("id") or "").strip()
        if not pid or pid in seen:
            pid = f"rpm_{uuid.uuid4().hex[:12]}"
            changed = True
        seen.add(pid)
        if "id" not in p or "approved" not in p or "ordered" not in p or "stocked" not in p:
            changed = True
        if "tracking_number" not in p:
            changed = True
        normalized.append({
            "id": pid,
            "number": str(p.get("number") or "").strip(),
            "name": str(p.get("name") or "").strip(),
            "qty": str(p.get("qty") or "").strip(),
            "amount": str(p.get("amount") or "").strip(),
            "approved": bool(p.get("approved", False)),
            "ordered": bool(p.get("ordered", False)),
            "stocked": bool(p.get("stocked", False)),
            # DVI-609 tracking fields. tracking_status/delivered_at are populated
            # by the (deferred) carrier integration; "delivered" drives the
            # mark-stocked-on-delivered policy in _rpm_effective_stocked.
            "tracking_number": str(p.get("tracking_number") or "").strip(),
            "carrier": str(p.get("carrier") or "").strip(),
            "tracking_status": str(p.get("tracking_status") or "").strip(),
            "delivered_at": str(p.get("delivered_at") or "").strip(),
        })
    return normalized, changed


def _rpm_register_submitted_parts(parts, wor_type=None, user=None):
    """Phase 4 WO-Request coupling: normalize parts submitted on the Mobile
    Machine / Preventative Maintenance request form into the RPM shape (stable
    id + approved/ordered defaults) and remember each Part Number -> Part Name
    in the known-parts catalog so they surface in RPM and the WO Admin Parts
    dialog. Returns the normalized list to store on the WO sidecar.

    DVI-614: when ``wor_type`` + ``user`` are supplied and the WO creator matches
    the Auto Approval policy for that type, every submitted part is approved up
    front. The created WO file does not exist yet here, so part-lifecycle emails
    are not fired from this helper — the submit handler owns notification."""
    normalized, _ = _rpm_normalize_parts(parts)
    auto = bool(wor_type and user and _rpm_auto_approve_for(user, wor_type))
    for p in normalized:
        if auto:
            p["approved"] = True
        _rpm_upsert_known_part(p["number"], p["name"])
    return normalized


def _rpm_load_wo(filename: str, wor_type: str | None = None):
    """Resolve a WO sidecar and load its data; returns (sidecar_path, data)."""
    sidecar = _resolve_wor_sidecar(filename, wor_type)
    if not sidecar:
        return None, None
    try:
        data = json.loads(sidecar.read_text()) or {}
    except (ValueError, OSError):
        return None, None
    return sidecar, data


def _rpm_wo_parts(sidecar, data, persist: bool = True):
    """Return normalized parts for a WO, persisting backfilled ids/flags once."""
    parts, changed = _rpm_normalize_parts(data.get("parts"))
    if changed and persist and sidecar is not None:
        data["parts"] = parts
        try:
            sidecar.write_text(json.dumps(data, indent=2))
        except OSError:
            pass
    return parts


def _rpm_save_wo_parts(sidecar, data, parts) -> None:
    data["parts"] = parts
    sidecar.write_text(json.dumps(data, indent=2))


def _rpm_part_matches(part, approved_f: str, ordered_f: str) -> bool:
    if approved_f == "yes" and not part.get("approved"):
        return False
    if approved_f == "no" and part.get("approved"):
        return False
    if ordered_f == "yes" and not part.get("ordered"):
        return False
    if ordered_f == "no" and part.get("ordered"):
        return False
    return True


@app.route("/rpm/parts")
@rpm_required
def rpm_parts():
    """Work Orders (Mobile Machine + Preventative Maintenance) that have parts,
    grouped WO -> parts. Filters: type (all|mobilemachine|preventive), approved
    and ordered (all|yes|no), completed (yes|no — default no hides completed WOs).
    Cancelled WOs are never listed. Only WOs with >=1 matching part are returned."""
    type_filter = (request.args.get("type") or "all").strip()
    approved_f = (request.args.get("approved") or "all").strip()
    ordered_f = (request.args.get("ordered") or "all").strip()
    include_completed = (request.args.get("completed") or "no").strip() == "yes"
    settings = _rpm_load_settings()
    assume = settings["assume_stocked_on_complete"]
    mark_delivered = settings["mark_stocked_on_delivered"]
    types = (type_filter,) if type_filter in RPM_PART_WOR_TYPES else RPM_PART_WOR_TYPES
    user = session.get("user") or {}
    can_approve_by_type = {wt: _rpm_user_can_approve(user, wt) for wt in RPM_PART_WOR_TYPES}
    groups = []
    for wor_type in types:
        for status_key, directory in _wor_dest_dirs(wor_type).items():
            if status_key == "cancelled":
                continue
            if status_key == "completed" and not include_completed:
                continue
            if not directory.is_dir():
                continue
            for f in directory.glob("*.pdf"):
                sidecar = f.with_suffix(".json")
                if not sidecar.is_file():
                    continue
                try:
                    data = json.loads(sidecar.read_text()) or {}
                except (ValueError, OSError):
                    continue
                parts = _rpm_wo_parts(sidecar, data)
                if not parts:
                    continue
                shown = []
                for p in parts:
                    if not _rpm_part_matches(p, approved_f, ordered_f):
                        continue
                    pp = dict(p)
                    pp["stocked"] = _rpm_effective_stocked(p, status_key, assume, mark_delivered)
                    shown.append(pp)
                if not shown:
                    continue
                groups.append({
                    "name": f.name,
                    "wor_type": wor_type,
                    "status": status_key,
                    "title": _wor_display_title(f.name, wor_type, data),
                    "wo_label": _wor_number_label(f.name, data),
                    "part_count": len(parts),
                    "parts": shown,
                    "can_approve": can_approve_by_type.get(wor_type, False),
                    "mtime": f.stat().st_mtime,
                })
    groups.sort(key=lambda g: g["mtime"], reverse=True)
    for g in groups:
        del g["mtime"]
    return jsonify(groups=groups)


@app.route("/rpm/wo-parts")
@rpm_required
def rpm_wo_parts():
    """Parts associated with a single Work Order (used by the WO Admin dialog)."""
    filename = (request.args.get("filename") or "").strip()
    wor_type = (request.args.get("type") or "").strip() or None
    sidecar, data = _rpm_load_wo(filename, wor_type)
    if not sidecar:
        return jsonify(error="Work order not found"), 404
    parts = _rpm_wo_parts(sidecar, data)
    resolved_type = data.get("_wor_type") or wor_type or ""
    return jsonify(
        name=filename,
        wor_type=resolved_type,
        title=_wor_display_title(filename, resolved_type, data),
        wo_label=_wor_number_label(filename, data),
        parts=parts,
        can_approve=_rpm_user_can_approve(session.get("user") or {}, resolved_type),
    )


@app.route("/rpm/manual-search")
@rpm_required
def rpm_manual_search():
    """Search Equipment Manuals for a part from the RPM / WO Admin parts lists
    (DVI-626). With ``scope=wo`` (default) the search is restricted to the manual
    attached to the Work Order, if one is provided; ``scope=all`` searches every
    manual. ``wo_manual`` echoes the attached manual so the client can offer the
    "Search All Manuals" fallback only when a WO-scoped manual exists."""
    q = (request.args.get("q") or "").strip()
    filename = (request.args.get("filename") or "").strip()
    wor_type = (request.args.get("type") or "").strip() or None
    scope = (request.args.get("scope") or "wo").strip()

    wo_manual = None
    if filename:
        _sidecar, data = _rpm_load_wo(filename, wor_type)
        if data:
            m = data.get("manual") or {}
            fn = m.get("filename")
            if fn and (WOR_MANUALS_DIR / fn).is_file():
                wo_manual = {"filename": fn, "original_name": m.get("original_name") or fn}

    only = wo_manual["filename"] if (scope == "wo" and wo_manual) else None
    results = _em_search_manuals(q, only_filename=only) if len(q) >= 2 else []
    return jsonify(results=results, query=q, scope=scope, wo_manual=wo_manual)


@app.route("/rpm/wo-parts/add", methods=["POST"])
@rpm_required
def rpm_wo_parts_add():
    """Associate a new part with a Work Order and remember its Part Number."""
    body = request.get_json(silent=True) or {}
    filename = (body.get("filename") or "").strip()
    wor_type = (body.get("type") or "").strip() or None
    number = (body.get("number") or "").strip()
    name = (body.get("name") or "").strip()
    qty = (body.get("qty") or "").strip()
    amount = (body.get("amount") or "").strip()
    # DVI-626: "Stocked Item" checkbox on the Add Parts dialog marks the part as
    # already in stock; the client also disables its Ordered button.
    stocked = bool(body.get("stocked"))
    if not (number or name or qty or amount):
        return jsonify(error="Enter at least one part detail"), 400
    sidecar, data = _rpm_load_wo(filename, wor_type)
    if not sidecar:
        return jsonify(error="Work order not found"), 404
    parts = _rpm_wo_parts(sidecar, data)
    resolved_type = data.get("_wor_type") or wor_type or ""
    # DVI-614: auto-approve when the acting user matches the Auto Approval policy.
    auto_approved = _rpm_auto_approve_for(session.get("user") or {}, resolved_type)
    new_part = {
        "id": f"rpm_{uuid.uuid4().hex[:12]}",
        "number": number, "name": name, "qty": qty, "amount": amount,
        "approved": auto_approved, "ordered": False, "stocked": stocked,
        "tracking_number": "", "carrier": "", "tracking_status": "", "delivered_at": "",
    }
    parts.append(new_part)
    _rpm_save_wo_parts(sidecar, data, parts)
    _rpm_upsert_known_part(number, name)
    # Notify opted-in users: approved straight away, or newly awaiting approval.
    _rpm_notify_part(
        notifications.EVENT_RPM_PART_APPROVED if auto_approved
        else notifications.EVENT_RPM_PART_AWAITING,
        resolved_type, filename, data, new_part)

    if _is_history_capture_enabled("rpm"):
        _action = "part_approved" if auto_approved else "part_requested"
        _label = "auto-approved" if auto_approved else "requested"
        record_history(
            "rpm", _action,
            actor=(session.get("user") or {}).get("name"),
            summary=f"Part {_label}: {number or name or 'unknown'} for WO {filename}",
            ref=filename,
            detail={"part_id": new_part["id"], "number": number, "name": name, "wor_type": resolved_type},
        )

    return jsonify(ok=True, part=new_part)


@app.route("/rpm/part/update", methods=["POST"])
@rpm_required
def rpm_part_update():
    """Edit a part's details in place; re-remembers the Part Number -> Name."""
    body = request.get_json(silent=True) or {}
    filename = (body.get("filename") or "").strip()
    wor_type = (body.get("type") or "").strip() or None
    pid = (body.get("id") or "").strip()
    sidecar, data = _rpm_load_wo(filename, wor_type)
    if not sidecar:
        return jsonify(error="Work order not found"), 404
    parts = _rpm_wo_parts(sidecar, data)
    target = next((p for p in parts if p["id"] == pid), None)
    if not target:
        return jsonify(error="Part not found"), 404
    for key in ("number", "name", "qty", "amount", "tracking_number", "carrier"):
        if body.get(key) is not None:
            target[key] = str(body[key]).strip()
    _rpm_save_wo_parts(sidecar, data, parts)
    _rpm_upsert_known_part(target["number"], target["name"])
    return jsonify(ok=True, part=target)


@app.route("/rpm/part/remove", methods=["POST"])
@rpm_required
def rpm_part_remove():
    """Remove a part from a Work Order."""
    body = request.get_json(silent=True) or {}
    filename = (body.get("filename") or "").strip()
    wor_type = (body.get("type") or "").strip() or None
    pid = (body.get("id") or "").strip()
    sidecar, data = _rpm_load_wo(filename, wor_type)
    if not sidecar:
        return jsonify(error="Work order not found"), 404
    parts = _rpm_wo_parts(sidecar, data)
    new_parts = [p for p in parts if p["id"] != pid]
    if len(new_parts) == len(parts):
        return jsonify(error="Part not found"), 404
    _rpm_save_wo_parts(sidecar, data, new_parts)
    return jsonify(ok=True)


@app.route("/rpm/part/flag", methods=["POST"])
@rpm_required
def rpm_part_flag():
    """Mark a part approved/ordered/stocked (booleans sent in the body)."""
    body = request.get_json(silent=True) or {}
    filename = (body.get("filename") or "").strip()
    wor_type = (body.get("type") or "").strip() or None
    pid = (body.get("id") or "").strip()
    sidecar, data = _rpm_load_wo(filename, wor_type)
    if not sidecar:
        return jsonify(error="Work order not found"), 404
    parts = _rpm_wo_parts(sidecar, data)
    target = next((p for p in parts if p["id"] == pid), None)
    if not target:
        return jsonify(error="Part not found"), 404
    resolved_type = data.get("_wor_type") or wor_type or ""
    was_approved = bool(target.get("approved"))
    was_ordered = bool(target.get("ordered"))
    was_stocked = bool(target.get("stocked"))
    # DVI-614: only users who meet the Approval permission policy may approve.
    if "approved" in body and bool(body["approved"]) and not was_approved:
        if not _rpm_user_can_approve(session.get("user") or {}, resolved_type):
            return jsonify(error="You do not have permission to approve parts."), 403
    if "approved" in body:
        target["approved"] = bool(body["approved"])
    if "ordered" in body:
        target["ordered"] = bool(body["ordered"])
    if "stocked" in body:
        target["stocked"] = bool(body["stocked"])
    # DVI-609: the Mark-Ordered popup may pass an optional tracking number
    # (and carrier) in the same call that flips ordered=true.
    if "tracking_number" in body:
        target["tracking_number"] = str(body["tracking_number"] or "").strip()
    if "carrier" in body:
        target["carrier"] = str(body["carrier"] or "").strip()
    _rpm_save_wo_parts(sidecar, data, parts)
    # DVI-614: fire part-lifecycle emails on off->on transitions only.
    if target.get("approved") and not was_approved:
        _rpm_notify_part(notifications.EVENT_RPM_PART_APPROVED, resolved_type, filename, data, target)
    if target.get("ordered") and not was_ordered:
        _rpm_notify_part(notifications.EVENT_RPM_PART_ORDERED, resolved_type, filename, data, target)
    if target.get("stocked") and not was_stocked:
        _rpm_notify_part(notifications.EVENT_RPM_PART_STOCKED, resolved_type, filename, data, target)

    if _is_history_capture_enabled("rpm"):
        _part_label = target.get("number") or target.get("name") or target.get("id", "")
        if target.get("approved") and not was_approved:
            record_history(
                "rpm", "part_approved",
                actor=(session.get("user") or {}).get("name"),
                summary=f"Part approved: {_part_label} for WO {filename}",
                ref=filename,
                detail={"part_id": pid, "number": target.get("number"), "wor_type": resolved_type},
            )
        if target.get("ordered") and not was_ordered:
            record_history(
                "rpm", "part_ordered",
                actor=(session.get("user") or {}).get("name"),
                summary=f"Part ordered: {_part_label} for WO {filename}",
                ref=filename,
                detail={"part_id": pid, "number": target.get("number"),
                        "tracking": target.get("tracking_number"), "wor_type": resolved_type},
            )
        if target.get("stocked") and not was_stocked:
            record_history(
                "rpm", "part_stocked",
                actor=(session.get("user") or {}).get("name"),
                summary=f"Part stocked: {_part_label} for WO {filename}",
                ref=filename,
                detail={"part_id": pid, "number": target.get("number"), "wor_type": resolved_type},
            )

    return jsonify(ok=True, part=target)


@app.route("/rpm/known-part")
@rpm_required
def rpm_known_part():
    """Autofill lookup: return the remembered Part Name for a Part Number."""
    number = (request.args.get("number") or "").strip()
    if not number:
        return jsonify(name="")
    return jsonify(name=_rpm_load_known_parts().get(number, ""))


@app.route("/rpm/settings")
@rpm_required
def rpm_settings_get():
    """Return the RPM policy settings (Settings tab -> Policy pane)."""
    return jsonify(settings=_rpm_load_settings())


@app.route("/rpm/settings", methods=["POST"])
@rpm_required
def rpm_settings_post():
    """Persist RPM policy settings. Only known keys are accepted."""
    body = request.get_json(silent=True) or {}
    settings = _rpm_load_settings()
    for key in _RPM_SETTINGS_DEFAULTS:
        if key in body:
            settings[key] = bool(body[key])
    _rpm_save_settings(settings)
    return jsonify(ok=True, settings=settings)


@app.route("/rpm/approval-policy")
@rpm_required
def rpm_approval_policy_get():
    """Return the Approval permission + Auto Approval policy (DVI-614)."""
    return jsonify(policy=_rpm_load_approval())


@app.route("/rpm/approval-policy", methods=["POST"])
@rpm_required
def rpm_approval_policy_post():
    """Persist the Approval permission + Auto Approval policy (DVI-614)."""
    body = request.get_json(silent=True) or {}
    pol = body.get("policy")
    if not isinstance(pol, dict):
        return jsonify(error="Invalid policy."), 400
    # Re-validate/normalize through the loader's shape by round-tripping the
    # supplied values, then save the clean result.
    clean = {
        "approve_mode": pol.get("approve_mode") if pol.get("approve_mode") in _RPM_APPROVE_MODES else "all_admins",
        "approve_users": _rpm_norm_emails(pol.get("approve_users")),
        "auto_approval": {"enabled": False,
                          "mobilemachine": {"mode": "approvers", "users": []},
                          "preventive": {"mode": "approvers", "users": []}},
        "policy_notify": {wt: [] for wt in RPM_PART_WOR_TYPES},
    }
    auto = pol.get("auto_approval")
    if isinstance(auto, dict):
        clean["auto_approval"]["enabled"] = bool(auto.get("enabled"))
        for wt in RPM_PART_WOR_TYPES:
            blk = auto.get(wt)
            if isinstance(blk, dict):
                if blk.get("mode") in _RPM_AUTO_MODES:
                    clean["auto_approval"][wt]["mode"] = blk["mode"]
                clean["auto_approval"][wt]["users"] = _rpm_norm_emails(blk.get("users"))
    pn = pol.get("policy_notify")
    if isinstance(pn, dict):
        for wt in RPM_PART_WOR_TYPES:
            clean["policy_notify"][wt] = _rpm_norm_emails(pn.get(wt))
    try:
        _rpm_save_approval(clean)
    except OSError as exc:
        return jsonify(error=f"Failed to save: {exc}"), 500
    return jsonify(ok=True, policy=_rpm_load_approval())


# RPM per-user notification grid (DVI-614). Columns mirror RPM_EVENT_COL.
_RPM_NOTIFY_COLS = ("awaiting", "approved", "ordered", "delivered", "stocked")


def _rpm_notify_grid_from_settings(settings: dict) -> dict:
    """Return the {wor_type: {col: bool}} RPM notifications grid, all-off default."""
    stored = settings.get(notifications.RPM_NOTIFY_KEY)
    grid = {}
    for wt in RPM_PART_WOR_TYPES:
        src = stored.get(wt) if isinstance(stored, dict) else None
        src = src if isinstance(src, dict) else {}
        grid[wt] = {col: bool(src.get(col)) for col in _RPM_NOTIFY_COLS}
    return grid


@app.route("/rpm/notifications")
@login_required
def rpm_notifications_get():
    """Return the current user's RPM notifications grid (DVI-614). Available to
    all users — the grid is mirrored into User Settings > Notifications."""
    email = (session.get("user") or {}).get("email", "")
    return jsonify(notifications=_rpm_notify_grid_from_settings(_load_user_settings(email)))


@app.route("/rpm/notifications", methods=["POST"])
@login_required
def rpm_notifications_post():
    """Persist the current user's RPM notifications grid (DVI-614)."""
    body = request.get_json(silent=True) or {}
    grid_in = body.get("notifications")
    if not isinstance(grid_in, dict):
        return jsonify(error="Invalid notifications grid."), 400
    email = (session.get("user") or {}).get("email", "")
    settings = _load_user_settings(email)
    grid = {}
    for wt in RPM_PART_WOR_TYPES:
        src = grid_in.get(wt) if isinstance(grid_in.get(wt), dict) else {}
        grid[wt] = {col: bool(src.get(col)) for col in _RPM_NOTIFY_COLS}
    settings[notifications.RPM_NOTIFY_KEY] = grid
    if email:
        settings["email"] = email
    try:
        _save_user_settings(email, settings)
    except OSError as exc:
        return jsonify(error=f"Failed to save: {exc}"), 500
    return jsonify(ok=True, notifications=grid)


@app.route("/rpm/processing")
@rpm_required
def rpm_processing():
    """Processing tab: parts that still need action across active Work Orders.
    Returns awaiting_approval (not yet approved) and ordered_not_stocked (ordered
    but not yet stocked). Parts that are effectively stocked (explicitly, or via
    the assume-stocked-on-complete policy) drop out of both lists. Cancelled WOs
    are skipped."""
    settings = _rpm_load_settings()
    assume = settings["assume_stocked_on_complete"]
    mark_delivered = settings["mark_stocked_on_delivered"]
    awaiting_approval, ordered_not_stocked = [], []
    for wor_type in RPM_PART_WOR_TYPES:
        for status_key, directory in _wor_dest_dirs(wor_type).items():
            if status_key == "cancelled" or not directory.is_dir():
                continue
            for f in directory.glob("*.pdf"):
                sidecar = f.with_suffix(".json")
                if not sidecar.is_file():
                    continue
                try:
                    data = json.loads(sidecar.read_text()) or {}
                except (ValueError, OSError):
                    continue
                parts = _rpm_wo_parts(sidecar, data)
                if not parts:
                    continue
                for p in parts:
                    if _rpm_effective_stocked(p, status_key, assume, mark_delivered):
                        continue
                    item = {
                        "name": f.name,
                        "wor_type": wor_type,
                        "status": status_key,
                        "title": _wor_display_title(f.name, wor_type, data),
                        "wo_label": _wor_number_label(f.name, data),
                        "part": p,
                        "mtime": f.stat().st_mtime,
                    }
                    if not p.get("approved"):
                        awaiting_approval.append(item)
                    if p.get("ordered"):
                        ordered_not_stocked.append(item)
    for lst in (awaiting_approval, ordered_not_stocked):
        lst.sort(key=lambda it: it["mtime"], reverse=True)
        for it in lst:
            del it["mtime"]
    return jsonify(awaiting_approval=awaiting_approval, ordered_not_stocked=ordered_not_stocked)


@app.route("/wor-admin-status", methods=["POST"])
@wor_admin_required
def wor_admin_status():
    """Change a WOR's status by moving it between directories."""
    body = request.get_json(silent=True) or {}
    filename = body.get("filename", "")
    new_status = body.get("status", "")

    if not filename or ".." in filename or "/" in filename:
        return jsonify(error="Invalid filename"), 400
    if new_status not in ("new", "in_progress", "completed", "cancelled"):
        return jsonify(error="Invalid status"), 400

    src_path = _resolve_wor_file(filename)
    if not src_path:
        return jsonify(error="File not found"), 404

    wor_type = _detect_wor_type(src_path)

    # When moving to in_progress for maintenance WOs, fill maintenance fields and regenerate PDF
    if new_status == "in_progress" and wor_type == "maintenance":
        sidecar_path = _resolve_wor_sidecar(filename)
        if sidecar_path:
            try:
                form_data = json.loads(sidecar_path.read_text())
            except (json.JSONDecodeError, OSError):
                form_data = {}
        else:
            form_data = {}

        user = session.get("user", {})
        maintenance = form_data.get("_maintenance", {})
        if not maintenance.get("accepted_by"):
            maintenance["accepted_by"] = user.get("name", "")
        if not maintenance.get("date"):
            maintenance["date"] = _datetime_type.now().strftime("%Y-%m-%d")
        if not maintenance.get("work_order_number"):
            maintenance["work_order_number"] = str(_next_work_order_number())
        form_data["_maintenance"] = maintenance

        pdf_bytes = _generate_wor_pdf(form_data, maintenance=maintenance)
        src_path.write_bytes(pdf_bytes)

        if sidecar_path:
            sidecar_path.write_text(json.dumps(form_data, indent=2))

    # When moving to in_progress for preventive maintenance WOs, fill maintenance
    # acceptance fields and regenerate PDF.
    if new_status == "in_progress" and wor_type == "preventive":
        sidecar_path = _resolve_wor_sidecar(filename)
        if sidecar_path:
            try:
                form_data = json.loads(sidecar_path.read_text())
            except (json.JSONDecodeError, OSError):
                form_data = {}
        else:
            form_data = {}

        user = session.get("user", {})
        maintenance = form_data.get("_maintenance", {})
        if not maintenance.get("accepted_by"):
            maintenance["accepted_by"] = user.get("name", "")
        if not maintenance.get("date"):
            maintenance["date"] = _datetime_type.now().strftime("%Y-%m-%d")
        if not maintenance.get("work_order_number"):
            maintenance["work_order_number"] = str(_next_work_order_number())
        form_data["_maintenance"] = maintenance

        pdf_bytes = _generate_preventive_wor_pdf(form_data, maintenance=maintenance)
        src_path.write_bytes(pdf_bytes)

        if sidecar_path:
            sidecar_path.write_text(json.dumps(form_data, indent=2))

    # When moving to in_progress for safety WOs, fill safety acceptance fields and regenerate PDF
    if new_status == "in_progress" and wor_type == "safety":
        sidecar_path = _resolve_wor_sidecar(filename)
        if sidecar_path:
            try:
                form_data = json.loads(sidecar_path.read_text())
            except (json.JSONDecodeError, OSError):
                form_data = {}
        else:
            form_data = {}

        user = session.get("user", {})
        safety = form_data.get("_safety", {})
        if not safety.get("accepted_by"):
            safety["accepted_by"] = user.get("name", "")
        if not safety.get("date"):
            safety["date"] = _datetime_type.now().strftime("%Y-%m-%d")
        if not safety.get("work_order_number"):
            safety["work_order_number"] = str(_next_work_order_number())
        form_data["_safety"] = safety

        pdf_bytes = _generate_safety_wor_pdf(form_data, safety=safety)
        src_path.write_bytes(pdf_bytes)

        if sidecar_path:
            sidecar_path.write_text(json.dumps(form_data, indent=2))

    # When moving to in_progress for mobile machine WOs, assign order number and regenerate PDF
    if new_status == "in_progress" and wor_type == "mobilemachine":
        sidecar_path = _resolve_wor_sidecar(filename)
        if sidecar_path:
            try:
                form_data = json.loads(sidecar_path.read_text())
            except (json.JSONDecodeError, OSError):
                form_data = {}
        else:
            form_data = {}

        if not form_data.get("_order_number"):
            form_data["_order_number"] = str(_next_work_order_number())

        pdf_bytes = _generate_mm_wor_pdf(form_data)
        src_path.write_bytes(pdf_bytes)

        if sidecar_path:
            sidecar_path.write_text(json.dumps(form_data, indent=2))

    # Locate the sidecar before moving anything
    cur_sidecar = _resolve_wor_sidecar(filename)

    dest_dir = _wor_dest_dirs(wor_type)[new_status]
    dest_dir.mkdir(parents=True, exist_ok=True)
    dest_path = dest_dir / filename

    if src_path.resolve() != dest_path.resolve():
        shutil.move(str(src_path), str(dest_path))

    # Move JSON sidecar alongside the PDF
    dest_sidecar = None
    if cur_sidecar:
        dest_sidecar = dest_path.with_suffix(".json")
        if cur_sidecar.resolve() != dest_sidecar.resolve():
            shutil.move(str(cur_sidecar), str(dest_sidecar))

    # Raise the matching lifecycle event (one branded email per recipient).
    # "new" (reopen/back-to-new) is not a notifiable transition.
    event = _WOR_STATUS_EVENTS.get(new_status)
    if event:
        sidecar_data = {}
        if dest_sidecar and dest_sidecar.is_file():
            try:
                sidecar_data = json.loads(dest_sidecar.read_text())
            except (json.JSONDecodeError, OSError):
                sidecar_data = {}
        notifications.notify(event, _wor_event_context(filename, wor_type, sidecar_data))

    if _is_history_capture_enabled("wor-admin"):
        _status_label = {
            "new": "reopened", "in_progress": "accepted",
            "completed": "completed", "cancelled": "cancelled",
        }.get(new_status, new_status)
        record_history(
            "wor-admin", "status_changed",
            actor=(session.get("user") or {}).get("name"),
            summary=f"WO {filename} {_status_label}",
            ref=filename,
            detail={"wor_type": wor_type, "new_status": new_status},
        )

    return jsonify(ok=True, status=new_status)


@app.route("/wor-admin-data")
@wor_admin_required
def wor_admin_data():
    """Return JSON sidecar data for a WOR so the admin can populate the form."""
    filename = request.args.get("file", "")
    sidecar = _resolve_wor_sidecar(filename)
    if sidecar:
        try:
            data = json.loads(sidecar.read_text())
            return jsonify(data=data)
        except (json.JSONDecodeError, OSError):
            pass

    # Fallback: extract form data from the PDF itself
    pdf_path = _resolve_wor_file(filename)
    if not pdf_path:
        return jsonify(error="Work order not found."), 404
    try:
        data = _extract_wor_data_from_pdf(pdf_path)
        return jsonify(data=data)
    except Exception as exc:
        return jsonify(error=f"Could not extract form data: {exc}"), 500


@app.route("/wor-edit", methods=["POST"])
@wor_admin_required
def wor_edit():
    """Admin-only: update an existing WOR with edited form data and regenerate PDF."""
    edit_filename = request.form.get("edit_filename", "").strip()
    if not edit_filename or ".." in edit_filename or "/" in edit_filename:
        return jsonify(error="Invalid filename"), 400

    pdf_path = _resolve_wor_file(edit_filename)
    if not pdf_path:
        return jsonify(error="Original work order not found"), 404

    data = {
        "requestor_name": request.form.get("requestor_name", "").strip(),
        "phone": request.form.get("phone", "").strip(),
        "email": request.form.get("email", "").strip(),
        "date_of_request": request.form.get("date_of_request", "").strip(),
        "location": request.form.get("location", "").strip(),
        "building": request.form.get("building", "").strip(),
        "man_hole": request.form.get("man_hole", "").strip(),
        "equipment": request.form.get("equipment", "").strip(),
        "description": request.form.get("description", "").strip(),
        "issue_type": request.form.getlist("issue_type"),
        "issue_type_other": request.form.get("issue_type_other", "").strip(),
        "best_times": request.form.get("best_times", "").strip(),
        "desired_completion_date": request.form.get("desired_completion_date", "").strip(),
        "urgency": request.form.get("urgency", "").strip(),
    }

    if not data["requestor_name"]:
        return jsonify(error="Requestor name is required"), 400
    if not data["description"]:
        return jsonify(error="Request description is required"), 400

    # Preserve existing maintenance fields from sidecar
    sidecar_path = _resolve_wor_sidecar(edit_filename)
    maintenance = {}
    if sidecar_path:
        try:
            old_data = json.loads(sidecar_path.read_text())
            maintenance = old_data.get("_maintenance", {})
        except (json.JSONDecodeError, OSError):
            pass

    data["_wor_type"] = "maintenance"
    data["_maintenance"] = maintenance
    pdf_bytes = _generate_wor_pdf(data, maintenance=maintenance or None)

    pdf_path.write_bytes(pdf_bytes)

    # Update sidecar in the same directory as the PDF
    json_path = pdf_path.with_suffix(".json")
    json_path.write_text(json.dumps(data, indent=2))

    if _is_history_capture_enabled("wor-admin"):
        record_history(
            "wor-admin", "edited",
            actor=(session.get("user") or {}).get("name"),
            summary=f"Maintenance WO edited — {data.get('requestor_name', edit_filename)}",
            ref=edit_filename,
            detail={"wor_type": "maintenance"},
        )

    return jsonify(filename=edit_filename)


@app.route("/safety-wor-edit", methods=["POST"])
@wor_admin_required
def safety_wor_edit():
    """Admin-only: update an existing Safety WOR with edited form data and regenerate PDF."""
    edit_filename = request.form.get("edit_filename", "").strip()
    if not edit_filename or ".." in edit_filename or "/" in edit_filename:
        return jsonify(error="Invalid filename"), 400

    pdf_path = _resolve_wor_file(edit_filename, wor_type="safety")
    if not pdf_path:
        return jsonify(error="Original work order not found"), 404

    data = {
        "requestor_name": request.form.get("requestor_name", "").strip(),
        "phone": request.form.get("phone", "").strip(),
        "email": request.form.get("email", "").strip(),
        "date_of_request": request.form.get("date_of_request", "").strip(),
        "location": request.form.get("location", "").strip(),
        "building": request.form.get("building", "").strip(),
        "man_hole": request.form.get("man_hole", "").strip(),
        "equipment": request.form.get("equipment", "").strip(),
        "description": request.form.get("description", "").strip(),
        "issue_type": request.form.getlist("issue_type"),
        "issue_type_other": request.form.get("issue_type_other", "").strip(),
        "best_times": request.form.get("best_times", "").strip(),
        "desired_completion_date": request.form.get("desired_completion_date", "").strip(),
        "urgency": request.form.get("urgency", "").strip(),
    }

    if not data["requestor_name"]:
        return jsonify(error="Requestor name is required"), 400
    if not data["description"]:
        return jsonify(error="Request description is required"), 400

    # Preserve existing safety acceptance fields from sidecar
    sidecar_path = _resolve_wor_sidecar(edit_filename, wor_type="safety")
    safety = {}
    if sidecar_path:
        try:
            old_data = json.loads(sidecar_path.read_text())
            safety = old_data.get("_safety", {})
        except (json.JSONDecodeError, OSError):
            pass

    data["_wor_type"] = "safety"
    data["_safety"] = safety
    pdf_bytes = _generate_safety_wor_pdf(data, safety=safety or None)

    pdf_path.write_bytes(pdf_bytes)

    # Update sidecar in the same directory as the PDF
    json_path = pdf_path.with_suffix(".json")
    json_path.write_text(json.dumps(data, indent=2))

    if _is_history_capture_enabled("wor-admin"):
        record_history(
            "wor-admin", "edited",
            actor=(session.get("user") or {}).get("name"),
            summary=f"Safety WO edited — {data.get('requestor_name', edit_filename)}",
            ref=edit_filename,
            detail={"wor_type": "safety"},
        )

    return jsonify(filename=edit_filename)


@app.route("/mm-wor-edit", methods=["POST"])
@wor_admin_required
def mm_wor_edit():
    """Admin-only: update an existing Mobile Machine WOR and regenerate PDF."""
    edit_filename = request.form.get("edit_filename", "").strip()
    if not edit_filename or ".." in edit_filename or "/" in edit_filename:
        return jsonify(error="Invalid filename"), 400

    pdf_path = _resolve_wor_file(edit_filename, wor_type="mobilemachine")
    if not pdf_path:
        return jsonify(error="Original work order not found"), 404

    part_numbers = request.form.getlist("part_number[]")
    part_names = request.form.getlist("part_name[]")
    part_qtys = request.form.getlist("part_qty[]")
    part_amounts = request.form.getlist("part_amount[]")
    parts = []
    for i in range(len(part_numbers)):
        pn = part_numbers[i].strip() if i < len(part_numbers) else ""
        nm = part_names[i].strip() if i < len(part_names) else ""
        qt = part_qtys[i].strip() if i < len(part_qtys) else ""
        am = part_amounts[i].strip() if i < len(part_amounts) else ""
        if pn or nm or qt or am:
            parts.append({"number": pn, "name": nm, "qty": qt, "amount": am})

    data = {
        "machine": request.form.get("machine", "").strip(),
        "asset_number": request.form.get("asset_number", "").strip(),
        "order_date_time": request.form.get("order_date_time", "").strip(),
        "date_promised": request.form.get("date_promised", "").strip(),
        "date_delivered": request.form.get("date_delivered", "").strip(),
        "order_received_by": request.form.get("order_received_by", "").strip(),
        "make_model": request.form.get("make_model", "").strip(),
        "vin": request.form.get("vin", "").strip(),
        "odometer": request.form.get("odometer", "").strip(),
        "license_state": request.form.get("license_state", "").strip(),
        "motor_number": request.form.get("motor_number", "").strip(),
        "service_type": request.form.getlist("service_type"),
        "labor_description": request.form.get("labor_description", "").strip(),
        "labor_time": request.form.get("labor_time", "").strip(),
        "labor_total": request.form.get("labor_total", "").strip(),
        "parts": parts,
        "awaiting_approval": "awaiting_approval" in request.form,
        "parts_ordered": "parts_ordered" in request.form,
        "parts_total": request.form.get("parts_total", "").strip(),
        "compiled_by": request.form.get("compiled_by", "").strip(),
        "compiled_date": request.form.get("compiled_date", "").strip(),
        "authorized_by": request.form.get("authorized_by", "").strip(),
        "authorization_date": request.form.get("authorization_date", "").strip(),
        "_wor_type": "mobilemachine",
    }

    if not data["machine"]:
        return jsonify(error="Machine name is required"), 400

    sidecar_path = _resolve_wor_sidecar(edit_filename, wor_type="mobilemachine")
    if sidecar_path:
        try:
            old_data = json.loads(sidecar_path.read_text())
            if old_data.get("_order_number"):
                data["_order_number"] = old_data["_order_number"]
            # Preserve a previously-attached manual unless this edit replaces it.
            if old_data.get("manual"):
                data["manual"] = old_data["manual"]
        except (json.JSONDecodeError, OSError):
            pass

    manual_info, manual_err = _pm_process_manual(data["machine"], session.get("user") or {})
    if manual_err:
        return jsonify(error=manual_err), 400
    if manual_info:
        data["manual"] = manual_info

    pdf_bytes = _generate_mm_wor_pdf(data)
    pdf_path.write_bytes(pdf_bytes)

    json_path = pdf_path.with_suffix(".json")
    json_path.write_text(json.dumps(data, indent=2))

    if _is_history_capture_enabled("wor-admin"):
        record_history(
            "wor-admin", "edited",
            actor=(session.get("user") or {}).get("name"),
            summary=f"Mobile Machine WO edited — {data.get('machine', edit_filename)}",
            ref=edit_filename,
            detail={"wor_type": "mobilemachine"},
        )

    return jsonify(filename=edit_filename)


@app.route("/preventive-wor-edit", methods=["POST"])
@wor_admin_required
def preventive_wor_edit():
    """Admin-only: update an existing Preventative Maintenance WOR and regenerate PDF."""
    edit_filename = request.form.get("edit_filename", "").strip()
    if not edit_filename or ".." in edit_filename or "/" in edit_filename:
        return jsonify(error="Invalid filename"), 400

    pdf_path = _resolve_wor_file(edit_filename, wor_type="preventive")
    if not pdf_path:
        return jsonify(error="Original work order not found"), 404

    part_numbers = request.form.getlist("part_number[]")
    part_names = request.form.getlist("part_name[]")
    part_qtys = request.form.getlist("part_qty[]")
    part_amounts = request.form.getlist("part_amount[]")
    parts = []
    for i in range(len(part_numbers)):
        pn = part_numbers[i].strip() if i < len(part_numbers) else ""
        nm = part_names[i].strip() if i < len(part_names) else ""
        qt = part_qtys[i].strip() if i < len(part_qtys) else ""
        am = part_amounts[i].strip() if i < len(part_amounts) else ""
        if pn or nm or qt or am:
            parts.append({"number": pn, "name": nm, "qty": qt, "amount": am})

    data = {
        "requestor_name": request.form.get("requestor_name", "").strip(),
        "phone": request.form.get("phone", "").strip(),
        "email": request.form.get("email", "").strip(),
        "date_of_request": request.form.get("date_of_request", "").strip(),
        "location": request.form.get("location", "").strip(),
        "building": request.form.get("building", "").strip(),
        "man_hole": request.form.get("man_hole", "").strip(),
        "equipment": request.form.get("equipment", "").strip(),
        "description": request.form.get("description", "").strip(),
        "issue_type": request.form.getlist("issue_type"),
        "issue_type_other": request.form.get("issue_type_other", "").strip(),
        "best_times": request.form.get("best_times", "").strip(),
        "desired_completion_date": request.form.get("desired_completion_date", "").strip(),
        "urgency": request.form.get("urgency", "").strip(),
        "parts": parts,
        "awaiting_approval": "awaiting_approval" in request.form,
        "parts_ordered": "parts_ordered" in request.form,
        "parts_total": request.form.get("parts_total", "").strip(),
    }

    if not data["requestor_name"]:
        return jsonify(error="Requestor name is required"), 400
    if not data["description"]:
        return jsonify(error="Request description is required"), 400

    sidecar_path = _resolve_wor_sidecar(edit_filename, wor_type="preventive")
    maintenance = {}
    if sidecar_path:
        try:
            old_data = json.loads(sidecar_path.read_text())
            maintenance = old_data.get("_maintenance", {})
            # Preserve a previously-attached manual unless this edit replaces it.
            if old_data.get("manual"):
                data["manual"] = old_data["manual"]
        except (json.JSONDecodeError, OSError):
            pass

    manual_info, manual_err = _pm_process_manual(data["equipment"], session.get("user") or {})
    if manual_err:
        return jsonify(error=manual_err), 400
    if manual_info:
        data["manual"] = manual_info

    data["_wor_type"] = "preventive"
    data["_maintenance"] = maintenance
    pdf_bytes = _generate_preventive_wor_pdf(data, maintenance=maintenance or None)

    pdf_path.write_bytes(pdf_bytes)

    json_path = pdf_path.with_suffix(".json")
    json_path.write_text(json.dumps(data, indent=2))

    if _is_history_capture_enabled("wor-admin"):
        record_history(
            "wor-admin", "edited",
            actor=(session.get("user") or {}).get("name"),
            summary=f"Preventive Maint. WO edited — {data.get('requestor_name', edit_filename)}",
            ref=edit_filename,
            detail={"wor_type": "preventive"},
        )

    return jsonify(filename=edit_filename, manual=data.get("manual"))


# ---------------------------------------------------------------------------
# Routes — Recurring Work Orders
# ---------------------------------------------------------------------------

@app.route("/wor-make-recurring", methods=["POST"])
@wor_admin_required
def wor_make_recurring():
    """Mark a work order as recurring and register it in the recurring registry."""
    body = request.get_json(silent=True) or {}
    filename = body.get("filename", "")
    frequency_days = body.get("frequency_days")
    start_date = body.get("start_date", "")
    end_date = body.get("end_date", "")

    if not filename or ".." in filename or "/" in filename:
        return jsonify(error="Invalid filename"), 400
    try:
        frequency_days = int(frequency_days)
        if frequency_days < 1:
            raise ValueError
    except (TypeError, ValueError):
        return jsonify(error="Frequency must be a positive number of days"), 400
    if not start_date:
        start_date = _datetime_type.now().strftime("%Y-%m-%d")

    pdf_path = _resolve_wor_file(filename)
    if not pdf_path:
        return jsonify(error="Work order not found"), 404

    wor_type = _detect_wor_type(pdf_path)

    sidecar = _resolve_wor_sidecar(filename)
    if not sidecar:
        return jsonify(error="Work order sidecar data not found"), 404
    try:
        form_data = json.loads(sidecar.read_text())
    except (json.JSONDecodeError, OSError):
        return jsonify(error="Could not read work order data"), 500

    next_run = _calc_next_run(start_date, frequency_days)

    recurring_config = {
        "frequency_days": frequency_days,
        "start_date": start_date,
        "end_date": end_date,
        "next_run": next_run,
        "wor_type": wor_type,
        "source_filename": filename,
        "enabled": True,
        "created_by": session.get("user", {}).get("name", ""),
        "created_at": _datetime_type.now().strftime("%Y-%m-%dT%H:%M:%S"),
    }

    form_data["_recurring"] = recurring_config
    try:
        sidecar.write_text(json.dumps(form_data, indent=2))
    except OSError as exc:
        return jsonify(error=f"Failed to save recurring config: {exc}"), 500

    registry = _load_recurring_registry()
    registry[filename] = recurring_config
    registry[filename]["template_data"] = {k: v for k, v in form_data.items() if k != "_recurring"}
    try:
        _save_recurring_registry(registry)
    except (OSError, ValueError, TypeError) as exc:
        return jsonify(error=f"Failed to update recurring registry: {exc}"), 500

    return jsonify(ok=True, next_run=next_run, recurring=recurring_config)


@app.route("/wor-remove-recurring", methods=["POST"])
@wor_admin_required
def wor_remove_recurring():
    """Remove recurring status from a work order."""
    body = request.get_json(silent=True) or {}
    filename = body.get("filename", "")

    if not filename or ".." in filename or "/" in filename:
        return jsonify(error="Invalid filename"), 400

    sidecar = _resolve_wor_sidecar(filename)
    if sidecar:
        try:
            form_data = json.loads(sidecar.read_text())
            form_data.pop("_recurring", None)
            sidecar.write_text(json.dumps(form_data, indent=2))
        except (json.JSONDecodeError, OSError):
            pass

    registry = _load_recurring_registry()
    registry.pop(filename, None)
    try:
        _save_recurring_registry(registry)
    except OSError as exc:
        return jsonify(error=f"Failed to update recurring registry: {exc}"), 500

    return jsonify(ok=True)


@app.route("/wor-recurring-run", methods=["POST"])
@wor_admin_required
def wor_recurring_run():
    """Manually trigger processing of due recurring work orders."""
    created = _process_recurring_work_orders()
    return jsonify(ok=True, created=created)


def _process_recurring_work_orders() -> list[str]:
    """Check registry for due recurring WOs and create new instances. Returns list of created filenames."""
    from datetime import timedelta
    registry = _load_recurring_registry()
    today = _datetime_type.now().strftime("%Y-%m-%d")
    created = []
    changed = False

    for source_filename, entry in list(registry.items()):
        if not entry.get("enabled", True):
            continue
        end_date = entry.get("end_date", "")
        if end_date and end_date < today:
            entry["enabled"] = False
            changed = True
            continue

        next_run = entry.get("next_run", "")
        if not next_run or next_run > today:
            continue

        template = entry.get("template_data", {})
        if not template:
            continue

        wor_type = entry.get("wor_type", template.get("_wor_type", "maintenance"))
        template_clean = {k: v for k, v in template.items() if not k.startswith("_")}

        wor_num = _next_work_order_number()
        if wor_type == "maintenance":
            template_clean["date_of_request"] = today
            template_clean["_wor_type"] = "maintenance"
            template_clean["_wor_number"] = wor_num
            WOR_MAINTENANCE_DIR.mkdir(parents=True, exist_ok=True)
            pdf_bytes = _generate_wor_pdf(template_clean)
            safe_name = re.sub(r"[^\w\s\-]", "", template_clean.get("requestor_name", "Recurring"))[:40].strip().replace(" ", "_")
            timestamp = _datetime_type.now().strftime("%Y-%m-%d_%H%M%S")
            new_filename = f"WOR_{wor_num:05d}_{timestamp}_{safe_name}.pdf"
            out_path = WOR_MAINTENANCE_DIR / new_filename
            out_path.write_bytes(pdf_bytes)
            template_clean["_recurring_source"] = source_filename
            out_path.with_suffix(".json").write_text(json.dumps(template_clean, indent=2))
        elif wor_type == "safety":
            template_clean["date_of_request"] = today
            template_clean["_wor_type"] = "safety"
            template_clean["_wor_number"] = wor_num
            WOR_SAFETY_DIR.mkdir(parents=True, exist_ok=True)
            pdf_bytes = _generate_safety_wor_pdf(template_clean)
            safe_name = re.sub(r"[^\w\s\-]", "", template_clean.get("requestor_name", "Recurring"))[:40].strip().replace(" ", "_")
            timestamp = _datetime_type.now().strftime("%Y-%m-%d_%H%M%S")
            new_filename = f"SWO_{wor_num:05d}_{timestamp}_{safe_name}.pdf"
            out_path = WOR_SAFETY_DIR / new_filename
            out_path.write_bytes(pdf_bytes)
            template_clean["_recurring_source"] = source_filename
            out_path.with_suffix(".json").write_text(json.dumps(template_clean, indent=2))
        elif wor_type == "preventive":
            template_clean["date_of_request"] = today
            template_clean["_wor_type"] = "preventive"
            template_clean["_wor_number"] = wor_num
            WOR_PREVENTIVE_DIR.mkdir(parents=True, exist_ok=True)
            pdf_bytes = _generate_preventive_wor_pdf(template_clean)
            safe_name = re.sub(r"[^\w\s\-]", "", template_clean.get("requestor_name", "Recurring"))[:40].strip().replace(" ", "_")
            timestamp = _datetime_type.now().strftime("%Y-%m-%d_%H%M%S")
            new_filename = f"PMWO_{wor_num:05d}_{timestamp}_{safe_name}.pdf"
            out_path = WOR_PREVENTIVE_DIR / new_filename
            out_path.write_bytes(pdf_bytes)
            template_clean["_recurring_source"] = source_filename
            out_path.with_suffix(".json").write_text(json.dumps(template_clean, indent=2))
        else:
            template_clean["order_date_time"] = _datetime_type.now().strftime("%Y-%m-%dT%H:%M")
            template_clean["_wor_type"] = "mobilemachine"
            template_clean["_wor_number"] = wor_num
            WOR_MOBILEMACHINE_DIR.mkdir(parents=True, exist_ok=True)
            pdf_bytes = _generate_mm_wor_pdf(template_clean)
            safe_machine = re.sub(r"[^\w\s\-]", "", template_clean.get("machine", "Recurring"))[:40].strip().replace(" ", "_")
            timestamp = _datetime_type.now().strftime("%Y-%m-%d_%H%M%S")
            new_filename = f"MMWO_{wor_num:05d}_{timestamp}_{safe_machine}.pdf"
            out_path = WOR_MOBILEMACHINE_DIR / new_filename
            out_path.write_bytes(pdf_bytes)
            template_clean["_recurring_source"] = source_filename
            out_path.with_suffix(".json").write_text(json.dumps(template_clean, indent=2))

        created.append(new_filename)

        freq = entry.get("frequency_days", 90)
        entry["next_run"] = _calc_next_run(entry.get("start_date", today), freq, after_date=today)
        entry["last_created"] = new_filename
        entry["last_run"] = today
        changed = True

    if changed:
        _save_recurring_registry(registry)

    return created


# ---------------------------------------------------------------------------
# Route — create carrier sheet from scratch (DVI-305)
# ---------------------------------------------------------------------------

_CARRIER_FIELDS = [
    "job_number", "part_ref", "product_code", "weight",
    "production_date", "structure_id", "spec_type", "structure_size",
    "station", "customer_name", "project_name",
]


def _run_create_carrier(job_folder: Path, shop_path: Path, submittal_path: "Path | None",
                        fields: dict) -> None:
    """Background thread: build carrier sheet from scratch then merge QR codes."""
    try:
        from qr_merge import create_carrier_sheet  # noqa: E402 — implemented by Linux Automation Engineer (DVI-305)
    except ImportError:
        (job_folder / "error.txt").write_text(
            "create_carrier_sheet not yet available in qr_merge.py. "
            "Waiting on Linux Automation Engineer (DVI-305).\n"
        )
        return

    # Step 1: generate carrier sheet PDF with text fields populated
    carrier_path = create_carrier_sheet(fields=fields, output_folder=job_folder)

    # Step 2: merge QR codes onto the generated carrier sheet
    folder_name = job_folder.name
    folder_url = f"{_get_qr_base_url().rstrip('/')}/{quote(folder_name)}"
    shop_url = f"{folder_url}/{quote(shop_path.name)}"
    submittal_url = (
        f"{folder_url}/{quote(submittal_path.name)}" if submittal_path else None
    )

    blank_bytes = carrier_path.read_bytes()
    result_bytes = build_carrier_pdf(blank_bytes, submittal_url, shop_url)

    output_path = job_folder / f"{job_folder.name} CarrierQR generated.pdf"
    output_path.write_bytes(result_bytes)
    carrier_path.unlink(missing_ok=True)  # remove intermediate; keep final generated


@app.route("/create-carrier", methods=["POST"])
@login_required
def create_carrier():
    """Accept shop/submittal PDFs + carrier field data, create carrier sheet from scratch."""
    shop      = request.files.get("shop")
    submittal = request.files.get("submittal")

    if not (shop and shop.filename):
        return jsonify(error="Shop Drawing PDF is required"), 400
    if not shop.filename.lower().endswith(".pdf"):
        return jsonify(error="'shop' must be a PDF file"), 400

    for req_field in ("job_number", "product_code", "customer_name", "project_name"):
        if not request.form.get(req_field, "").strip():
            return jsonify(error=f"'{req_field}' is required"), 400

    fields = {f: request.form.get(f, "").strip() for f in _CARRIER_FIELDS}
    job_name = request.form.get("job_name", "").strip()
    job_id = (
        re.sub(r"[^\w\s\-\.]", "_", job_name)[:80].strip()
        if job_name else str(uuid.uuid4())[:8]
    )
    job_folder = _safe_job_folder(job_id)

    if _find_generated(job_folder):
        return jsonify(job_id=job_id)

    job_folder.mkdir(parents=True, exist_ok=True)
    shop_path = job_folder / secure_filename(shop.filename)
    shop.save(shop_path)

    submittal_path: "Path | None" = None
    if submittal and submittal.filename and submittal.filename.lower().endswith(".pdf"):
        submittal_path = job_folder / secure_filename(submittal.filename)
        submittal.save(submittal_path)

    t = threading.Thread(
        target=_run_create_carrier,
        args=(job_folder, shop_path, submittal_path, fields),
        daemon=True,
    )
    t.start()

    if _is_history_capture_enabled("single-carrier-sheet"):
        record_history(
            "single-carrier-sheet", "generated",
            actor=(session.get("user") or {}).get("name"),
            summary=(
                f"Carrier sheet generated — {fields.get('customer_name', '')} / "
                f"{fields.get('project_name', '')} ({fields.get('product_code', '')})"
            ),
            ref=job_id,
            detail={
                "customer": fields.get("customer_name"),
                "project": fields.get("project_name"),
                "vendor_code": fields.get("vendor_project_code"),
                "product_code": fields.get("product_code"),
            },
        )

    return jsonify(job_id=job_id)


def _suggest_fields_from_pdf(pdf_path: Path, allowed: list[str]) -> dict:
    """Best-effort title-block extraction, filtered to `allowed` Create fields.

    Returns {} on any failure — OCR prefill is a convenience and must never
    block the Create flow. Honors the never-add-CSV-columns rule by only ever
    suggesting values for fields that already exist on the form.
    """
    try:
        from ocr_engine import extract_title_block_fields
        suggestions = extract_title_block_fields(pdf_path)
    except Exception as exc:  # noqa: BLE001
        app.logger.warning("title-block extraction failed for %s: %s", pdf_path, exc)
        return {}
    return {k: v for k, v in suggestions.items() if k in allowed and v}


@app.route("/create-carrier/extract", methods=["POST"])
@takeoff_workflow_required
def create_carrier_extract():
    """OCR a just-selected drawing's title block (DVI-498) and return suggested,
    user-editable Create-Carrier field values. The upload is read in a temp dir
    and discarded — nothing is persisted and the take-off CSV is untouched."""
    f = request.files.get("file") or request.files.get("shop")
    if not (f and f.filename):
        return jsonify(error="A PDF file is required"), 400
    if not f.filename.lower().endswith(".pdf"):
        return jsonify(error="File must be a PDF"), 400

    with tempfile.TemporaryDirectory() as td:
        tmp = Path(td) / secure_filename(f.filename)
        f.save(str(tmp))
        fields = _suggest_fields_from_pdf(tmp, _CARRIER_FIELDS)
    return jsonify(fields=fields)


# Meta-modal fields the Take-off Bridge project-info popup can be prefilled with.
_META_FIELDS = [
    "customer_name", "project_name", "job_number",
    "production_date", "spec_type", "station",
]


@app.route("/takeoff/extract-metadata", methods=["POST"])
@takeoff_workflow_required
def takeoff_extract_metadata():
    """OCR a representative drawing in a published folder (DVI-498) to suggest
    project-info values for the Take-off Bridge metadata popup. Prefers a carrier
    sheet (metadata sits below the QR zone), else a shop/submittal drawing."""
    data = request.get_json(silent=True) or {}
    folder_name = data.get("folder", "")
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify(error="Invalid folder"), 400
    folder = WATCH_DIR / folder_name
    if not folder.exists():
        return jsonify(error="Folder not found"), 400

    state = _load_state(folder)
    files = state.get("files", {}) or {}
    candidate: "Path | None" = None
    for ftype in ("carrier", "shop", "submittal"):
        for item in files.values():
            name = (item or {}).get(ftype)
            if not name:
                continue
            p = (folder / name).resolve()
            if str(p).startswith(str(folder.resolve())) and p.is_file() \
                    and p.suffix.lower() == ".pdf":
                candidate = p
                break
        if candidate:
            break

    if candidate is None:
        return jsonify(fields={})
    return jsonify(fields=_suggest_fields_from_pdf(candidate, _META_FIELDS))


def _takeoff_resolve_file(folder_name: str, rel: str) -> "Path | None":
    """Resolve folder/rel under WATCH_DIR, rejecting traversal. Returns the file
    Path or None (shared by the DVI-641 project-file preview endpoints)."""
    if not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name or ".." in rel:
        return None
    folder = (WATCH_DIR / folder_name).resolve()
    file_path = (folder / rel).resolve()
    if not str(file_path).startswith(str(folder) + os.sep):
        return None
    if not file_path.is_file():
        return None
    return file_path


@app.route("/takeoff/extract-metadata-file", methods=["POST"])
@takeoff_workflow_required
def takeoff_extract_metadata_file():
    """OCR the ONE document the user has open in the preview (DVI-641) and
    suggest project-info values. Unlike /takeoff/extract-metadata (which
    auto-picks a representative drawing), this reads the exact selected file.
    PDF-only — image/text documents have no title block to read."""
    data = request.get_json(silent=True) or {}
    file_path = _takeoff_resolve_file(data.get("folder", ""), data.get("file", ""))
    if file_path is None:
        return jsonify(error="Invalid path"), 400
    if file_path.suffix.lower() != ".pdf":
        return jsonify(error="OCR can only read PDF documents."), 400
    return jsonify(fields=_suggest_fields_from_pdf(file_path, _META_FIELDS))


@app.route("/takeoff/pdf-pages")
@takeoff_workflow_required
def takeoff_pdf_pages():
    """Return a project PDF's page count so the preview can render each page as
    a fit-to-width image (DVI-641 — native PDF viewers zoom too far in on mobile
    and offer no controls)."""
    file_path = _takeoff_resolve_file(request.args.get("folder", ""),
                                      request.args.get("file", ""))
    if file_path is None:
        return jsonify(error="Invalid path"), 400
    if file_path.suffix.lower() != ".pdf":
        return jsonify(error="Not a PDF"), 400
    try:
        import fitz
        doc = fitz.open(str(file_path))
        try:
            pages = doc.page_count
        finally:
            doc.close()
    except Exception as exc:  # noqa: BLE001 — surface to UI for iframe fallback
        return jsonify(error=f"Could not read PDF: {exc}"), 500
    return jsonify(pages=pages)


@app.route("/takeoff/pdf-page")
@takeoff_workflow_required
def takeoff_pdf_page():
    """Render one page of a project PDF as a PNG sized for fit-to-width preview
    (DVI-641)."""
    file_path = _takeoff_resolve_file(request.args.get("folder", ""),
                                      request.args.get("file", ""))
    if file_path is None or file_path.suffix.lower() != ".pdf":
        return "Invalid path", 400
    try:
        page_no = int(request.args.get("page", "0"))
    except ValueError:
        return "Invalid page", 400
    import io
    try:
        import fitz
        doc = fitz.open(str(file_path))
        try:
            if page_no < 0 or page_no >= doc.page_count:
                return "Page out of range", 404
            page = doc.load_page(page_no)
            zoom = min(2000.0 / max(page.rect.width, 1), 3.0)
            if zoom <= 0:
                zoom = 1.0
            pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom))
            png = pix.tobytes("png")
        finally:
            doc.close()
    except Exception as exc:  # noqa: BLE001 — surface as error; UI falls back
        return f"Could not render page: {exc}", 500
    return send_file(io.BytesIO(png), mimetype="image/png",
                     download_name=f"page-{page_no + 1}.png")


# Fields the per-field region OCR (DVI-645) may write a raw value into. Adds
# vendor_project_code to _META_FIELDS: the user is pointing at an exact value,
# so it is filled even though it is not an auto-discovered title-block field.
_META_REGION_FIELDS = _META_FIELDS + ["vendor_project_code"]


@app.route("/takeoff/extract-region", methods=["POST"])
@takeoff_workflow_required
def takeoff_extract_region():
    """OCR a user-drawn rectangle on one page of the previewed PDF (DVI-645).

    Body: {folder, file, page, rect:{x0,y0,x1,y1 as 0..1 fractions}, field?}.
    Without `field`, returns {fields:{…}} parsed from "LABEL: value" lines (the
    "OCR this region → best guess" flow). With `field`, returns {field, text}
    where text is the raw recognized text for that one targeted input."""
    data = request.get_json(silent=True) or {}
    file_path = _takeoff_resolve_file(data.get("folder", ""), data.get("file", ""))
    if file_path is None:
        return jsonify(error="Invalid path"), 400
    if file_path.suffix.lower() != ".pdf":
        return jsonify(error="OCR can only read PDF documents."), 400
    try:
        page = int(data.get("page", 0))
    except (TypeError, ValueError):
        return jsonify(error="Invalid page"), 400
    rect = data.get("rect") or {}
    try:
        x0, y0 = float(rect.get("x0")), float(rect.get("y0"))
        x1, y1 = float(rect.get("x1")), float(rect.get("y1"))
    except (TypeError, ValueError):
        return jsonify(error="Invalid region"), 400
    # Normalize to (min,max) and clamp to the page so a slightly out-of-bounds
    # drag (or a flipped selection) still maps to a valid clip.
    fx0, fx1 = sorted((max(0.0, min(1.0, x0)), max(0.0, min(1.0, x1))))
    fy0, fy1 = sorted((max(0.0, min(1.0, y0)), max(0.0, min(1.0, y1))))
    if (fx1 - fx0) < 0.005 or (fy1 - fy0) < 0.005:
        return jsonify(error="Selection too small — draw a larger box."), 400

    field = (data.get("field") or "").strip()
    from ocr_engine import extract_region
    try:
        result = extract_region(file_path, page, (fx0, fy0, fx1, fy1),
                                 raw=bool(field))
    except Exception as exc:  # noqa: BLE001 — region OCR is best-effort prefill
        app.logger.warning("region OCR failed for %s: %s", file_path, exc)
        result = "" if field else {}

    if _is_history_capture_enabled("takeoff-bridge"):
        record_history(
            "takeoff-bridge", "region-ocr",
            actor=(session.get("user") or {}).get("name"),
            summary=f"Region OCR: {data.get('file', '')} p{page}",
            ref=data.get("file", ""),
            detail={"folder": data.get("folder", ""), "page": page,
                    "field": field or None},
        )
    if field:
        if field not in _META_REGION_FIELDS:
            return jsonify(error="Unknown field"), 400
        return jsonify(field=field, text=result or "")
    fields = {k: v for k, v in (result or {}).items()
              if k in _META_REGION_FIELDS and v}
    return jsonify(fields=fields)


# ---------------------------------------------------------------------------
# Routes — OCR Tool (DVI-490): upload PDF -> searchable PDF (background job)
# ---------------------------------------------------------------------------

def _ocr_job_dir(job_id: str) -> "Path | None":
    """Resolve a job folder under OCR_DIR, rejecting traversal."""
    if not job_id or "/" in job_id or ".." in job_id:
        return None
    job_dir = (OCR_DIR / job_id).resolve()
    if not str(job_dir).startswith(str(OCR_DIR.resolve())):
        return None
    return job_dir


def _ocr_write_status(job_dir: Path, status: dict) -> None:
    (job_dir / "status.json").write_text(json.dumps(status, indent=2))


def _ocr_read_status(job_dir: Path) -> dict:
    p = job_dir / "status.json"
    if not p.exists():
        return {"status": "pending"}
    try:
        return json.loads(p.read_text())
    except (json.JSONDecodeError, OSError):
        return {"status": "pending"}


def _prune_ocr_jobs() -> None:
    """Remove OCR job folders older than OCR_JOB_TTL_DAYS."""
    if not OCR_DIR.is_dir():
        return
    cutoff = time.time() - OCR_JOB_TTL_DAYS * 86400
    for child in OCR_DIR.iterdir():
        try:
            if child.is_dir() and child.stat().st_mtime < cutoff:
                shutil.rmtree(str(child), ignore_errors=True)
        except OSError:
            continue


def _run_ocr_job(job_dir: Path, in_path: Path, out_path: Path, mode: str,
                 *, actor=None, orig_name=None) -> None:
    """Background thread: OCR the uploaded PDF into a searchable PDF."""
    try:
        from ocr_engine import ocr_pdf, OcrEngineUnavailable
        try:
            result = ocr_pdf(in_path, out_path, mode=mode)
        except OcrEngineUnavailable as exc:
            _ocr_write_status(job_dir, {"status": "error", "error": str(exc),
                                        "filename": out_path.name})
            return
        pages = len(result.pages)
        _ocr_write_status(job_dir, {
            "status": "done",
            "filename": out_path.name,
            "pages": pages,
        })
        if _is_history_capture_enabled("ocr-tool"):
            record_history(
                "ocr-tool", "ocr-run",
                actor=actor,
                summary=f"OCR: {orig_name or out_path.name} ({pages}p)",
                ref=orig_name or "",
                detail={"pages": pages, "mode": mode},
            )
    except Exception as exc:  # noqa: BLE001 — surface any OCR failure to the UI
        _ocr_write_status(job_dir, {"status": "error", "error": str(exc),
                                    "filename": out_path.name})


@app.route("/ocr/submit", methods=["POST"])
@login_required
def ocr_submit():
    """Accept a PDF upload and start a background OCR job."""
    try:
        _prune_ocr_jobs()
        f = request.files.get("pdf")
        if not f or not f.filename:
            return jsonify(error="A PDF file is required"), 400
        if not f.filename.lower().endswith(".pdf"):
            return jsonify(error="File must be a PDF"), 400

        mode = request.form.get("mode", "skip-text")
        if mode not in ("skip-text", "redo-ocr", "force-ocr"):
            mode = "skip-text"

        try:
            OCR_DIR.mkdir(parents=True, exist_ok=True)
        except OSError as exc:
            return jsonify(error=f"OCR storage unavailable: {exc}"), 500

        job_id = str(uuid.uuid4())[:8]
        job_dir = OCR_DIR / job_id
        job_dir.mkdir(parents=True, exist_ok=True)

        timestamp = _datetime_type.now().strftime("%Y-%m-%d_%H%M%S")
        base = secure_filename(f.filename) or "document.pdf"
        in_name = f"{timestamp}_{base}"
        in_path = job_dir / in_name
        f.save(in_path)

        out_name = f"{Path(base).stem}_searchable.pdf"
        out_path = job_dir / out_name

        _ocr_write_status(job_dir, {"status": "pending", "filename": out_name})

        _ocr_actor = (session.get("user") or {}).get("name")
        t = threading.Thread(
            target=_run_ocr_job,
            args=(job_dir, in_path, out_path, mode),
            kwargs={"actor": _ocr_actor, "orig_name": f.filename},
            daemon=True,
        )
        t.start()
        return jsonify(job_id=job_id)
    except Exception as exc:  # noqa: BLE001 — always return JSON to the browser
        return jsonify(error=f"Unexpected error: {exc}"), 500


@app.route("/ocr/status/<job_id>")
@login_required
def ocr_status(job_id):
    job_dir = _ocr_job_dir(job_id)
    if not job_dir or not job_dir.is_dir():
        return jsonify(status="error", error="Job not found"), 404
    status = _ocr_read_status(job_dir)
    return jsonify(status=status.get("status", "pending"),
                   filename=status.get("filename", ""),
                   error=status.get("error", ""))


@app.route("/ocr/download/<job_id>")
@login_required
def ocr_download(job_id):
    job_dir = _ocr_job_dir(job_id)
    if not job_dir or not job_dir.is_dir():
        abort(404)
    status = _ocr_read_status(job_dir)
    if status.get("status") != "done":
        abort(404)
    out_path = (job_dir / status.get("filename", "")).resolve()
    if not str(out_path).startswith(str(job_dir.resolve())) or not out_path.exists():
        abort(404)
    return send_file(str(out_path), mimetype="application/pdf",
                     as_attachment=True, download_name=out_path.name)


@app.route("/ocr/backend")
@login_required
def ocr_backend_get():
    """Return the selected OCR backend and the registered backend list."""
    from ocr_engine import list_backends, load_backend
    return jsonify(backend=load_backend(), backends=list_backends())


@app.route("/ocr/backend", methods=["POST"])
@togen_admin_required
def ocr_backend_post():
    """Set the OCR backend. Admin only."""
    from ocr_engine import OCR_BACKENDS, save_backend
    body = request.get_json(silent=True) or {}
    backend = (body.get("backend") or "").strip()
    if backend not in OCR_BACKENDS:
        return jsonify(error="Invalid OCR backend selected."), 400
    save_backend(backend)
    return jsonify(ok=True, backend=backend)


# ---------------------------------------------------------------------------
# OCR Tool — Projects tab (DVI-646)
#
# OCR a file that already lives in the shared Projects tree (PDF, image, or
# Office doc), present the searchable result inline, then optionally save the
# searchable PDF back into the project directory. The browse/save endpoints are
# gated to the Take-off Workflow group + Togen admins (same rule as the File
# Browser's /fb/* routes via @takeoff_workflow_required).
# ---------------------------------------------------------------------------

def _run_ocr_project_job(job_dir: Path, in_path: Path, out_path: Path, mode: str,
                         *, actor=None, orig_name=None) -> None:
    """Background thread: OCR an existing project file into a searchable PDF
    and ensure a text sidecar exists for in-document search."""
    try:
        from ocr_engine import ocr_file, OcrEngineUnavailable
        try:
            result = ocr_file(in_path, out_path, mode=mode)
        except (OcrEngineUnavailable, ValueError) as exc:
            _ocr_write_status(job_dir, {"status": "error", "error": str(exc),
                                        "filename": out_path.name})
            return
        # The local backend writes <out>.txt; guarantee a sidecar regardless of
        # backend so /ocr/search has text to work with.
        sidecar = out_path.with_suffix(".txt")
        if not sidecar.exists():
            try:
                sidecar.write_text(result.text or "", encoding="utf-8")
            except OSError:
                pass
        pages = len(result.pages)
        _ocr_write_status(job_dir, {"status": "done", "filename": out_path.name,
                                    "pages": pages})
        if _is_history_capture_enabled("ocr-tool"):
            record_history(
                "ocr-tool", "ocr-project",
                actor=actor,
                summary=f"OCR (project): {orig_name or out_path.name} ({pages}p)",
                ref=orig_name or "",
                detail={"pages": pages, "mode": mode},
            )
    except Exception as exc:  # noqa: BLE001 — surface any OCR failure to the UI
        _ocr_write_status(job_dir, {"status": "error", "error": str(exc),
                                    "filename": out_path.name})


@app.route("/ocr/process-project", methods=["POST"])
@takeoff_workflow_required
def ocr_process_project():
    """Start an OCR job on a file already in the shared Projects tree."""
    try:
        from ocr_engine import is_processable
        _prune_ocr_jobs()
        body = request.get_json(silent=True) or {}
        rel = (body.get("path") or "").strip()
        src = _fb_safe_path(rel) if rel else None
        if src is None or not src.is_file():
            return jsonify(error="File not found in the Projects directory."), 400
        if not is_processable(src.name):
            return jsonify(error="This file type cannot be OCR-processed."), 400

        mode = body.get("mode", "skip-text")
        if mode not in ("skip-text", "redo-ocr", "force-ocr"):
            mode = "skip-text"

        try:
            OCR_DIR.mkdir(parents=True, exist_ok=True)
        except OSError as exc:
            return jsonify(error=f"OCR storage unavailable: {exc}"), 500

        job_id = str(uuid.uuid4())[:8]
        job_dir = OCR_DIR / job_id
        job_dir.mkdir(parents=True, exist_ok=True)

        in_name = secure_filename(src.name) or "document"
        in_path = job_dir / in_name
        shutil.copy2(str(src), str(in_path))

        out_name = f"{Path(src.name).stem}_searchable.pdf"
        out_path = job_dir / out_name
        _ocr_write_status(job_dir, {"status": "pending", "filename": out_name})

        _ocr_proj_actor = (session.get("user") or {}).get("name")
        threading.Thread(target=_run_ocr_project_job,
                         args=(job_dir, in_path, out_path, mode),
                         kwargs={"actor": _ocr_proj_actor, "orig_name": src.name},
                         daemon=True).start()

        src_dir = str(src.parent.relative_to(_FB_ROOT.resolve()))
        if src_dir == ".":
            src_dir = ""
        return jsonify(job_id=job_id,
                       suggested_name=f"{Path(src.name).stem}-ocr_processed",
                       source_dir=src_dir,
                       source_name=src.name)
    except Exception as exc:  # noqa: BLE001 — always return JSON to the browser
        return jsonify(error=f"Unexpected error: {exc}"), 500


@app.route("/ocr/view/<job_id>")
@login_required
def ocr_view(job_id):
    """Serve a finished job's searchable PDF inline (for the preview iframe)."""
    job_dir = _ocr_job_dir(job_id)
    if not job_dir or not job_dir.is_dir():
        abort(404)
    status = _ocr_read_status(job_dir)
    if status.get("status") != "done":
        abort(404)
    out_path = (job_dir / status.get("filename", "")).resolve()
    if not str(out_path).startswith(str(job_dir.resolve())) or not out_path.exists():
        abort(404)
    return send_file(str(out_path), mimetype="application/pdf",
                     as_attachment=False, download_name=out_path.name)


@app.route("/ocr/search/<job_id>")
@login_required
def ocr_search(job_id):
    """Search a finished job's OCR text. Returns match count + per-page hits so
    the UI can jump the inline viewer to the first matching page."""
    job_dir = _ocr_job_dir(job_id)
    if not job_dir or not job_dir.is_dir():
        return jsonify(error="Job not found"), 404
    q = (request.args.get("q") or "").strip()
    if not q:
        return jsonify(count=0, matches=[], first_page=0)
    status = _ocr_read_status(job_dir)
    out_name = status.get("filename", "")
    sidecar = (job_dir / out_name).with_suffix(".txt") if out_name else None
    if not sidecar or not sidecar.exists():
        return jsonify(count=0, matches=[], first_page=0)
    try:
        text = sidecar.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return jsonify(count=0, matches=[], first_page=0)

    ql = q.lower()
    matches = []
    total = 0
    for i, ptext in enumerate(text.split("\f")):
        low = ptext.lower()
        n = low.count(ql)
        if n:
            total += n
            idx = low.find(ql)
            start = max(0, idx - 40)
            snippet = ptext[start:idx + len(q) + 40].replace("\n", " ").strip()
            matches.append({"page": i + 1, "count": n, "snippet": snippet})
    return jsonify(count=total, matches=matches,
                   first_page=(matches[0]["page"] if matches else 0))


@app.route("/ocr/save-project", methods=["POST"])
@takeoff_workflow_required
def ocr_save_project():
    """Save a finished OCR job's searchable PDF into the Projects tree.

    Returns {needs_confirm: true} (HTTP 200) when the target already exists and
    the caller has not set overwrite, so the UI can ask before clobbering."""
    try:
        body = request.get_json(silent=True) or {}
        job_id = (body.get("job_id") or "").strip()
        dest_dir = (body.get("dir") or "").strip()
        filename = (body.get("filename") or "").strip()
        overwrite = bool(body.get("overwrite"))

        job_dir = _ocr_job_dir(job_id)
        if not job_dir or not job_dir.is_dir():
            return jsonify(error="OCR job not found."), 404
        status = _ocr_read_status(job_dir)
        if status.get("status") != "done":
            return jsonify(error="OCR is not finished yet."), 400
        src_pdf = (job_dir / status.get("filename", "")).resolve()
        if not str(src_pdf).startswith(str(job_dir.resolve())) or not src_pdf.exists():
            return jsonify(error="Processed file is unavailable."), 404

        if not filename or "/" in filename or "\\" in filename or filename in (".", ".."):
            return jsonify(error="Invalid filename."), 400
        if not filename.lower().endswith(".pdf"):
            filename += ".pdf"

        rel = f"{dest_dir}/{filename}" if dest_dir else filename
        dest = _fb_safe_path(rel)
        if dest is None:
            return jsonify(error="Invalid destination path."), 400
        if dest.parent != _FB_ROOT.resolve() and not dest.parent.is_dir():
            return jsonify(error="Destination directory does not exist."), 400
        if dest.exists() and not overwrite:
            return jsonify(needs_confirm=True, filename=filename), 200

        shutil.copy2(str(src_pdf), str(dest))
        return jsonify(ok=True, path=str(dest.relative_to(_FB_ROOT.resolve())),
                       filename=filename)
    except Exception as exc:  # noqa: BLE001 — always return JSON to the browser
        return jsonify(error=f"Unexpected error: {exc}"), 500


# ---------------------------------------------------------------------------
# Routes — single-item legacy workflow (unchanged)
# ---------------------------------------------------------------------------

@app.route("/upload", methods=["POST"])
@login_required
def upload():
    shop      = request.files.get("shop")
    submittal = request.files.get("submittal")
    carrier   = request.files.get("carrier")
    csv_file  = request.files.get("csv")
    job_name  = request.form.get("job_name", "").strip()

    if not (shop and shop.filename and submittal and submittal.filename
            and carrier and carrier.filename):
        return jsonify(error="shop, submittal, and carrier PDFs are required"), 400

    for label, f in [("shop", shop), ("submittal", submittal), ("carrier", carrier)]:
        if not f.filename.lower().endswith(".pdf"):
            return jsonify(error=f"'{label}' must be a PDF file"), 400

    if csv_file and csv_file.filename and not csv_file.filename.lower().endswith(".csv"):
        return jsonify(error="csv must be a .csv file"), 400

    job_id = re.sub(r"[^\w\s\-\.]", "_", job_name)[:80].strip() if job_name else str(uuid.uuid4())[:8]
    job_folder = _safe_job_folder(job_id)

    if _find_generated(job_folder):
        return jsonify(job_id=job_id)

    job_folder.mkdir(parents=True, exist_ok=True)
    shop.save(job_folder / secure_filename(shop.filename))
    submittal.save(job_folder / secure_filename(submittal.filename))
    carrier.save(job_folder / secure_filename(carrier.filename))
    if csv_file and csv_file.filename:
        csv_file.save(job_folder / secure_filename(csv_file.filename))

    t = threading.Thread(target=_run_merge_single, args=(job_folder,), daemon=True)
    t.start()
    return jsonify(job_id=job_id)


@app.route("/status/<path:job_id>")
@login_required
def status(job_id):
    job_folder = _safe_job_folder(job_id)
    if not job_folder.is_dir():
        abort(404)
    generated = _find_generated(job_folder)
    if generated:
        return jsonify(status="done", filename=generated.name)
    return jsonify(status="pending")


@app.route("/preview/<path:job_id>")
@login_required
def preview(job_id):
    job_folder = _safe_job_folder(job_id)
    generated = _find_generated(job_folder)
    if not generated:
        abort(404)
    return send_file(generated, mimetype="application/pdf")


@app.route("/download/<path:job_id>")
@login_required
def download_pdf(job_id):
    job_folder = _safe_job_folder(job_id)
    generated = _find_generated(job_folder)
    if not generated:
        abort(404)
    return send_file(generated, as_attachment=True, download_name=generated.name)


@app.route("/single-carrier-viewer-url")
@login_required
def single_carrier_viewer_url():
    """Return the Document Viewer URL for a generated carrier sheet."""
    job_id = request.args.get("job_id", "")
    if not job_id:
        return jsonify({"error": "missing job_id"}), 400
    job_folder = _safe_job_folder(job_id)
    generated = _find_generated(job_folder)
    if not generated:
        return jsonify({"error": "not found"}), 404
    prefix = _load_shared_url_prefix()
    url = f"{prefix}/shared/{quote(job_folder.name)}/{quote(generated.name)}"
    # rel = path under SHARED_DIR, for the Print button's print-job file_ref (DVI-670)
    return jsonify({"url": url, "rel": f"{job_folder.name}/{generated.name}"})


# ---------------------------------------------------------------------------
# Routes — Reprint Carrier Sheet (DVI-296)
# ---------------------------------------------------------------------------


@app.route("/reprint-list-projects", methods=["GET"])
@login_required
def reprint_list_projects():
    """Return project folder names under SHARED_DIR, sorted newest-to-oldest."""
    folders = []
    if SHARED_DIR.is_dir():
        try:
            for entry in sorted(
                SHARED_DIR.iterdir(),
                key=lambda e: e.stat().st_mtime,
                reverse=True,
            ):
                if entry.is_dir() and not entry.name.startswith("."):
                    folders.append(entry.name)
        except OSError:
            pass
    return jsonify({"folders": folders})


@app.route("/reprint-list-carriers", methods=["POST"])
@login_required
def reprint_list_carriers():
    """List carrier sheet PDFs in a project folder, ordered by CSV/JSON."""
    data = request.get_json(silent=True) or {}
    folder_name = data.get("folder_name", "").strip()
    if not folder_name or not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify({"error": "Invalid folder name"}), 400

    folder_path = (SHARED_DIR / folder_name).resolve()
    if not str(folder_path).startswith(str(SHARED_DIR.resolve())) or not folder_path.is_dir():
        return jsonify({"error": "Folder not found"}), 404

    # Derive job name from metadata or folder name
    job_name = folder_name
    parts = folder_name.split(" - ")
    if len(parts) >= 3:
        job_name = parts[1].strip()
    elif len(parts) == 2:
        job_name = parts[1].strip()

    meta_path = folder_path / "project_metadata.json"
    if meta_path.is_file():
        try:
            meta = json.loads(meta_path.read_text(encoding="utf-8"))
            if meta.get("project_name"):
                job_name = meta["project_name"]
        except (json.JSONDecodeError, OSError):
            pass

    all_pdfs = list(folder_path.rglob("*.pdf"))
    carriers: list[dict] = []
    seen: set[str] = set()

    # Strategy 1: Parse CSV for ordered items and find matching carrier files
    csv_path = None
    for f in folder_path.iterdir():
        if f.suffix.lower() == ".csv" and f.is_file():
            csv_path = f
            break

    if csv_path:
        try:
            carriers = _reprint_carriers_from_csv(csv_path, all_pdfs, folder_path, seen)
        except (OSError, csv.Error):
            pass

    # Strategy 2: state.json file mappings
    if not carriers:
        state_path = folder_path / "state.json"
        if state_path.is_file():
            try:
                state = json.loads(state_path.read_text(encoding="utf-8"))
                for i, item_name in enumerate(state.get("items", [])):
                    fm = state.get("files", {}).get(str(i), {})
                    carrier_name = fm.get("carrier")
                    if not carrier_name:
                        continue
                    cp = folder_path / carrier_name
                    if not cp.is_file():
                        continue
                    rel = str(cp.relative_to(SHARED_DIR))
                    if rel in seen:
                        continue
                    seen.add(rel)
                    shop_name = fm.get("shop")
                    sub_name = fm.get("submittal")
                    carriers.append({
                        "name": item_name,
                        "carrier_path": rel,
                        "shop_path": _reprint_rel(folder_path, shop_name, SHARED_DIR),
                        "submittal_path": _reprint_rel(folder_path, sub_name, SHARED_DIR),
                    })
            except (json.JSONDecodeError, OSError):
                pass

    # Strategy 3: Scan folder for any carrier PDFs
    if not carriers:
        for pdf in sorted(all_pdfs, key=lambda f: f.name.lower()):
            stem_lower = pdf.stem.lower()
            rel = str(pdf.relative_to(SHARED_DIR))
            if rel in seen:
                continue
            if stem_lower.endswith("carrierqr") or (
                "carrier" in stem_lower and "generated" in stem_lower
            ):
                seen.add(rel)
                carriers.append({
                    "name": pdf.stem,
                    "carrier_path": rel,
                    "shop_path": None,
                    "submittal_path": None,
                })

    return jsonify({
        "job_name": job_name,
        "folder_name": folder_name,
        "carriers": carriers,
    })


def _reprint_rel(folder: Path, filename: str | None, shared: Path) -> str | None:
    if not filename:
        return None
    p = folder / filename
    return str(p.relative_to(shared)) if p.is_file() else None


def _reprint_find_pdf(
    url: str,
    all_pdfs: list[Path],
    shared_dir: Path,
    identifiers: list[str],
    suffix: str,
) -> str | None:
    """Locate a PDF by URL-extracted filename or by identifier+suffix matching."""
    if url:
        filename = unquote(url.rstrip("/").rsplit("/", 1)[-1])
        for pdf in all_pdfs:
            if pdf.name == filename:
                return str(pdf.relative_to(shared_dir))
    idents = [s.strip().lower() for s in identifiers if s.strip()]
    if idents:
        for pdf in all_pdfs:
            if all(i in pdf.stem.lower() for i in idents) and pdf.stem.lower().endswith(suffix):
                return str(pdf.relative_to(shared_dir))
    return None


def _reprint_carriers_from_csv(
    csv_path: Path,
    all_pdfs: list[Path],
    folder_path: Path,
    seen: set[str],
) -> list[dict]:
    """Parse a project CSV and return carrier entries in row order."""
    carriers: list[dict] = []
    with open(csv_path, newline="", encoding="utf-8-sig") as fh:
        reader = csv.reader(fh)
        header = next(reader, [])
        header_lower = [h.strip().lower() for h in header]

        col = {}
        for i, h in enumerate(header_lower):
            if "tranid" in h:
                col.setdefault("tid", i)
            elif "structure" in h:
                col.setdefault("struct", i)
            elif h in ("itemline_item", "item"):
                col.setdefault("item", i)
            elif "part reference" in h:
                col.setdefault("pref", i)
            elif "qr label" in h:
                col.setdefault("curl", i)
            elif "drawingimage" in h:
                col.setdefault("surl", i)
            elif "submittal" in h and "link" in h:
                col.setdefault("burl", i)

        # Positional fallbacks for standard 12-column layout
        if "item" not in col and len(header) > 2:
            col.setdefault("item", 2)
        if "curl" not in col and len(header) > 11:
            col.setdefault("curl", 11)
        if "surl" not in col and len(header) > 9:
            col.setdefault("surl", 9)
        if "burl" not in col and len(header) > 10:
            col.setdefault("burl", 10)

        def _cell(row: list[str], key: str) -> str:
            idx = col.get(key)
            return row[idx].strip() if idx is not None and idx < len(row) else ""

        for row in reader:
            item = _cell(row, "item")
            struct = _cell(row, "struct")
            tid = _cell(row, "tid")
            pref = _cell(row, "pref")
            if not item and not struct:
                continue

            display = item or struct or pref or tid
            ids = [tid, pref, item]

            carrier_file = _reprint_find_pdf(_cell(row, "curl"), all_pdfs, SHARED_DIR, ids, "carrierqr")
            if not carrier_file or carrier_file in seen:
                continue
            seen.add(carrier_file)

            carriers.append({
                "name": display,
                "carrier_path": carrier_file,
                "shop_path": _reprint_find_pdf(_cell(row, "surl"), all_pdfs, SHARED_DIR, ids, "shop"),
                "submittal_path": _reprint_find_pdf(_cell(row, "burl"), all_pdfs, SHARED_DIR, ids, "submittal"),
            })

    return carriers


# ---------------------------------------------------------------------------
# Route — SCS save to project folder (DVI-309)
# ---------------------------------------------------------------------------


def _scs_find_job_pdf(job_folder: Path, sent_name: str, keyword: str, exclude: set) -> "Path | None":
    """Resolve a shop/submittal PDF in the job folder: client-sent filename first, keyword fallback."""
    if sent_name:
        cand = job_folder / secure_filename(sent_name)
        if cand.is_file() and cand not in exclude:
            return cand
    for f in sorted(job_folder.iterdir()):
        if not f.is_file() or f.suffix.lower() != ".pdf" or f in exclude:
            continue
        name = f.name.lower()
        if "generated" in name:
            continue
        if keyword in name:
            return f
    return None


@app.route("/scs-save-to-project", methods=["POST"])
@takeoff_workflow_required
def scs_save_to_project():
    """Copy an SCS carrier sheet (+ drawings) into a project folder and update its CSV records."""
    data = request.get_json(silent=True) or {}

    folder_name = (data.get("folder_name") or "").strip()
    if not folder_name or not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify({"error": "Invalid folder name"}), 400
    project_folder = (SHARED_DIR / folder_name).resolve()
    if not str(project_folder).startswith(str(SHARED_DIR.resolve()) + os.sep):
        return jsonify({"error": "Invalid folder name"}), 400
    if not project_folder.is_dir():
        return jsonify({"error": "Project folder not found"}), 404

    product_code = (data.get("product_code") or "").strip()
    if not product_code:
        return jsonify({"error": "Product Code is required"}), 400
    append_row = bool(data.get("append_row", False))

    _qr_base = _get_qr_base_url().rstrip("/")
    folder_url = f"{_qr_base}/{quote(folder_name, safe='')}"

    # shop/submittal URLs: None = leave the CSV cell unchanged
    shop_url: "str | None" = None
    submittal_url: "str | None" = None

    carrier_rel = (data.get("carrier_path") or "").strip()
    if carrier_rel:
        # Reprint mode: source carrier already lives in SHARED_DIR; copy only across folders.
        if ".." in carrier_rel:
            return jsonify({"error": "Invalid carrier path"}), 400
        carrier_src = (SHARED_DIR / carrier_rel).resolve()
        if not str(carrier_src).startswith(str(SHARED_DIR.resolve()) + os.sep) or not carrier_src.is_file():
            return jsonify({"error": "Carrier sheet not found"}), 404
        copy_sources = [] if carrier_src.parent == project_folder else [carrier_src]
        # J/K point at the source project's existing files (not copied) when known
        shop_rel = (data.get("shop_path") or "").strip()
        submittal_rel = (data.get("submittal_path") or "").strip()
        if shop_rel and ".." not in shop_rel:
            shop_url = f"{_qr_base}/{quote(shop_rel, safe='/')}"
        if submittal_rel and ".." not in submittal_rel:
            submittal_url = f"{_qr_base}/{quote(submittal_rel, safe='/')}"
    else:
        job_id = (data.get("job_id") or "").strip()
        if not job_id:
            return jsonify({"error": "Missing job_id"}), 400
        job_folder = _safe_job_folder(job_id)
        carrier_src = _find_generated(job_folder)
        if not carrier_src:
            return jsonify({"error": "No generated carrier sheet found"}), 404
        shop_src = _scs_find_job_pdf(job_folder, data.get("shop_filename") or "", "shop", {carrier_src})
        submittal_src = _scs_find_job_pdf(
            job_folder, data.get("submittal_filename") or "", "submittal",
            {carrier_src} | ({shop_src} if shop_src else set()),
        )
        copy_sources = [carrier_src] + [p for p in (shop_src, submittal_src) if p]
        if shop_src:
            shop_url = f"{folder_url}/{quote(shop_src.name, safe='')}"
        if submittal_src:
            submittal_url = f"{folder_url}/{quote(submittal_src.name, safe='')}"

    carrier_url = f"{folder_url}/{quote(carrier_src.name, safe='')}"

    # Copy files (overwrite on collision — same as the publish flow; idempotent)
    saved_files = []
    for src in copy_sources:
        shutil.copy2(str(src), str(project_folder / src.name))
        saved_files.append(src.name)

    # Locate the project CSV: state.json csv_filename first, else a single *.csv
    csv_path = None
    csv_warning = None
    state_path = project_folder / "state.json"
    if state_path.is_file():
        try:
            state = json.loads(state_path.read_text(encoding="utf-8"))
            name = state.get("csv_filename")
            if name and (project_folder / name).is_file():
                csv_path = project_folder / name
        except (json.JSONDecodeError, OSError):
            pass
    if csv_path is None:
        csvs = sorted(f for f in project_folder.iterdir() if f.is_file() and f.suffix.lower() == ".csv")
        if len(csvs) == 1:
            csv_path = csvs[0]
        elif len(csvs) > 1:
            csv_warning = "Multiple CSV files found — project records not updated"
        else:
            csv_warning = "No CSV found — project records not updated"
    if csv_warning:
        return jsonify({"ok": True, "saved_files": saved_files, "csv_warning": csv_warning})

    # Update the CSV, mirroring the Take-off Bridge process: match column C (index 2),
    # pad rows to 12 columns, write J(9)/K(10)/L(11) URLs.
    with open(csv_path, newline="", encoding="utf-8-sig") as fh:
        rows = list(csv.reader(fh))

    code_lower = product_code.lower()
    matched = [
        idx for idx, row in enumerate(rows)
        if idx > 0 and len(row) >= 3 and row[2].strip().lower() == code_lower
    ]

    appended = False
    if matched:
        # Also covers the append_row retry racing a concurrent edit: update, don't duplicate.
        for idx in matched:
            row = rows[idx]
            while len(row) < 12:
                row.append("")
            if shop_url is not None:
                row[9] = shop_url
            if submittal_url is not None:
                row[10] = submittal_url
            row[11] = carrier_url
    elif not append_row:
        return jsonify({
            "ok": True,
            "saved_files": saved_files,
            "csv_name": csv_path.name,
            "csv_match": False,
        })
    else:
        new_row = [""] * 12
        new_row[1] = (data.get("structure_id") or "").strip()
        new_row[2] = product_code
        new_row[5] = (data.get("weight") or "").strip()
        new_row[6] = (data.get("structure_size") or "").strip()
        new_row[7] = (data.get("part_ref") or "").strip()
        if shop_url is not None:
            new_row[9] = shop_url
        if submittal_url is not None:
            new_row[10] = submittal_url
        new_row[11] = carrier_url
        rows.append(new_row)
        appended = True

    with open(csv_path, "w", newline="", encoding="utf-8") as fh:
        csv.writer(fh).writerows(rows)

    return jsonify({
        "ok": True,
        "saved_files": saved_files,
        "csv_name": csv_path.name,
        "csv_match": True,
        "appended": appended,
        "updated_rows": len(matched),
        "product_code": product_code,
    })


# ---------------------------------------------------------------------------
# Routes — Sync Admin (DVI-235)
# ---------------------------------------------------------------------------

RCLONE_LOG_PATH = Path("/home/manager/rclone.log")
RCLONE_RESYNC_SCRIPT = "/home/manager/rclone_resync.sh"
RCLONE_FORCE_RESYNC_SCRIPT = "/home/manager/rclone_force_resync.sh"
SYNC_ADMIN_DEFAULT_LINES = 40


@app.route("/sync-admin/log")
@takeoff_workflow_required
def sync_admin_log():
    if not RCLONE_LOG_PATH.is_file():
        return jsonify(error="Log file not found at " + str(RCLONE_LOG_PATH)), 404

    try:
        lines_requested = request.args.get("lines", SYNC_ADMIN_DEFAULT_LINES, type=int)
        lines_requested = max(1, min(lines_requested, 500))

        with open(RCLONE_LOG_PATH, "rb") as f:
            # Read from end of file efficiently
            f.seek(0, 2)
            size = f.tell()
            # Read up to 64KB from end — enough for ~500 lines
            chunk_size = min(size, 65536)
            f.seek(max(0, size - chunk_size))
            raw = f.read()

        text = raw.decode("utf-8", errors="replace")
        all_lines = text.splitlines()
        tail = all_lines[-lines_requested:]

        needs_resync = False
        needs_lock_fix = False
        has_errors = False
        if tail:
            last_line = tail[-1]
            if "Bisync aborted" in last_line and "Must run --resync to recover" in last_line:
                needs_resync = True
            for line in tail:
                if "failed to bisync: prior lock file found:" in line:
                    needs_lock_fix = True
                if re.search(r'\b(?:error|failed|aborted)\b', line, re.IGNORECASE):
                    has_errors = True

        return jsonify(
            lines=tail,
            needs_resync=needs_resync,
            needs_lock_fix=needs_lock_fix,
            has_errors=has_errors,
            total_lines=len(all_lines),
        )
    except Exception as exc:
        return jsonify(error=str(exc)), 500


@app.route("/sync-admin/resync", methods=["POST"])
@takeoff_workflow_required
def sync_admin_resync():
    if not os.path.isfile(RCLONE_RESYNC_SCRIPT):
        return jsonify(error="Resync script not found at " + RCLONE_RESYNC_SCRIPT), 404

    try:
        subprocess.Popen(
            ["/bin/bash", RCLONE_RESYNC_SCRIPT],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            start_new_session=True,
        )
        record_history(
            "sync-admin", "resync",
            actor=(session.get("user") or {}).get("name"),
            summary="Resync initiated",
        )
        return jsonify(ok=True, message="Resync script launched.")
    except Exception as exc:
        return jsonify(error=str(exc)), 500


@app.route("/sync-admin/force-resync", methods=["POST"])
@takeoff_workflow_required
def sync_admin_force_resync():
    """Stop running rclone, remove the lock file, and run a fresh sync."""
    if not os.path.isfile(RCLONE_FORCE_RESYNC_SCRIPT):
        return jsonify(error="Force-resync script not found at " + RCLONE_FORCE_RESYNC_SCRIPT), 404

    try:
        subprocess.Popen(
            ["/bin/bash", RCLONE_FORCE_RESYNC_SCRIPT],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            start_new_session=True,
        )
        record_history(
            "sync-admin", "force-resync",
            actor=(session.get("user") or {}).get("name"),
            summary="Force resync initiated (lock cleared)",
        )
        return jsonify(ok=True, message="Force-resync script launched. rclone will be stopped, lock cleared, and a fresh sync started.")
    except Exception as exc:
        return jsonify(error=str(exc)), 500


@app.route("/sync-admin/log-tail")
@takeoff_workflow_required
def sync_admin_log_tail():
    """Return the last 50 lines of the rclone log for support request pre-population."""
    if not RCLONE_LOG_PATH.is_file():
        return jsonify(error="Log file not found.", text=""), 404
    try:
        with open(RCLONE_LOG_PATH, "rb") as f:
            f.seek(0, 2)
            size = f.tell()
            chunk_size = min(size, 65536)
            f.seek(max(0, size - chunk_size))
            raw = f.read()
        text = raw.decode("utf-8", errors="replace")
        lines = text.splitlines()[-50:]
        return jsonify(text="\n".join(lines))
    except Exception as exc:
        return jsonify(error=str(exc), text=""), 500


# ---------------------------------------------------------------------------
# Togen Admin — version info & update trigger
# ---------------------------------------------------------------------------

_GITHUB_REPO = "andycrawford/Togen"
_TOGEN_APP_DIR = Path(__file__).resolve().parent

# DVI-768: the version pane (and every admin's Home-tile poll, every 5 min) hits
# the GitHub API, which unauthenticated rate-limits to 60 req/hr/IP — several
# admins easily exceed that and surface a "GitHub API returned 429" error. A
# shared server-side cache collapses all callers onto one upstream request per
# TTL window, and on any upstream error we serve the last good result instead of
# bubbling up 429/timeout to the UI.
_GH_VERSION_CACHE = {"data": None, "ts": 0.0}
_GH_VERSION_TTL = 300  # seconds
_GH_VERSION_LOCK = threading.Lock()


def _fetch_github_latest(force=False):
    """Return (remote_info_dict, error_str) for the latest main commit.

    Caches successful results for ``_GH_VERSION_TTL`` and, on any upstream
    error (including 429 rate limiting or timeouts), serves the last cached
    result rather than failing — so the version pane stays usable. Only when no
    cached result exists is an error returned.
    """
    import time as _t
    now = _t.time()
    with _GH_VERSION_LOCK:
        cached = _GH_VERSION_CACHE.get("data")
        cached_ts = _GH_VERSION_CACHE.get("ts", 0.0)
    if cached and not force and (now - cached_ts) < _GH_VERSION_TTL:
        return cached, None

    try:
        gh_headers = {"Accept": "application/vnd.github.v3+json"}
        gh_token = os.environ.get("GITHUB_TOKEN")
        if gh_token:
            gh_headers["Authorization"] = f"Bearer {gh_token}"
        gh_resp = _http_requests.get(
            f"https://api.github.com/repos/{_GITHUB_REPO}/commits/main",
            headers=gh_headers,
            timeout=15,
        )
        if gh_resp.status_code != 200:
            if cached:
                return cached, None
            return None, f"GitHub API returned {gh_resp.status_code}"
        gh_data = gh_resp.json()
        remote_info = {
            "hash": gh_data["sha"][:12],
            "date": gh_data["commit"]["author"]["date"],
            "message": gh_data["commit"]["message"].split("\n")[0],
        }
        with _GH_VERSION_LOCK:
            _GH_VERSION_CACHE["data"] = remote_info
            _GH_VERSION_CACHE["ts"] = now
        return remote_info, None
    except Exception as exc:
        if cached:
            return cached, None
        return None, f"GitHub version check failed: {exc}"


@app.route("/togen-admin/version")
@togen_admin_required
def togen_admin_version():
    """Return deployed commit info and latest GitHub commit for comparison."""
    version_file = _TOGEN_APP_DIR / "VERSION"
    try:
        if not version_file.is_file():
            return jsonify(error="VERSION file not found. Redeploy required."), 500
        ver = json.loads(version_file.read_text())
        local_info = {
            "hash": ver["hash"][:12],
            "date": ver["date"],
            "message": ver.get("message", ""),
        }
    except Exception as exc:
        return jsonify(error=f"Local version check failed: {exc}"), 500

    remote_info, gh_err = _fetch_github_latest()
    if gh_err:
        return jsonify(error=gh_err), 502

    update_available = local_info["hash"] != remote_info["hash"]

    return jsonify(
        local=local_info,
        remote=remote_info,
        update_available=update_available,
    )


@app.route("/togen-admin/update", methods=["POST"])
@togen_admin_required
def togen_admin_update():
    """Trigger a git pull and service restart (wired by Linux Automation Engineer)."""
    update_script = Path("/opt/togen/update.sh")
    if not update_script.is_file():
        return jsonify(error="Update script not yet configured. Contact your system administrator."), 501

    try:
        subprocess.Popen(
            ["/bin/bash", str(update_script)],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            start_new_session=True,
        )
        record_history(
            "togen-admin", "update-applied",
            actor=(session.get("user") or {}).get("name"),
            summary="Immediate update applied (git pull + service restart)",
        )
        return jsonify(ok=True, message="Update started. The application will restart momentarily.")
    except Exception as exc:
        return jsonify(error=str(exc)), 500


# ---------------------------------------------------------------------------
# Togen Admin — scheduled update (DVI-768)
# ---------------------------------------------------------------------------

UPDATE_SCHEDULE_FILE = _TOGEN_APP_DIR / "update_schedule.json"
_SCHEDULED_UPDATE_NOTIFY_EMAIL = "help@voyagetech.com"
_UPDATE_SCHEDULE_LOCK = threading.Lock()


def _load_update_schedule():
    """Return the persisted scheduled-update record, or a disabled default."""
    try:
        if UPDATE_SCHEDULE_FILE.is_file():
            return json.loads(UPDATE_SCHEDULE_FILE.read_text())
    except Exception:
        app.logger.exception("Failed to read update schedule")
    return {"enabled": False, "version": "", "scheduled_for": "", "status": "idle"}


def _save_update_schedule(sched):
    try:
        UPDATE_SCHEDULE_FILE.write_text(json.dumps(sched, indent=2))
    except OSError:
        app.logger.exception("Failed to write update schedule")


def _next_2am_central_utc():
    """Return the next 02:00 America/Chicago instant as a naive UTC datetime."""
    try:
        from zoneinfo import ZoneInfo
        central = ZoneInfo("America/Chicago")
        now_c = _datetime_type.now(central)
        target = now_c.replace(hour=2, minute=0, second=0, microsecond=0)
        if target <= now_c:
            target += _timedelta_type(days=1)
        return target.astimezone(_dt_timezone.utc).replace(tzinfo=None)
    except Exception:
        # Fallback: 2am next day in server-local time, treated as UTC.
        target = _datetime_type.utcnow().replace(hour=2, minute=0, second=0, microsecond=0)
        if target <= _datetime_type.utcnow():
            target += _timedelta_type(days=1)
        return target


@app.route("/togen-admin/scheduled-update")
@togen_admin_required
def togen_admin_scheduled_update_get():
    """Return the current scheduled-update state (DVI-768)."""
    return jsonify(schedule=_load_update_schedule())


@app.route("/togen-admin/scheduled-update", methods=["POST"])
@togen_admin_required
def togen_admin_scheduled_update_post():
    """Enable or cancel a scheduled update for 2:00 AM next morning (DVI-768).

    Body: {enabled: bool, version: str}. When enabled, ``version`` is the public
    version string to display to users once the update completes; the update is
    applied by the background scheduler at the next 2:00 AM Central.
    """
    body = request.get_json(silent=True) or {}
    enabled = bool(body.get("enabled"))
    if not enabled:
        _save_update_schedule({"enabled": False, "version": "", "scheduled_for": "", "status": "idle"})
        return jsonify(ok=True, schedule=_load_update_schedule())

    version = str(body.get("version", "")).strip()
    if not version:
        return jsonify(error="Version is required when scheduling an update."), 400

    scheduled_for = _next_2am_central_utc().strftime("%Y-%m-%dT%H:%M:%SZ")
    user = session.get("user", {})
    sched = {
        "enabled": True,
        "version": version,
        "scheduled_for": scheduled_for,
        "status": "pending",
        "created_by": (user.get("email") or user.get("name") or ""),
        "created_at": _datetime_type.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
    }
    _save_update_schedule(sched)
    record_history(
        "togen-admin", "update-scheduled",
        actor=(session.get("user") or {}).get("name"),
        summary=f"Update v{version} scheduled for {scheduled_for}",
    )
    return jsonify(ok=True, schedule=sched)


def _send_scheduled_update_email(sched, remote, gh_err):
    """Email help@voyagetech.com that a scheduled update was applied (DVI-768)."""
    version = sched.get("version", "")
    scheduled_for = sched.get("scheduled_for", "")
    applied_at = sched.get("applied_at", "")
    created_by = sched.get("created_by", "")
    if remote:
        gh_block = (
            f"<li><strong>Commit:</strong> <code>{remote.get('hash', '')}</code></li>"
            f"<li><strong>Committed:</strong> {remote.get('date', '')}</li>"
            f"<li><strong>Message:</strong> {remote.get('message', '')}</li>"
        )
    else:
        gh_block = f"<li>GitHub commit details unavailable: {gh_err or 'unknown error'}</li>"
    html = (
        "<h2>Togen Scheduled Update Applied</h2>"
        "<p>A scheduled application update has been applied per schedule.</p>"
        "<ul>"
        f"<li><strong>Scheduled for:</strong> {scheduled_for}</li>"
        f"<li><strong>Applied at (UTC):</strong> {applied_at}</li>"
        f"<li><strong>Scheduled by:</strong> {created_by}</li>"
        f"<li><strong>Version shown to users:</strong> {version}</li>"
        "</ul>"
        "<h3>GitHub update information</h3>"
        f"<ul>{gh_block}</ul>"
    )
    try:
        notifications.send_html_email(
            _SCHEDULED_UPDATE_NOTIFY_EMAIL,
            "Togen scheduled update applied",
            html,
        )
    except Exception:
        app.logger.exception("Failed to send scheduled-update email")


def _maybe_run_scheduled_update():
    """If a scheduled update is due, set the public version and apply it."""
    with _UPDATE_SCHEDULE_LOCK:
        sched = _load_update_schedule()
        if not sched.get("enabled") or sched.get("status") != "pending":
            return
        scheduled_for = sched.get("scheduled_for")
        if not scheduled_for:
            return
        now_str = _datetime_type.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
        if now_str < scheduled_for:
            return

        update_script = Path("/opt/togen/update.sh")
        if not update_script.is_file():
            app.logger.warning("Scheduled update due but update.sh missing; leaving pending")
            return

        version = sched.get("version", "").strip()
        remote, gh_err = _fetch_github_latest(force=True)

        if version:
            try:
                PUBLIC_VERSION_FILE.write_text(version + "\n")
            except OSError:
                app.logger.exception("Scheduled update: failed to write public version")

        # Mark applied and disable BEFORE launching the update — update.sh
        # restarts the service, which restarts this loop; persisting the
        # applied status first prevents a re-run after restart.
        sched["status"] = "applied"
        sched["enabled"] = False
        sched["applied_at"] = now_str
        _save_update_schedule(sched)

        _send_scheduled_update_email(sched, remote, gh_err)

        try:
            subprocess.Popen(
                ["/bin/bash", str(update_script)],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                start_new_session=True,
            )
            app.logger.info("Scheduled update launched (version=%s)", version)
        except Exception:
            app.logger.exception("Scheduled update: failed to launch update.sh")


# ---------------------------------------------------------------------------
# Togen Admin — tool visibility (DVI-254)
# ---------------------------------------------------------------------------


@app.route("/togen-admin/tool-visibility")
@togen_admin_required
def togen_admin_tool_visibility_get():
    """Return the current enabled/disabled state for each manageable tool."""
    return jsonify(tools=_load_tool_visibility())


@app.route("/togen-admin/tool-visibility", methods=["POST"])
@togen_admin_required
def togen_admin_tool_visibility_post():
    """Save tool visibility settings. Body: {tools: {tool_id: bool, ...}}."""
    body = request.get_json(silent=True) or {}
    incoming = body.get("tools", {})
    visibility = _load_tool_visibility()
    for tid in _ALL_TOOL_IDS:
        if tid in incoming:
            visibility[tid] = bool(incoming[tid])
    try:
        TOOL_VISIBILITY_FILE.write_text(json.dumps(visibility, indent=2))
    except OSError as exc:
        return jsonify(error=f"Failed to save settings: {exc}"), 500
    return jsonify(ok=True, tools=visibility)


@app.route("/togen-admin/toolbar-visibility")
@togen_admin_required
def togen_admin_toolbar_visibility_get():
    """Return Admins/Users enable state for each top-bar function (DVI-616)."""
    return jsonify(toolbars=_load_toolbar_visibility())


@app.route("/togen-admin/toolbar-visibility", methods=["POST"])
@togen_admin_required
def togen_admin_toolbar_visibility_post():
    """Save toolbar visibility. Body: {toolbars: {id: {admins: bool, users: bool}}}."""
    body = request.get_json(silent=True) or {}
    incoming = body.get("toolbars", {})
    visibility = _load_toolbar_visibility()
    for tid in _TOOLBAR_IDS:
        row = incoming.get(tid)
        if isinstance(row, dict):
            visibility[tid] = {
                "admins": bool(row.get("admins", visibility[tid]["admins"])),
                "users": bool(row.get("users", visibility[tid]["users"])),
            }
    try:
        TOOLBAR_VISIBILITY_FILE.write_text(json.dumps(visibility, indent=2))
    except OSError as exc:
        return jsonify(error=f"Failed to save settings: {exc}"), 500
    return jsonify(ok=True, toolbars=visibility)


@app.route("/togen-admin/tool-order")
@togen_admin_required
def togen_admin_tool_order_get():
    """Return the current tool display order."""
    return jsonify(order=_load_tool_order())


@app.route("/togen-admin/tool-order", methods=["POST"])
@togen_admin_required
def togen_admin_tool_order_post():
    """Save tool display order. Body: {order: [tool_id, ...]}."""
    body = request.get_json(silent=True) or {}
    incoming = body.get("order", [])
    if not isinstance(incoming, list):
        return jsonify(error="order must be a list"), 400
    seen, valid = set(), []
    for tid in incoming:
        if tid in _ALL_TOOL_IDS and tid not in seen:
            seen.add(tid)
            valid.append(tid)
    for tid in _ALL_TOOL_IDS:
        if tid not in seen:
            seen.add(tid)
            valid.append(tid)
    try:
        TOOL_ORDER_FILE.write_text(json.dumps(valid, indent=2))
    except OSError as exc:
        return jsonify(error=f"Failed to save order: {exc}"), 500
    return jsonify(ok=True, order=valid)


@app.route("/togen-admin/tool-overrides")
@togen_admin_required
def togen_admin_tool_overrides_get():
    """Return per-tool display name/description overrides (DVI-588)."""
    return jsonify(overrides=_load_tool_overrides())


@app.route("/togen-admin/tool-overrides", methods=["POST"])
@togen_admin_required
def togen_admin_tool_overrides_post():
    """Save one tool's name/description override (DVI-588).

    Body: {tool_id, name, description}. Blank name or description clears that
    field so the tool falls back to its built-in default; clearing both removes
    the tool's entry entirely.
    """
    body = request.get_json(silent=True) or {}
    tid = str(body.get("tool_id", "")).strip()
    if tid not in _ALL_TOOL_IDS:
        return jsonify(error="Unknown tool"), 400
    name = str(body.get("name", "")).strip()
    desc = str(body.get("description", "")).strip()
    overrides = _load_tool_overrides()
    entry = {}
    if name:
        entry["name"] = name
    if desc:
        entry["description"] = desc
    if entry:
        overrides[tid] = entry
    else:
        overrides.pop(tid, None)
    try:
        TOOL_OVERRIDES_FILE.write_text(json.dumps(overrides, indent=2))
    except OSError as exc:
        return jsonify(error=f"Failed to save overrides: {exc}"), 500
    return jsonify(ok=True, overrides=overrides)


# ---------------------------------------------------------------------------
# Togen Admin — Work Order type visibility/order (DVI-349)
# ---------------------------------------------------------------------------


@app.route("/togen-admin/wor-type-visibility")
@togen_admin_required
def togen_admin_wor_type_visibility_get():
    """Return the enabled/disabled state for each Work Order type."""
    return jsonify(types=_load_wor_type_visibility())


@app.route("/togen-admin/wor-type-visibility", methods=["POST"])
@togen_admin_required
def togen_admin_wor_type_visibility_post():
    """Save Work Order type visibility. Body: {types: {wor_type: bool, ...}}."""
    body = request.get_json(silent=True) or {}
    incoming = body.get("types", {})
    visibility = _load_wor_type_visibility()
    for tid in _WOR_TYPE_IDS:
        if tid in incoming:
            visibility[tid] = bool(incoming[tid])
    try:
        WOR_TYPE_VISIBILITY_FILE.write_text(json.dumps(visibility, indent=2))
    except OSError as exc:
        return jsonify(error=f"Failed to save settings: {exc}"), 500
    return jsonify(ok=True, types=visibility)


@app.route("/togen-admin/wor-type-order")
@togen_admin_required
def togen_admin_wor_type_order_get():
    """Return the current Work Order type display order."""
    return jsonify(order=_load_wor_type_order())


@app.route("/togen-admin/wor-type-order", methods=["POST"])
@togen_admin_required
def togen_admin_wor_type_order_post():
    """Save Work Order type display order. Body: {order: [wor_type, ...]}."""
    body = request.get_json(silent=True) or {}
    incoming = body.get("order", [])
    if not isinstance(incoming, list):
        return jsonify(error="order must be a list"), 400
    seen, valid = set(), []
    for tid in incoming:
        if tid in _WOR_TYPE_IDS and tid not in seen:
            seen.add(tid)
            valid.append(tid)
    for tid in _WOR_TYPE_IDS:
        if tid not in seen:
            seen.add(tid)
            valid.append(tid)
    try:
        WOR_TYPE_ORDER_FILE.write_text(json.dumps(valid, indent=2))
    except OSError as exc:
        return jsonify(error=f"Failed to save order: {exc}"), 500
    return jsonify(ok=True, order=valid)


# ---------------------------------------------------------------------------
# Routes — User Settings (DVI-274)
# ---------------------------------------------------------------------------


@app.route("/user-settings")
@login_required
def user_settings_get():
    """Return the current user's personal settings."""
    email = session["user"].get("email", "")
    return jsonify(settings=_user_settings_with_grid(email))


@app.route("/user-settings", methods=["POST"])
@login_required
def user_settings_post():
    """Save the current user's personal settings."""
    email = session["user"].get("email", "")
    body = request.get_json(silent=True) or {}
    settings = _load_user_settings(email)

    if "tool_visibility" in body:
        tv = body["tool_visibility"]
        if isinstance(tv, dict):
            if tv:
                # togen-admin is per-user togglable (DVI-524) even though it is not
                # an admin-managed tool, so it is accepted alongside _ALL_TOOL_IDS.
                allowed_vis = list(_ALL_TOOL_IDS) + ["togen-admin"]
                settings["tool_visibility"] = {
                    tid: bool(tv[tid]) for tid in allowed_vis if tid in tv
                }
            else:
                settings.pop("tool_visibility", None)

    if "tool_order" in body:
        incoming = body["tool_order"]
        if isinstance(incoming, list):
            if incoming:
                # Beyond the admin-managed tools, the per-user order may carry the
                # Togen Admin tile and "separator" markers (DVI-524). Dedup keeps a
                # single separator at its submitted position.
                allowed_order = set(_ALL_TOOL_IDS) | {"togen-admin", "separator"}
                seen, valid = set(), []
                for tid in incoming:
                    if tid in allowed_order and tid not in seen:
                        seen.add(tid)
                        valid.append(tid)
                for tid in _ALL_TOOL_IDS:
                    if tid not in seen:
                        seen.add(tid)
                        valid.append(tid)
                settings["tool_order"] = valid
            else:
                settings.pop("tool_order", None)

    if "theme" in body:
        if body["theme"] in ("dark", "light", "system"):
            if body["theme"] == "system":
                settings.pop("theme", None)
            else:
                settings["theme"] = body["theme"]

    if "notifications" in body:
        grid = body["notifications"]
        if isinstance(grid, dict):
            wo = _notification_grid_to_storage(grid)
            if wo:
                settings["wo_notifications"] = wo
            else:
                settings.pop("wo_notifications", None)

    # Per-user SCADA notification prefs (DVI-465). Mirrored between the SCADA
    # View Settings tab and the User Settings Notifications pane.
    if "scada_notifications" in body:
        sn = body["scada_notifications"]
        if isinstance(sn, dict):
            settings["scada_notifications"] = {
                "stale_data": bool(sn.get("stale_data")),
                "out_of_range": bool(sn.get("out_of_range")),
            }

    # Background-mode override (DVI-364). A blank/null value clears the override
    # so the user inherits the company default; a valid mode overrides it.
    if "bg_mode_override" in body:
        val = body["bg_mode_override"]
        if val in (None, ""):
            settings.pop("bg_mode_override", None)
        elif val in USER_BG_MODES:
            settings["bg_mode_override"] = val
        else:
            return jsonify(error="Invalid background mode."), 400

    # Per-user pane opacity overrides (DVI-366). Separate light/dark keys; a
    # blank/null value clears that key (inherit the company default).
    for key in ("bg_opacity_light", "bg_opacity_dark"):
        if key in body:
            val = body[key]
            if val in (None, ""):
                settings.pop(key, None)
            else:
                opacity = _coerce_bg_opacity(val)
                if opacity is None:
                    return jsonify(error="Opacity must be an integer between 40 and 100."), 400
                settings[key] = opacity

    # Per-user home tile grid overrides (DVI-512). "tile_columns": a blank/null
    # value clears the override (inherit the company default); otherwise 3-9 or
    # "unlimited". "tile_view": grid or list presentation, persisted across reloads.
    if "tile_columns" in body:
        val = body["tile_columns"]
        if val in (None, "", "default"):
            settings.pop("tile_columns", None)
        else:
            tile_columns = _coerce_tile_columns(val)
            if tile_columns is None:
                return jsonify(error='Tile columns must be 3-9 or "unlimited".'), 400
            settings["tile_columns"] = tile_columns

    if "tile_view" in body:
        val = body["tile_view"]
        if val in TILE_VIEW_MODES:
            settings["tile_view"] = val
        else:
            return jsonify(error="Invalid tile view."), 400

    # Per-user side padding override (DVI-515). Blank/null clears the override
    # so the home grid inherits the company default; otherwise 0-40 (%).
    if "tile_side_padding" in body:
        val = body["tile_side_padding"]
        if val in (None, "", "default"):
            settings.pop("tile_side_padding", None)
        else:
            tile_side_padding = _coerce_tile_side_padding(val)
            if tile_side_padding is None:
                return jsonify(error="Side padding must be an integer between 0 and 40."), 400
            settings["tile_side_padding"] = tile_side_padding

    try:
        _save_user_settings(email, settings)
    except OSError as exc:
        return jsonify(error=f"Failed to save settings: {exc}"), 500
    resp = dict(settings)
    resp["notifications"] = _notification_grid_from_settings(settings)
    return jsonify(ok=True, settings=resp)


# Per-user custom background (DVI-518). Upload/serve/remove a personal image used
# as the Togen interface background when the user's bg override is "custom".
@app.route("/user-settings/custom-bg")
@login_required
def user_settings_custom_bg_get():
    """Serve the current user's uploaded custom background image; 404 if absent."""
    email = session["user"].get("email", "")
    fp = _find_custom_bg(email)
    if not fp:
        abort(404)
    mime = CUSTOM_BG_EXT_MIMES.get(fp.suffix.lstrip(".").lower(), "application/octet-stream")
    return send_file(fp, mimetype=mime)


@app.route("/user-settings/custom-bg", methods=["POST"])
@login_required
def user_settings_custom_bg_post():
    """Accept a per-user custom background image upload (DVI-518).

    Validates type and size, replaces any previously uploaded image, records the
    extension in user settings, and switches the user's background override to
    "custom" so the new image takes effect immediately.
    """
    email = session["user"].get("email", "")
    f = request.files.get("background")
    if f is None or not f.filename:
        return jsonify(error="No file provided."), 400
    ext = f.filename.rsplit(".", 1)[-1].lower() if "." in f.filename else ""
    if ext not in CUSTOM_BG_EXT_MIMES:
        return jsonify(error="Unsupported image type. Use PNG, JPG, WEBP, GIF, or SVG."), 400
    f.stream.seek(0, os.SEEK_END)
    size = f.stream.tell()
    f.stream.seek(0)
    if size > CUSTOM_BG_MAX_BYTES:
        return jsonify(error="Image too large (max 8 MB)."), 400
    CUSTOM_BG_DIR.mkdir(parents=True, exist_ok=True)
    # Remove any previously stored custom bg with a different extension so a user
    # never has two files on disk.
    old = _find_custom_bg(email)
    if old and old.suffix.lstrip(".").lower() != ext:
        try:
            old.unlink()
        except OSError:
            pass
    f.save(str(_custom_bg_path(email, ext)))
    settings = _load_user_settings(email)
    settings["custom_bg_ext"] = ext
    settings["bg_mode_override"] = "custom"
    try:
        _save_user_settings(email, settings)
    except OSError as exc:
        return jsonify(error=f"Failed to save settings: {exc}"), 500
    resp = dict(settings)
    resp["notifications"] = _notification_grid_from_settings(settings)
    return jsonify(ok=True, settings=resp)


@app.route("/user-settings/custom-bg", methods=["DELETE"])
@login_required
def user_settings_custom_bg_delete():
    """Remove the current user's custom background and clear the "custom" override."""
    email = session["user"].get("email", "")
    fp = _find_custom_bg(email)
    if fp:
        try:
            fp.unlink()
        except OSError:
            pass
    settings = _load_user_settings(email)
    settings.pop("custom_bg_ext", None)
    if settings.get("bg_mode_override") == "custom":
        settings.pop("bg_mode_override", None)
    try:
        _save_user_settings(email, settings)
    except OSError as exc:
        return jsonify(error=f"Failed to save settings: {exc}"), 500
    resp = dict(settings)
    resp["notifications"] = _notification_grid_from_settings(settings)
    return jsonify(ok=True, settings=resp)


@app.route("/notifications-settings", methods=["POST"])
@login_required
def notifications_settings_post():
    """Persist the current user's notification grid (DVI-335).

    Stores the friendly 3x5 grid as the core module's event-keyed
    ``wo_notifications`` block inside the per-user settings JSON.
    """
    # Defense in depth: the WO notifications grid is only available to Work
    # Order Admins (the UI hides it for everyone else), so non-admins must not
    # be able to persist WO notification prefs via a direct POST.
    if not session["user"].get("wor_admin"):
        return jsonify(error="Access denied: Work Order Admin group membership required."), 403

    email = session["user"].get("email", "")
    body = request.get_json(silent=True) or {}
    grid = body.get("notifications")
    if not isinstance(grid, dict):
        return jsonify(error="Missing 'notifications' grid"), 400

    settings = _load_user_settings(email)
    wo = _notification_grid_to_storage(grid)
    if wo:
        settings["wo_notifications"] = wo
        # Persist the email so the recipient scan in notifications.py can resolve
        # this admin even if the sanitized filename ever differs from the address.
        if email:
            settings["email"] = email
    else:
        settings.pop("wo_notifications", None)

    try:
        _save_user_settings(email, settings)
    except OSError as exc:
        return jsonify(error=f"Failed to save settings: {exc}"), 500
    return jsonify(ok=True, notifications=_notification_grid_from_settings(settings))


@app.route("/wor-primary", methods=["GET"])
@wor_admin_required
def wor_primary_get():
    """Return the Primary recipient lists keyed by WO type (DVI-405)."""
    return jsonify(primary=_load_wor_primary())


@app.route("/wor-primary", methods=["POST"])
@wor_admin_required
def wor_primary_post():
    """Add or remove a Primary recipient email for a WO type (DVI-405)."""
    body = request.get_json(silent=True) or {}
    wor_type = body.get("wor_type", "")
    action = body.get("action", "")
    email = (body.get("email") or "").strip()

    if wor_type not in _NOTIFY_GRID_TYPES:
        return jsonify(error="Invalid work order type."), 400
    if action not in ("add", "remove"):
        return jsonify(error="Invalid action."), 400

    norm = email.lower()
    if action == "add" and ("@" not in norm or "." not in norm.split("@")[-1]):
        return jsonify(error="A valid email address is required."), 400

    primary = _load_wor_primary()
    emails = primary.get(wor_type, [])
    if action == "add":
        if norm not in emails:
            emails.append(norm)
    else:
        emails = [e for e in emails if e != norm]
    primary[wor_type] = emails

    try:
        _save_wor_primary(primary)
    except OSError as exc:
        return jsonify(error=f"Failed to save: {exc}"), 500
    return jsonify(ok=True, primary=_load_wor_primary())


@app.route("/wor-policy", methods=["GET"])
@wor_admin_required
def wor_policy_get():
    """Return the Policy-enforced notifications grid (DVI-597)."""
    return jsonify(policy=_load_wor_policy_grid())


@app.route("/wor-policy", methods=["POST"])
@wor_admin_required
def wor_policy_post():
    """Persist the Policy-enforced notifications grid (DVI-597)."""
    body = request.get_json(silent=True) or {}
    grid = body.get("policy")
    if not isinstance(grid, dict):
        return jsonify(error="Invalid policy grid."), 400
    try:
        _save_wor_policy_grid(grid)
    except OSError as exc:
        return jsonify(error=f"Failed to save: {exc}"), 500
    return jsonify(ok=True, policy=_load_wor_policy_grid())


# ---------------------------------------------------------------------------
# Routes — Support Request (DVI-246)
# ---------------------------------------------------------------------------


@app.route("/support-request-submit", methods=["POST"])
@login_required
def support_request_submit():
    """Accept a support request, store it as JSON, and email it."""
    body = request.get_json(silent=True) or {}

    name = body.get("name", "").strip()
    email = body.get("email", "").strip()
    description = body.get("description", "").strip()
    sr_datetime = body.get("datetime", "").strip()
    active_tool = body.get("active_tool", "").strip()
    app_state = body.get("app_state", "").strip()
    send_to = body.get("send_to", "").strip() or "icast.voyagetech.com@email.autotask.net"

    if not description:
        return jsonify(error="Issue description is required."), 400

    user = session.get("user", {})
    if not email:
        email = user.get("email", "")
    if not name:
        name = user.get("name", "Unknown")

    try:
        SUPPORT_DIR.mkdir(parents=True, exist_ok=True)
    except OSError as exc:
        return jsonify(error=f"Cannot create support request directory: {exc}"), 500

    timestamp = _datetime_type.now().strftime("%Y-%m-%d_%H%M%S")
    safe_name = re.sub(r"[^\w\s\-]", "", name)[:40].strip().replace(" ", "_")
    filename = f"SR_{timestamp}_{safe_name}.json"
    out_path = SUPPORT_DIR / filename

    record = {
        "name": name,
        "email": email,
        "description": description,
        "datetime": sr_datetime or _datetime_type.now().isoformat(),
        "active_tool": active_tool,
        "app_state_json": app_state,
        "send_to": send_to,
        "submitted_at": _datetime_type.now().isoformat(),
    }
    try:
        out_path.write_text(json.dumps(record, indent=2))
    except OSError as exc:
        return jsonify(error=f"Failed to save support request: {exc}"), 500

    # Send email via Microsoft Graph API if configured
    email_sent = False
    email_error = ""
    if AZURE_MAIL_FROM:
        try:
            app_client = msal.ConfidentialClientApplication(
                _AZ_CLIENT_ID, authority=_AZ_AUTHORITY_HOME, client_credential=_AZ_SECRET)
            token_result = app_client.acquire_token_for_client(
                scopes=["https://graph.microsoft.com/.default"])

            if "access_token" in token_result:
                email_body = (
                    f"Support Request from {name} ({email})\n"
                    f"Date/Time: {sr_datetime}\n"
                    f"Active Tool: {active_tool}\n\n"
                    f"Issue Description:\n{description}\n\n"
                    f"Application State:\n{app_state}\n\n"
                    f"Reference: {filename}"
                )
                graph_body = {
                    "message": {
                        "subject": f"Togen Support Request — {name} — {sr_datetime}",
                        "body": {
                            "contentType": "Text",
                            "content": email_body,
                        },
                        "from": {
                            "emailAddress": {"address": AZURE_MAIL_FROM},
                        },
                        "replyTo": [
                            {"emailAddress": {"address": email}},
                        ],
                        "toRecipients": [
                            {"emailAddress": {"address": send_to}},
                        ],
                    },
                    "saveToSentItems": "false",
                }
                resp = _http_requests.post(
                    f"https://graph.microsoft.com/v1.0/users/{AZURE_MAIL_FROM}/sendMail",
                    headers={
                        "Authorization": f"Bearer {token_result['access_token']}",
                        "Content-Type": "application/json",
                    },
                    json=graph_body,
                    timeout=30,
                )
                if resp.status_code in (200, 202):
                    email_sent = True
                else:
                    detail = resp.json().get("error", {}).get("message", resp.text[:200])
                    email_error = f"Graph API error: {detail}"
            else:
                email_error = "Failed to acquire mail token"
        except Exception as exc:
            email_error = str(exc)

    result = {"filename": filename, "email_sent": email_sent}
    if email_error:
        result["email_error"] = email_error
    return jsonify(result)


@app.route("/feature-request-submit", methods=["POST"])
@login_required
def feature_request_submit():
    """Accept a feature request, store it as JSON, and email it."""
    body = request.get_json(silent=True) or {}

    name = body.get("name", "").strip()
    email = body.get("email", "").strip()
    description = body.get("description", "").strip()
    beneficiaries = body.get("beneficiaries", "").strip()
    send_to = body.get("send_to", "").strip() or "icast.voyagetech.com@email.autotask.net"

    if not description:
        return jsonify(error="Feature description is required."), 400

    user = session.get("user", {})
    if not email:
        email = user.get("email", "")
    if not name:
        name = user.get("name", "Unknown")

    try:
        FEATURE_DIR.mkdir(parents=True, exist_ok=True)
    except OSError as exc:
        return jsonify(error=f"Cannot create feature request directory: {exc}"), 500

    timestamp = _datetime_type.now().strftime("%Y-%m-%d_%H%M%S")
    safe_name = re.sub(r"[^\w\s\-]", "", name)[:40].strip().replace(" ", "_")
    filename = f"FR_{timestamp}_{safe_name}.json"
    out_path = FEATURE_DIR / filename

    record = {
        "name": name,
        "email": email,
        "description": description,
        "beneficiaries": beneficiaries,
        "send_to": send_to,
        "submitted_at": _datetime_type.now().isoformat(),
    }
    try:
        out_path.write_text(json.dumps(record, indent=2))
    except OSError as exc:
        return jsonify(error=f"Failed to save feature request: {exc}"), 500

    # Send email via Microsoft Graph API if configured
    email_sent = False
    email_error = ""
    if AZURE_MAIL_FROM:
        try:
            app_client = msal.ConfidentialClientApplication(
                _AZ_CLIENT_ID, authority=_AZ_AUTHORITY_HOME, client_credential=_AZ_SECRET)
            token_result = app_client.acquire_token_for_client(
                scopes=["https://graph.microsoft.com/.default"])

            if "access_token" in token_result:
                email_body = (
                    f"Feature Request from {name} ({email})\n\n"
                    f"Feature Description:\n{description}\n\n"
                    f"Who may use or benefit from this feature:\n{beneficiaries}\n\n"
                    f"Reference: {filename}"
                )
                graph_body = {
                    "message": {
                        "subject": f"Togen Feature Request — {name}",
                        "body": {
                            "contentType": "Text",
                            "content": email_body,
                        },
                        "from": {
                            "emailAddress": {"address": AZURE_MAIL_FROM},
                        },
                        "replyTo": [
                            {"emailAddress": {"address": email}},
                        ],
                        "toRecipients": [
                            {"emailAddress": {"address": send_to}},
                        ],
                    },
                    "saveToSentItems": "false",
                }
                resp = _http_requests.post(
                    f"https://graph.microsoft.com/v1.0/users/{AZURE_MAIL_FROM}/sendMail",
                    headers={
                        "Authorization": f"Bearer {token_result['access_token']}",
                        "Content-Type": "application/json",
                    },
                    json=graph_body,
                    timeout=30,
                )
                if resp.status_code in (200, 202):
                    email_sent = True
                else:
                    detail = resp.json().get("error", {}).get("message", resp.text[:200])
                    email_error = f"Graph API error: {detail}"
            else:
                email_error = "Failed to acquire mail token"
        except Exception as exc:
            email_error = str(exc)

    result = {"filename": filename, "email_sent": email_sent}
    if email_error:
        result["email_error"] = email_error
    return jsonify(result)


@app.route("/it-help-desk-submit", methods=["POST"])
@login_required
def it_help_desk_submit():
    """Accept an IT Help Desk ticket, store it, and email icast.voyagetech.com@email.autotask.net as the user."""
    if not _wor_type_enabled_for("ithelpdesk", session["user"]):
        return jsonify(error="This work order type is not currently enabled."), 403
    body = request.get_json(silent=True) or {}

    name = body.get("name", "").strip()
    email = body.get("email", "").strip()
    description = body.get("description", "").strip()
    related = body.get("related", [])
    if not isinstance(related, list):
        related = []
    related = [str(r).strip() for r in related if str(r).strip()]

    if not description:
        return jsonify(error="Issue description is required."), 400

    user = session.get("user", {})
    if not email:
        email = user.get("email", "")
    if not name:
        name = user.get("name", "Unknown")

    send_to = "icast.voyagetech.com@email.autotask.net"

    try:
        SUPPORT_DIR.mkdir(parents=True, exist_ok=True)
    except OSError as exc:
        return jsonify(error=f"Cannot create request directory: {exc}"), 500

    timestamp = _datetime_type.now().strftime("%Y-%m-%d_%H%M%S")
    safe_name = re.sub(r"[^\w\s\-]", "", name)[:40].strip().replace(" ", "_")
    filename = f"IT_{timestamp}_{safe_name}.json"
    out_path = SUPPORT_DIR / filename

    record = {
        "name": name,
        "email": email,
        "description": description,
        "related": related,
        "send_to": send_to,
        "submitted_at": _datetime_type.now().isoformat(),
    }
    try:
        out_path.write_text(json.dumps(record, indent=2))
    except OSError as exc:
        return jsonify(error=f"Failed to save request: {exc}"), 500

    related_line = ", ".join(related) if related else "(none selected)"

    # Send the email AS the submitting user so the ticketing software attributes
    # it to the requester. Falls back to the shared mailbox if the user's email
    # is unavailable (DVI-413).
    email_sent = False
    email_error = ""
    send_as = email or AZURE_MAIL_FROM
    if AZURE_MAIL_FROM and send_as:
        try:
            app_client = msal.ConfidentialClientApplication(
                _AZ_CLIENT_ID, authority=_AZ_AUTHORITY_HOME, client_credential=_AZ_SECRET)
            token_result = app_client.acquire_token_for_client(
                scopes=["https://graph.microsoft.com/.default"])

            if "access_token" in token_result:
                email_body = (
                    f"IT Help Desk ticket from {name} ({email})\n"
                    f"Issue Related To: {related_line}\n\n"
                    f"Issue Description:\n{description}\n\n"
                    f"Reference: {filename}"
                )
                graph_body = {
                    "message": {
                        "subject": f"IT Help Desk Request — {name}",
                        "body": {
                            "contentType": "Text",
                            "content": email_body,
                        },
                        "from": {
                            "emailAddress": {"address": send_as},
                        },
                        "toRecipients": [
                            {"emailAddress": {"address": send_to}},
                        ],
                    },
                    "saveToSentItems": "false",
                }
                resp = _http_requests.post(
                    f"https://graph.microsoft.com/v1.0/users/{send_as}/sendMail",
                    headers={
                        "Authorization": f"Bearer {token_result['access_token']}",
                        "Content-Type": "application/json",
                    },
                    json=graph_body,
                    timeout=30,
                )
                if resp.status_code in (200, 202):
                    email_sent = True
                else:
                    detail = resp.json().get("error", {}).get("message", resp.text[:200])
                    email_error = f"Graph API error: {detail}"
            else:
                email_error = "Failed to acquire mail token"
        except Exception as exc:
            email_error = str(exc)

    result = {"filename": filename, "email_sent": email_sent}
    if email_error:
        result["email_error"] = email_error
    return jsonify(result)


# ---------------------------------------------------------------------------
# Routes — Project Builder
# ---------------------------------------------------------------------------

_SP_CSV_HEADERS = [
    "tranId",
    "itemLine_custom:Structure",
    "itemLine_item",
    "itemLine_quantity",
    "itemLine_salesPrice",
    "itemLine_weight",
    "Item_Size",
    "Part Reference",
    "itemLine_description",
    "itemLine_custom:Stack-It PART - DrawingImage",
    "itemLine_custom:Submittal Drawing link",
    "itemLine_custom:QR Label",
]


def _sp_next_project_number():
    """Compute the next project number for today: T + YYYYMMDD + 2-digit seq."""
    today = _datetime_type.now().strftime("%Y%m%d")
    prefix = f"T{today}"
    max_seq = 0
    for search_dir in (SHARED_DIR, PROJECTBUILDER_DIR):
        if not search_dir.is_dir():
            continue
        for entry in search_dir.iterdir():
            if not entry.is_dir():
                continue
            name = entry.name
            idx = name.find(prefix)
            if idx == -1:
                continue
            tail = name[idx + len(prefix):]
            digits = ""
            for ch in tail:
                if ch.isdigit():
                    digits += ch
                else:
                    break
            if digits:
                max_seq = max(max_seq, int(digits))
    return f"{prefix}{max_seq + 1:02d}"


@app.route("/project-builder-next-number", methods=["GET"])
@takeoff_workflow_required
def project_builder_next_number():
    return jsonify({"number": _sp_next_project_number()})


@app.route("/project-builder-check", methods=["POST"])
@takeoff_workflow_required
def project_builder_check():
    data = request.get_json(silent=True) or {}
    name = data.get("folder_name", data.get("project_name", "")).strip()
    if not name or not _SAFE_JOB_RE.match(name) or ".." in name:
        return jsonify({"error": "Invalid folder name"}), 400
    exists = (SHARED_DIR / name).is_dir()
    return jsonify({"exists": exists})


def _sp_filename_part(value):
    """Sanitize a project number / tranId for use in a CSV filename."""
    return re.sub(r"[^A-Za-z0-9._-]+", "-", str(value).strip()).strip("-._")


def _sp_csv_filename(project_number, rows, folder_name):
    """CSV name convention: <Project Number>-<Transaction ID>.csv (DVI-323)."""
    tran_id = ""
    for row in rows:
        if isinstance(row, list) and row and str(row[0]).strip():
            tran_id = str(row[0]).strip()
            break
    num_part = _sp_filename_part(project_number)
    tran_part = _sp_filename_part(tran_id)
    if num_part and tran_part:
        return f"{num_part}-{tran_part}.csv"
    return f"{folder_name}.csv"


@app.route("/project-builder-submit", methods=["POST"])
@takeoff_workflow_required
def project_builder_submit():
    data = request.get_json(silent=True) or {}
    project_name = data.get("project_name", "").strip()
    customer_name = data.get("customer_name", "").strip()
    vendor_project_code = data.get("vendor_project_code", "").strip()
    folder_name = data.get("folder_name", "").strip()
    project_number = data.get("project_number", "").strip()
    products = data.get("products", [])
    rows = data.get("rows", [])
    editing_folder = data.get("editing_folder", "").strip()
    import_source_folder = data.get("import_source_folder", "").strip()

    if not folder_name or not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify({"error": "Invalid folder name"}), 400
    if editing_folder and (not _SAFE_JOB_RE.match(editing_folder) or ".." in editing_folder):
        return jsonify({"error": "Invalid folder name"}), 400
    if import_source_folder and (not _SAFE_JOB_RE.match(import_source_folder) or ".." in import_source_folder):
        return jsonify({"error": "Invalid folder name"}), 400
    if not project_name:
        return jsonify({"error": "Project name is required"}), 400
    if not rows:
        return jsonify({"error": "No product rows provided"}), 400

    project_number = project_number or _sp_next_project_number()
    clean_rows = [row for row in rows if isinstance(row, list) and len(row) == len(_SP_CSV_HEADERS)]
    if not clean_rows:
        return jsonify({"error": "No product rows provided"}), 400
    csv_filename = _sp_csv_filename(project_number, clean_rows, folder_name)

    if editing_folder:
        return _sp_update_existing_project(
            editing_folder, folder_name, csv_filename, clean_rows,
            customer_name, project_name, vendor_project_code, project_number, products,
        )

    if import_source_folder:
        return _sp_import_project(
            import_source_folder, folder_name, csv_filename, clean_rows,
            customer_name, project_name, vendor_project_code, project_number, products,
        )

    try:
        PROJECTBUILDER_DIR.mkdir(parents=True, exist_ok=True)
    except PermissionError:
        return jsonify({"error": "Server cannot write to temp directory. Contact admin."}), 500

    tmp_folder = PROJECTBUILDER_DIR / folder_name
    tmp_folder.mkdir(parents=True, exist_ok=True)

    csv_path = tmp_folder / csv_filename

    metadata = {
        "customer_name": customer_name,
        "project_name": project_name,
        "vendor_project_code": vendor_project_code,
        "project_number": project_number,
        "folder_name": folder_name,
        "created_at": _datetime_type.now().isoformat(),
        "products": products,
    }
    meta_path = tmp_folder / "project_metadata.json"

    try:
        with open(csv_path, "w", newline="", encoding="utf-8") as fh:
            writer = csv.writer(fh)
            writer.writerow(_SP_CSV_HEADERS)
            writer.writerows(clean_rows)

        with open(meta_path, "w", encoding="utf-8") as fh:
            json.dump(metadata, fh, indent=2)

        _pb_move_temp_uploads(tmp_folder)

        dest = SHARED_DIR / folder_name
        if dest.exists():
            shutil.rmtree(str(dest))
        SHARED_DIR.mkdir(parents=True, exist_ok=True)
        shutil.copytree(str(tmp_folder), str(dest))
    except PermissionError as exc:
        return jsonify({"error": f"Permission denied: {exc}"}), 500
    except OSError as exc:
        return jsonify({"error": f"File system error: {exc}"}), 500
    finally:
        shutil.rmtree(str(tmp_folder), ignore_errors=True)

    if _is_history_capture_enabled("project-builder"):
        record_history(
            "project-builder", "created",
            actor=(session.get("user") or {}).get("name"),
            summary=f"Project created: {project_name} ({folder_name})",
            ref=folder_name,
            detail={"project_number": project_number, "rows": len(clean_rows)},
        )
    return jsonify({"folder": folder_name, "csv": csv_filename})


def _sp_preserve_url_columns(source_csvs, clean_rows):
    """Carry forward cols J/K/L (DrawingImage / Submittal Drawing link / QR Label,
    indices 9-11) from existing CSV rows onto clean_rows so user edits in the builder
    don't wipe Bridge-written links. Matches first on (tranId, structure, item, part
    ref); falls back to (structure, item) for unmatched rows (DVI-323/DVI-387)."""
    entries = []
    for old_csv in source_csvs:
        try:
            with open(old_csv, newline="", encoding="utf-8-sig") as fh:
                reader = csv.reader(fh)
                next(reader, None)
                for old_row in reader:
                    if len(old_row) < 12:
                        continue
                    urls = [old_row[9], old_row[10], old_row[11]]
                    if not any(u.strip() for u in urls):
                        continue
                    entries.append({
                        "key": tuple((old_row[i] or "").strip() for i in (0, 1, 2, 7)),
                        "fb": tuple((old_row[i] or "").strip() for i in (1, 2)),
                        "urls": urls,
                        "used": False,
                    })
        except (OSError, csv.Error):
            continue

    def _take(field, value):
        for entry in entries:
            if not entry["used"] and entry[field] == value:
                entry["used"] = True
                return entry["urls"]
        return None

    unmatched = []
    for row in clean_rows:
        key = tuple(str(row[i] or "").strip() for i in (0, 1, 2, 7))
        urls = _take("key", key)
        if urls:
            row[9], row[10], row[11] = urls
        else:
            unmatched.append(row)
    for row in unmatched:
        fb = tuple(str(row[i] or "").strip() for i in (1, 2))
        urls = _take("fb", fb)
        if urls:
            row[9], row[10], row[11] = urls


def _sp_import_project(source_folder, folder_name, csv_filename, clean_rows,
                       customer_name, project_name, vendor_project_code,
                       project_number, products):
    """Import an externally-built (CSV-only, no metadata) project as a NEW project:
    copy its drawings, write a fresh CSV carrying over the J/K/L URLs, and write
    project_metadata.json — leaving the source folder untouched. The target folder
    name must be unique; never edit/overwrite an external project in place (DVI-387)."""
    src = SHARED_DIR / source_folder
    if not src.is_dir():
        return jsonify({"error": "Source project folder not found"}), 404
    dest = SHARED_DIR / folder_name
    if dest.exists():
        return jsonify({"error": f'A project folder named "{folder_name}" already exists.'}), 409

    source_csvs = sorted(f for f in src.iterdir() if f.is_file() and f.suffix.lower() == ".csv")
    _sp_preserve_url_columns(source_csvs, clean_rows)

    try:
        PROJECTBUILDER_DIR.mkdir(parents=True, exist_ok=True)
    except PermissionError:
        return jsonify({"error": "Server cannot write to temp directory. Contact admin."}), 500

    tmp_folder = PROJECTBUILDER_DIR / folder_name
    shutil.rmtree(str(tmp_folder), ignore_errors=True)
    tmp_folder.mkdir(parents=True, exist_ok=True)

    try:
        for item in src.iterdir():
            if item.is_file():
                if item.suffix.lower() == ".csv" or item.name == "project_metadata.json":
                    continue
                shutil.copy2(str(item), str(tmp_folder / item.name))
            elif item.is_dir():
                shutil.copytree(str(item), str(tmp_folder / item.name))

        with open(tmp_folder / csv_filename, "w", newline="", encoding="utf-8") as fh:
            writer = csv.writer(fh)
            writer.writerow(_SP_CSV_HEADERS)
            writer.writerows(clean_rows)

        metadata = {
            "customer_name": customer_name,
            "project_name": project_name,
            "vendor_project_code": vendor_project_code,
            "project_number": project_number,
            "folder_name": folder_name,
            "created_at": _datetime_type.now().isoformat(),
            "imported_from": source_folder,
            "products": products,
        }
        with open(tmp_folder / "project_metadata.json", "w", encoding="utf-8") as fh:
            json.dump(metadata, fh, indent=2)

        _pb_move_temp_uploads(tmp_folder)

        if dest.exists():
            return jsonify({"error": f'A project folder named "{folder_name}" already exists.'}), 409
        SHARED_DIR.mkdir(parents=True, exist_ok=True)
        shutil.copytree(str(tmp_folder), str(dest))
    except PermissionError as exc:
        return jsonify({"error": f"Permission denied: {exc}"}), 500
    except OSError as exc:
        return jsonify({"error": f"File system error: {exc}"}), 500
    finally:
        shutil.rmtree(str(tmp_folder), ignore_errors=True)

    if _is_history_capture_enabled("project-builder"):
        record_history(
            "project-builder", "imported",
            actor=(session.get("user") or {}).get("name"),
            summary=f"Project imported: {project_name} ({folder_name}) from {source_folder}",
            ref=folder_name,
            detail={"project_number": project_number, "rows": len(clean_rows),
                    "source": source_folder},
        )
    return jsonify({"folder": folder_name, "csv": csv_filename})


def _sp_update_existing_project(editing_folder, folder_name, csv_filename, clean_rows,
                                customer_name, project_name, vendor_project_code,
                                project_number, products):
    """Update an opened project in place: rewrite its CSV (preserving the J/K/L
    drawing URLs already written by the Take-off Bridge), refresh
    project_metadata.json, and leave every other file (drawing PDFs) untouched
    (DVI-323)."""
    src_folder = SHARED_DIR / editing_folder
    if not src_folder.is_dir():
        return jsonify({"error": "Project folder not found"}), 404
    dest = SHARED_DIR / folder_name

    try:
        if editing_folder != folder_name:
            if dest.exists():
                shutil.rmtree(str(dest))
            shutil.move(str(src_folder), str(dest))

        existing_csvs = sorted(f for f in dest.iterdir() if f.is_file() and f.suffix.lower() == ".csv")

        _sp_preserve_url_columns(existing_csvs, clean_rows)

        with open(dest / csv_filename, "w", newline="", encoding="utf-8") as fh:
            writer = csv.writer(fh)
            writer.writerow(_SP_CSV_HEADERS)
            writer.writerows(clean_rows)

        for old_csv in existing_csvs:
            if old_csv.name != csv_filename:
                old_csv.unlink()

        state_path = dest / "state.json"
        if state_path.is_file():
            try:
                state = json.loads(state_path.read_text(encoding="utf-8"))
                if isinstance(state, dict) and state.get("csv_filename") != csv_filename:
                    state["csv_filename"] = csv_filename
                    state_path.write_text(json.dumps(state, indent=2), encoding="utf-8")
            except (json.JSONDecodeError, OSError):
                pass

        meta_path = dest / "project_metadata.json"
        metadata = {}
        if meta_path.is_file():
            try:
                with open(meta_path, "r", encoding="utf-8") as fh:
                    existing_meta = json.load(fh)
                if isinstance(existing_meta, dict):
                    metadata = existing_meta
            except (json.JSONDecodeError, OSError):
                pass
        metadata.update({
            "customer_name": customer_name,
            "project_name": project_name,
            "vendor_project_code": vendor_project_code,
            "project_number": project_number,
            "folder_name": folder_name,
            "products": products,
        })
        metadata.setdefault("created_at", _datetime_type.now().isoformat())
        metadata["updated_at"] = _datetime_type.now().isoformat()
        with open(meta_path, "w", encoding="utf-8") as fh:
            json.dump(metadata, fh, indent=2)

        _pb_move_temp_uploads(dest)
    except PermissionError as exc:
        return jsonify({"error": f"Permission denied: {exc}"}), 500
    except OSError as exc:
        return jsonify({"error": f"File system error: {exc}"}), 500

    if _is_history_capture_enabled("project-builder"):
        record_history(
            "project-builder", "edited",
            actor=(session.get("user") or {}).get("name"),
            summary=f"Project edited: {project_name} ({folder_name})",
            ref=folder_name,
            detail={"project_number": project_number, "rows": len(clean_rows)},
        )
    return jsonify({"folder": folder_name, "csv": csv_filename})


@app.route("/project-builder-list-projects", methods=["GET"])
@takeoff_workflow_required
def project_builder_list_projects():
    folders = []
    if SHARED_DIR.is_dir():
        for entry in sorted(SHARED_DIR.iterdir(), key=lambda e: e.stat().st_mtime, reverse=True):
            if entry.is_dir() and not entry.name.startswith("."):
                folders.append({
                    "name": entry.name,
                    "has_meta": (entry / "project_metadata.json").is_file(),
                })
    return jsonify({"folders": folders})


# Drawing-link columns on the take-off CSV (DVI-314): J=shop (idx 9), K=submittal
# (idx 10). NO new columns — extra metadata lives in project_metadata.json.
_SP_DRAWING_COL = {"shop": 9, "submittal": 10}
_SP_DRAWING_EXTS = (
    ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".tif", ".tiff",
    ".doc", ".docx", ".xls", ".xlsx", ".dwg", ".dxf",
)


def _sp_row_key(row):
    """4-tuple identity for a CSV/product row: tranId, structure, item, part ref
    (cols A,B,C,H / idx 0,1,2,7) — matches _sp_preserve_url_columns."""
    return tuple((row[i].strip() if len(row) > i else "") for i in (0, 1, 2, 7))


def _sp_overlay_drawing_urls(products, csv_rows):
    """Copy shop/submittal URLs from the CSV rows onto the metadata products list
    so the builder's row model surfaces drawing links on load. Matches first on the
    row key (tranId, structure, item, partRef); falls back to positional alignment
    for unmatched products (DVI-658)."""
    def key(d):
        return tuple(
            str(d.get(k, "") or "").strip()
            for k in ("tranId", "structure", "item", "partRef")
        )

    pool = [{
        "key": key(r),
        "shopUrl": r.get("shopUrl", ""),
        "submittalUrl": r.get("submittalUrl", ""),
        "used": False,
    } for r in csv_rows]

    for i, prod in enumerate(products):
        if not isinstance(prod, dict):
            continue
        match = None
        pk = key(prod)
        for entry in pool:
            if not entry["used"] and entry["key"] == pk:
                match = entry
                break
        if match is None and i < len(pool) and not pool[i]["used"]:
            match = pool[i]
        if match is not None:
            match["used"] = True
            prod["shopUrl"] = match["shopUrl"]
            prod["submittalUrl"] = match["submittalUrl"]


def _sp_locate_csv(project_folder):
    """Return (csv_path, None) for a project folder's CSV — state.json csv_filename
    first, else the sole *.csv — or (None, error_message). Mirrors scs-save-to-project."""
    state_path = project_folder / "state.json"
    if state_path.is_file():
        try:
            state = json.loads(state_path.read_text(encoding="utf-8"))
            name = state.get("csv_filename")
            if name and (project_folder / name).is_file():
                return project_folder / name, None
        except (json.JSONDecodeError, OSError):
            pass
    csvs = sorted(f for f in project_folder.iterdir() if f.is_file() and f.suffix.lower() == ".csv")
    if len(csvs) == 1:
        return csvs[0], None
    if len(csvs) > 1:
        return None, "Multiple CSV files found — project records not updated"
    return None, "No CSV found for this project"


def _sp_match_data_row(rows, row_index, key):
    """Index (0-based, among data rows) of the CSV row a drawing belongs to.
    Prefers row_index when its key matches the supplied key, else first key match.
    Returns None when no row matches."""
    data = rows[1:]
    has_key = any(key)
    if isinstance(row_index, int) and 0 <= row_index < len(data):
        if not has_key or _sp_row_key(data[row_index]) == key:
            return row_index
    if has_key:
        for i, r in enumerate(data):
            if _sp_row_key(r) == key:
                return i
    return None


def _sp_update_meta_product(project_folder, data_index, file_type, url):
    """Best-effort: reflect a drawing add/clear on the matching products entry in
    project_metadata.json so metadata stays consistent with the CSV."""
    meta_path = project_folder / "project_metadata.json"
    if not meta_path.is_file():
        return
    try:
        meta = json.loads(meta_path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return
    products = meta.get("products")
    if not isinstance(products, list) or not (0 <= data_index < len(products)):
        return
    prod = products[data_index]
    if not isinstance(prod, dict):
        return
    prod["shopUrl" if file_type == "shop" else "submittalUrl"] = url
    try:
        meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
    except OSError:
        pass


@app.route("/project-builder-upload-drawing", methods=["POST"])
@takeoff_workflow_required
def project_builder_upload_drawing():
    """Attach (or replace) a shop/submittal drawing on a Project Builder product
    row: store the file in the existing project folder and write its public URL
    into CSV col J (shop) or K (submittal). Mirrors Take-off /upload-file scoped to
    a SHARED_DIR project folder (DVI-658)."""
    if "file" not in request.files:
        return jsonify({"error": "No file provided"}), 400
    f = request.files["file"]
    if not f.filename:
        return jsonify({"error": "No file provided"}), 400
    ext = os.path.splitext(f.filename)[1].lower()
    if ext not in _SP_DRAWING_EXTS:
        return jsonify({"error": "Unsupported file type"}), 400

    folder_name = (request.form.get("folder") or "").strip()
    file_type = (request.form.get("file_type") or "").strip()
    if file_type not in _SP_DRAWING_COL:
        return jsonify({"error": "Invalid file_type"}), 400
    if not folder_name or not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify({"error": "Invalid folder"}), 400

    project_folder = (SHARED_DIR / folder_name).resolve()
    if not str(project_folder).startswith(str(SHARED_DIR.resolve()) + os.sep) or not project_folder.is_dir():
        return jsonify({"error": "Project folder not found"}), 404

    csv_path, warn = _sp_locate_csv(project_folder)
    if csv_path is None:
        return jsonify({"error": warn}), 400

    save_name = secure_filename(f.filename)
    if not save_name:
        return jsonify({"error": "Invalid file name"}), 400

    row_index = request.form.get("row_index", type=int)
    key = tuple((request.form.get(k) or "").strip() for k in ("tranId", "structure", "item", "partRef"))

    with open(csv_path, newline="", encoding="utf-8-sig") as fh:
        rows = list(csv.reader(fh))
    matched = _sp_match_data_row(rows, row_index, key)
    if matched is None:
        return jsonify({"error": "Could not match the product row in the CSV"}), 409

    f.save(str(project_folder / save_name))

    col = _SP_DRAWING_COL[file_type]
    _qr_base = _get_qr_base_url().rstrip("/")
    url = f"{_qr_base}/{quote(folder_name, safe='')}/{quote(save_name, safe='')}"

    row = rows[matched + 1]
    while len(row) < 12:
        row.append("")
    row[col] = url
    with open(csv_path, "w", newline="", encoding="utf-8") as fh:
        csv.writer(fh).writerows(rows)

    _sp_update_meta_product(project_folder, matched, file_type, url)
    if _is_history_capture_enabled("project-builder"):
        record_history(
            "project-builder", "drawing-attached",
            actor=(session.get("user") or {}).get("name"),
            summary=f"Drawing attached to {folder_name} ({file_type}): {save_name}",
            ref=folder_name,
            detail={"file_type": file_type, "filename": save_name},
        )
    return jsonify({"ok": True, "url": url, "filename": save_name})


@app.route("/project-builder-detach-drawing", methods=["POST"])
@takeoff_workflow_required
def project_builder_detach_drawing():
    """Clear a shop/submittal drawing link from a Project Builder product row
    (CSV col J/K + metadata). The file itself is left in the folder, mirroring
    Take-off /detach-file (DVI-658)."""
    data = request.get_json(silent=True) or {}
    folder_name = (data.get("folder") or "").strip()
    file_type = (data.get("file_type") or "").strip()
    if file_type not in _SP_DRAWING_COL:
        return jsonify({"error": "Invalid file_type"}), 400
    if not folder_name or not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify({"error": "Invalid folder"}), 400

    project_folder = (SHARED_DIR / folder_name).resolve()
    if not str(project_folder).startswith(str(SHARED_DIR.resolve()) + os.sep) or not project_folder.is_dir():
        return jsonify({"error": "Project folder not found"}), 404

    csv_path, warn = _sp_locate_csv(project_folder)
    if csv_path is None:
        return jsonify({"error": warn}), 400

    row_index = data.get("row_index")
    if not isinstance(row_index, int):
        try:
            row_index = int(row_index)
        except (TypeError, ValueError):
            row_index = None
    key = tuple((str(data.get(k) or "")).strip() for k in ("tranId", "structure", "item", "partRef"))

    with open(csv_path, newline="", encoding="utf-8-sig") as fh:
        rows = list(csv.reader(fh))
    matched = _sp_match_data_row(rows, row_index, key)
    if matched is None:
        return jsonify({"error": "Could not match the product row in the CSV"}), 409

    col = _SP_DRAWING_COL[file_type]
    row = rows[matched + 1]
    if len(row) > col:
        row[col] = ""
    with open(csv_path, "w", newline="", encoding="utf-8") as fh:
        csv.writer(fh).writerows(rows)

    _sp_update_meta_product(project_folder, matched, file_type, "")
    return jsonify({"ok": True})


# ===== Project Builder file upload + OCR discovery (DVI-661) =================
# Files OCR'd for project-info discovery; everything else is stored/moved but
# never read (per approved plan: store any type, OCR only PDF + images).
_PB_OCR_EXTS = (".pdf", ".png", ".jpg", ".jpeg")

# iCast transaction id: ##-####[letter] where the leading 2 digits are a recent
# year (24-27) and the optional trailing letter is a b-version (DVI-661). The
# dash is optional in source text; we always normalize back to ##-####[letter].
_PB_TRANID_RE = re.compile(r"\b(2[4-7])-?(\d{4})([A-Za-z]?)\b")


def _parse_tran_id(text: str) -> str:
    """Pull the first iCast tranId out of arbitrary text and normalize it to
    ##-####[letter]. Returns "" when no match. The trailing letter's case is
    preserved (b-versions are conventionally lowercase)."""
    m = _PB_TRANID_RE.search(text or "")
    if not m:
        return ""
    return f"{m.group(1)}-{m.group(2)}{m.group(3) or ''}"


def _pb_image_to_pdf(img_path: Path, dest_pdf: str) -> None:
    """Convert a raster image to a one-page PDF so the title-block OCR path
    (which expects a PDF) can read it. Raises on failure — callers guard."""
    import fitz  # noqa: PLC0415 — PyMuPDF, already a dependency
    doc = fitz.open(str(img_path))
    try:
        pdf_bytes = doc.convert_to_pdf()
    finally:
        doc.close()
    Path(dest_pdf).write_bytes(pdf_bytes)


def _pb_discover_fields(path: Path) -> dict:
    """OCR one PDF/image for Project Builder prefill. Returns any subset of
    {customer_name, project_name, tranId}. Images are converted to a one-page
    PDF first. Best-effort: never raises, returns {} for non-OCR types."""
    ext = path.suffix.lower()
    if ext not in _PB_OCR_EXTS:
        return {}
    pdf_path = path
    tmp_pdf = None
    try:
        if ext != ".pdf":
            fd, tmp_pdf = tempfile.mkstemp(suffix=".pdf")
            os.close(fd)
            try:
                _pb_image_to_pdf(path, tmp_pdf)
                pdf_path = Path(tmp_pdf)
            except Exception:  # noqa: BLE001 — unreadable image → nothing to OCR
                return {}
        out = {}
        labeled = _suggest_fields_from_pdf(
            pdf_path, ["customer_name", "project_name", "job_number"]
        )
        if labeled.get("customer_name"):
            out["customer_name"] = labeled["customer_name"]
        if labeled.get("project_name"):
            out["project_name"] = labeled["project_name"]
        try:
            from ocr_engine import extract_title_block_text
            raw = extract_title_block_text(pdf_path)
        except Exception:  # noqa: BLE001
            raw = ""
        tran = (_parse_tran_id(raw)
                or _parse_tran_id(labeled.get("job_number", ""))
                or _parse_tran_id(path.name))
        if tran:
            out["tranId"] = tran
        return out
    finally:
        if tmp_pdf:
            try:
                os.unlink(tmp_pdf)
            except OSError:
                pass


def _pb_aggregate_discovery(paths) -> dict:
    """OCR each path in order; first non-empty value per field wins. Stops once
    all three target fields are filled to keep large folders responsive."""
    suggested = {}
    for p in paths:
        for k, v in _pb_discover_fields(p).items():
            if v and not suggested.get(k):
                suggested[k] = v
        if all(suggested.get(k) for k in ("customer_name", "project_name", "tranId")):
            break
    return suggested


def _pb_session_id() -> str:
    """Stable per-browser-session id for scoping temp uploads. Stored in the
    Flask session so a user's staged files never collide with another user's."""
    sid = session.get("pb_upload_sid")
    if not sid or not re.match(r"^[a-f0-9]{32}$", str(sid)):
        sid = uuid.uuid4().hex
        session["pb_upload_sid"] = sid
    return sid


def _pb_temp_dir(create: bool = False):
    """Resolve PROJECTBUILDER_DIR/_uploads/<session_id>/, rejecting traversal.
    Returns the Path (creating it when asked) or None on a bad session id."""
    base = (PROJECTBUILDER_DIR / "_uploads").resolve()
    d = (base / _pb_session_id()).resolve()
    if not str(d).startswith(str(base) + os.sep):
        return None
    if create:
        d.mkdir(parents=True, exist_ok=True)
    return d


def _pb_temp_listing() -> list:
    """List this session's staged uploads: [{name, ocr}] where ocr flags a
    PDF/image that OCR Discovery can read."""
    d = _pb_temp_dir(create=False)
    out = []
    if d and d.is_dir():
        for item in sorted(d.iterdir()):
            if item.is_file():
                out.append({"name": item.name,
                            "ocr": item.suffix.lower() in _PB_OCR_EXTS})
    return out


def _pb_move_temp_uploads(dest_folder: Path) -> None:
    """Move this session's staged uploads into dest_folder (on Submit), then
    clear the session temp dir. Files whose name already exists in dest are
    skipped (e.g. the CSV/metadata written by the submit handler)."""
    src = _pb_temp_dir(create=False)
    if not src or not src.is_dir():
        return
    try:
        for item in src.iterdir():
            if not item.is_file():
                continue
            target = dest_folder / item.name
            if target.exists():
                continue
            shutil.move(str(item), str(target))
    except OSError as exc:
        app.logger.warning("PB temp upload move failed for %s: %s", dest_folder, exc)
    shutil.rmtree(str(src), ignore_errors=True)
    session.pop("pb_upload_sid", None)


@app.route("/project-builder/upload-temp", methods=["POST"])
@takeoff_workflow_required
def project_builder_upload_temp():
    """Stage one or more uploaded files in a session-scoped temp dir. They are
    not in the final folder yet — Submit moves them into SHARED_DIR/<folder>/.
    Any file type is accepted (DVI-661)."""
    files = request.files.getlist("files") or request.files.getlist("file")
    if not files:
        return jsonify(error="No files provided"), 400
    d = _pb_temp_dir(create=True)
    if d is None:
        return jsonify(error="Invalid session"), 400
    for f in files:
        if not f or not f.filename:
            continue
        name = secure_filename(f.filename)
        if not name:
            continue
        target = (d / name).resolve()
        if not str(target).startswith(str(d) + os.sep):
            continue
        f.save(str(target))
    return jsonify(ok=True, files=_pb_temp_listing())


@app.route("/project-builder/remove-temp", methods=["POST"])
@takeoff_workflow_required
def project_builder_remove_temp():
    """Remove one staged upload from the session temp dir (DVI-661)."""
    data = request.get_json(silent=True) or {}
    name = secure_filename((data.get("filename") or "").strip())
    if not name:
        return jsonify(error="Invalid filename"), 400
    d = _pb_temp_dir(create=False)
    if d and d.is_dir():
        target = (d / name).resolve()
        if str(target).startswith(str(d) + os.sep) and target.is_file():
            try:
                target.unlink()
            except OSError:
                pass
    return jsonify(ok=True, files=_pb_temp_listing())


@app.route("/project-builder/temp-files", methods=["GET"])
@takeoff_workflow_required
def project_builder_temp_files():
    """List the current session's staged uploads (DVI-661)."""
    return jsonify(files=_pb_temp_listing())


@app.route("/project-builder/discover-temp", methods=["POST"])
@takeoff_workflow_required
def project_builder_discover_temp():
    """OCR the session's staged PDF/image uploads and return suggested,
    user-editable Project Information values (DVI-661 A.1)."""
    d = _pb_temp_dir(create=False)
    paths = []
    if d and d.is_dir():
        paths = [p for p in sorted(d.iterdir())
                 if p.is_file() and p.suffix.lower() in _PB_OCR_EXTS]
    return jsonify(fields=_pb_aggregate_discovery(paths))


@app.route("/project-builder/discover-folder", methods=["POST"])
@takeoff_workflow_required
def project_builder_discover_folder():
    """OCR the documents in an opened/imported project folder to discover
    Project Information — primarily the Customer/Contractor name (DVI-661 B)."""
    data = request.get_json(silent=True) or {}
    folder_name = (data.get("folder_name") or "").strip()
    if not folder_name or not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify(error="Invalid folder"), 400
    project_folder = (SHARED_DIR / folder_name).resolve()
    if not str(project_folder).startswith(str(SHARED_DIR.resolve()) + os.sep) \
            or not project_folder.is_dir():
        return jsonify(error="Project folder not found"), 404
    paths = [p for p in sorted(project_folder.iterdir())
             if p.is_file() and p.suffix.lower() in _PB_OCR_EXTS]
    return jsonify(fields=_pb_aggregate_discovery(paths))


# Assistant document correlation (DVI-662, Feature D1). Caps keep the seeded
# prompt's token cost bounded regardless of how many files are in scope.
_PB_CORR_MAX_DOCS = 12
_PB_CORR_SNIPPET_CHARS = 600


def _pb_doc_snippet(path: Path) -> str:
    """Short, whitespace-collapsed title-block text snippet for one document,
    capped at _PB_CORR_SNIPPET_CHARS. Only OCR-able types (PDF/image) yield a
    snippet; everything else returns "". Best-effort: never raises."""
    ext = path.suffix.lower()
    if ext not in _PB_OCR_EXTS:
        return ""
    pdf_path = path
    tmp_pdf = None
    try:
        if ext != ".pdf":
            fd, tmp_pdf = tempfile.mkstemp(suffix=".pdf")
            os.close(fd)
            try:
                _pb_image_to_pdf(path, tmp_pdf)
                pdf_path = Path(tmp_pdf)
            except Exception:  # noqa: BLE001 — unreadable image → no snippet
                return ""
        try:
            from ocr_engine import extract_title_block_text
            raw = extract_title_block_text(pdf_path) or ""
        except Exception:  # noqa: BLE001
            raw = ""
        raw = re.sub(r"\s+", " ", raw).strip()
        return raw[:_PB_CORR_SNIPPET_CHARS]
    finally:
        if tmp_pdf:
            try:
                os.unlink(tmp_pdf)
            except OSError:
                pass


def _pb_correlation_paths(folder_name: str) -> list:
    """Collect the documents currently in scope for correlation: this session's
    temp uploads plus the opened/imported project folder's files. OCR-able docs
    are listed first so they win the _PB_CORR_MAX_DOCS budget for snippets."""
    paths = []
    seen = set()

    def _add(p: Path):
        key = p.name.lower()
        if key not in seen and p.is_file():
            seen.add(key)
            paths.append(p)

    temp_dir = _pb_temp_dir(create=False)
    folder_paths = []
    if folder_name and _SAFE_JOB_RE.match(folder_name) and ".." not in folder_name:
        project_folder = (SHARED_DIR / folder_name).resolve()
        if str(project_folder).startswith(str(SHARED_DIR.resolve()) + os.sep) \
                and project_folder.is_dir():
            folder_paths = [p for p in sorted(project_folder.iterdir()) if p.is_file()]
    temp_paths = []
    if temp_dir and temp_dir.is_dir():
        temp_paths = [p for p in sorted(temp_dir.iterdir()) if p.is_file()]

    candidates = temp_paths + folder_paths
    # OCR-able types first so they keep their snippets within the doc cap.
    for p in sorted(candidates, key=lambda p: p.suffix.lower() not in _PB_OCR_EXTS):
        _add(p)
    return paths


def _pb_build_correlation_prompt(paths: list) -> str:
    """Build the Assistant seed prompt: a correlation instruction followed by a
    markdown list of each in-scope document with a short OCR snippet."""
    lines = [
        "I'm setting up a project in our Project Builder and need help "
        "understanding how the following documents relate to each other.",
        "",
        "For each document, help me determine:",
        "- which is the **submittal** package vs **shop drawings** vs other reference docs,",
        "- which **product line** each drawing maps to (e.g. by transaction/part number),",
        "- the overall **project identity** (customer/contractor, project name, transaction number).",
        "",
        "## Documents in scope",
    ]
    for i, p in enumerate(paths, 1):
        lines.append("")
        lines.append(f"### {i}. {p.name}")
        snippet = _pb_doc_snippet(p)
        if snippet:
            lines.append("```")
            lines.append(snippet)
            lines.append("```")
        else:
            lines.append("_(no readable text extracted — non-OCR file type or empty)_")
    return "\n".join(lines)


@app.route("/project-builder/correlate-docs", methods=["POST"])
@takeoff_workflow_required
def project_builder_correlate_docs():
    """Build a correlation seed prompt from the documents currently in scope —
    this session's temp uploads plus any opened/imported project folder — for
    the Assistant chat surface (DVI-662, Feature D1). Returns the prompt text and
    a doc count; the frontend pre-seeds the existing Assistant with it. No LLM
    call or auto-apply happens here."""
    data = request.get_json(silent=True) or {}
    folder_name = (data.get("folder_name") or "").strip()
    paths = _pb_correlation_paths(folder_name)[:_PB_CORR_MAX_DOCS]
    if not paths:
        return jsonify(error="No documents in scope to correlate. Upload files or open a project first."), 400
    return jsonify(prompt=_pb_build_correlation_prompt(paths), doc_count=len(paths))


@app.route("/project-builder-load-project", methods=["POST"])
@takeoff_workflow_required
def project_builder_load_project():
    data = request.get_json(silent=True) or {}
    folder_name = data.get("folder_name", "").strip()
    if not folder_name or not _SAFE_JOB_RE.match(folder_name) or ".." in folder_name:
        return jsonify({"error": "Invalid folder name"}), 400

    folder_path = SHARED_DIR / folder_name
    if not folder_path.is_dir():
        return jsonify({"error": "Folder not found"}), 404

    result = {"folder_name": folder_name, "meta": None, "csv_rows": []}

    meta_path = folder_path / "project_metadata.json"
    if meta_path.is_file():
        try:
            with open(meta_path, "r", encoding="utf-8") as fh:
                result["meta"] = json.load(fh)
        except (json.JSONDecodeError, OSError):
            pass

    csv_path = None
    for f in folder_path.iterdir():
        if f.suffix.lower() == ".csv" and f.is_file():
            csv_path = f
            break

    if csv_path:
        try:
            with open(csv_path, "r", encoding="utf-8") as fh:
                reader = csv.DictReader(fh)
                for row in reader:
                    mapped = {}
                    for field in _SP_CSV_HEADERS:
                        for sp_field in [
                            {"csv": "tranId", "id": "tranId"},
                            {"csv": "itemLine_custom:Structure", "id": "structure"},
                            {"csv": "itemLine_item", "id": "item"},
                            {"csv": "itemLine_quantity", "id": "quantity"},
                            {"csv": "itemLine_salesPrice", "id": "salesPrice"},
                            {"csv": "itemLine_weight", "id": "weight"},
                            {"csv": "Item_Size", "id": "itemSize"},
                            {"csv": "Part Reference", "id": "partRef"},
                            {"csv": "itemLine_description", "id": "description"},
                        ]:
                            if sp_field["csv"] == field and field in row:
                                mapped[sp_field["id"]] = row[field]
                    # Surface the J/K drawing links (preserved in the CSV but never
                    # mapped onto the row model) so the builder can show the drawing
                    # indicator and manage attachments (DVI-658).
                    mapped["shopUrl"] = (row.get(_SP_CSV_HEADERS[9]) or "").strip()
                    mapped["submittalUrl"] = (row.get(_SP_CSV_HEADERS[10]) or "").strip()
                    if any(v for v in mapped.values()):
                        result["csv_rows"].append(mapped)
        except (OSError, csv.Error):
            pass

    # The opened project prefers meta.products as its row model; overlay the
    # authoritative CSV J/K links onto it so drawing state survives a reload.
    if isinstance(result["meta"], dict) and isinstance(result["meta"].get("products"), list):
        _sp_overlay_drawing_urls(result["meta"]["products"], result["csv_rows"])

    # When project_metadata.json is absent, meta stays None and the client prefills
    # from the shared parseFolderNameForProject() JS parser — keep folder-name
    # parsing in that single implementation.
    return jsonify(result)


# Top-bar global Search (DVI-534). Backend half: matches take-off project folders
# by name. Tool/navigation matches are computed client-side from the TOOLS array
# (visibility/membership already live there). Project results are gated to
# take-off / admin users; everyone else simply gets an empty project list and
# still searches tools client-side.
@app.route("/search", methods=["GET"])
@login_required
def global_search():
    q = (request.args.get("q") or "").strip()
    projects = []
    if q:
        u = session.get("user", {})
        if (u.get("takeoff_workflow") or u.get("togen_admin")) and SHARED_DIR.is_dir():
            needle = q.lower()
            for entry in sorted(
                SHARED_DIR.iterdir(), key=lambda e: e.stat().st_mtime, reverse=True
            ):
                if not entry.is_dir() or entry.name.startswith("."):
                    continue
                if needle in entry.name.lower():
                    projects.append({
                        "name": entry.name,
                        "has_meta": (entry / "project_metadata.json").is_file(),
                    })
                if len(projects) >= 20:
                    break
    return jsonify({"query": q, "projects": projects})


_TUTORIALS_DIR = Path("/var/www/html/togen/tutorials")


@app.route("/togen/tutorials/manifest.json")
@login_required
def tutorials_manifest():
    """Return the list of available tutorial videos from the tutorials directory."""
    manifest_path = _TUTORIALS_DIR / "manifest.json"
    if manifest_path.is_file():
        try:
            data = json.loads(manifest_path.read_text())
            return jsonify(data)
        except Exception:
            return jsonify([])
    tutorials = []
    if _TUTORIALS_DIR.is_dir():
        for f in sorted(_TUTORIALS_DIR.iterdir()):
            if f.suffix.lower() in (".mp4", ".webm", ".mov"):
                tutorials.append({
                    "title": f.stem.replace("-", " ").replace("_", " ").title(),
                    "url": f"/togen/tutorials/{quote(f.name)}",
                    "thumbnail": "",
                })
    return jsonify(tutorials)


# ---------------------------------------------------------------------------
# File Browser routes
# ---------------------------------------------------------------------------

_FB_ROOT = SHARED_DIR  # /var/www/html/shared/


def _fb_safe_path(rel: str) -> Path | None:
    """Resolve a relative path under _FB_ROOT; return None if it escapes."""
    cleaned = rel.strip().strip("/")
    if ".." in cleaned.split("/") or cleaned.startswith("/"):
        return None
    resolved = (_FB_ROOT / cleaned).resolve()
    if not str(resolved).startswith(str(_FB_ROOT.resolve())):
        return None
    return resolved


@app.route("/fb/browse")
@takeoff_workflow_required
def fb_browse():
    """List files and folders in the given directory under SHARED_DIR."""
    rel = request.args.get("path", "")
    target = _fb_safe_path(rel) if rel else _FB_ROOT
    if target is None or not target.is_dir():
        return jsonify({"error": "Invalid directory"}), 400

    entries = []
    try:
        for item in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
            if item.name.startswith("."):
                continue
            stat = item.stat()
            entries.append({
                "name": item.name,
                "is_dir": item.is_dir(),
                "size": stat.st_size if item.is_file() else 0,
                "modified": _datetime_type.fromtimestamp(stat.st_mtime).isoformat(),
            })
    except PermissionError:
        return jsonify({"error": "Permission denied"}), 403

    rel_display = str(target.relative_to(_FB_ROOT))
    if rel_display == ".":
        rel_display = ""
    return jsonify({"path": rel_display, "entries": entries})


# ---------------------------------------------------------------------------
# File Browser folder watchers (DVI-471)
#
# A watcher is a per-user automation that emails the user (via SSO address)
# whenever anything under a chosen folder in the shared Projects tree changes.
# Watcher definitions live in the user's settings JSON under ``fb_watchers``;
# the background loop snapshots each folder every 15 minutes and emails on diff.
# ---------------------------------------------------------------------------

def _fb_watchers_from_settings(settings: dict) -> list:
    """Return the list of watcher dicts stored in a user's settings."""
    watchers = settings.get("fb_watchers")
    return watchers if isinstance(watchers, list) else []


def _fb_parse_expiry(value):
    """Parse a watcher termination date (``YYYY-MM-DD``) into a ``date``.

    Returns ``None`` for empty/invalid input. The watcher stays active through
    the termination date itself and expires once the day has fully passed.
    """
    if not value:
        return None
    try:
        return _datetime_type.fromisoformat(str(value).strip()[:10]).date()
    except (ValueError, TypeError):
        return None


def _fb_watcher_expired(watcher: dict) -> bool:
    """True when the watcher carries a termination date that has passed."""
    exp = _fb_parse_expiry(watcher.get("expires_at"))
    return exp is not None and _datetime_type.now().date() > exp


# Public site URL used for 1-click email action links (matches News emails).
_FB_APP_URL = "https://togen.icastinc.com"


def _fb_watcher_remove_link(wid: str) -> str:
    """1-click link that opens Togen and prompts to remove a specific watcher."""
    return f"{_FB_APP_URL}/?fb_remove_watcher={quote(str(wid))}"


def _fb_current_email() -> str:
    return (session.get("user") or {}).get("email", "")


def _fb_snapshot_folder(folder: Path) -> dict:
    """Build a {relative_path: 'mtime:size'} map of every file under folder.

    Dotfiles are skipped (mirrors the browse listing). Used to detect adds,
    removals, and modifications between watcher cycles.
    """
    snap = {}
    if not folder.is_dir():
        return snap
    try:
        for fpath in folder.rglob("*"):
            if not fpath.is_file() or fpath.name.startswith("."):
                continue
            try:
                st = fpath.stat()
            except OSError:
                continue
            snap[str(fpath.relative_to(folder))] = f"{int(st.st_mtime)}:{st.st_size}"
    except OSError:
        pass
    return snap


def _fb_load_watcher_state() -> dict:
    if not FB_WATCHER_STATE_FILE.is_file():
        return {}
    try:
        data = json.loads(FB_WATCHER_STATE_FILE.read_text())
        return data if isinstance(data, dict) else {}
    except (json.JSONDecodeError, OSError):
        return {}


def _fb_save_watcher_state(state: dict) -> None:
    try:
        FB_WATCHER_STATE_FILE.write_text(json.dumps(state, indent=2))
    except OSError:
        app.logger.exception("Failed to persist fb_watcher_state.json")


@app.route("/fb/watchers")
@takeoff_workflow_required
def fb_watchers_list():
    """Return the current user's folder watchers."""
    email = _fb_current_email()
    return jsonify(watchers=_fb_watchers_from_settings(_load_user_settings(email)))


@app.route("/fb/watchers", methods=["POST"])
@takeoff_workflow_required
def fb_watchers_add():
    """Create a folder watcher for the current user on a folder in the tree."""
    email = _fb_current_email()
    body = request.get_json(silent=True) or {}
    rel = (body.get("path") or "").strip().strip("/")
    if not rel:
        return jsonify(error="A folder path is required."), 400
    target = _fb_safe_path(rel)
    if target is None or not target.is_dir():
        return jsonify(error="Folder not found."), 400

    expires_raw = (body.get("expires_at") or "").strip()
    if expires_raw and _fb_parse_expiry(expires_raw) is None:
        return jsonify(error="Invalid expiration date."), 400

    settings = _load_user_settings(email)
    watchers = list(_fb_watchers_from_settings(settings))
    if any(w.get("path") == rel for w in watchers):
        return jsonify(error="That folder is already being watched."), 409

    wid = uuid.uuid4().hex
    watchers.append({
        "id": wid,
        "path": rel,
        "label": Path(rel).name or rel,
        "enabled": True,
        "created_at": _datetime_type.now().isoformat(),
        "expires_at": expires_raw or None,
    })
    settings["fb_watchers"] = watchers
    try:
        _save_user_settings(email, settings)
    except OSError as exc:
        return jsonify(error=f"Failed to save watcher: {exc}"), 500

    # Seed the snapshot so the first cycle does not fire on pre-existing files.
    state = _fb_load_watcher_state()
    state[wid] = {
        "owner": email,
        "path": rel,
        "snapshot": _fb_snapshot_folder(target),
        "last_checked": _datetime_type.now().isoformat(),
    }
    _fb_save_watcher_state(state)
    return jsonify(ok=True, watchers=watchers)


@app.route("/fb/watchers/<wid>/toggle", methods=["POST"])
@takeoff_workflow_required
def fb_watchers_toggle(wid):
    """Enable/disable email notifications for a watcher (keeps the watcher)."""
    email = _fb_current_email()
    body = request.get_json(silent=True) or {}
    enabled = bool(body.get("enabled"))
    settings = _load_user_settings(email)
    watchers = list(_fb_watchers_from_settings(settings))
    found = False
    for w in watchers:
        if w.get("id") == wid:
            w["enabled"] = enabled
            found = True
            break
    if not found:
        return jsonify(error="Watcher not found."), 404
    settings["fb_watchers"] = watchers
    try:
        _save_user_settings(email, settings)
    except OSError as exc:
        return jsonify(error=f"Failed to save watcher: {exc}"), 500
    return jsonify(ok=True, watchers=watchers)


@app.route("/fb/watchers/<wid>", methods=["PUT"])
@takeoff_workflow_required
def fb_watchers_update(wid):
    """Edit a watcher's termination date (set, change, or clear it)."""
    email = _fb_current_email()
    body = request.get_json(silent=True) or {}
    expires_raw = (body.get("expires_at") or "").strip()
    if expires_raw and _fb_parse_expiry(expires_raw) is None:
        return jsonify(error="Invalid expiration date."), 400
    settings = _load_user_settings(email)
    watchers = list(_fb_watchers_from_settings(settings))
    found = False
    for w in watchers:
        if w.get("id") == wid:
            w["expires_at"] = expires_raw or None
            found = True
            break
    if not found:
        return jsonify(error="Watcher not found."), 404
    settings["fb_watchers"] = watchers
    try:
        _save_user_settings(email, settings)
    except OSError as exc:
        return jsonify(error=f"Failed to save watcher: {exc}"), 500
    return jsonify(ok=True, watchers=watchers)


@app.route("/fb/watchers/<wid>", methods=["DELETE"])
@takeoff_workflow_required
def fb_watchers_delete(wid):
    """Delete a watcher automation and its stored snapshot."""
    email = _fb_current_email()
    settings = _load_user_settings(email)
    watchers = [w for w in _fb_watchers_from_settings(settings) if w.get("id") != wid]
    settings["fb_watchers"] = watchers
    try:
        _save_user_settings(email, settings)
    except OSError as exc:
        return jsonify(error=f"Failed to delete watcher: {exc}"), 500
    state = _fb_load_watcher_state()
    if wid in state:
        state.pop(wid, None)
        _fb_save_watcher_state(state)
    return jsonify(ok=True, watchers=watchers)


@app.route("/fb/upload", methods=["POST"])
@takeoff_workflow_required
def fb_upload():
    """Upload one or more files to the given directory. Extract ZIP files automatically."""
    rel = request.form.get("path", "")
    target = _fb_safe_path(rel) if rel else _FB_ROOT
    if target is None or not target.is_dir():
        return jsonify({"error": "Invalid directory"}), 400

    files = request.files.getlist("files")
    if not files:
        return jsonify({"error": "No files provided"}), 400

    uploaded_names = []
    for f in files:
        if not f.filename:
            continue
        safe_name = secure_filename(f.filename)
        if not safe_name:
            continue

        dest = target / safe_name
        if safe_name.lower().endswith(".zip"):
            tmp_path = target / f"_tmp_{uuid.uuid4().hex}_{safe_name}"
            try:
                f.save(str(tmp_path))
                with zipfile.ZipFile(str(tmp_path)) as zf:
                    for member in zf.namelist():
                        member_dest = (target / member).resolve()
                        if not str(member_dest).startswith(str(target.resolve())):
                            continue
                    zf.extractall(str(target))
                uploaded_names.append(f"{safe_name} (extracted)")
            except zipfile.BadZipFile:
                f.seek(0)
                f.save(str(dest))
                uploaded_names.append(safe_name)
            finally:
                if tmp_path.exists():
                    tmp_path.unlink()
        else:
            f.save(str(dest))
            uploaded_names.append(safe_name)

    if uploaded_names and _is_history_capture_enabled("file-browser"):
        record_history(
            "file-browser", "upload",
            actor=(session.get("user") or {}).get("name"),
            summary=f"Uploaded {len(uploaded_names)} file(s) to {rel or '/'}",
            ref=rel or "/",
            detail={"count": len(uploaded_names), "dir": rel or "/"},
        )
    return jsonify({"uploaded": uploaded_names})


@app.route("/fb/download")
@takeoff_workflow_required
def fb_download():
    """Download a file or folder. Folders are zipped on the fly."""
    rel = request.args.get("path", "")
    if not rel:
        return jsonify({"error": "No path specified"}), 400

    target = _fb_safe_path(rel)
    if target is None or not target.exists():
        return jsonify({"error": "File not found"}), 404

    if target.is_file():
        if _is_history_capture_enabled("file-browser"):
            record_history(
                "file-browser", "download",
                actor=(session.get("user") or {}).get("name"),
                summary=f"Downloaded {rel}",
                ref=rel,
                detail={"type": "file"},
            )
        return send_file(str(target), as_attachment=True, download_name=target.name)

    if target.is_dir():
        if target.resolve() == _FB_ROOT.resolve():
            return jsonify({"error": "Cannot download root directory"}), 400
        buf = io.BytesIO()
        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
            for fpath in target.rglob("*"):
                if fpath.is_file() and not fpath.name.startswith("."):
                    zf.write(str(fpath), str(fpath.relative_to(target)))
        buf.seek(0)
        if _is_history_capture_enabled("file-browser"):
            record_history(
                "file-browser", "download",
                actor=(session.get("user") or {}).get("name"),
                summary=f"Downloaded folder {rel} (zip)",
                ref=rel,
                detail={"type": "folder"},
            )
        return send_file(buf, as_attachment=True, download_name=f"{target.name}.zip",
                         mimetype="application/zip")

    return jsonify({"error": "Unknown target type"}), 400


@app.route("/fb/delete", methods=["POST"])
@takeoff_workflow_required
def fb_delete():
    """Delete a file or folder."""
    data = request.get_json(silent=True) or {}
    rel = data.get("path", "")
    if not rel:
        return jsonify({"error": "No path specified"}), 400

    target = _fb_safe_path(rel)
    if target is None or not target.exists():
        return jsonify({"error": "File not found"}), 404

    if target.resolve() == _FB_ROOT.resolve():
        return jsonify({"error": "Cannot delete root directory"}), 400

    try:
        if target.is_dir():
            shutil.rmtree(str(target))
        else:
            target.unlink()
    except PermissionError:
        return jsonify({"error": "Permission denied"}), 403

    if _is_history_capture_enabled("file-browser"):
        record_history(
            "file-browser", "delete",
            actor=(session.get("user") or {}).get("name"),
            summary=f"Deleted {rel}",
            ref=rel,
        )
    return jsonify({"deleted": rel})


@app.route("/fb/rename", methods=["POST"])
@takeoff_workflow_required
def fb_rename():
    """Rename a file or folder."""
    data = request.get_json(silent=True) or {}
    rel = data.get("path", "")
    new_name = data.get("new_name", "").strip()
    if not rel or not new_name:
        return jsonify({"error": "Missing path or new_name"}), 400

    if "/" in new_name or "\\" in new_name or ".." in new_name:
        return jsonify({"error": "Invalid new name"}), 400

    target = _fb_safe_path(rel)
    if target is None or not target.exists():
        return jsonify({"error": "File not found"}), 404

    if target.resolve() == _FB_ROOT.resolve():
        return jsonify({"error": "Cannot rename root directory"}), 400

    new_path = target.parent / secure_filename(new_name)
    if new_path.exists():
        return jsonify({"error": "A file or folder with that name already exists"}), 409

    try:
        target.rename(new_path)
    except PermissionError:
        return jsonify({"error": "Permission denied"}), 403

    if _is_history_capture_enabled("file-browser"):
        record_history(
            "file-browser", "rename",
            actor=(session.get("user") or {}).get("name"),
            summary=f"Renamed {target.name} → {new_path.name}",
            ref=rel,
        )
    return jsonify({"renamed": new_path.name})


@app.route("/fb/preview")
@takeoff_workflow_required
def fb_preview():
    """Serve a file inline for preview."""
    rel = request.args.get("path", "")
    if not rel:
        return "Missing path", 400

    target = _fb_safe_path(rel)
    if target is None or not target.is_file():
        return "File not found", 404

    return send_file(str(target), download_name=target.name)


# ---------------------------------------------------------------------------
# Routes — News (DVI-267)
# ---------------------------------------------------------------------------


def _promote_scheduled_posts():
    """Publish any scheduled posts whose publish_at has arrived.

    Scheduled posts carry status "scheduled" plus a UTC publish_at. On the first
    read at/after that time they flip to "published" and the subscriber email
    goes out — a daemon-free lazy publish driven by page loads. Returns the
    (possibly mutated) posts list, newest first.
    """
    posts = _load_news_posts()
    now_str = _datetime_type.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
    due = [p for p in posts
           if p.get("status") == "scheduled" and p.get("publish_at", "") <= now_str]
    if not due:
        return posts

    for post in due:
        post["status"] = "published"
    _save_news_posts(posts)

    subscribers = _load_subscribers()
    if subscribers and AZURE_MAIL_FROM:
        for post in due:
            _send_news_email(post, subscribers)
    return posts


@app.route("/news/posts")
@login_required
def news_list():
    """Return news posts, newest first. Scheduled posts are admin-only."""
    posts = _promote_scheduled_posts()
    if not session.get("user", {}).get("togen_admin"):
        posts = [p for p in posts if p.get("status") != "scheduled"]
    return jsonify(posts=posts)


@app.route("/news/posts", methods=["POST"])
@togen_admin_required
def news_create():
    """Create a news post, optionally scheduled for future publish. Admin only."""
    body = request.get_json(silent=True) or {}
    title = body.get("title", "").strip()
    content = body.get("body", "").strip()
    publish_at_raw = (body.get("publish_at") or "").strip()

    if not title or not content:
        return jsonify(error="Title and body are required."), 400

    now_str = _datetime_type.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")

    publish_at = ""
    if publish_at_raw:
        try:
            dt = _datetime_type.fromisoformat(publish_at_raw.replace("Z", "+00:00"))
        except ValueError:
            return jsonify(error="Invalid publish date/time."), 400
        if dt.tzinfo is not None:
            dt = dt.astimezone(_dt_timezone.utc).replace(tzinfo=None)
        publish_at = dt.strftime("%Y-%m-%dT%H:%M:%SZ")

    scheduled = bool(publish_at) and publish_at > now_str

    user = session.get("user", {})
    post = {
        "id": str(uuid.uuid4()),
        "title": title,
        "body": content,
        "author": user.get("name", "Unknown"),
        "author_email": user.get("email", ""),
        "date": publish_at if scheduled else now_str,
        "status": "scheduled" if scheduled else "published",
    }
    if scheduled:
        post["publish_at"] = publish_at

    posts = _load_news_posts()
    posts.insert(0, post)
    _save_news_posts(posts)

    if not scheduled:
        subscribers = _load_subscribers()
        if subscribers and AZURE_MAIL_FROM:
            _send_news_email(post, subscribers)

    return jsonify(ok=True, post=post)


@app.route("/news/posts/<post_id>", methods=["PUT"])
@togen_admin_required
def news_update(post_id):
    """Edit a scheduled (not-yet-published) news post. Original poster only.

    Allows changing title, body, and the scheduled publish time before the post
    goes live. Published posts are immutable here. Only the user who authored the
    post may edit it.
    """
    body = request.get_json(silent=True) or {}
    title = body.get("title", "").strip()
    content = body.get("body", "").strip()
    publish_at_raw = (body.get("publish_at") or "").strip()

    if not title or not content:
        return jsonify(error="Title and body are required."), 400

    posts = _load_news_posts()
    post = next((p for p in posts if p.get("id") == post_id), None)
    if post is None:
        return jsonify(error="Post not found."), 404

    if post.get("status") != "scheduled":
        return jsonify(error="Only scheduled posts can be edited."), 400

    user = session.get("user", {})
    user_email = (user.get("email") or "").lower()
    if (post.get("author_email") or "").lower() != user_email:
        return jsonify(error="Only the original poster can edit this post."), 403

    now_str = _datetime_type.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")

    if not publish_at_raw:
        return jsonify(error="A scheduled publish date/time is required."), 400
    try:
        dt = _datetime_type.fromisoformat(publish_at_raw.replace("Z", "+00:00"))
    except ValueError:
        return jsonify(error="Invalid publish date/time."), 400
    if dt.tzinfo is not None:
        dt = dt.astimezone(_dt_timezone.utc).replace(tzinfo=None)
    publish_at = dt.strftime("%Y-%m-%dT%H:%M:%SZ")

    if publish_at <= now_str:
        return jsonify(error="Pick a publish time in the future."), 400

    post["title"] = title
    post["body"] = content
    post["publish_at"] = publish_at
    post["date"] = publish_at
    _save_news_posts(posts)

    return jsonify(ok=True, post=post)


@app.route("/news/posts/<post_id>", methods=["DELETE"])
@togen_admin_required
def news_delete(post_id):
    """Delete a news post. Admin only."""
    posts = _load_news_posts()
    posts = [p for p in posts if p.get("id") != post_id]
    _save_news_posts(posts)
    return jsonify(ok=True)


def _md_escape(text):
    """Escape HTML special chars so user content can't inject markup."""
    return (text.replace("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;"))


def _render_news_markdown(text):
    """Convert a lightweight Markdown subset to email-safe HTML.

    Supports: ATX headings (#, ##, ###), unordered (-, *) and ordered lists,
    blank-line-separated paragraphs with single newlines as <br>, **bold**,
    *italic*, [text](url) links, and bare http(s) autolinks. All text is
    HTML-escaped first, so the source is untrusted-safe. Explicit block tags
    are emitted (rather than relying on white-space:pre-wrap) because Outlook
    ignores pre-wrap and collapses the post's spacing.
    """
    link_style = "color:#0077A8;text-decoration:underline;"

    def _inline(s):
        s = _md_escape(s)
        placeholders = []

        def _stash(tag_html):
            placeholders.append(tag_html)
            return "\x00{}\x00".format(len(placeholders) - 1)

        # Markdown links: [text](url) — stash so later passes don't touch them.
        def _md_link(m):
            return _stash('<a href="{}" style="{}">{}</a>'.format(
                m.group(2), link_style, m.group(1)))
        s = re.sub(r"\[([^\]]+)\]\((https?://[^\s)]+)\)", _md_link, s)

        # Bare URL autolink (URLs already inside a stashed link are safe).
        def _bare_link(m):
            url = m.group(1)
            return _stash('<a href="{}" style="{}">{}</a>'.format(
                url, link_style, url))
        s = re.sub(r"(?<![\"'>=])(https?://[^\s<]+)", _bare_link, s)

        s = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", s)
        s = re.sub(r"(?<!\*)\*([^*\s][^*]*?)\*(?!\*)", r"<em>\1</em>", s)

        for idx, html_frag in enumerate(placeholders):
            s = s.replace("\x00{}\x00".format(idx), html_frag)
        return s

    lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
    parts = []
    para = []
    list_items = []
    list_tag = None

    def flush_para():
        if para:
            parts.append('<p style="margin:0 0 14px;line-height:1.6;">'
                         + "<br>".join(para) + "</p>")
            para.clear()

    def flush_list():
        nonlocal list_tag
        if list_items:
            items = "".join(
                '<li style="margin:0 0 6px;line-height:1.6;">{}</li>'.format(it)
                for it in list_items)
            parts.append('<{0} style="margin:0 0 14px;padding-left:22px;">{1}</{0}>'
                         .format(list_tag, items))
            list_items.clear()
        list_tag = None

    for line in lines:
        stripped = line.strip()
        if not stripped:
            flush_para()
            flush_list()
            continue

        heading = re.match(r"^(#{1,3})\s+(.*)$", stripped)
        if heading:
            flush_para()
            flush_list()
            level = len(heading.group(1))
            sizes = {1: "22px", 2: "18px", 3: "16px"}
            parts.append(
                '<h{0} style="margin:18px 0 10px;font-size:{1};'
                'color:#1f2937;font-weight:700;">{2}</h{0}>'.format(
                    level, sizes[level], _inline(heading.group(2))))
            continue

        ul = re.match(r"^[-*]\s+(.*)$", stripped)
        ol = re.match(r"^\d+\.\s+(.*)$", stripped)
        if ul or ol:
            flush_para()
            want = "ul" if ul else "ol"
            if list_tag and list_tag != want:
                flush_list()
            list_tag = want
            list_items.append(_inline((ul or ol).group(1)))
            continue

        flush_list()
        para.append(_inline(stripped))

    flush_para()
    flush_list()
    return "".join(parts) or '<p style="margin:0;">&nbsp;</p>'


def _news_email_shell(title, meta_html, body_html, cta_html, footer_html):
    """Wrap content in a branded, Outlook-safe (table-based) HTML email."""
    return (
        '<!DOCTYPE html><html><head><meta charset="utf-8">'
        '<meta name="viewport" content="width=device-width,initial-scale=1">'
        '</head>'
        '<body style="margin:0;padding:0;background:#f3f4f6;">'
        '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
        'style="background:#f3f4f6;padding:24px 0;">'
        '<tr><td align="center">'
        '<table role="presentation" width="600" cellpadding="0" cellspacing="0" '
        'style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;'
        'overflow:hidden;border:1px solid #e5e7eb;'
        'font-family:\'Helvetica Neue\',Helvetica,Arial,sans-serif;">'
        # Header bar
        '<tr><td style="background:#0077A8;'
        'background:linear-gradient(135deg,#00B4D8 0%,#0077A8 100%);'
        'padding:22px 28px;">'
        '<span style="font-size:26px;font-weight:700;color:#ffffff;'
        'letter-spacing:-0.5px;">togen</span>'
        '<span style="display:inline-block;width:7px;height:7px;border-radius:50%;'
        'background:#a7e8f7;margin-left:2px;vertical-align:super;"></span>'
        '<div style="font-size:12px;color:#d4f1f9;margin-top:2px;">News Update</div>'
        '</td></tr>'
        # Body
        '<tr><td style="padding:28px;color:#374151;font-size:15px;">'
        '<h1 style="margin:0 0 6px;font-size:22px;color:#111827;font-weight:700;'
        'line-height:1.3;">{title}</h1>'
        '{meta}'
        '<hr style="margin:16px 0;border:none;border-top:1px solid #e5e7eb;">'
        '{body}'
        '{cta}'
        '</td></tr>'
        # Footer
        '<tr><td style="padding:0 28px 24px;">{footer}</td></tr>'
        '</table></td></tr></table></body></html>'
    ).format(title=title, meta=meta_html, body=body_html, cta=cta_html,
             footer=footer_html)


_NEWS_UNSUBSCRIBE_FOOTER = (
    '<hr style="margin:8px 0 14px;border:none;border-top:1px solid #e5e7eb;">'
    '<p style="font-size:12px;color:#9ca3af;text-align:center;margin:0;'
    'line-height:1.6;">'
    'You are receiving this email because you subscribed to Togen News updates.<br>'
    '<a href="https://togen.icastinc.com/?news_unsubscribe=1" '
    'style="color:#0077A8;">Unsubscribe</a> from future updates.'
    '</p>'
)


def _send_news_email(post, subscribers):
    """Send a news post to all subscribers via Microsoft Graph API (fire-and-forget)."""
    def _do_send():
        try:
            meta_html = (
                '<p style="margin:0;color:#6b7280;font-size:13px;">'
                'Posted by {author} on {date}</p>'.format(
                    author=_md_escape(post.get("author", "")),
                    date=_md_escape(post.get("date", "")[:10]))
            )
            cta_html = (
                '<div style="margin-top:24px;">'
                '<a href="https://togen.icastinc.com/?open_news=1" '
                'style="display:inline-block;background:#0077A8;color:#ffffff;'
                'text-decoration:none;font-size:14px;font-weight:600;'
                'padding:10px 20px;border-radius:8px;">View on Togen News</a>'
                '</div>'
            )
            content = _news_email_shell(
                title=_md_escape(post["title"]),
                meta_html=meta_html,
                body_html=_render_news_markdown(post.get("body", "")),
                cta_html=cta_html,
                footer_html=_NEWS_UNSUBSCRIBE_FOOTER,
            )
            notifications.send_html_email(
                list(subscribers), f"Togen News: {post['title']}", content)
        except Exception:
            pass

    threading.Thread(target=_do_send, daemon=True).start()


def _send_subscribe_confirmation(email):
    """Send a subscription confirmation email (fire-and-forget)."""
    def _do_send():
        try:
            app_client = msal.ConfidentialClientApplication(
                _AZ_CLIENT_ID, authority=_AZ_AUTHORITY_HOME, client_credential=_AZ_SECRET)
            token_result = app_client.acquire_token_for_client(
                scopes=["https://graph.microsoft.com/.default"])
            if "access_token" not in token_result:
                return

            body_html = (
                '<p style="margin:0 0 14px;line-height:1.6;">You will now receive '
                'email notifications when new posts are published on Togen.</p>'
            )
            cta_html = (
                '<div style="margin-top:24px;">'
                '<a href="https://togen.icastinc.com/?open_news=1" '
                'style="display:inline-block;background:#0077A8;color:#ffffff;'
                'text-decoration:none;font-size:14px;font-weight:600;'
                'padding:10px 20px;border-radius:8px;">View recent posts</a>'
                '</div>'
            )
            content = _news_email_shell(
                title="You're subscribed to Togen News!",
                meta_html="",
                body_html=body_html,
                cta_html=cta_html,
                footer_html=_NEWS_UNSUBSCRIBE_FOOTER,
            )
            graph_body = {
                "message": {
                    "subject": "Togen News: Subscription Confirmed",
                    "body": {
                        "contentType": "HTML",
                        "content": content,
                    },
                    "toRecipients": [
                        {"emailAddress": {"address": email}},
                    ],
                },
                "saveToSentItems": "false",
            }

            _http_requests.post(
                f"https://graph.microsoft.com/v1.0/users/{AZURE_MAIL_FROM}/sendMail",
                headers={
                    "Authorization": f"Bearer {token_result['access_token']}",
                    "Content-Type": "application/json",
                },
                json=graph_body,
                timeout=30,
            )
        except Exception:
            pass

    threading.Thread(target=_do_send, daemon=True).start()


# ---------------------------------------------------------------------------
# Routes — News Subscribers (DVI-267)
# ---------------------------------------------------------------------------


@app.route("/news/subscribers", methods=["GET"])
@login_required
def news_subscribers_get():
    """Check if the current user is subscribed."""
    user = session.get("user", {})
    email = user.get("email", "").lower()
    subs = _load_subscribers()
    return jsonify(subscribed=email in [s.lower() for s in subs])


@app.route("/news/subscribe", methods=["POST"])
@login_required
def news_subscribe():
    """Subscribe an email to news notifications."""
    body = request.get_json(silent=True) or {}
    email = body.get("email", "").strip().lower()
    if not email or "@" not in email:
        return jsonify(error="Valid email address is required."), 400

    subs = _load_subscribers()
    existing = [s.lower() for s in subs]
    is_new = email not in existing
    if is_new:
        subs.append(email)
        _save_subscribers(subs)

    if is_new and AZURE_MAIL_FROM:
        _send_subscribe_confirmation(email)

    return jsonify(ok=True, subscribed=True)


@app.route("/news/unsubscribe", methods=["POST"])
@login_required
def news_unsubscribe():
    """Unsubscribe an email from news notifications."""
    body = request.get_json(silent=True) or {}
    email = body.get("email", "").strip().lower()

    subs = _load_subscribers()
    subs = [s for s in subs if s.lower() != email]
    _save_subscribers(subs)

    return jsonify(ok=True, subscribed=False)


# ---------------------------------------------------------------------------
# Routes — Public Version (DVI-267)
# ---------------------------------------------------------------------------


@app.route("/togen-admin/public-version")
@login_required
def togen_admin_public_version_get():
    """Return the current public version string."""
    return jsonify(version=_load_public_version())


@app.route("/togen-admin/public-version", methods=["POST"])
@togen_admin_required
def togen_admin_public_version_post():
    """Update the public version string. Admin only."""
    body = request.get_json(silent=True) or {}
    version = body.get("version", "").strip()
    if not version:
        return jsonify(error="Version text is required."), 400
    try:
        PUBLIC_VERSION_FILE.write_text(version + "\n")
    except OSError as exc:
        return jsonify(error=f"Failed to save version: {exc}"), 500
    return jsonify(ok=True, version=version)


# ---------------------------------------------------------------------------
# Routes — About content (DVI-412)
# ---------------------------------------------------------------------------


@app.route("/togen-admin/about")
@login_required
def togen_admin_about_get():
    """Return the editable About popup text (Markdown). Any logged-in user."""
    return jsonify(content=_load_about_content())


@app.route("/togen-admin/about", methods=["POST"])
@togen_admin_required
def togen_admin_about_post():
    """Update the About popup text. Admin only."""
    body = request.get_json(silent=True) or {}
    content = (body.get("content") or "").strip()
    if not content:
        return jsonify(error="About text is required."), 400
    try:
        _save_about_content(content)
    except OSError as exc:
        return jsonify(error=f"Failed to save About text: {exc}"), 500
    return jsonify(ok=True, content=content)


@app.route("/togen-admin/site-text")
@login_required
def togen_admin_site_text_get():
    """Return the editable header title/tagline. Any logged-in user."""
    return jsonify(_load_site_text())


@app.route("/togen-admin/site-text", methods=["POST"])
@togen_admin_required
def togen_admin_site_text_post():
    """Update the header title/tagline. Admin only."""
    body = request.get_json(silent=True) or {}
    title = (body.get("title") or "").strip()
    tagline = (body.get("tagline") or "").strip()
    if not title:
        return jsonify(error="Title is required."), 400
    if not tagline:
        return jsonify(error="Tagline is required."), 400
    try:
        _save_site_text(title, tagline)
    except OSError as exc:
        return jsonify(error=f"Failed to save title: {exc}"), 500
    return jsonify(ok=True, title=title, tagline=tagline)


# ---------------------------------------------------------------------------
# Routes — Sync service stop/start (DVI-412)
# stop/start invoke `sudo systemctl` against rclone-bisync.service; the state
# file is kept as fallback / desired-state for dev environments where systemctl
# is unavailable. Requires sudoers entry for the web-server user (see services/).
# ---------------------------------------------------------------------------


@app.route("/togen-admin/sync-service/status")
@togen_admin_required
def togen_admin_sync_service_status():
    return jsonify(running=_load_sync_service_running())


@app.route("/togen-admin/sync-service/stop", methods=["POST"])
@togen_admin_required
def togen_admin_sync_service_stop():
    try:
        subprocess.run(
            ["sudo", "systemctl", "stop", _BISYNC_UNIT],
            check=True, capture_output=True, timeout=15,
        )
    except Exception:
        pass  # best-effort; state file tracks intent for UI
    _save_sync_service_running(False)
    running = _load_sync_service_running()
    return jsonify(ok=True, running=running)


@app.route("/togen-admin/sync-service/start", methods=["POST"])
@togen_admin_required
def togen_admin_sync_service_start():
    try:
        subprocess.run(
            ["sudo", "systemctl", "start", _BISYNC_UNIT],
            check=True, capture_output=True, timeout=15,
        )
    except Exception:
        pass  # best-effort; state file tracks intent for UI
    _save_sync_service_running(True)
    running = _load_sync_service_running()
    return jsonify(ok=True, running=running)


# ---------------------------------------------------------------------------
# Routes — Shared URL Prefix (DVI-271)
# ---------------------------------------------------------------------------


@app.route("/togen-admin/shared-url-prefix")
@togen_admin_required
def togen_admin_url_prefix_get():
    """Return the current shared URL prefix and available options."""
    return jsonify(
        prefix=_load_shared_url_prefix(),
        options=SHARED_URL_PREFIX_OPTIONS,
    )


@app.route("/togen-admin/shared-url-prefix", methods=["POST"])
@togen_admin_required
def togen_admin_url_prefix_post():
    """Update the shared URL prefix. Admin only."""
    body = request.get_json(silent=True) or {}
    prefix = (body.get("prefix") or "").strip().rstrip("/")
    valid_values = [opt["value"] for opt in SHARED_URL_PREFIX_OPTIONS]
    if prefix not in valid_values:
        return jsonify(error="Invalid URL prefix selected."), 400
    _save_shared_url_prefix(prefix)
    return jsonify(ok=True, prefix=prefix)


# ---------------------------------------------------------------------------
# Routes — Branding (DVI-357)
# ---------------------------------------------------------------------------


@app.route("/togen-admin/branding")
@togen_admin_required
def togen_admin_branding_get():
    """Return current branding settings plus which corporate assets exist."""
    settings = _load_branding_settings()
    return jsonify(
        logo_mode=settings["logo_mode"],
        bg_mode=settings["bg_mode"],
        bg_opacity_light=settings["bg_opacity_light"],
        bg_opacity_dark=settings["bg_opacity_dark"],
        tile_columns=settings["tile_columns"],
        tile_side_padding=settings["tile_side_padding"],
        has_logo=CORPORATE_LOGO_FILE.is_file(),
        has_bg_light=CORPORATE_BG_LIGHT_FILE.is_file(),
        has_bg_dark=CORPORATE_BG_DARK_FILE.is_file(),
    )


@app.route("/togen-admin/branding", methods=["POST"])
@togen_admin_required
def togen_admin_branding_post():
    """Update branding logo_mode and/or bg_mode. Admin only."""
    body = request.get_json(silent=True) or {}
    settings = _load_branding_settings()
    if "logo_mode" in body:
        if body["logo_mode"] not in BRANDING_LOGO_MODES:
            return jsonify(error="Invalid logo mode."), 400
        settings["logo_mode"] = body["logo_mode"]
    if "bg_mode" in body:
        if body["bg_mode"] not in BRANDING_BG_MODES:
            return jsonify(error="Invalid background mode."), 400
        settings["bg_mode"] = body["bg_mode"]
    for key in ("bg_opacity_light", "bg_opacity_dark"):
        if key in body:
            opacity = _coerce_bg_opacity(body[key])
            if opacity is None:
                return jsonify(error="Opacity must be an integer between 40 and 100."), 400
            settings[key] = opacity
    if "tile_columns" in body:
        tile_columns = _coerce_tile_columns(body["tile_columns"])
        if tile_columns is None:
            return jsonify(error='Tile columns must be 3-9 or "unlimited".'), 400
        settings["tile_columns"] = tile_columns
    if "tile_side_padding" in body:
        tile_side_padding = _coerce_tile_side_padding(body["tile_side_padding"])
        if tile_side_padding is None:
            return jsonify(error="Side padding must be an integer between 0 and 40."), 400
        settings["tile_side_padding"] = tile_side_padding
    _save_branding_settings(settings)
    return jsonify(ok=True, **settings)


@app.route("/togen-admin/branding/logo.svg")
def togen_admin_branding_logo():
    """Serve the corporate logo SVG. No auth (referenced by <img>); 404 if absent."""
    if not CORPORATE_LOGO_FILE.is_file():
        abort(404)
    return send_file(CORPORATE_LOGO_FILE, mimetype="image/svg+xml")


@app.route("/togen-admin/branding/bg-light.svg")
def togen_admin_branding_bg_light():
    """Serve the corporate light-theme background SVG. No auth; 404 if absent."""
    if not CORPORATE_BG_LIGHT_FILE.is_file():
        abort(404)
    return send_file(CORPORATE_BG_LIGHT_FILE, mimetype="image/svg+xml")


@app.route("/togen-admin/branding/bg-dark.svg")
def togen_admin_branding_bg_dark():
    """Serve the corporate dark-theme background SVG. No auth; 404 if absent."""
    if not CORPORATE_BG_DARK_FILE.is_file():
        abort(404)
    return send_file(CORPORATE_BG_DARK_FILE, mimetype="image/svg+xml")


@app.route("/togen-admin/subscribers")
@togen_admin_required
def togen_admin_subscribers_list():
    """Return all news subscriber emails. Admin only."""
    subs = _load_subscribers()
    return jsonify(subscribers=subs)


# ---------------------------------------------------------------------------
# Routes — Printing (DVI-548)
# ---------------------------------------------------------------------------

def _load_printing_settings():
    """Return default printer settings: {document_printer, label_printer, enabled}.

    ``enabled`` (DVI-764) gates whether the Print system is offered to non-admin
    users. It defaults to ``True`` so existing deployments keep working; an admin
    can clear the checkbox to hide/disable Print for regular users (Togen admins
    always retain access)."""
    settings = {"document_printer": "", "label_printer": "", "enabled": True}
    if PRINTING_SETTINGS_FILE.is_file():
        try:
            saved = json.loads(PRINTING_SETTINGS_FILE.read_text())
            if isinstance(saved, dict):
                if isinstance(saved.get("document_printer"), str):
                    settings["document_printer"] = saved["document_printer"]
                if isinstance(saved.get("label_printer"), str):
                    settings["label_printer"] = saved["label_printer"]
                if isinstance(saved.get("enabled"), bool):
                    settings["enabled"] = saved["enabled"]
        except (json.JSONDecodeError, OSError):
            pass
    return settings


def _print_system_enabled():
    """True when the Print system is enabled for regular users (DVI-764)."""
    return _load_printing_settings().get("enabled", True)


def _user_can_print(user):
    """Whether ``user`` may use the Print system (DVI-764).

    Togen admins always may; everyone else only when the system is enabled."""
    if user and user.get("togen_admin"):
        return True
    return _print_system_enabled()


_WINSTON_DEFAULTS = {
    "draft_mailbox": "sales@icastinc.com",
    "clickup_api_key": "",
    "clickup_list_id": "",
    "azure_client_id": "",
    "azure_tenant_id": "",
    "azure_client_secret": "",
    "onedrive_user": "",
    "takeoffs_folder": "Infrastructure Precast Inc_ - Takeoffs",
    "report_to": [],
    "report_cc": [],
    "shared_quotes_location": "",
    "shared_quote_takeoff_location": "",
}


def _load_winston_settings() -> dict:
    settings = dict(_WINSTON_DEFAULTS)
    if WINSTON_SETTINGS_FILE.is_file():
        try:
            saved = json.loads(WINSTON_SETTINGS_FILE.read_text())
            if isinstance(saved, dict):
                for k, default in _WINSTON_DEFAULTS.items():
                    if k in saved and type(saved[k]) is type(default):
                        settings[k] = saved[k]
        except (json.JSONDecodeError, OSError):
            pass
    return settings


def _save_winston_settings(settings: dict):
    WINSTON_SETTINGS_FILE.write_text(json.dumps(settings, indent=2))


def _available_printers():
    """Printers published by active Print agents, split for the two dropdowns.

    Returns ``{"documents": [...], "labels": [...]}`` — sorted, de-duplicated
    lists of ``published_name`` strings drawn from included printers flagged
    ``for_documents`` / ``for_labels``. A printer with no published_name falls
    back to its raw_name so it is still selectable. Only printers belonging to
    agents in the ``active`` status are offered (pending/revoked agents are not
    trusted). (DVI-668 / DVI-657 P2)
    """
    docs, labels = {}, {}
    conn = _print_db()
    try:
        rows = conn.execute(
            "SELECT p.raw_name, p.published_name, p.for_documents, p.for_labels"
            " FROM print_printers p"
            " JOIN print_agents a ON a.machine_id = p.machine_id"
            " WHERE p.included = 1 AND a.status = 'active'"
        ).fetchall()
    finally:
        conn.close()
    for r in rows:
        name = (r["published_name"] or "").strip() or (r["raw_name"] or "").strip()
        if not name:
            continue
        key = name.lower()
        if r["for_documents"]:
            docs.setdefault(key, name)
        if r["for_labels"]:
            labels.setdefault(key, name)
    return {
        "documents": sorted(docs.values(), key=str.lower),
        "labels": sorted(labels.values(), key=str.lower),
    }


# Sentinel persisted for a user dropdown left on "Use Default" — the effective
# resolver treats it (and a blank value) as "fall back to the global default".
PRINT_USER_DEFAULT = "__default__"


def _user_printing_overrides(email):
    """Return the user's stored printer overrides, defaulting to the sentinel.

    Stored under ``user_settings/<email>.json`` -> ``printing: {document_printer,
    label_printer}``. Missing keys default to ``__default__`` (use global)."""
    stored = (_load_user_settings(email) or {}).get("printing") or {}
    doc = stored.get("document_printer")
    lbl = stored.get("label_printer")
    return {
        "document_printer": doc if isinstance(doc, str) and doc else PRINT_USER_DEFAULT,
        "label_printer": lbl if isinstance(lbl, str) and lbl else PRINT_USER_DEFAULT,
    }


def _effective_default_printers(email):
    """Resolve the effective default printers for a user (DVI-668 / P2).

    A user override (a non-sentinel, non-empty published_name) wins; otherwise
    the global default from ``printing_settings.json`` is used. Shared helper
    for the print-dispatch (P3) and tool Print buttons (P4)."""
    glob = _load_printing_settings()
    user = _user_printing_overrides(email)

    def _pick(key):
        chosen = user.get(key)
        if chosen and chosen != PRINT_USER_DEFAULT:
            return chosen
        return glob.get(key, "")

    return {
        "document_printer": _pick("document_printer"),
        "label_printer": _pick("label_printer"),
    }


@app.route("/togen-admin/printing")
@togen_admin_required
def togen_admin_printing_get():
    """Return default printer settings + available printers. Admin only."""
    settings = _load_printing_settings()
    return jsonify(
        document_printer=settings["document_printer"],
        label_printer=settings["label_printer"],
        enabled=settings["enabled"],
        available_printers=_available_printers(),
    )


@app.route("/user-settings/printing")
@login_required
def user_settings_printing_get():
    """Return the user's printer overrides + global defaults + the picker lists.

    Each dropdown offers a "Use Default" sentinel (``__default__``) plus the
    published printers for that kind. (DVI-668 / DVI-657 P2)"""
    email = session["user"].get("email", "")
    glob = _load_printing_settings()
    return jsonify(
        available_printers=_available_printers(),
        global_defaults={
            "document_printer": glob["document_printer"],
            "label_printer": glob["label_printer"],
        },
        user=_user_printing_overrides(email),
        default_sentinel=PRINT_USER_DEFAULT,
    )


@app.route("/user-settings/printing", methods=["POST"])
@login_required
def user_settings_printing_post():
    """Persist the user's default-printer overrides. The sentinel ``__default__``
    (or a blank value) clears the override so the global default applies."""
    email = session["user"].get("email", "")
    body = request.get_json(silent=True) or {}
    avail = _available_printers()
    allowed = {
        "document_printer": {n.lower() for n in avail["documents"]},
        "label_printer": {n.lower() for n in avail["labels"]},
    }
    settings = _load_user_settings(email)
    printing = dict(settings.get("printing") or {})
    for key in ("document_printer", "label_printer"):
        if key not in body:
            continue
        val = body[key]
        if val in (None, "", PRINT_USER_DEFAULT):
            printing.pop(key, None)
            continue
        if not isinstance(val, str):
            return jsonify(error=f"Invalid value for {key}."), 400
        if val.lower() not in allowed[key]:
            return jsonify(error=f"{val} is not an available printer."), 400
        printing[key] = val
    if printing:
        settings["printing"] = printing
    else:
        settings.pop("printing", None)
    try:
        _save_user_settings(email, settings)
    except OSError as exc:
        return jsonify(error=f"Failed to save settings: {exc}"), 500
    return jsonify(ok=True, user=_user_printing_overrides(email))


@app.route("/togen-admin/printing", methods=["POST"])
@togen_admin_required
def togen_admin_printing_post():
    """Save default document/label printer selections. Admin only."""
    body = request.get_json(silent=True) or {}
    settings = _load_printing_settings()
    if "document_printer" in body:
        settings["document_printer"] = str(body["document_printer"] or "")
    if "label_printer" in body:
        settings["label_printer"] = str(body["label_printer"] or "")
    if "enabled" in body:
        settings["enabled"] = bool(body["enabled"])
    PRINTING_SETTINGS_FILE.write_text(json.dumps(settings, indent=2))
    return jsonify(ok=True, enabled=settings["enabled"])


# ---------------------------------------------------------------------------
# Winston settings — DVI-808
# Admin: shared quotes location; User: personal quotes location.
# ---------------------------------------------------------------------------

@app.route("/togen-admin/quotes-location")
@togen_admin_required
def togen_admin_quotes_location_get():
    """Return the admin-configured shared quotes paths (completed + take-offs)."""
    settings = _load_winston_settings()
    return jsonify(
        shared_quotes_location=settings.get("shared_quotes_location", ""),
        shared_quote_takeoff_location=settings.get("shared_quote_takeoff_location", ""),
    )


@app.route("/togen-admin/quotes-location", methods=["POST"])
@togen_admin_required
def togen_admin_quotes_location_post():
    """Persist the shared quotes paths (completed + take-offs). Admin only."""
    body = request.get_json(silent=True) or {}
    settings = _load_winston_settings()

    for key in ("shared_quotes_location", "shared_quote_takeoff_location"):
        if key in body:
            val = body[key]
            if not isinstance(val, str):
                return jsonify(error=f"{key} must be a string."), 400
            settings[key] = val.strip()

    try:
        _save_winston_settings(settings)
    except OSError as exc:
        return jsonify(error=f"Failed to save: {exc}"), 500
    return jsonify(
        ok=True,
        shared_quotes_location=settings.get("shared_quotes_location", ""),
        shared_quote_takeoff_location=settings.get("shared_quote_takeoff_location", ""),
    )


@app.route("/user-settings/documents")
@login_required
def user_settings_documents_get():
    """Return the user's personal documents settings (quotes paths)."""
    email = session["user"].get("email", "")
    settings = _load_user_settings(email)
    docs = settings.get("documents") or {}
    return jsonify(
        quotes_location=docs.get("quotes_location", ""),
        quote_takeoff_location=docs.get("quote_takeoff_location", ""),
    )


@app.route("/user-settings/documents", methods=["POST"])
@login_required
def user_settings_documents_post():
    """Persist the user's personal documents settings (quotes paths)."""
    email = session["user"].get("email", "")
    body = request.get_json(silent=True) or {}
    settings = _load_user_settings(email)
    docs = dict(settings.get("documents") or {})

    for key in ("quotes_location", "quote_takeoff_location"):
        if key in body:
            val = body[key]
            if not isinstance(val, str):
                return jsonify(error=f"{key} must be a string."), 400
            if val.strip():
                docs[key] = val.strip()
            else:
                docs.pop(key, None)

    if docs:
        settings["documents"] = docs
    else:
        settings.pop("documents", None)
    try:
        _save_user_settings(email, settings)
    except OSError as exc:
        return jsonify(error=f"Failed to save settings: {exc}"), 500
    return jsonify(
        ok=True,
        quotes_location=docs.get("quotes_location", ""),
        quote_takeoff_location=docs.get("quote_takeoff_location", ""),
    )


@app.route("/winston/quote-paths")
@login_required
def winston_quote_paths_get():
    """Aggregate read-only view of all four resolved quote path values for the current user.

    Returns:
      shared_completed       — shared_quotes_location from admin settings
      shared_quote_takeoff   — shared_quote_takeoff_location from admin settings
      personal_completed     — quotes_location from the user's documents settings
      personal_quote_takeoff — quote_takeoff_location from the user's documents settings
    """
    shared = _load_winston_settings()
    email = session["user"].get("email", "")
    user_docs = (_load_user_settings(email).get("documents") or {})
    return jsonify(
        shared_completed=shared.get("shared_quotes_location", ""),
        shared_quote_takeoff=shared.get("shared_quote_takeoff_location", ""),
        personal_completed=user_docs.get("quotes_location", ""),
        personal_quote_takeoff=user_docs.get("quote_takeoff_location", ""),
    )


# ---------------------------------------------------------------------------
# Winston P4 — page UI backend (DVI-817)
# Settings, Schedules, Runs-History, Run-now
# All routes gated togen_admin_required (mirrors the tool's default-disabled
# → admins-only registration).
# ---------------------------------------------------------------------------

_WINSTON_SCHEDULE_CADENCES = ("daily", "weekdays", "custom")
_WINSTON_SCHEDULE_PROFILES = ("prev_biz", "today")
_WINSTON_MAX_RUN_RECORDS = 100
# Use the shared lock exported from winston_scheduler so scheduler threads and
# the run-now route both hold the same lock when writing winston_run_records.json.
from togen import winston_scheduler as _winston_sched_mod
_WINSTON_RUN_LOCK = _winston_sched_mod.run_records_lock
_winston_active_runs: dict = {}   # run_id → True while running


def _load_winston_schedules() -> list:
    if WINSTON_SCHEDULES_FILE.is_file():
        try:
            data = json.loads(WINSTON_SCHEDULES_FILE.read_text())
            if isinstance(data, list):
                return [s for s in data if isinstance(s, dict)]
        except (json.JSONDecodeError, OSError):
            pass
    return []


def _save_winston_schedules(schedules: list):
    WINSTON_SCHEDULES_FILE.write_text(json.dumps(schedules, indent=2))


def _load_winston_run_records() -> list:
    if WINSTON_RUN_RECORDS_FILE.is_file():
        try:
            data = json.loads(WINSTON_RUN_RECORDS_FILE.read_text())
            if isinstance(data, list):
                return [r for r in data if isinstance(r, dict)]
        except (json.JSONDecodeError, OSError):
            pass
    return []


def _save_winston_run_records(records: list):
    WINSTON_RUN_RECORDS_FILE.write_text(json.dumps(records, indent=2))


def _append_winston_run_record(record: dict):
    """Thread-safe append + trim to _WINSTON_MAX_RUN_RECORDS, newest first."""
    with _WINSTON_RUN_LOCK:
        records = _load_winston_run_records()
        records = [r for r in records if r.get("id") != record.get("id")]
        records.insert(0, record)
        records = records[:_WINSTON_MAX_RUN_RECORDS]
        _save_winston_run_records(records)


def _winston_schedule_from_body(body: dict, existing: dict | None = None):
    """Build/validate a schedule dict. Returns (schedule, error)."""
    name = (body.get("name") or "").strip()
    if not name:
        return None, "Schedule name is required."
    if len(name) > 120:
        return None, "Schedule name is too long (max 120 characters)."

    cadence = (body.get("cadence") or "daily").strip()
    if cadence not in _WINSTON_SCHEDULE_CADENCES:
        return None, f"Invalid cadence; expected one of {', '.join(_WINSTON_SCHEDULE_CADENCES)}."

    custom_days = None
    if cadence == "custom":
        raw_days = body.get("custom_days") or []
        if not isinstance(raw_days, list):
            return None, "custom_days must be a list."
        try:
            custom_days = sorted({int(d) for d in raw_days if 0 <= int(d) <= 6})
        except (TypeError, ValueError):
            return None, "custom_days values must be integers 0–6 (Mon=0 … Sun=6)."
        if not custom_days:
            return None, "custom_days must have at least one day."

    try:
        hour = int(body.get("hour", 6))
        if not (0 <= hour <= 23):
            raise ValueError
    except (TypeError, ValueError):
        return None, "hour must be an integer 0–23."
    try:
        minute = int(body.get("minute", 0))
        if not (0 <= minute <= 59):
            raise ValueError
    except (TypeError, ValueError):
        return None, "minute must be an integer 0–59."

    profile = (body.get("profile") or "today").strip()
    from togen import winston_date_logic as _wdl
    valid_profiles = _wdl.valid_pipeline_ids() | set(_WINSTON_SCHEDULE_PROFILES)
    if profile not in valid_profiles:
        return None, "Invalid Date Logic; select an existing pipeline."

    now = _datetime_type.now().isoformat()
    schedule = {
        "id": (existing or {}).get("id") or str(uuid.uuid4()),
        "name": name,
        "cadence": cadence,
        "custom_days": custom_days,
        "hour": hour,
        "minute": minute,
        "profile": profile,
        "enabled": bool(body.get("enabled", True)),
        "created_at": (existing or {}).get("created_at") or now,
        "updated_at": now,
    }
    return schedule, None


@app.route("/winston/config")
@togen_admin_required
def winston_config_get():
    """Return Winston configuration status for the Settings tab."""
    from togen import winston_graph as _wg
    settings = _load_winston_settings()

    # Resolve effective values: saved settings take precedence over env vars.
    azure_client_id = settings.get("azure_client_id", "") or _wg._AZ_CLIENT_ID
    azure_tenant_id = settings.get("azure_tenant_id", "") or _wg._AZ_TENANT
    azure_secret_set = bool(settings.get("azure_client_secret", "") or _wg._AZ_SECRET)
    clickup_list_id = settings.get("clickup_list_id", "") or os.environ.get("CLICKUP_LIST_ID", "").strip()
    onedrive_user = settings.get("onedrive_user", "") or _wg.WINSTON_ONEDRIVE_USER
    takeoffs_folder = (
        settings.get("takeoffs_folder", "")
        or os.environ.get("WINSTON_TAKEOFFS_FOLDER", "")
        or "Infrastructure Precast Inc_ - Takeoffs"
    )

    clickup_key_set = bool(
        os.environ.get("CLICKUP_API_KEY") or settings.get("clickup_api_key", "")
    )
    azure_configured = bool(azure_client_id and azure_secret_set and azure_tenant_id)
    return jsonify(
        draft_mailbox=settings.get("draft_mailbox", ""),
        clickup_api_key_configured=clickup_key_set,
        clickup_list_id=clickup_list_id,
        azure_configured=azure_configured,
        azure_client_id=azure_client_id,
        azure_tenant_id=azure_tenant_id,
        azure_client_secret="****" if azure_secret_set else "",
        onedrive_user=onedrive_user,
        takeoffs_folder=takeoffs_folder,
        report_to=settings.get("report_to", []),
        report_cc=settings.get("report_cc", []),
    )


@app.route("/winston/config", methods=["POST"])
@togen_admin_required
def winston_config_post():
    """Save Winston configuration (mailbox, ClickUp key, recipients)."""
    body = request.get_json(silent=True) or {}
    settings = _load_winston_settings()

    if "draft_mailbox" in body:
        val = str(body["draft_mailbox"] or "").strip()
        settings["draft_mailbox"] = val

    # Secrets / single-value fields: empty string = leave unchanged.
    for key in (
        "clickup_api_key",
        "clickup_list_id",
        "onedrive_user",
        "takeoffs_folder",
        "azure_client_id",
        "azure_client_secret",
        "azure_tenant_id",
    ):
        if key in body:
            val = str(body[key] or "").strip()
            if val:
                settings[key] = val

    for key in ("report_to", "report_cc"):
        if key in body:
            raw = body[key]
            if not isinstance(raw, list):
                return jsonify(error=f"{key} must be a list."), 400
            cleaned = []
            seen = set()
            for e in raw:
                if not isinstance(e, str):
                    continue
                e = e.strip().lower()
                if e and e not in seen:
                    seen.add(e)
                    cleaned.append(e)
            settings[key] = cleaned

    try:
        _save_winston_settings(settings)
    except OSError as exc:
        return jsonify(error=f"Failed to save: {exc}"), 500
    return jsonify(ok=True)


@app.route("/winston/schedules")
@togen_admin_required
def winston_schedules_list():
    return jsonify(schedules=_load_winston_schedules())


@app.route("/winston/schedules", methods=["POST"])
@togen_admin_required
def winston_schedules_create():
    body = request.get_json(silent=True) or {}
    schedule, err = _winston_schedule_from_body(body)
    if err:
        return jsonify(error=err), 400
    schedules = _load_winston_schedules()
    schedules.append(schedule)
    _save_winston_schedules(schedules)
    return jsonify(schedule=schedule), 201


@app.route("/winston/schedules/<sched_id>", methods=["PUT"])
@togen_admin_required
def winston_schedules_update(sched_id):
    body = request.get_json(silent=True) or {}
    schedules = _load_winston_schedules()
    idx = next((i for i, s in enumerate(schedules) if s.get("id") == sched_id), None)
    if idx is None:
        return jsonify(error="Schedule not found."), 404
    schedule, err = _winston_schedule_from_body(body, existing=schedules[idx])
    if err:
        return jsonify(error=err), 400
    schedules[idx] = schedule
    _save_winston_schedules(schedules)
    return jsonify(schedule=schedule)


@app.route("/winston/schedules/<sched_id>", methods=["DELETE"])
@togen_admin_required
def winston_schedules_delete(sched_id):
    schedules = _load_winston_schedules()
    remaining = [s for s in schedules if s.get("id") != sched_id]
    if len(remaining) == len(schedules):
        return jsonify(error="Schedule not found."), 404
    _save_winston_schedules(remaining)
    return jsonify(ok=True)


@app.route("/winston/date-logic")
@togen_admin_required
def winston_date_logic_list():
    """List Date Logic pipelines + the canonical process order and base rules."""
    from togen import winston_date_logic as _wdl
    return jsonify(
        pipelines=_wdl.list_pipelines(),
        base_rules=[{"value": k, "label": v} for k, v in _wdl.BASE_RULES.items()],
        process_keys=_wdl.PROCESS_KEYS,
    )


@app.route("/winston/date-logic", methods=["POST"])
@togen_admin_required
def winston_date_logic_create():
    from togen import winston_date_logic as _wdl
    body = request.get_json(silent=True) or {}
    pipeline, err = _wdl.create(body.get("name"), (body.get("base_rule") or "").strip())
    if err:
        return jsonify(error=err), 400
    return jsonify(pipeline=pipeline), 201


@app.route("/winston/date-logic/<pipeline_id>", methods=["PATCH"])
@togen_admin_required
def winston_date_logic_update(pipeline_id):
    from togen import winston_date_logic as _wdl
    body = request.get_json(silent=True) or {}
    name = body.get("name") if "name" in body else None
    process_labels = body.get("process_labels") if "process_labels" in body else None
    pipeline, err = _wdl.update(pipeline_id, name=name, process_labels=process_labels)
    if err:
        status = 404 if err == "Date Logic not found." else 400
        return jsonify(error=err), status
    return jsonify(pipeline=pipeline)


@app.route("/winston/date-logic/<pipeline_id>", methods=["DELETE"])
@togen_admin_required
def winston_date_logic_delete(pipeline_id):
    from togen import winston_date_logic as _wdl
    referenced = any(s.get("profile") == pipeline_id for s in _load_winston_schedules())
    ok, err, status = _wdl.delete(pipeline_id, referenced)
    if not ok:
        return jsonify(error=err), status
    return jsonify(ok=True)


@app.route("/winston/run-history")
@togen_admin_required
def winston_run_history():
    def _db_run_to_record(r: dict) -> dict:
        """Transform bot_diagnostics DB row to the legacy run record shape the UI expects."""
        summary: dict = {}
        if r.get("summary_json"):
            try:
                summary = json.loads(r["summary_json"])
            except (json.JSONDecodeError, TypeError):
                pass
        started = r.get("started_at") or 0
        ran_at = (
            _datetime_type.fromtimestamp(started, tz=_dt_timezone.utc).isoformat()
            if started else ""
        )
        return {
            "id": r["id"],
            "ran_at": ran_at,
            "date": r.get("target_date") or "",
            "triggered_by": r.get("trigger") or "",
            "status": r.get("status") or "done",
            "quotes_found": summary.get("quotes_found", 0),
            "drafts_created": summary.get("drafts_created", 0),
            "drafts_skipped": summary.get("drafts_skipped", 0),
            "drafts_failed": summary.get("drafts_failed", 0),
            "pdf_routed": summary.get("pdf_routed", 0),
            "pdf_route_failed": summary.get("pdf_route_failed", 0),
            "error": r.get("error"),
            "quotes": summary.get("quotes", []),
        }

    db_runs = _diag_mod.list_runs("winston", limit=_WINSTON_MAX_RUN_RECORDS)
    db_ids = {r["id"] for r in db_runs}
    result = [_db_run_to_record(r) for r in db_runs]

    # Fallback: include legacy JSON records not yet in bot_diagnostics.db
    # (one-release read-only migration — retire in next release)
    for old_rec in _load_winston_run_records():
        if old_rec.get("id") not in db_ids:
            result.append(old_rec)

    result = result[:_WINSTON_MAX_RUN_RECORDS]
    return jsonify(records=result)


@app.route("/winston/run-now", methods=["POST"])
@togen_admin_required
def winston_run_now():
    """Kick off the Winston pipeline for a given date in a background thread."""
    from togen import winston_pipeline as _wp
    body = request.get_json(silent=True) or {}
    date_str = (body.get("date") or "").strip()
    if not date_str:
        date_str = _datetime_type.now().strftime("%Y-%m-%d")
    try:
        _datetime_type.strptime(date_str, "%Y-%m-%d")
    except ValueError:
        return jsonify(error="Invalid date; expected YYYY-MM-DD."), 400

    run_id = str(uuid.uuid4())

    def _run_bg(rid, dt):
        try:
            _wp.run(dt, trigger="user", run_id=rid)
        except Exception:
            app.logger.exception("Winston run-now background error run_id=%s", rid)

    t = threading.Thread(target=_run_bg, args=(run_id, date_str), daemon=True)
    t.start()
    return jsonify(run_id=run_id, status="running", date=date_str), 202


# ---------------------------------------------------------------------------
# History capture (DVI-789) — shared history.db spine + per-tool adapters.
# ---------------------------------------------------------------------------

_HISTORY_RETENTION_DAYS = 365
_HISTORY_MAX_ROWS_PER_TOOL = 50_000


def _history_db():
    """Open history.db, creating the schema on first use. Caller must close."""
    conn = sqlite3.connect(str(HISTORY_DB_PATH), check_same_thread=False)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.executescript("""
        CREATE TABLE IF NOT EXISTS tool_history (
            id         INTEGER PRIMARY KEY AUTOINCREMENT,
            tool       TEXT NOT NULL,
            action     TEXT NOT NULL,
            actor      TEXT,
            summary    TEXT,
            ref        TEXT,
            detail     TEXT,
            created_at TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_hist_tool_time
            ON tool_history (tool, created_at DESC);
    """)
    return conn


def _prune_history_db():
    """Remove rows older than retention window or exceeding per-tool cap."""
    try:
        conn = _history_db()
        try:
            cutoff = (
                _datetime_type.utcnow()
                - _timedelta_type(days=_HISTORY_RETENTION_DAYS)
            ).strftime("%Y-%m-%dT%H:%M:%SZ")
            conn.execute(
                "DELETE FROM tool_history WHERE created_at < ?", (cutoff,)
            )
            tools = [
                r[0]
                for r in conn.execute(
                    "SELECT DISTINCT tool FROM tool_history"
                ).fetchall()
            ]
            for tool in tools:
                excess = conn.execute(
                    "SELECT id FROM tool_history WHERE tool = ?"
                    " ORDER BY created_at DESC LIMIT -1 OFFSET ?",
                    (tool, _HISTORY_MAX_ROWS_PER_TOOL),
                ).fetchall()
                if excess:
                    ids = [r[0] for r in excess]
                    conn.execute(
                        f"DELETE FROM tool_history WHERE id IN ({','.join('?' * len(ids))})",
                        ids,
                    )
            conn.commit()
        finally:
            conn.close()
    except Exception:
        app.logger.exception("history prune error")


def record_history(tool, action, *, actor=None, summary="", ref="", detail=None):
    """Insert one history event. Fire-and-forget; never raises."""
    try:
        import json as _json
        conn = _history_db()
        try:
            created_at = _datetime_type.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
            detail_str = _json.dumps(detail) if detail is not None else None
            conn.execute(
                "INSERT INTO tool_history (tool, action, actor, summary, ref, detail, created_at)"
                " VALUES (?, ?, ?, ?, ?, ?, ?)",
                (tool, action, actor, summary or "", ref or "", detail_str, created_at),
            )
            conn.commit()
        finally:
            conn.close()
    except Exception:
        pass


def _load_history_settings():
    """Return {capture: {tool: bool}} — defaults all captured to True."""
    defaults = {"capture": {"print": True}}
    if HISTORY_SETTINGS_FILE.is_file():
        try:
            saved = json.loads(HISTORY_SETTINGS_FILE.read_text())
            if isinstance(saved, dict) and isinstance(saved.get("capture"), dict):
                for k, v in saved["capture"].items():
                    if isinstance(v, bool):
                        defaults["capture"][k] = v
        except (json.JSONDecodeError, OSError):
            pass
    return defaults


def _is_history_capture_enabled(tool):
    """True when history capture is enabled for this tool (default True)."""
    return _load_history_settings()["capture"].get(tool, True)


# --- Print adapter for /history/print ---


def _print_history_as_generic(limit, offset, f_user, f_action):
    """Adapt print_jobs into the generic history entry shape."""
    where, params = [], []
    if f_user:
        where.append("LOWER(j.created_by) LIKE ?")
        params.append(f"%{f_user}%")
    if f_action:
        where.append("LOWER(j.source) LIKE ?")
        params.append(f"%{f_action}%")
    clause = (" WHERE " + " AND ".join(where)) if where else ""

    conn = _print_db()
    try:
        total = conn.execute(
            "SELECT COUNT(*) AS n FROM print_jobs j" + clause, params
        ).fetchone()["n"]
        rows = conn.execute(
            "SELECT j.id, j.created_by, j.source, j.file_ref, j.published_name,"
            "       j.kind, j.copies, j.created_at, a.hostname AS machine_name,"
            "       c.acked_status"
            " FROM print_jobs j"
            " JOIN print_commands c ON c.id = j.id"
            " LEFT JOIN print_agents a ON a.machine_id = j.machine_id"
            + clause
            + " ORDER BY j.created_at DESC LIMIT ? OFFSET ?",
            params + [limit, offset],
        ).fetchall()
    finally:
        conn.close()

    entries = []
    for r in rows:
        src = r["source"] or ""
        label = _PRINT_SOURCE_LABELS.get(src, src or "—")
        printer = r["published_name"] or r["machine_name"] or ""
        summary = f"{label} — {r['file_ref'] or r['id']}"
        if printer:
            summary += f" → {printer}"
        entries.append({
            "id": r["id"],
            "tool": "print",
            "action": src or "print",
            "actor": r["created_by"] or "",
            "summary": summary,
            "ref": r["file_ref"] or "",
            "created_at": _datetime_type.utcfromtimestamp(
                float(r["created_at"])
            ).strftime("%Y-%m-%dT%H:%M:%SZ") if r["created_at"] else "",
            "detail": None,
        })
    return entries, total


@app.route("/history/<tool>")
@togen_admin_required
def history_tool(tool):
    """Generic history endpoint. Print is served by an adapter; others from history.db.

    Query params: limit, offset, user (actor substring), action (action substring), q.
    """
    try:
        limit = min(int(request.args.get("limit", 100)), 500)
        offset = max(int(request.args.get("offset", 0)), 0)
    except (TypeError, ValueError):
        return jsonify(error="Invalid limit or offset."), 400

    f_user = request.args.get("user", "").strip().lower()
    f_action = request.args.get("action", "").strip().lower()
    f_q = request.args.get("q", "").strip().lower()

    if tool == "print":
        entries, total = _print_history_as_generic(limit, offset, f_user, f_action or f_q)
        return jsonify(entries=entries, total=total, offset=offset, limit=limit)

    # Generic history.db path for all other tools.
    where, params = ["tool = ?"], [tool]
    if f_user:
        where.append("LOWER(COALESCE(actor,'')) LIKE ?")
        params.append(f"%{f_user}%")
    if f_action:
        where.append("LOWER(COALESCE(action,'')) LIKE ?")
        params.append(f"%{f_action}%")
    if f_q:
        where.append("(LOWER(COALESCE(summary,'')) LIKE ? OR LOWER(COALESCE(ref,'')) LIKE ?)")
        params += [f"%{f_q}%", f"%{f_q}%"]
    clause = " WHERE " + " AND ".join(where)

    conn = _history_db()
    try:
        total = conn.execute(
            "SELECT COUNT(*) AS n FROM tool_history" + clause, params
        ).fetchone()["n"]
        rows = conn.execute(
            "SELECT id, tool, action, actor, summary, ref, created_at, detail"
            " FROM tool_history" + clause +
            " ORDER BY created_at DESC LIMIT ? OFFSET ?",
            params + [limit, offset],
        ).fetchall()
    finally:
        conn.close()

    import json as _json
    entries = []
    for r in rows:
        entries.append({
            "id": r["id"],
            "tool": r["tool"],
            "action": r["action"],
            "actor": r["actor"] or "",
            "summary": r["summary"] or "",
            "ref": r["ref"] or "",
            "created_at": r["created_at"],
            "detail": _json.loads(r["detail"]) if r["detail"] else None,
        })
    return jsonify(entries=entries, total=total, offset=offset, limit=limit)


@app.route("/togen-admin/history", methods=["GET"])
@togen_admin_required
def togen_admin_history_get():
    """Return history capture settings."""
    return jsonify(_load_history_settings())


@app.route("/togen-admin/history", methods=["POST"])
@togen_admin_required
def togen_admin_history_post():
    """Save per-tool history capture toggles."""
    body = request.get_json(silent=True) or {}
    settings = _load_history_settings()
    if isinstance(body.get("capture"), dict):
        for k, v in body["capture"].items():
            if isinstance(v, bool):
                settings["capture"][k] = v
    HISTORY_SETTINGS_FILE.write_text(json.dumps(settings, indent=2))
    return jsonify(ok=True, capture=settings["capture"])


# ---------------------------------------------------------------------------
# Print System (DVI-667 / DVI-657 P1) — agent enrollment, check-in, admin pane.
# Mirrors the Vision agent machinery (vision.db tables + /vision/* routes).
# ---------------------------------------------------------------------------

_PRINT_ENROLL_TTL_S = 15 * 60
_PRINT_OPEN_ENROLL_KEY = "open_enroll_secret"
# Authoritative agent status: pending (self-enrolled, quarantined) / active
# (quick-enrolled or approved) / revoked (token refused). The legacy `revoked`
# INTEGER mirrors status='revoked' for parity with the Vision schema.
_PRINT_AGENT_STATUSES = ("pending", "active", "revoked")


def _print_db():
    """Open the Print SQLite DB, creating tables on first use. Caller must close."""
    conn = sqlite3.connect(str(PRINT_DB_PATH), check_same_thread=False)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.executescript("""
        CREATE TABLE IF NOT EXISTS print_enrollment_tokens (
            token       TEXT PRIMARY KEY,
            machine_id  TEXT,
            issued_at   REAL NOT NULL,
            expires_at  REAL NOT NULL,
            consumed_at REAL
        );
        CREATE TABLE IF NOT EXISTS print_agents (
            machine_id   TEXT PRIMARY KEY,
            hostname     TEXT,
            agent_token  TEXT UNIQUE NOT NULL,
            enrolled_at  REAL NOT NULL,
            last_seen_at REAL,
            revoked      INTEGER NOT NULL DEFAULT 0,
            status       TEXT NOT NULL DEFAULT 'active',
            os_version      TEXT,
            logged_in_user  TEXT,
            agent_version   TEXT
        );
        CREATE TABLE IF NOT EXISTS print_printers (
            id             INTEGER PRIMARY KEY AUTOINCREMENT,
            machine_id     TEXT NOT NULL,
            raw_name       TEXT NOT NULL,
            driver         TEXT,
            port           TEXT,
            is_default     INTEGER NOT NULL DEFAULT 0,
            printer_status TEXT,
            included       INTEGER NOT NULL DEFAULT 0,
            for_documents  INTEGER NOT NULL DEFAULT 0,
            for_labels     INTEGER NOT NULL DEFAULT 0,
            published_name TEXT,
            first_seen_at  REAL,
            last_seen_at   REAL,
            UNIQUE (machine_id, raw_name),
            FOREIGN KEY (machine_id) REFERENCES print_agents (machine_id)
        );
        CREATE TABLE IF NOT EXISTS print_kv (
            key        TEXT PRIMARY KEY,
            value      TEXT,
            updated_at REAL
        );
        CREATE TABLE IF NOT EXISTS print_commands (
            id            TEXT PRIMARY KEY,
            machine_id    TEXT NOT NULL,
            action        TEXT NOT NULL,
            params_json   TEXT,
            created_at    REAL NOT NULL,
            delivered_at  REAL,
            acked_at      REAL,
            acked_status  TEXT,
            error_message TEXT,
            FOREIGN KEY (machine_id) REFERENCES print_agents (machine_id)
        );
        CREATE TABLE IF NOT EXISTS print_jobs (
            id             TEXT PRIMARY KEY,
            created_by     TEXT,
            source         TEXT,
            file_ref       TEXT,
            file_path      TEXT NOT NULL,
            machine_id     TEXT NOT NULL,
            printer_raw    TEXT NOT NULL,
            published_name TEXT,
            kind           TEXT NOT NULL,
            copies         INTEGER NOT NULL DEFAULT 1,
            created_at     REAL NOT NULL,
            FOREIGN KEY (id) REFERENCES print_commands (id),
            FOREIGN KEY (machine_id) REFERENCES print_agents (machine_id)
        );
        CREATE INDEX IF NOT EXISTS idx_print_printers_machine
            ON print_printers (machine_id);
        CREATE INDEX IF NOT EXISTS idx_print_jobs_creator
            ON print_jobs (created_by);
    """)
    return conn


def _print_kv_get(conn, key):
    row = conn.execute("SELECT value FROM print_kv WHERE key = ?", (key,)).fetchone()
    return row["value"] if row else None


def _print_kv_set(conn, key, value):
    conn.execute(
        "INSERT INTO print_kv (key, value, updated_at) VALUES (?, ?, ?)"
        " ON CONFLICT(key) DO UPDATE SET value = excluded.value,"
        " updated_at = excluded.updated_at",
        (key, value, time.time()),
    )


def _print_get_open_enroll_secret(conn, create=True):
    """Return the current open-enroll shared secret, minting one on first use."""
    secret = _print_kv_get(conn, _PRINT_OPEN_ENROLL_KEY)
    if not secret and create:
        secret = uuid.uuid4().hex
        _print_kv_set(conn, _PRINT_OPEN_ENROLL_KEY, secret)
        conn.commit()
    return secret


def _print_upsert_printers(conn, machine_id, printers):
    """Upsert agent-reported printers. Raw fields (driver/port/is_default/status)
    refresh on every check-in; admin-set config (included / for_documents /
    for_labels / published_name) is preserved across reports."""
    now = time.time()
    for p in printers or []:
        if not isinstance(p, dict):
            continue
        raw_name = (p.get("raw_name") or "").strip()
        if not raw_name:
            continue
        conn.execute(
            """INSERT INTO print_printers
                   (machine_id, raw_name, driver, port, is_default, printer_status,
                    first_seen_at, last_seen_at)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?)
               ON CONFLICT(machine_id, raw_name) DO UPDATE SET
                   driver = excluded.driver,
                   port = excluded.port,
                   is_default = excluded.is_default,
                   printer_status = excluded.printer_status,
                   last_seen_at = excluded.last_seen_at""",
            (machine_id, raw_name, p.get("driver") or "", p.get("port") or "",
             1 if p.get("is_default") else 0, p.get("status") or "",
             now, now),
        )


@app.route("/print/agents/enroll", methods=["POST"])
@togen_admin_required
def print_agents_enroll():
    """Issue a short-lived enrollment token. machine_id optional: supplying it
    pre-binds the token (quick-enroll → active); omitting it lands the agent
    pending (admin approval required)."""
    body = request.get_json(silent=True) or {}
    machine_id = (body.get("machine_id") or "").strip() or None
    token = str(uuid.uuid4())
    now = time.time()
    conn = _print_db()
    try:
        conn.execute(
            "INSERT INTO print_enrollment_tokens (token, machine_id, issued_at, expires_at)"
            " VALUES (?, ?, ?, ?)",
            (token, machine_id, now, now + _PRINT_ENROLL_TTL_S),
        )
        conn.commit()
    finally:
        conn.close()
    return jsonify(enrollment_token=token, machine_id=machine_id,
                   mode="quick" if machine_id else "self")


@app.route("/print/agents/register", methods=["POST"])
def print_agent_register():
    """Machine presents a credential; receives a permanent agent_token.

    Three trust paths (mirrors Vision): machine-bound token → active;
    NULL-machine token → pending; open-enroll secret → pending.
    """
    body = request.get_json(silent=True) or {}
    auth = request.headers.get("Authorization", "")
    credential = (body.get("enrollment_token") or
                  (auth[7:].strip() if auth.startswith("Bearer ") else ""))
    open_secret_body = (body.get("open_enroll_secret") or "").strip()
    machine_id_body = (body.get("machine_id") or "").strip()
    hostname = (body.get("hostname") or "").strip() or None
    if not credential and not open_secret_body:
        return jsonify(error="enrollment_token or open_enroll_secret required."), 400

    now = time.time()
    conn = _print_db()
    try:
        open_secret = _print_get_open_enroll_secret(conn, create=False)
        via_open = bool(open_secret) and (
            open_secret_body == open_secret or credential == open_secret
        )

        if via_open:
            if not machine_id_body:
                return jsonify(error="machine_id required for self-enroll."), 400
            effective_machine_id, status = machine_id_body, "pending"
        else:
            row = conn.execute(
                "SELECT * FROM print_enrollment_tokens WHERE token = ?", (credential,)
            ).fetchone()
            if not row:
                return jsonify(error="Invalid enrollment_token."), 401
            if row["consumed_at"] is not None:
                return jsonify(error="enrollment_token already used."), 401
            if now > row["expires_at"]:
                return jsonify(error="enrollment_token expired."), 401
            if row["machine_id"]:
                if machine_id_body and machine_id_body != row["machine_id"]:
                    return jsonify(error="machine_id does not match enrollment."), 403
                effective_machine_id, status = row["machine_id"], "active"
            else:
                if not machine_id_body:
                    return jsonify(error="machine_id required for self-enroll."), 400
                effective_machine_id, status = machine_id_body, "pending"
            conn.execute(
                "UPDATE print_enrollment_tokens SET consumed_at = ? WHERE token = ?",
                (now, credential),
            )

        existing = conn.execute(
            "SELECT agent_token, status FROM print_agents WHERE machine_id = ?",
            (effective_machine_id,),
        ).fetchone()
        if existing:
            agent_token = existing["agent_token"]
            status = existing["status"]  # re-register keeps prior disposition
        else:
            agent_token = str(uuid.uuid4())
            conn.execute(
                "INSERT INTO print_agents"
                " (machine_id, hostname, agent_token, enrolled_at, status, revoked)"
                " VALUES (?, ?, ?, ?, ?, ?)",
                (effective_machine_id, hostname, agent_token, now, status,
                 1 if status == "revoked" else 0),
            )
        conn.commit()
    finally:
        conn.close()
    return jsonify(agent_token=agent_token, machine_id=effective_machine_id, status=status)


@app.route("/print/checkin", methods=["POST"])
def print_checkin():
    """Agent → server check-in. Validates the bearer agent_token, refreshes the
    agent fingerprint, upserts reported printers, and dequeues the oldest unacked
    command (acking the prior delivery)."""
    auth = request.headers.get("Authorization", "")
    token = auth[7:].strip() if auth.startswith("Bearer ") else ""
    if not token:
        return jsonify(error="Missing bearer agent_token."), 401

    conn = _print_db()
    try:
        agent = conn.execute(
            "SELECT machine_id, status FROM print_agents WHERE agent_token = ? AND revoked = 0",
            (token,),
        ).fetchone()
        if not agent:
            return jsonify(error="Invalid or revoked agent_token."), 401

        machine_id = agent["machine_id"]
        agent_status = agent["status"]
        body = request.get_json(silent=True) or {}
        now = time.time()

        conn.execute(
            "UPDATE print_agents SET last_seen_at = ?,"
            " hostname = COALESCE(?, hostname),"
            " os_version = COALESCE(?, os_version),"
            " logged_in_user = COALESCE(?, logged_in_user),"
            " agent_version = COALESCE(?, agent_version)"
            " WHERE machine_id = ?",
            (now, body.get("hostname"), body.get("os_version"),
             body.get("logged_in_user"), body.get("agent_version"), machine_id),
        )

        # Only trust reported printers from an approved agent.
        if agent_status == "active":
            _print_upsert_printers(conn, machine_id, body.get("printers"))

        cmd_row = conn.execute(
            "SELECT id, action, params_json FROM print_commands"
            " WHERE machine_id = ? AND acked_at IS NULL"
            " ORDER BY created_at ASC LIMIT 1",
            (machine_id,),
        ).fetchone()
        if cmd_row:
            conn.execute(
                "UPDATE print_commands SET delivered_at = COALESCE(delivered_at, ?)"
                " WHERE id = ?",
                (now, cmd_row["id"]),
            )
        conn.commit()
    finally:
        conn.close()

    command = None
    if cmd_row:
        params = None
        if cmd_row["params_json"]:
            try:
                params = json.loads(cmd_row["params_json"])
            except (json.JSONDecodeError, TypeError):
                params = None
        command = {"id": cmd_row["id"], "action": cmd_row["action"], "params": params}

    return jsonify(ack=True, next_interval_s=10, command=command,
                   agent_status=agent_status)


@app.route("/print/commands/<cmd_id>/ack", methods=["POST"])
def print_command_ack(cmd_id):
    """Agent acknowledges execution of a delivered command."""
    auth = request.headers.get("Authorization", "")
    token = auth[7:].strip() if auth.startswith("Bearer ") else ""
    if not token:
        return jsonify(error="Missing bearer agent_token."), 401

    body = request.get_json(silent=True) or {}
    status = (body.get("status") or "").strip()
    if status not in ("ok", "error"):
        return jsonify(error="status must be 'ok' or 'error'."), 400
    error_message = body.get("error_message") or None

    now = time.time()
    conn = _print_db()
    try:
        agent = conn.execute(
            "SELECT machine_id FROM print_agents WHERE agent_token = ? AND revoked = 0",
            (token,),
        ).fetchone()
        if not agent:
            return jsonify(error="Invalid or revoked agent_token."), 401
        cmd = conn.execute(
            "SELECT id FROM print_commands WHERE id = ? AND machine_id = ?",
            (cmd_id, agent["machine_id"]),
        ).fetchone()
        if not cmd:
            return jsonify(error="Command not found."), 404
        conn.execute(
            "UPDATE print_commands SET acked_at = ?, acked_status = ?, error_message = ?"
            " WHERE id = ?",
            (now, status, error_message, cmd_id),
        )
        conn.commit()
    finally:
        conn.close()
    return jsonify(ack=True)


@app.route("/print/agents")
@togen_admin_required
def print_agents_list():
    """List enrolled print agents with last-seen + status. Pending agents carry
    their fingerprint so the Print pane can render an approval card."""
    now = time.time()
    conn = _print_db()
    try:
        rows = conn.execute(
            "SELECT machine_id, hostname, enrolled_at, last_seen_at, revoked, status,"
            "       os_version, logged_in_user, agent_version"
            " FROM print_agents ORDER BY enrolled_at DESC"
        ).fetchall()
    finally:
        conn.close()
    agents = []
    for r in rows:
        last_seen = r["last_seen_at"]
        agents.append({
            "machine_id": r["machine_id"],
            "hostname": r["hostname"],
            "enrolled_at": r["enrolled_at"],
            "last_seen": int(last_seen) if last_seen else None,
            "online": bool(last_seen) and (now - last_seen) <= PRINT_STALE_SECONDS,
            "status": r["status"],
            "revoked": bool(r["revoked"]),
            "fingerprint": {
                "hostname": r["hostname"],
                "os_version": r["os_version"],
                "logged_in_user": r["logged_in_user"],
                "agent_version": r["agent_version"],
            },
        })
    return jsonify(agents=agents)


def _print_set_agent_status(machine_id, new_status, require_pending=False):
    """Shared approve/deny/revoke transition. Returns (payload, http_status)."""
    machine_id = (machine_id or "").strip()
    conn = _print_db()
    try:
        row = conn.execute(
            "SELECT status FROM print_agents WHERE machine_id = ?", (machine_id,)
        ).fetchone()
        if not row:
            return {"error": "machine_id not found."}, 404
        if require_pending and row["status"] != "pending":
            return {"error": f"machine is '{row['status']}', not pending."}, 409
        conn.execute(
            "UPDATE print_agents SET status = ?, revoked = ? WHERE machine_id = ?",
            (new_status, 1 if new_status == "revoked" else 0, machine_id),
        )
        conn.commit()
    finally:
        conn.close()
    return {"ok": True, "machine_id": machine_id, "status": new_status}, 200


@app.route("/print/agents/<machine_id>/approve", methods=["POST"])
@togen_admin_required
def print_agent_approve(machine_id):
    """Approve a self-enrolled print agent (pending → active)."""
    payload, code = _print_set_agent_status(machine_id, "active", require_pending=True)
    return jsonify(**payload), code


@app.route("/print/agents/<machine_id>/deny", methods=["POST"])
@togen_admin_required
def print_agent_deny(machine_id):
    """Deny a self-enrolled print agent (pending → revoked)."""
    payload, code = _print_set_agent_status(machine_id, "revoked", require_pending=True)
    return jsonify(**payload), code


@app.route("/print/agents/<machine_id>/revoke", methods=["POST"])
@togen_admin_required
def print_agent_revoke(machine_id):
    """Revoke a print agent's token (status=revoked); future check-ins refused."""
    payload, code = _print_set_agent_status(machine_id, "revoked")
    return jsonify(**payload), code


@app.route("/print/agents/<machine_id>", methods=["DELETE"])
@togen_admin_required
def print_agent_delete(machine_id):
    """Delete a revoked print agent and its orphan rows (printers/commands/jobs).
    Only revoked agents are removable (409 otherwise) so a live agent can't be
    silently dropped out from under an active check-in loop."""
    machine_id = (machine_id or "").strip()
    conn = _print_db()
    try:
        row = conn.execute(
            "SELECT revoked FROM print_agents WHERE machine_id = ?", (machine_id,)
        ).fetchone()
        if not row:
            return jsonify(error="machine_id not found."), 404
        if not row["revoked"]:
            return jsonify(error="agent is not revoked; revoke it before deleting."), 409
        conn.execute("DELETE FROM print_jobs WHERE machine_id = ?", (machine_id,))
        conn.execute("DELETE FROM print_commands WHERE machine_id = ?", (machine_id,))
        conn.execute("DELETE FROM print_printers WHERE machine_id = ?", (machine_id,))
        conn.execute("DELETE FROM print_agents WHERE machine_id = ?", (machine_id,))
        conn.commit()
    finally:
        conn.close()
    return jsonify(ok=True, machine_id=machine_id, deleted=True)


@app.route("/print/agents/<machine_id>/printers")
@togen_admin_required
def print_agent_printers_get(machine_id):
    """List the printers discovered by an agent with their Togen config."""
    machine_id = (machine_id or "").strip()
    conn = _print_db()
    try:
        agent = conn.execute(
            "SELECT machine_id, hostname, status FROM print_agents WHERE machine_id = ?",
            (machine_id,),
        ).fetchone()
        if not agent:
            return jsonify(error="machine_id not found."), 404
        rows = conn.execute(
            "SELECT id, raw_name, driver, port, is_default, printer_status,"
            "       included, for_documents, for_labels, published_name, last_seen_at"
            " FROM print_printers WHERE machine_id = ? ORDER BY raw_name COLLATE NOCASE",
            (machine_id,),
        ).fetchall()
    finally:
        conn.close()
    printers = [{
        "id": r["id"],
        "raw_name": r["raw_name"],
        "driver": r["driver"],
        "port": r["port"],
        "is_default": bool(r["is_default"]),
        "printer_status": r["printer_status"],
        "included": bool(r["included"]),
        "for_documents": bool(r["for_documents"]),
        "for_labels": bool(r["for_labels"]),
        "published_name": r["published_name"] or "",
        "last_seen": int(r["last_seen_at"]) if r["last_seen_at"] else None,
    } for r in rows]
    return jsonify(machine_id=machine_id, hostname=agent["hostname"],
                   status=agent["status"], printers=printers)


@app.route("/print/agents/<machine_id>/printers", methods=["POST"])
@togen_admin_required
def print_agent_printers_post(machine_id):
    """Save per-printer Togen config: included / for_documents / for_labels /
    published_name. Body: {printers: [{id, included, for_documents, for_labels,
    published_name}, ...]}. Only printers belonging to this agent are updated."""
    machine_id = (machine_id or "").strip()
    body = request.get_json(silent=True) or {}
    items = body.get("printers")
    if not isinstance(items, list):
        return jsonify(error="printers list required."), 400
    conn = _print_db()
    try:
        agent = conn.execute(
            "SELECT machine_id FROM print_agents WHERE machine_id = ?", (machine_id,)
        ).fetchone()
        if not agent:
            return jsonify(error="machine_id not found."), 404
        for it in items:
            if not isinstance(it, dict) or "id" not in it:
                continue
            try:
                pid = int(it["id"])
            except (TypeError, ValueError):
                continue
            included = 1 if it.get("included") else 0
            for_documents = 1 if it.get("for_documents") else 0
            for_labels = 1 if it.get("for_labels") else 0
            published = str(it.get("published_name") or "").strip()
            conn.execute(
                "UPDATE print_printers SET included = ?, for_documents = ?,"
                " for_labels = ?, published_name = ? WHERE id = ? AND machine_id = ?",
                (included, for_documents, for_labels, published, pid, machine_id),
            )
        conn.commit()
    finally:
        conn.close()
    return jsonify(ok=True)


@app.route("/print/enroll-secret")
@togen_admin_required
def print_enroll_secret_get():
    """Return the current open-enroll shared secret (minted on first read)."""
    conn = _print_db()
    try:
        secret = _print_get_open_enroll_secret(conn, create=True)
    finally:
        conn.close()
    return jsonify(open_enroll_secret=secret)


@app.route("/print/enroll-secret/rotate", methods=["POST"])
@togen_admin_required
def print_enroll_secret_rotate():
    """Rotate the open-enroll secret. Prior secret stops working immediately;
    already-enrolled agents keep their agent_token (unaffected)."""
    new_secret = uuid.uuid4().hex
    conn = _print_db()
    try:
        _print_kv_set(conn, _PRINT_OPEN_ENROLL_KEY, new_secret)
        conn.commit()
    finally:
        conn.close()
    return jsonify(open_enroll_secret=new_secret)


# ---------------------------------------------------------------------------
# Print job dispatch (DVI-669 / DVI-657 P3)
# ---------------------------------------------------------------------------

# Server-side spool for PDFs produced by job creation (e.g. image→PDF wraps).
# Originals (already-PDF refs) are referenced in place; only conversions land here.
PRINT_SPOOL_DIR = Path(
    os.environ.get("PRINT_SPOOL_DIR") or (Path(tempfile.gettempdir()) / "togen_print_spool")
)

# file_ref sources mapped to the directory root they resolve against. Anything
# else (and the "wor" pseudo-source) is handled explicitly in the resolver.
_PRINT_SOURCE_ROOTS = {
    "watch": WATCH_DIR,
    "projectbuilder": PROJECTBUILDER_DIR,
    "shared": SHARED_DIR,          # File Browser + Single Carrier Sheet (DVI-670)
    "mtslabels": MTS_LABELS_DIR,   # MTS Label Creator (DVI-670)
}

_PRINT_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tif", ".tiff"}
_PRINT_MAX_COPIES = 50


def _print_roots_allowlist():
    """Absolute roots a resolved print file may live under (defense in depth)."""
    roots = [WATCH_DIR.resolve(), PROJECTBUILDER_DIR.resolve(), PRINT_SPOOL_DIR.resolve(),
             SHARED_DIR.resolve(), MTS_LABELS_DIR.resolve(), PRODRUN_DIR.resolve()]
    try:
        roots.extend(d.resolve() for d in _wor_type_dirs(None))
    except Exception:
        pass
    return roots


def _print_path_within_allowlist(path):
    p = str(Path(path).resolve())
    for root in _print_roots_allowlist():
        r = str(root)
        if p == r or p.startswith(r + os.sep):
            return True
    return False


def _print_ensure_pdf(path):
    """Return a printable PDF path for ``path``. PDFs pass through; raster images
    are wrapped into a one-page PDF in the spool dir. Returns (pdf_path, error)."""
    src = Path(path)
    ext = src.suffix.lower()
    if ext == ".pdf":
        return src, None
    if ext in _PRINT_IMAGE_EXTS:
        try:
            PRINT_SPOOL_DIR.mkdir(parents=True, exist_ok=True)
            out = PRINT_SPOOL_DIR / f"{uuid.uuid4().hex}.pdf"
            with _pil_image.open(src) as im:
                im.convert("RGB").save(str(out), "PDF", resolution=300.0)
            return out, None
        except Exception as exc:  # noqa: BLE001 — surface conversion failure to caller
            return None, f"Could not convert image to PDF: {exc}"
    return None, f"Unsupported file type for printing: {ext or '(none)'}"


def _resolve_print_file(source, file_ref):
    """Resolve a (source, file_ref) pair to a server-side PDF path.

    Sources are allowlisted directory roots; ``file_ref`` is always treated as a
    path relative to that root and is traversal-guarded. The ``wor`` pseudo-source
    delegates to the WOR file finder. Non-PDF raster images are converted to PDF.
    Returns (pdf_path, error_message)."""
    source = (source or "").strip().lower()
    file_ref = (file_ref or "").strip()
    if not file_ref:
        return None, "file_ref is required."

    if source == "wor":
        resolved = _resolve_wor_file(file_ref)
        if not resolved:
            return None, "WOR file not found."
        return _print_ensure_pdf(resolved)

    if source == "prodrun":
        # file_ref is "<job_id>/<filename>"; delegate to the merge-file finder
        # (it searches the job's date subdirs and is traversal-guarded).
        job_id, _, fname = file_ref.partition("/")
        if not job_id or not fname or ".." in job_id or ".." in fname:
            return None, "Invalid file_ref."
        resolved = _prodrun_find_file(job_id, fname)
        if not resolved:
            return None, "File not found."
        return _print_ensure_pdf(resolved)

    root = _PRINT_SOURCE_ROOTS.get(source)
    if root is None:
        return None, f"Unknown print source: {source or '(none)'}"
    if ".." in file_ref:
        return None, "Invalid file_ref."
    root_resolved = root.resolve()
    candidate = (root_resolved / file_ref.lstrip("/")).resolve()
    if candidate != root_resolved and not str(candidate).startswith(str(root_resolved) + os.sep):
        return None, "file_ref escapes the source root."
    if not candidate.is_file():
        return None, "File not found."
    return _print_ensure_pdf(candidate)


def _resolve_published_printer(published_name):
    """Map a published printer name to (machine_id, raw_name) for an included
    printer on an active agent. Falls back to matching raw_name. Prefers the
    most-recently-seen printer when several match. Returns None if unresolved."""
    name = (published_name or "").strip()
    if not name:
        return None
    conn = _print_db()
    try:
        rows = conn.execute(
            "SELECT p.machine_id, p.raw_name, p.published_name, p.last_seen_at"
            " FROM print_printers p"
            " JOIN print_agents a ON a.machine_id = p.machine_id"
            " WHERE p.included = 1 AND a.status = 'active'"
            " ORDER BY p.last_seen_at DESC"
        ).fetchall()
    finally:
        conn.close()
    low = name.lower()
    # Prefer an explicit published_name match, then raw_name.
    for r in rows:
        if (r["published_name"] or "").strip().lower() == low:
            return r["machine_id"], r["raw_name"]
    for r in rows:
        if (r["raw_name"] or "").strip().lower() == low:
            return r["machine_id"], r["raw_name"]
    return None


def _print_kind_settings(kind):
    """SumatraPDF -print-settings string per document kind. Documents shrink to
    the printable area; labels print at native size (no scaling) so barcodes and
    QR modules keep their exact dimensions."""
    return "noscale" if kind == "label" else "shrink"


def _print_job_status(cmd_row):
    """Derive a job status from its command row's lifecycle timestamps."""
    if cmd_row["acked_at"] is not None:
        return "done" if cmd_row["acked_status"] == "ok" else "error"
    if cmd_row["delivered_at"] is not None:
        return "printing"
    return "queued"


@app.route("/print/options")
@login_required
def print_options():
    """Printer choices + the caller's effective defaults for the print popups.

    Returns ``{available_printers: {documents, labels}, effective_defaults:
    {document_printer, label_printer}}``. The tool Print buttons (DVI-670 / P4)
    use this to pre-select the effective default and populate the dropdown."""
    email = session["user"].get("email", "")
    return jsonify(
        available_printers=_available_printers(),
        effective_defaults=_effective_default_printers(email),
    )


@app.route("/print/jobs", methods=["POST"])
@login_required
def print_jobs_create():
    """Create a print job: resolve the file + target printer, then enqueue a
    ``print`` command for the owning agent. (DVI-669 / DVI-657 P3)

    Body: {source, file_ref, printer|"__default__"|"", kind: document|label, copies?}
    """
    if not _user_can_print(session.get("user")):
        return jsonify(error="The Print system is currently disabled."), 403
    email = session["user"].get("email", "")
    body = request.get_json(silent=True) or {}

    kind = (body.get("kind") or "document").strip().lower()
    if kind not in ("document", "label"):
        return jsonify(error="kind must be 'document' or 'label'."), 400

    try:
        copies = int(body.get("copies") or 1)
    except (TypeError, ValueError):
        return jsonify(error="copies must be an integer."), 400
    if copies < 1 or copies > _PRINT_MAX_COPIES:
        return jsonify(error=f"copies must be between 1 and {_PRINT_MAX_COPIES}."), 400

    # Resolve the target printer's published name (explicit, or the user's
    # effective default for this kind via the P2 resolver).
    requested = body.get("printer")
    if requested in (None, "", PRINT_USER_DEFAULT):
        defaults = _effective_default_printers(email)
        published = defaults.get("label_printer" if kind == "label" else "document_printer", "")
        if not published:
            return jsonify(error=f"No default {kind} printer configured."), 400
    else:
        published = str(requested).strip()

    resolved_printer = _resolve_published_printer(published)
    if not resolved_printer:
        return jsonify(error=f"Printer not available: {published}"), 400
    machine_id, raw_name = resolved_printer

    pdf_path, ferr = _resolve_print_file(body.get("source"), body.get("file_ref"))
    if ferr:
        # Missing file → 404; everything else is a bad request.
        code = 404 if ferr in ("File not found.", "WOR file not found.") else 400
        return jsonify(error=ferr), code

    job_id = str(uuid.uuid4())
    now = time.time()
    params = {
        "printer": raw_name,
        "kind": kind,
        "copies": copies,
        "options": {"print_settings": _print_kind_settings(kind)},
    }
    conn = _print_db()
    try:
        conn.execute(
            "INSERT INTO print_commands (id, machine_id, action, params_json, created_at)"
            " VALUES (?, ?, 'print', ?, ?)",
            (job_id, machine_id, json.dumps(params), now),
        )
        conn.execute(
            "INSERT INTO print_jobs (id, created_by, source, file_ref, file_path,"
            " machine_id, printer_raw, published_name, kind, copies, created_at)"
            " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (job_id, email, (body.get("source") or "").strip(),
             (body.get("file_ref") or "").strip(), str(pdf_path), machine_id,
             raw_name, published, kind, copies, now),
        )
        conn.commit()
    finally:
        conn.close()

    return jsonify(job_id=job_id, status="queued", printer=published,
                   machine_id=machine_id, kind=kind, copies=copies), 201


@app.route("/print/jobs/<job_id>")
@login_required
def print_job_status(job_id):
    """Return a print job's status. Visible to its creator or any Togen admin."""
    email = session["user"].get("email", "")
    conn = _print_db()
    try:
        job = conn.execute(
            "SELECT j.id, j.created_by, j.published_name, j.kind, j.copies,"
            "       j.created_at, c.delivered_at, c.acked_at, c.acked_status,"
            "       c.error_message"
            " FROM print_jobs j JOIN print_commands c ON c.id = j.id"
            " WHERE j.id = ?",
            (job_id,),
        ).fetchone()
    finally:
        conn.close()
    if not job:
        return jsonify(error="Job not found."), 404
    if job["created_by"] != email and not session["user"].get("togen_admin"):
        return jsonify(error="Forbidden."), 403
    return jsonify(
        job_id=job["id"],
        status=_print_job_status(job),
        printer=job["published_name"] or "",
        kind=job["kind"],
        copies=job["copies"],
        created_at=job["created_at"],
        error_message=job["error_message"] or None,
    )


@app.route("/print/jobs/<job_id>/file")
def print_job_file(job_id):
    """Agent download of a job's PDF. Gated by the agent bearer token; the job
    must belong to the requesting agent's machine."""
    auth = request.headers.get("Authorization", "")
    token = auth[7:].strip() if auth.startswith("Bearer ") else ""
    if not token:
        return jsonify(error="Missing bearer agent_token."), 401
    conn = _print_db()
    try:
        agent = conn.execute(
            "SELECT machine_id FROM print_agents WHERE agent_token = ? AND revoked = 0",
            (token,),
        ).fetchone()
        if not agent:
            return jsonify(error="Invalid or revoked agent_token."), 401
        job = conn.execute(
            "SELECT machine_id, file_path FROM print_jobs WHERE id = ?", (job_id,)
        ).fetchone()
    finally:
        conn.close()
    if not job:
        return jsonify(error="Job not found."), 404
    if job["machine_id"] != agent["machine_id"]:
        return jsonify(error="Forbidden."), 403
    file_path = job["file_path"]
    if not _print_path_within_allowlist(file_path) or not os.path.isfile(file_path):
        return jsonify(error="Job file unavailable."), 404
    return send_file(file_path, mimetype="application/pdf",
                     as_attachment=True, download_name=f"{job_id}.pdf")


@app.route("/enroll/print-agent.zip")
def print_enroll_agent_zip():
    """Serve the frozen print-agent bundle (exe + installer). 404 with guidance
    until the artifact is built and deployed. No auth: the ZIP has no secrets."""
    path = PRINT_AGENT_DIST_DIR / PRINT_AGENT_ZIP_NAME
    if not path.is_file():
        return jsonify(
            error="Print agent bundle not available yet.",
            detail=f"Expected {PRINT_AGENT_ZIP_NAME} in PRINT_AGENT_DIST_DIR "
                   f"({PRINT_AGENT_DIST_DIR}); run build_print_agent.ps1 on a "
                   f"Windows host to produce the bundle, then deploy to the server.",
        ), 404
    return send_file(str(path), as_attachment=True, download_name=PRINT_AGENT_ZIP_NAME)


@app.route("/enroll/print-bootstrap.ps1")
def print_enroll_bootstrap():
    """Render a PowerShell onboarding script that downloads the Print Agent bundle
    and enrolls it. ?token=<enrollment_token> embeds a per-machine token
    (quick-enroll → active); otherwise the current open-enroll secret is embedded
    (self-enroll → pending). Mirrors /enroll/bootstrap.ps1 but targets the Print
    bundle and install-print.ps1.

    Auth: the script runs on a fresh machine with no Togen session cookie, so the
    quick-enroll path authenticates via the ?token= enrollment token itself (the
    same bearer credential /print/agents/register already trusts). The token-less
    self-enroll path embeds the open-enroll secret, so it stays gated behind a
    Togen Admin session to avoid serving that secret anonymously. Requiring an
    admin session unconditionally would 302-redirect the sessionless machine to
    the Microsoft SSO page, whose HTML then gets piped into `iex` (DVI-674)."""
    base = _vision_server_base_url()
    token = (request.args.get("token") or "").strip()
    if token:
        credential, mode = token, "quick"
    else:
        if not (session.get("user") and session["user"].get("togen_admin")):
            return (
                "Write-Host 'ERROR: This bootstrap URL needs a ?token= enrollment "
                "token (generate one via Togen Admin -> Agents -> Print -> Enroll), "
                "or download it from an authenticated Togen Admin session.' "
                "-ForegroundColor Red\nexit 1\n",
                403,
                {"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store"},
            )
        conn = _print_db()
        try:
            credential = _print_get_open_enroll_secret(conn, create=True)
        finally:
            conn.close()
        mode = "self"
    zip_url = f"{base}/enroll/print-agent.zip"
    ps_cred = credential.replace("'", "''")
    ps_base = base.replace("'", "''")
    script = f"""# Togen Print agent bootstrap ({mode}-enroll) — generated {time.strftime('%Y-%m-%d %H:%M:%S')}
# Run as Administrator.  Downloads the agent bundle and delegates to install-print.ps1.
$ErrorActionPreference = 'Stop'
$Server     = '{ps_base}'
$Credential = '{ps_cred}'
$Zip        = Join-Path $env:TEMP 'togen-print-agent.zip'
$ExtractDir = Join-Path $env:TEMP 'togen-print-agent'

# -- Download (no auth required; ZIP contains no secrets) -------------------
Write-Host 'Downloading Togen Print agent...'
try {{
    Invoke-WebRequest -Uri '{zip_url}' -OutFile $Zip -UseBasicParsing
}} catch {{
    Write-Host "ERROR: Download failed: $_" -ForegroundColor Red
    Write-Host 'Ensure the print agent bundle has been built and deployed. Contact your IT admin.'
    exit 1
}}
$ZipSize = (Get-Item $Zip -ErrorAction SilentlyContinue).Length
if (-not $ZipSize -or $ZipSize -lt 4096) {{
    Write-Host 'ERROR: Print agent bundle not available on this server yet.' -ForegroundColor Red
    Write-Host 'The Windows build step must be run first. Contact your IT admin.'
    Remove-Item $Zip -Force -ErrorAction SilentlyContinue
    exit 1
}}

# -- Extract ----------------------------------------------------------------
if (Test-Path $ExtractDir) {{ Remove-Item -Recurse -Force $ExtractDir }}
Expand-Archive -Path $Zip -DestinationPath $ExtractDir -Force
Remove-Item $Zip -Force -ErrorAction SilentlyContinue

# -- Install (enroll + Scheduled Task via install-print.ps1 from the bundle) -
$Installer = Join-Path $ExtractDir 'install-print.ps1'
if (-not (Test-Path $Installer)) {{
    Write-Host 'ERROR: install-print.ps1 missing from agent bundle.' -ForegroundColor Red
    exit 1
}}
Write-Host 'Running installer...'
& powershell.exe -ExecutionPolicy Bypass -NonInteractive -File $Installer -Server $Server -Enroll $Credential
Write-Host ''
Write-Host 'Bootstrap complete.' -ForegroundColor Green
Write-Host 'If this machine registered as PENDING, visit Togen Admin'
Write-Host '  -> Agents -> Print -> Status tab -> Approve this machine.'
"""
    return script, 200, {
        "Content-Type": "text/plain; charset=utf-8",
        "Content-Disposition": "attachment; filename=install-print.ps1",
        "Cache-Control": "no-store",
    }


# ---------------------------------------------------------------------------
# Routes — Assistant (DVI-529)
# ---------------------------------------------------------------------------

@app.route("/togen-admin/assistant")
@togen_admin_required
def togen_admin_assistant_get():
    """Return global assistant settings (key masked). Admin only."""
    from assistant import load_assistant_settings, mask_key, ASSISTANT_PROVIDERS
    cfg = load_assistant_settings()
    return jsonify(
        enabled=cfg.get("enabled", False),
        accessible_to_users=cfg.get("accessible_to_users", False),
        default_context_md=cfg.get("default_context_md", ""),
        api_key=mask_key(cfg.get("api_key")),
        providers=list(ASSISTANT_PROVIDERS.values()),
    )


@app.route("/togen-admin/assistant", methods=["POST"])
@togen_admin_required
def togen_admin_assistant_post():
    """Update global assistant settings. Admin only.
    Accepts: enabled, accessible_to_users, default_context_md, api_key.
    Key is write-only; omit the field to leave existing key unchanged.
    """
    from assistant import load_assistant_settings, save_assistant_settings
    body = request.get_json(silent=True) or {}
    cfg = load_assistant_settings()

    if "enabled" in body:
        cfg["enabled"] = bool(body["enabled"])
    if "accessible_to_users" in body:
        cfg["accessible_to_users"] = bool(body["accessible_to_users"])
    if "default_context_md" in body:
        cfg["default_context_md"] = str(body["default_context_md"])
    if "api_key" in body:
        key_val = str(body["api_key"]).strip() if body["api_key"] else ""
        if key_val and not all(c in "•" for c in key_val):
            cfg["api_key"] = key_val
        elif key_val == "" and "api_key" in cfg:
            del cfg["api_key"]

    save_assistant_settings(cfg)
    return jsonify(ok=True)


@app.route("/assistant/connection")
@login_required
def assistant_connection_get():
    """Return the current user's assistant connection (key masked)."""
    from assistant import get_user_assistant, mask_key, ASSISTANT_PROVIDERS
    email = session["user"]["email"]
    user_cfg = _load_user_settings(email)
    ua = get_user_assistant(user_cfg)
    return jsonify(
        api_key=mask_key(ua.get("api_key")),
        model=ua.get("model", ""),
        providers=list(ASSISTANT_PROVIDERS.values()),
    )


@app.route("/assistant/status")
@login_required
def assistant_status():
    """Live status for the topbar Assistant indicator dot (DVI-629).

    available -> MCP/provider connection usable (drives green vs red)
    busy      -> a background prompt/workflow is running (drives yellow). No
                 background processes exist yet, so this is always False today;
                 the field is here so the future feature flips it without a
                 frontend change.
    """
    from assistant import is_assistant_connected
    email = session["user"]["email"]
    is_admin = session["user"].get("togen_admin", False)
    user_cfg = _load_user_settings(email)
    available = is_assistant_connected(email, is_admin, user_cfg)
    return jsonify(available=available, busy=False)


@app.route("/assistant/connection", methods=["POST"])
@login_required
def assistant_connection_post():
    """Save the current user's assistant connection (provider + key + model).
    Key is write-only; omit or send empty to clear.
    """
    from assistant import get_user_assistant, set_user_assistant
    email = session["user"]["email"]
    body = request.get_json(silent=True) or {}

    user_cfg = _load_user_settings(email)
    ua = dict(get_user_assistant(user_cfg))

    if "model" in body:
        ua["model"] = str(body["model"]).strip()
    if "api_key" in body:
        key_val = str(body["api_key"]).strip() if body["api_key"] else ""
        if key_val and not all(c in "•" for c in key_val):
            ua["api_key"] = key_val
        elif key_val == "":
            ua.pop("api_key", None)

    user_cfg = set_user_assistant(user_cfg, ua)
    _save_user_settings(email, user_cfg)
    return jsonify(ok=True)


@app.route("/assistant/context")
@login_required
def assistant_context_get():
    """Return the effective context markdown for the current user."""
    from assistant import _effective_context_md
    email = session["user"]["email"]
    user_cfg = _load_user_settings(email)
    return jsonify(context_md=_effective_context_md(user_cfg))


@app.route("/assistant/context", methods=["PUT"])
@login_required
def assistant_context_put():
    """Set the current user's context md override."""
    from assistant import get_user_assistant, set_user_assistant
    email = session["user"]["email"]
    body = request.get_json(silent=True) or {}
    user_cfg = _load_user_settings(email)
    ua = dict(get_user_assistant(user_cfg))
    ua["context_md"] = str(body.get("context_md", ""))
    user_cfg = set_user_assistant(user_cfg, ua)
    _save_user_settings(email, user_cfg)
    return jsonify(ok=True)


@app.route("/assistant/chat", methods=["POST"])
@login_required
def assistant_chat():
    """Proxy a chat request to the configured LLM provider.

    Request body: { messages: [{role, content}], context_md?: str }
    context_md overrides the effective context for this request (optional).
    Returns: { reply: str, agentic: bool }
             OR { pending_action: {token, tool, display_name, display_args},
                  partial_reply: str, agentic: bool }
             when a Tier-1 (mutating) tool needs user confirmation.

    Agentic mode (DVI-557/560/561): any user with at least one authorized tool
    gets tool-calling via chat_agentic() — each user sees only the tools their
    role permits.  Users with no tool access fall back to plain chat().
    """
    from assistant import (
        is_assistant_connected, chat, chat_agentic, has_any_tool_access,
        AssistantUnavailable, AssistantError,
        PendingActionRequired,
        _effective_context_md,
    )

    user = session["user"]
    email = user["email"]
    is_admin = bool(user.get("togen_admin"))
    user_cfg = _load_user_settings(email)

    if not is_assistant_connected(email, is_admin, user_cfg):
        return jsonify(error="Assistant is not available. Configure an API key in Settings."), 403

    body = request.get_json(silent=True) or {}
    messages = body.get("messages") or []
    if not isinstance(messages, list) or not messages:
        return jsonify(error="messages must be a non-empty list."), 400

    ctx_md = body.get("context_md")
    if ctx_md is None:
        ctx_md = _effective_context_md(user_cfg)
    system = ctx_md.strip() if ctx_md else ""

    # Phase C (DVI-561): any user with at least one authorized tool uses the
    # agentic loop; chat_agentic() filters tools to the user's own roles.
    is_agentic = is_admin or has_any_tool_access(user)

    try:
        if is_agentic:
            reply = chat_agentic(messages, system, user=user, user_settings=user_cfg)
        else:
            reply = chat(messages, system, user_settings=user_cfg)
    except PendingActionRequired as exc:
        return jsonify(
            pending_action={
                "token":        exc.token,
                "tool":         exc.tool,
                "display_name": exc.display_name,
                "display_args": exc.display_args,
            },
            partial_reply=exc.partial_reply,
            agentic=True,
        )
    except AssistantUnavailable as exc:
        return jsonify(error=str(exc)), 403
    except AssistantError as exc:
        return jsonify(error=str(exc)), 502

    return jsonify(reply=reply, agentic=is_agentic)


@app.route("/assistant/confirm", methods=["POST"])
@login_required
def assistant_confirm():
    """Execute or cancel a pending Tier-1 tool action (Phase B+C).

    Request body: { token: str, confirmed: bool }
    Returns: { reply: str, agentic: bool }
             OR { pending_action: {...}, partial_reply: str, agentic: bool }
             when the resumed loop hits another Tier-1 tool.
    Security: the token is bound to the requesting user's email in _pending_consume();
    any user who reached the agentic loop can confirm their own pending actions.
    """
    from assistant import (
        is_assistant_connected, resume_agentic,
        AssistantUnavailable, AssistantError,
        PendingActionRequired,
    )

    user = session["user"]
    email = user["email"]
    is_admin = bool(user.get("togen_admin"))
    user_cfg = _load_user_settings(email)
    if not is_assistant_connected(email, is_admin, user_cfg):
        return jsonify(error="Assistant is not available."), 403

    body = request.get_json(silent=True) or {}
    token = str(body.get("token") or "").strip()
    confirmed = bool(body.get("confirmed", False))
    if not token:
        return jsonify(error="token is required."), 400

    try:
        reply = resume_agentic(token, confirmed, user=user, user_settings=user_cfg)
    except PendingActionRequired as exc:
        return jsonify(
            pending_action={
                "token":        exc.token,
                "tool":         exc.tool,
                "display_name": exc.display_name,
                "display_args": exc.display_args,
            },
            partial_reply=exc.partial_reply,
            agentic=True,
        )
    except AssistantUnavailable as exc:
        return jsonify(error=str(exc)), 403
    except AssistantError as exc:
        return jsonify(error=str(exc)), 502

    return jsonify(reply=reply, agentic=True)


# ---------------------------------------------------------------------------
# Auditor — Assistant tool-use audit review (DVI-558)
# ---------------------------------------------------------------------------

@app.route("/auditor/entries")
@togen_admin_required
def auditor_entries():
    """Return Assistant tool-use audit log entries. Admin only.

    Query params:
      limit:         max rows to return (default 100, max 500)
      offset:        skip N most-recent rows (default 0)
      failures_only: '1' to return only non-success outcomes
      tool:          exact tool name filter
      user:          substring match on user email (case-insensitive)
    """
    from assistant import TOOL_AUDIT_LOG

    try:
        limit = min(int(request.args.get("limit", 100)), 500)
        offset = max(int(request.args.get("offset", 0)), 0)
    except (TypeError, ValueError):
        return jsonify(error="Invalid limit or offset."), 400

    failures_only = request.args.get("failures_only", "") == "1"
    f_tool = request.args.get("tool", "").strip()
    f_user = request.args.get("user", "").strip().lower()

    entries: list[dict] = []
    if TOOL_AUDIT_LOG.is_file():
        try:
            with TOOL_AUDIT_LOG.open(encoding="utf-8") as fh:
                for line in fh:
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        entry = json.loads(line)
                    except json.JSONDecodeError:
                        continue
                    if failures_only and str(entry.get("outcome", "")) == "success":
                        continue
                    if f_tool and entry.get("tool") != f_tool:
                        continue
                    if f_user and f_user not in str(entry.get("user", "")).lower():
                        continue
                    entries.append(entry)
        except OSError as exc:
            return jsonify(error=f"Could not read audit log: {exc}"), 500

    # Reverse-chronological; paginate
    entries.reverse()
    total = len(entries)
    page = entries[offset:offset + limit]
    return jsonify(entries=page, total=total, offset=offset, limit=limit)


# Friendly tool label for a print job's ``source`` (the originating tool's Print
# button). New sources should be added here so the Auditor names them clearly.
_PRINT_SOURCE_LABELS = {
    "shared": "Carrier Sheet / File Browser",
    "wor": "Work Order Admin",
    "prodrun": "Production Run",
    "mtslabels": "MTS Labels",
}


@app.route("/auditor/print-history")
@togen_admin_required
def auditor_print_history():
    """Return Print-system job history for the Auditor → Printing tab. Admin only.

    Every Print button funnels through ``POST /print/jobs``, which records the
    job in ``print_jobs``; this endpoint is the read side of that audit trail.

    Query params:
      limit:  max rows (default 100, max 500)
      offset: skip N most-recent rows (default 0)
      user:   substring match on creator email (case-insensitive)
      source: exact source filter (shared|wor|prodrun|mtslabels)
    """
    try:
        limit = min(int(request.args.get("limit", 100)), 500)
        offset = max(int(request.args.get("offset", 0)), 0)
    except (TypeError, ValueError):
        return jsonify(error="Invalid limit or offset."), 400

    f_user = request.args.get("user", "").strip().lower()
    f_source = request.args.get("source", "").strip()

    where, params = [], []
    if f_user:
        where.append("LOWER(j.created_by) LIKE ?")
        params.append(f"%{f_user}%")
    if f_source:
        where.append("j.source = ?")
        params.append(f_source)
    clause = (" WHERE " + " AND ".join(where)) if where else ""

    conn = _print_db()
    try:
        total = conn.execute(
            "SELECT COUNT(*) AS n FROM print_jobs j" + clause, params
        ).fetchone()["n"]
        rows = conn.execute(
            "SELECT j.id, j.created_by, j.source, j.file_ref, j.published_name,"
            "       j.kind, j.copies, j.created_at, a.hostname AS machine_name,"
            "       c.delivered_at, c.acked_at, c.acked_status, c.error_message"
            " FROM print_jobs j"
            " JOIN print_commands c ON c.id = j.id"
            " LEFT JOIN print_agents a ON a.machine_id = j.machine_id"
            + clause +
            " ORDER BY j.created_at DESC LIMIT ? OFFSET ?",
            params + [limit, offset],
        ).fetchall()
    finally:
        conn.close()

    jobs = []
    for r in rows:
        src = r["source"] or ""
        jobs.append({
            "id": r["id"],
            "user": r["created_by"] or "",
            "source": src,
            "source_label": _PRINT_SOURCE_LABELS.get(src, src or "—"),
            "file_ref": r["file_ref"] or "",
            "printer": r["published_name"] or "",
            "machine_name": r["machine_name"] or "",
            "kind": r["kind"],
            "copies": r["copies"],
            "created_at": r["created_at"],
            "status": _print_job_status(r),
            "error_message": r["error_message"] or None,
        })
    return jsonify(jobs=jobs, total=total, offset=offset, limit=limit)


# ---------------------------------------------------------------------------
# Simple QR Creator (DVI-270)
# ---------------------------------------------------------------------------

@app.route("/simple-qr/generate", methods=["POST"])
@login_required
def simple_qr_generate():
    """Generate a QR code PNG from a URL string."""
    body = request.get_json(silent=True) or {}
    url = (body.get("url") or "").strip()
    if not url:
        return jsonify(error="URL is required."), 400

    try:
        qr_obj = _qrcode_mod.QRCode(
            error_correction=_qrcode_mod.constants.ERROR_CORRECT_M,
            box_size=10,
            border=2,
        )
        qr_obj.add_data(url)
        qr_obj.make(fit=True)
        img = qr_obj.make_image(fill_color="black", back_color="white").convert("RGB")
        img = img.resize((400, 400), _pil_image.LANCZOS)

        buf = io.BytesIO()
        img.save(buf, format="PNG")
        buf.seek(0)
        img_b64 = base64.b64encode(buf.read()).decode("ascii")
        return jsonify(ok=True, image=img_b64)
    except Exception as exc:
        return jsonify(error=f"QR generation failed: {exc}"), 500


@app.route("/fb/email-link", methods=["POST"])
@login_required
def fb_email_link():
    """Email a document link via Microsoft Graph API."""
    body = request.get_json(silent=True) or {}
    to_email = (body.get("email") or "").strip()
    url = (body.get("url") or "").strip()
    name = (body.get("name") or "Document").strip()

    if not to_email:
        return jsonify(error="Email address is required."), 400
    if not url:
        return jsonify(error="No document URL provided."), 400

    if not AZURE_MAIL_FROM:
        return jsonify(error="Email sender is not configured. Set AZURE_MAIL_FROM."), 500

    app_client = msal.ConfidentialClientApplication(
        _AZ_CLIENT_ID, authority=_AZ_AUTHORITY_HOME, client_credential=_AZ_SECRET)
    token_result = app_client.acquire_token_for_client(
        scopes=["https://graph.microsoft.com/.default"])

    if "access_token" not in token_result:
        err = token_result.get("error_description", token_result.get("error", "unknown"))
        return jsonify(error=f"Failed to acquire mail token: {err}"), 500

    subject = f"Document — {name}"
    graph_body = {
        "message": {
            "subject": subject,
            "body": {
                "contentType": "Text",
                "content": (
                    f"A document has been shared with you:\n\n"
                    f"{name}\n{url}\n\n"
                    "This email was sent from the ICAST Togen application."
                ),
            },
            "toRecipients": [
                {"emailAddress": {"address": to_email}},
            ],
        },
        "saveToSentItems": "false",
    }

    resp = _http_requests.post(
        f"https://graph.microsoft.com/v1.0/users/{AZURE_MAIL_FROM}/sendMail",
        headers={
            "Authorization": f"Bearer {token_result['access_token']}",
            "Content-Type": "application/json",
        },
        json=graph_body,
        timeout=30,
    )

    if resp.status_code not in (200, 202):
        detail = resp.json().get("error", {}).get("message", resp.text[:200])
        return jsonify(error=f"Graph API error: {detail}"), 502

    return jsonify(ok=True)


# ---------------------------------------------------------------------------
# Email tool (DVI-470) — read the signed-in user's SSO mailbox via Microsoft
# Graph and surface attachments for save/share/workflow actions.
#
# Reads use the SAME app-only (client-credentials) token already used for
# sendMail. Reading another user's mailbox requires the Azure app registration
# to hold the Graph *application* permission ``Mail.Read`` (admin consent). If
# that grant is missing Graph returns 403 and these routes degrade to an
# explicit "mailbox access not granted" state rather than failing hard.
# ---------------------------------------------------------------------------

_EMAIL_INBOX_TOP = 25
_GRAPH_BASE = "https://graph.microsoft.com/v1.0"
# Attachment extensions a Togen workflow can consume (mirror of the frontend
# _EMAIL_DOC_EXTS + xlsx/csv, i.e. anything that gets a "Workflow" button).
_EMAIL_WORKFLOW_EXTS = {"pdf", "doc", "docx", "png", "jpg", "jpeg", "xlsx", "csv"}


def _email_graph_token():
    """Acquire the shared app-only Graph token, or None if unavailable."""
    if not (_AZ_CLIENT_ID and _AZ_SECRET):
        return None
    try:
        app_client = msal.ConfidentialClientApplication(
            _AZ_CLIENT_ID, authority=_AZ_AUTHORITY_HOME, client_credential=_AZ_SECRET)
        result = app_client.acquire_token_for_client(
            scopes=["https://graph.microsoft.com/.default"])
        return result.get("access_token")
    except Exception:
        app.logger.exception("Email: Graph token acquisition error")
        return None


def _email_current_mailbox():
    """Return the signed-in user's mailbox address from the session."""
    return (session.get("user") or {}).get("email", "") or ""


def _email_fetch_attachment(token, mailbox, message_id, attachment_id):
    """Fetch one attachment. Returns (name, content_type, raw_bytes) or (None, None, None)."""
    try:
        resp = _http_requests.get(
            f"{_GRAPH_BASE}/users/{mailbox}/messages/{message_id}/attachments/{attachment_id}",
            headers={"Authorization": f"Bearer {token}"},
            timeout=60,
        )
        if resp.status_code != 200:
            app.logger.error("Email attachment fetch failed (%s): %s",
                             resp.status_code, resp.text[:300])
            return None, None, None
        data = resp.json()
        content_b64 = data.get("contentBytes")
        if not content_b64:
            return None, None, None
        return (data.get("name") or "attachment",
                data.get("contentType") or "application/octet-stream",
                base64.b64decode(content_b64))
    except Exception:
        app.logger.exception("Email attachment fetch error")
        return None, None, None


@app.route("/email/messages")
@login_required
def email_messages():
    """Return the signed-in user's recent inbox messages (attachments expanded)."""
    mailbox = _email_current_mailbox()
    if not mailbox:
        return jsonify(error="No mailbox associated with this session."), 400
    token = _email_graph_token()
    if not token:
        return jsonify(error="Mail service is not configured.", available=False,
                       reason="config"), 200

    params = {
        "$top": _EMAIL_INBOX_TOP,
        "$orderby": "receivedDateTime desc",
        "$select": "id,subject,from,receivedDateTime,isRead,hasAttachments,bodyPreview",
        "$expand": "attachments($select=id,name,contentType,size,isInline)",
    }
    try:
        resp = _http_requests.get(
            f"{_GRAPH_BASE}/users/{mailbox}/mailFolders/inbox/messages",
            headers={"Authorization": f"Bearer {token}"},
            params=params,
            timeout=60,
        )
    except Exception:
        app.logger.exception("Email messages fetch error")
        return jsonify(error="Could not reach the mail service."), 502

    if resp.status_code == 403:
        return jsonify(available=False, reason="permission", mailbox=mailbox,
                       error="Mailbox access has not been granted. An administrator "
                             "must grant the Mail.Read application permission to the "
                             "Togen app registration in Azure AD."), 200
    if resp.status_code != 200:
        detail = resp.json().get("error", {}).get("message", resp.text[:200]) if resp.text else resp.status_code
        return jsonify(error=f"Graph API error: {detail}"), 502

    messages = []
    unread = 0
    for m in resp.json().get("value", []):
        if not m.get("isRead", True):
            unread += 1
        atts = [
            {
                "id": a.get("id"),
                "name": a.get("name") or "attachment",
                "size": a.get("size", 0),
                "contentType": a.get("contentType") or "",
            }
            for a in (m.get("attachments") or [])
            if not a.get("isInline")
        ]
        messages.append({
            "id": m.get("id"),
            "subject": m.get("subject") or "(no subject)",
            "from": ((m.get("from") or {}).get("emailAddress") or {}).get("address", ""),
            "from_name": ((m.get("from") or {}).get("emailAddress") or {}).get("name", ""),
            "received": m.get("receivedDateTime"),
            "is_read": m.get("isRead", True),
            "preview": (m.get("bodyPreview") or "")[:200],
            "attachments": atts,
            "has_attachments": bool(atts),
        })
    return jsonify(available=True, mailbox=mailbox, messages=messages, unread=unread)


@app.route("/email/unread-count")
@login_required
def email_unread_count():
    """Counts for the Tools page tile badges: total unread, unread with
    attachments, and unread with a workflow-supported attachment (DVI-635)."""
    mailbox = _email_current_mailbox()
    if not mailbox:
        return jsonify(count=0, available=False)
    token = _email_graph_token()
    if not token:
        return jsonify(count=0, available=False)
    headers = {"Authorization": f"Bearer {token}"}
    try:
        resp = _http_requests.get(
            f"{_GRAPH_BASE}/users/{mailbox}/mailFolders/inbox",
            headers=headers,
            params={"$select": "unreadItemCount"},
            timeout=30,
        )
    except Exception:
        return jsonify(count=0, available=False)
    if resp.status_code != 200:
        return jsonify(count=0, available=False,
                       reason="permission" if resp.status_code == 403 else "error")
    total = resp.json().get("unreadItemCount", 0)

    # Inspect unread messages that carry attachments to break the badge into
    # "with attachments" and "with a workflow-supported attachment". Inline
    # attachments are ignored to match the inbox/attachments views.
    unread_attach = 0
    unread_workflow = 0
    try:
        a_resp = _http_requests.get(
            f"{_GRAPH_BASE}/users/{mailbox}/mailFolders/inbox/messages",
            headers=headers,
            params={
                "$filter": "isRead eq false and hasAttachments eq true",
                "$top": 100,
                "$select": "id",
                "$expand": "attachments($select=name,isInline)",
            },
            timeout=45,
        )
        if a_resp.status_code == 200:
            for m in a_resp.json().get("value", []):
                names = [a.get("name") or "" for a in (m.get("attachments") or [])
                         if not a.get("isInline")]
                if not names:
                    continue
                unread_attach += 1
                if any(name.rsplit(".", 1)[-1].lower() in _EMAIL_WORKFLOW_EXTS
                       for name in names if "." in name):
                    unread_workflow += 1
    except Exception:
        app.logger.exception("Email unread-count attachment scan error")

    return jsonify(count=total, attachments=unread_attach,
                   workflow=unread_workflow, available=True)


@app.route("/email/attachment")
@login_required
def email_attachment():
    """Stream an attachment's bytes (inline preview / direct download)."""
    mailbox = _email_current_mailbox()
    message_id = request.args.get("message_id", "")
    attachment_id = request.args.get("attachment_id", "")
    if not (mailbox and message_id and attachment_id):
        return jsonify(error="message_id and attachment_id are required."), 400
    token = _email_graph_token()
    if not token:
        return jsonify(error="Mail service is not configured."), 502
    name, content_type, raw = _email_fetch_attachment(token, mailbox, message_id, attachment_id)
    if raw is None:
        return jsonify(error="Attachment could not be retrieved."), 404
    as_attach = request.args.get("download") == "1"
    return send_file(io.BytesIO(raw), mimetype=content_type,
                     as_attachment=as_attach, download_name=name)


@app.route("/email/save-attachment", methods=["POST"])
@login_required
def email_save_attachment():
    """Save an email attachment into the shared Projects tree (File Browser root)."""
    body = request.get_json(silent=True) or {}
    message_id = (body.get("message_id") or "").strip()
    attachment_id = (body.get("attachment_id") or "").strip()
    dest_rel = (body.get("dest_path") or "").strip()
    filename = (body.get("filename") or "").strip()
    if not (message_id and attachment_id):
        return jsonify(error="message_id and attachment_id are required."), 400

    dest_dir = _fb_safe_path(dest_rel) if dest_rel else _FB_ROOT
    if dest_dir is None or not dest_dir.is_dir():
        return jsonify(error="Invalid destination folder."), 400

    mailbox = _email_current_mailbox()
    token = _email_graph_token()
    if not token:
        return jsonify(error="Mail service is not configured."), 502
    name, _ct, raw = _email_fetch_attachment(token, mailbox, message_id, attachment_id)
    if raw is None:
        return jsonify(error="Attachment could not be retrieved."), 404

    safe_name = secure_filename(filename or name) or "attachment"
    dest = dest_dir / safe_name
    if dest.exists():
        stem, ext = os.path.splitext(safe_name)
        dest = dest_dir / f"{stem}_{_datetime_type.now().strftime('%H%M%S')}{ext}"
    try:
        dest.write_bytes(raw)
    except OSError:
        app.logger.exception("Email save-attachment write error")
        return jsonify(error="Could not write the file to the destination."), 500
    saved_rel = str(dest.relative_to(_FB_ROOT))
    return jsonify(ok=True, saved_path=saved_rel, filename=dest.name)


@app.route("/email/share-attachment", methods=["POST"])
@login_required
def email_share_attachment():
    """Forward an email attachment to a recipient via Graph sendMail."""
    body = request.get_json(silent=True) or {}
    message_id = (body.get("message_id") or "").strip()
    attachment_id = (body.get("attachment_id") or "").strip()
    to_email = (body.get("email") or "").strip()
    if not (message_id and attachment_id):
        return jsonify(error="message_id and attachment_id are required."), 400
    if not to_email:
        return jsonify(error="A recipient email address is required."), 400
    if not AZURE_MAIL_FROM:
        return jsonify(error="Email sender is not configured. Set AZURE_MAIL_FROM."), 500

    mailbox = _email_current_mailbox()
    token = _email_graph_token()
    if not token:
        return jsonify(error="Mail service is not configured."), 502
    name, content_type, raw = _email_fetch_attachment(token, mailbox, message_id, attachment_id)
    if raw is None:
        return jsonify(error="Attachment could not be retrieved."), 404

    sender = _email_current_mailbox() or "a colleague"
    html = notifications.render_email_shell(
        "A file was shared with you",
        f"<p>{html.escape(sender)} shared the file <strong>{html.escape(name)}</strong> "
        "with you from the ICAST Togen Email tool.</p>",
        subtitle="Shared attachment",
    )
    ok = notifications.send_email_with_attachment(
        to_email, f"Shared file — {name}", html,
        filename=name, content=raw, content_type=content_type)
    if not ok:
        return jsonify(error="The mail service rejected the message."), 502
    return jsonify(ok=True)


@app.route("/email/add-to-manuals", methods=["POST"])
@login_required
def email_add_to_manuals():
    """Save an email attachment as an Equipment Manual associated with a name."""
    body = request.get_json(silent=True) or {}
    message_id = (body.get("message_id") or "").strip()
    attachment_id = (body.get("attachment_id") or "").strip()
    equipment = (body.get("equipment") or "").strip()
    if not (message_id and attachment_id):
        return jsonify(error="message_id and attachment_id are required."), 400
    if not equipment:
        return jsonify(error="Equipment / Asset / System name is required."), 400

    mailbox = _email_current_mailbox()
    token = _email_graph_token()
    if not token:
        return jsonify(error="Mail service is not configured."), 502
    name, _ct, raw = _email_fetch_attachment(token, mailbox, message_id, attachment_id)
    if raw is None:
        return jsonify(error="Attachment could not be retrieved."), 404

    ext = os.path.splitext(name)[1].lower()
    if ext not in ALLOWED_MANUAL_EXTS:
        allowed = ", ".join(sorted(ALLOWED_MANUAL_EXTS))
        return jsonify(error=f"Unsupported manual file type. Allowed: {allowed}"), 400

    WOR_MANUALS_DIR.mkdir(parents=True, exist_ok=True)
    base = secure_filename(name) or ("manual" + ext)
    stored = f"{_datetime_type.now().strftime('%Y%m%d_%H%M%S')}_{base}"
    try:
        (WOR_MANUALS_DIR / stored).write_bytes(raw)
    except OSError:
        app.logger.exception("Email add-to-manuals write error")
        return jsonify(error="Could not save the manual."), 500
    _manuals_index_set(equipment, stored, name, session.get("user") or {})
    _enqueue_manual_ocr(stored, name)
    return jsonify(ok=True, filename=stored, original_name=name,
                   equipment=equipment, url=_manual_viewer_url(stored))


# ---------------------------------------------------------------------------
# Calendar tool (DVI-556) — surface the signed-in user's Microsoft 365 calendar
# events in the Calendar tool. Uses the SAME app-only Graph token as the Email
# tool; reading another user's calendar requires the Azure app registration to
# hold the Graph application permission ``Calendars.Read`` (admin consent). If
# that grant is missing Graph returns 403 and the route degrades to an explicit
# "calendar access not granted" state rather than failing hard.
# ---------------------------------------------------------------------------

@app.route("/calendar/events")
@login_required
def calendar_events():
    """Return the signed-in user's M365 calendar events within a window.

    Query params ``start`` and ``end`` are ISO-8601 datetimes bounding the
    requested range (the client passes a padded window around the visible view).
    """
    mailbox = _email_current_mailbox()
    if not mailbox:
        return jsonify(error="No mailbox associated with this session.",
                       available=False, reason="config"), 200
    start = (request.args.get("start") or "").strip()
    end = (request.args.get("end") or "").strip()
    if not (start and end):
        return jsonify(error="start and end are required."), 400
    token = _email_graph_token()
    if not token:
        return jsonify(available=False, reason="config",
                       error="Calendar service is not configured."), 200

    params = {
        "startDateTime": start,
        "endDateTime": end,
        "$select": "id,subject,start,end,isAllDay,location,organizer,bodyPreview",
        "$orderby": "start/dateTime",
        "$top": 200,
    }
    try:
        resp = _http_requests.get(
            f"{_GRAPH_BASE}/users/{mailbox}/calendarView",
            headers={
                "Authorization": f"Bearer {token}",
                # Return start/end in UTC for consistent client-side parsing.
                "Prefer": 'outlook.timezone="UTC"',
            },
            params=params,
            timeout=60,
        )
    except Exception:
        app.logger.exception("Calendar events fetch error")
        return jsonify(error="Could not reach the calendar service."), 502

    if resp.status_code == 403:
        return jsonify(available=False, reason="permission", mailbox=mailbox,
                       error="Calendar access has not been granted. An administrator "
                             "must grant the Calendars.Read application permission to "
                             "the Togen app registration in Azure AD."), 200
    if resp.status_code != 200:
        detail = (resp.json().get("error", {}).get("message", resp.text[:200])
                  if resp.text else resp.status_code)
        return jsonify(error=f"Graph API error: {detail}"), 502

    events = []
    for ev in resp.json().get("value", []):
        events.append({
            "id": ev.get("id"),
            "subject": ev.get("subject") or "(no title)",
            "start": (ev.get("start") or {}).get("dateTime"),
            "end": (ev.get("end") or {}).get("dateTime"),
            "all_day": bool(ev.get("isAllDay")),
            "location": ((ev.get("location") or {}).get("displayName") or ""),
            "organizer": (((ev.get("organizer") or {}).get("emailAddress") or {}).get("name") or ""),
            "preview": (ev.get("bodyPreview") or "")[:200],
            "source": "microsoft",
        })
    return jsonify(available=True, mailbox=mailbox, events=events)


@app.route("/calendar/wo-events")
@login_required
def calendar_wo_events():
    """Work Order date events for the Calendar tool (DVI-630).

    Returns two kinds of all-day events within the requested window:
      * recurring schedules — one marker per due date across the window, and
      * active (new / in-progress) WOs that carry a Desired Completion Date or
        Date Promised.

    Visible only to Work Order Admins and Togen Admins (the ``togen_admin`` role
    already maps to both the iCast and Voyage Togen-Admin Azure groups), matching
    the PM-access gate. Non-privileged users get ``available:false`` so the
    client never renders the Work Orders filter for them.
    """
    user = session.get("user") or {}
    if not _user_can_access_pm(user):
        return jsonify(available=False, reason="permission", events=[]), 200

    start = (request.args.get("start") or "").strip()
    end = (request.args.get("end") or "").strip()
    if not (start and end):
        return jsonify(error="start and end are required."), 400
    try:
        win_start = _datetime_type.fromisoformat(start.replace("Z", "+00:00")).date()
        win_end = _datetime_type.fromisoformat(end.replace("Z", "+00:00")).date()
    except (ValueError, TypeError):
        return jsonify(error="Invalid start/end."), 400

    _type_label = {
        "maintenance": "Maintenance",
        "mobilemachine": "Mobile Machine",
        "safety": "Safety",
        "preventive": "Preventive",
    }
    events = []

    # 1) Recurring schedules — a marker on each due date in the window.
    for source_filename, rec in _load_recurring_registry().items():
        if not rec.get("enabled", True):
            continue
        wor_type = rec.get("wor_type", "maintenance")
        template = rec.get("template_data") or {}
        title = _wor_display_title(source_filename, wor_type, template)
        for run_date in _recurring_runs_in_window(
                rec.get("start_date", ""), rec.get("frequency_days", 0),
                rec.get("end_date", ""), win_start, win_end):
            iso = run_date.strftime("%Y-%m-%d")
            events.append({
                "id": f"recurring:{source_filename}:{iso}",
                "subject": title,
                "start": iso,
                "end": iso,
                "all_day": True,
                "location": template.get("location") or template.get("building") or "",
                "wo_file": source_filename,
                "wo_kind": "recurring",
                "wo_date_type": "Recurring due",
                "wor_type": wor_type,
                "wor_type_label": _type_label.get(wor_type, wor_type),
                "source": "workorder",
            })

    # 2) Active WOs with a Desired Completion Date or Date Promised.
    for wor_type in ("maintenance", "mobilemachine", "safety", "preventive"):
        dirs = _wor_dest_dirs(wor_type)
        for status_key in ("new", "in_progress"):
            directory = dirs.get(status_key)
            if not directory or not directory.is_dir():
                continue
            for f in directory.glob("*.pdf"):
                sidecar = f.with_suffix(".json")
                if not sidecar.is_file():
                    continue
                try:
                    sd = json.loads(sidecar.read_text()) or {}
                except (ValueError, OSError):
                    continue
                title = _wor_display_title(f.name, wor_type, sd)
                for field, label in (("desired_completion_date", "Desired completion"),
                                     ("date_promised", "Date promised")):
                    val = (sd.get(field) or "").strip()
                    if not val:
                        continue
                    try:
                        d = _datetime_type.strptime(val[:10], "%Y-%m-%d").date()
                    except (ValueError, TypeError):
                        continue
                    if d < win_start or d > win_end:
                        continue
                    events.append({
                        "id": f"{field}:{f.name}",
                        "subject": title,
                        "start": val[:10],
                        "end": val[:10],
                        "all_day": True,
                        "location": sd.get("location") or sd.get("building") or "",
                        "wo_file": f.name,
                        "wo_kind": "due",
                        "wo_date_type": label,
                        "wor_type": wor_type,
                        "wor_type_label": _type_label.get(wor_type, wor_type),
                        "source": "workorder",
                    })

    return jsonify(available=True, events=events)


@app.route("/simple-qr/email", methods=["POST"])
@login_required
def simple_qr_email():
    """Email a QR code PNG as an attachment via Microsoft Graph API."""
    body = request.get_json(silent=True) or {}
    to_email = (body.get("email") or "").strip()
    image_b64 = (body.get("image") or "").strip()
    url = (body.get("url") or "").strip()

    if not to_email:
        return jsonify(error="Email address is required."), 400
    if not image_b64:
        return jsonify(error="No QR code image provided."), 400

    if not AZURE_MAIL_FROM:
        return jsonify(error="Email sender is not configured. Set AZURE_MAIL_FROM."), 500

    app_client = msal.ConfidentialClientApplication(
        _AZ_CLIENT_ID, authority=_AZ_AUTHORITY_HOME, client_credential=_AZ_SECRET)
    token_result = app_client.acquire_token_for_client(
        scopes=["https://graph.microsoft.com/.default"])

    if "access_token" not in token_result:
        err = token_result.get("error_description", token_result.get("error", "unknown"))
        return jsonify(error=f"Failed to acquire mail token: {err}"), 500

    subject = f"QR Code — {url}" if url else "QR Code"
    graph_body = {
        "message": {
            "subject": subject,
            "body": {
                "contentType": "Text",
                "content": (
                    f"Please find the attached QR code for:\n{url}\n\n"
                    "This email was sent from the ICAST Togen application."
                ),
            },
            "toRecipients": [
                {"emailAddress": {"address": to_email}},
            ],
            "attachments": [
                {
                    "@odata.type": "#microsoft.graph.fileAttachment",
                    "name": "qr-code.png",
                    "contentType": "image/png",
                    "contentBytes": image_b64,
                },
            ],
        },
        "saveToSentItems": "false",
    }

    resp = _http_requests.post(
        f"https://graph.microsoft.com/v1.0/users/{AZURE_MAIL_FROM}/sendMail",
        headers={
            "Authorization": f"Bearer {token_result['access_token']}",
            "Content-Type": "application/json",
        },
        json=graph_body,
        timeout=30,
    )

    if resp.status_code not in (200, 202):
        detail = resp.json().get("error", {}).get("message", resp.text[:200])
        return jsonify(error=f"Graph API error: {detail}"), 502

    return jsonify(ok=True)


# ---------------------------------------------------------------------------
# MTS Label Creator routes
# ---------------------------------------------------------------------------

@app.route("/mts/products")
@mts_label_required
def mts_products_list():
    """Return a list of all stock products sorted by product code."""
    products = []
    if STOCK_PRODUCTS_DIR.is_dir():
        for f in STOCK_PRODUCTS_DIR.glob("*.json"):
            try:
                data = json.loads(f.read_text(encoding="utf-8"))
                has_drawing = (STOCK_DRAWINGS_DIR / f"{f.stem}.pdf").is_file()
                products.append({
                    "product_code": data.get("product_code", f.stem),
                    "description": data.get("description", ""),
                    "weight": data.get("weight", ""),
                    "has_drawing": has_drawing,
                })
            except (json.JSONDecodeError, OSError):
                continue
    products.sort(key=lambda p: p["product_code"].lower())
    return jsonify(products=products)


@app.route("/mts/product/<product_code>")
@mts_label_required
def mts_product_get(product_code):
    """Return stored product data (description, weight) for a product code."""
    safe = secure_filename(product_code)
    if not safe:
        return jsonify(error="Invalid product code"), 400
    json_path = STOCK_PRODUCTS_DIR / f"{safe}.json"
    if not json_path.is_file():
        return jsonify(found=False)
    try:
        data = json.loads(json_path.read_text(encoding="utf-8"))
    except Exception:
        return jsonify(found=False)
    has_drawing = (STOCK_DRAWINGS_DIR / f"{safe}.pdf").is_file()
    data["found"] = True
    data["has_drawing"] = has_drawing
    return jsonify(data)


@app.route("/mts/product", methods=["POST"])
@mts_label_required
def mts_product_save():
    """Save / update product data for a product code."""
    d = request.get_json(force=True)
    code = d.get("product_code", "").strip()
    safe = secure_filename(code)
    if not safe:
        return jsonify(error="Product code is required"), 400
    STOCK_PRODUCTS_DIR.mkdir(parents=True, exist_ok=True)
    json_path = STOCK_PRODUCTS_DIR / f"{safe}.json"
    payload = {
        "product_code": code,
        "description": d.get("description", "").strip(),
        "weight": d.get("weight", "").strip(),
    }
    json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
    return jsonify(ok=True)


@app.route("/mts/drawing/<product_code>", methods=["POST"])
@mts_label_required
def mts_drawing_upload(product_code):
    """Upload a drawing PDF for a product code."""
    safe = secure_filename(product_code)
    if not safe:
        return jsonify(error="Invalid product code"), 400
    if "drawing" not in request.files:
        return jsonify(error="No drawing file provided"), 400
    f = request.files["drawing"]
    if not f.filename or not f.filename.lower().endswith(".pdf"):
        return jsonify(error="Drawing must be a PDF file"), 400
    STOCK_DRAWINGS_DIR.mkdir(parents=True, exist_ok=True)
    dest = STOCK_DRAWINGS_DIR / f"{safe}.pdf"
    f.save(str(dest))
    return jsonify(ok=True)


@app.route("/mts/drawing/<product_code>")
@mts_label_required
def mts_drawing_get(product_code):
    """Serve the drawing PDF for a product code."""
    safe = secure_filename(product_code)
    if not safe:
        abort(400)
    path = STOCK_DRAWINGS_DIR / f"{safe}.pdf"
    if not path.is_file():
        abort(404)
    return send_file(str(path), mimetype="application/pdf")


@app.route("/mts/label", methods=["POST"])
@mts_label_required
def mts_label_create():
    """Generate an MTS label PDF and return it for preview."""
    d = request.get_json(force=True)
    code = d.get("product_code", "").strip()
    description = d.get("description", "").strip()
    weight = d.get("weight", "").strip()
    label_date = d.get("date", "").strip()
    created_by = session.get("user", {}).get("name", "Unknown")

    if not code or not description or not weight or not label_date:
        return jsonify(error="All fields are required"), 400

    safe_code = secure_filename(code)
    if not safe_code:
        return jsonify(error="Invalid product code"), 400

    safe_date = secure_filename(label_date)
    filename = f"{safe_date}_{safe_code}.pdf"
    MTS_LABELS_DIR.mkdir(parents=True, exist_ok=True)
    out_path = MTS_LABELS_DIR / filename

    drawing_path = STOCK_DRAWINGS_DIR / f"{safe_code}.pdf"
    has_drawing = drawing_path.is_file()
    qr_url = ""
    qr_tmp_path = None

    if has_drawing:
        prefix = _load_shared_url_prefix()
        qr_url = f"{prefix}/stockproducts/drawings/{quote(safe_code)}.pdf"
        try:
            qr_obj = _qrcode_mod.QRCode(
                error_correction=_qrcode_mod.constants.ERROR_CORRECT_M,
                box_size=10,
                border=2,
            )
            qr_obj.add_data(qr_url)
            qr_obj.make(fit=True)
            qr_img = qr_obj.make_image(fill_color="black", back_color="white").convert("RGB")
            fd, qr_tmp_path = tempfile.mkstemp(suffix=".png")
            os.close(fd)
            qr_img.save(qr_tmp_path, format="PNG")
        except Exception as exc:
            print(f"[MTS] QR generation failed for {code}: {exc}", flush=True)
            qr_tmp_path = None

    try:
        # 7in x 12in label size required by label printer
        pdf = FPDF(orientation="P", unit="mm", format=(177.8, 304.8))
        pdf.set_auto_page_break(auto=False)
        pdf.add_page()

        W = pdf.w   # 177.8mm (7in)
        H = pdf.h   # 304.8mm (12in)
        margin = 10
        pw = W - 2 * margin  # 157.8mm

        # --- Logo centered at top (large) ---
        logo_path = _TOGEN_APP_DIR / "logo-black.svg"
        logo_h = 72
        logo_w = logo_h * (334 / 307)  # ~78.3mm wide
        logo_x = (W - logo_w) / 2
        if logo_path.is_file():
            pdf.image(str(logo_path), x=logo_x, y=margin, h=logo_h)

        # --- Product code (auto-shrink to fit) above separator ---
        y = margin + logo_h + 5
        font_size = 46
        pdf.set_font("Helvetica", "B", font_size)
        while pdf.get_string_width(code) > pw - 4 and font_size > 14:
            font_size -= 2
            pdf.set_font("Helvetica", "B", font_size)
        cell_h = max(font_size * 0.45, 14)
        pdf.set_xy(margin, y)
        pdf.cell(pw, cell_h, code, align="C")
        y += cell_h + 4

        # --- Separator ---
        pdf.set_line_width(0.5)
        pdf.line(margin, y, W - margin, y)
        y += 8

        # --- Info box geometry (date + created by, bottom-right) ---
        box_w = 80
        box_h = 26
        box_x = W - margin - box_w
        box_y = H - margin - box_h

        # --- Field rows (description and weight only) ---
        label_col_w = 36
        val_col_w = pw - label_col_w
        row_h = 10
        for heading, value in [("Description:", description), ("Weight:", weight)]:
            pdf.set_font("Helvetica", "B", 13)
            pdf.set_xy(margin, y)
            pdf.cell(label_col_w, row_h, heading, align="L")
            pdf.set_font("Helvetica", "B" if heading == "Weight:" else "", 13)
            pdf.set_xy(margin + label_col_w, y)
            pdf.multi_cell(val_col_w, row_h, value, align="L")
            y = pdf.get_y() + 2

        # --- QR code centered in remaining space (if available) ---
        if qr_tmp_path:
            qr_bottom = box_y - 4
            remaining = qr_bottom - y
            qr_size = min(80.0, remaining - 4)
            if qr_size > 20:
                qr_x = (W - qr_size) / 2
                qr_y = y + (remaining - qr_size) / 2
                pdf.image(qr_tmp_path, x=qr_x, y=qr_y, w=qr_size, h=qr_size)

        # --- Date / Created by (bottom-right, no box) ---
        pdf.set_font("Helvetica", "", 9)
        pdf.set_xy(box_x, box_y + 3)
        pdf.cell(box_w, 5, f"Date: {label_date}", align="R")
        pdf.set_xy(box_x, box_y + 10)
        pdf.cell(box_w, 5, f"Created by: {created_by}", align="R")

        pdf.output(str(out_path))
    finally:
        if qr_tmp_path and os.path.exists(qr_tmp_path):
            os.unlink(qr_tmp_path)

    STOCK_PRODUCTS_DIR.mkdir(parents=True, exist_ok=True)
    prod_path = STOCK_PRODUCTS_DIR / f"{safe_code}.json"
    prod_data = {
        "product_code": code,
        "description": description,
        "weight": weight,
        "qr_url": qr_url,
    }
    prod_path.write_text(json.dumps(prod_data, indent=2), encoding="utf-8")

    if _is_history_capture_enabled("mts-label"):
        record_history(
            "mts-label", "created",
            actor=created_by,
            summary=f"MTS label created — {code}: {description}",
            ref=code,
            detail={"product_code": code, "weight": weight, "date": label_date, "filename": filename},
        )

    return jsonify(ok=True, filename=filename)


@app.route("/mts/label/preview/<filename>")
@mts_label_required
def mts_label_preview(filename):
    """Serve a generated MTS label PDF for preview/download."""
    safe = secure_filename(filename)
    if not safe:
        abort(400)
    path = MTS_LABELS_DIR / safe
    if not path.is_file():
        abort(404)
    return send_file(str(path), mimetype="application/pdf")


@app.route("/mts/email", methods=["POST"])
@mts_label_required
def mts_email_label():
    """Email an MTS label PDF as an attachment via Microsoft Graph API."""
    body = request.get_json(silent=True) or {}
    to_email = (body.get("email") or "").strip()
    filename = (body.get("filename") or "").strip()

    if not to_email:
        return jsonify(error="Email address is required."), 400
    if not filename:
        return jsonify(error="No label filename provided."), 400

    safe = secure_filename(filename)
    if not safe:
        return jsonify(error="Invalid filename."), 400
    path = MTS_LABELS_DIR / safe
    if not path.is_file():
        return jsonify(error="Label file not found."), 404

    if not AZURE_MAIL_FROM:
        return jsonify(error="Email sender is not configured. Set AZURE_MAIL_FROM."), 500

    pdf_bytes = path.read_bytes()
    pdf_b64 = base64.b64encode(pdf_bytes).decode("ascii")

    app_client = msal.ConfidentialClientApplication(
        _AZ_CLIENT_ID, authority=_AZ_AUTHORITY_HOME, client_credential=_AZ_SECRET)
    token_result = app_client.acquire_token_for_client(
        scopes=["https://graph.microsoft.com/.default"])

    if "access_token" not in token_result:
        err = token_result.get("error_description", token_result.get("error", "unknown"))
        return jsonify(error=f"Failed to acquire mail token: {err}"), 500

    subject = f"MTS Label — {safe}"
    graph_body = {
        "message": {
            "subject": subject,
            "body": {
                "contentType": "Text",
                "content": (
                    f"Please find the attached MTS label: {safe}\n\n"
                    "This email was sent from the ICAST Togen application."
                ),
            },
            "toRecipients": [
                {"emailAddress": {"address": to_email}},
            ],
            "attachments": [
                {
                    "@odata.type": "#microsoft.graph.fileAttachment",
                    "name": safe,
                    "contentType": "application/pdf",
                    "contentBytes": pdf_b64,
                },
            ],
        },
        "saveToSentItems": "false",
    }

    resp = _http_requests.post(
        f"https://graph.microsoft.com/v1.0/users/{AZURE_MAIL_FROM}/sendMail",
        headers={
            "Authorization": f"Bearer {token_result['access_token']}",
            "Content-Type": "application/json",
        },
        json=graph_body,
        timeout=30,
    )

    if resp.status_code not in (200, 202):
        detail = resp.json().get("error", {}).get("message", resp.text[:200])
        return jsonify(error=f"Graph API error: {detail}"), 502

    return jsonify(ok=True)


# ---------------------------------------------------------------------------
# SCADA View — read temperature sensor logs from shared mailbox (DVI-300)
# ---------------------------------------------------------------------------

SCADA_MAILBOX = "scadalogs@icastinc.com"


@app.route("/scada/logs")
@scada_required
def scada_logs():
    """Fetch recent messages from the SCADA logs mailbox via Microsoft Graph API.

    Requires the Azure AD app registration to have the application-level
    Mail.Read permission (or Mail.ReadBasic) and admin consent for the
    target mailbox.  The app already authenticates with client credentials
    for sendMail, so the same credential works here — the only addition is
    the Mail.Read (or Mail.ReadBasic.All) scope in Azure AD > App
    registrations > API permissions.
    """
    app_client = msal.ConfidentialClientApplication(
        _AZ_CLIENT_ID, authority=_AZ_AUTHORITY_HOME, client_credential=_AZ_SECRET)
    token_result = app_client.acquire_token_for_client(
        scopes=["https://graph.microsoft.com/.default"])

    if "access_token" not in token_result:
        err = token_result.get("error_description", token_result.get("error", "unknown"))
        return jsonify(error=f"Failed to acquire Graph API token: {err}"), 500

    headers = {
        "Authorization": f"Bearer {token_result['access_token']}",
        "Content-Type": "application/json",
    }

    resp = _http_requests.get(
        f"https://graph.microsoft.com/v1.0/users/{SCADA_MAILBOX}/messages"
        "?$top=50&$orderby=receivedDateTime desc"
        "&$select=subject,receivedDateTime,bodyPreview,body",
        headers=headers,
        timeout=30,
    )

    if resp.status_code == 403:
        return jsonify(
            error="Mail.Read permission not yet granted for the SCADA mailbox. "
                  "An Azure AD administrator must grant the Mail.Read (application) "
                  "permission and admin-consent it for this app registration."
        ), 403

    if resp.status_code != 200:
        detail = ""
        try:
            detail = resp.json().get("error", {}).get("message", resp.text[:300])
        except Exception:
            detail = resp.text[:300]
        return jsonify(error=f"Graph API error ({resp.status_code}): {detail}"), 502

    messages = resp.json().get("value", [])
    logs = []
    for msg in messages:
        received = msg.get("receivedDateTime", "")
        if received:
            try:
                dt = _datetime_type.fromisoformat(received.replace("Z", "+00:00"))
                received = dt.strftime("%Y-%m-%d %H:%M")
            except Exception:
                pass
        body_content = ""
        body_obj = msg.get("body", {})
        if body_obj.get("contentType") == "text":
            body_content = body_obj.get("content", "")
        else:
            body_content = msg.get("bodyPreview", "")

        logs.append({
            "date": received,
            "subject": msg.get("subject", "(no subject)"),
            "body": body_content,
        })

    return jsonify(logs=logs)


def _parse_scada_body(body_text):
    """Parse a SCADA email body into (timestamp, [{label, value}, ...])."""
    readings = []
    timestamp = None
    for line in body_text.splitlines():
        line = line.strip()
        if not line:
            continue
        if line.lower().startswith("time:"):
            timestamp = line.split(":", 1)[1].strip()
            continue
        if ":" in line:
            label, _, value = line.partition(":")
            label = label.strip()
            value = value.strip()
            if label and value:
                readings.append({"label": label, "value": value})
    return timestamp, readings


def _scada_message_body(msg):
    """Extract plain-text body content from a Graph message object."""
    body_obj = msg.get("body", {})
    if body_obj.get("contentType") == "text":
        return body_obj.get("content", "")
    return msg.get("bodyPreview", "")


@app.route("/scada/status")
@scada_required
def scada_status():
    """Parse the most recent SCADA email into structured sensor readings."""
    app_client = msal.ConfidentialClientApplication(
        _AZ_CLIENT_ID, authority=_AZ_AUTHORITY_HOME, client_credential=_AZ_SECRET)
    token_result = app_client.acquire_token_for_client(
        scopes=["https://graph.microsoft.com/.default"])

    if "access_token" not in token_result:
        err = token_result.get("error_description", token_result.get("error", "unknown"))
        return jsonify(error=f"Failed to acquire Graph API token: {err}"), 500

    headers = {
        "Authorization": f"Bearer {token_result['access_token']}",
        "Content-Type": "application/json",
    }

    # Fetch a small recent window rather than just the newest message: the
    # mailbox now interleaves "X-400 - Log" daily-table emails with the
    # "X-400 - Trigger: ..." status emails (DVI-538), and status must come from
    # the latest Trigger email, not whatever happens to be on top.
    resp = _http_requests.get(
        f"https://graph.microsoft.com/v1.0/users/{SCADA_MAILBOX}/messages"
        "?$top=25&$orderby=receivedDateTime desc"
        "&$select=subject,receivedDateTime,bodyPreview,body",
        headers=headers,
        timeout=30,
    )

    if resp.status_code == 403:
        return jsonify(
            error="Mail.Read permission not yet granted for the SCADA mailbox."
        ), 403

    if resp.status_code != 200:
        detail = ""
        try:
            detail = resp.json().get("error", {}).get("message", resp.text[:300])
        except Exception:
            detail = resp.text[:300]
        return jsonify(error=f"Graph API error ({resp.status_code}): {detail}"), 502

    messages = resp.json().get("value", [])
    trigger = next(
        (m for m in messages if scada_subject_kind(m.get("subject")) == "trigger"),
        None)
    if trigger is None:
        return jsonify(readings=[], time=None)

    timestamp, readings = _parse_scada_body(_scada_message_body(trigger))
    return jsonify(readings=readings, time=timestamp)


@app.route("/scada/history")
@scada_required
def scada_history():
    """Build per-label time series from recent SCADA emails (Option A: derive on demand).

    Re-parses up to ~200 recent messages from the SCADA mailbox into
    {label: [{time, value}, ...]} sorted ascending by message time. No new
    storage or scheduler — depth is bounded by mailbox retention + $top.
    """
    app_client = msal.ConfidentialClientApplication(
        _AZ_CLIENT_ID, authority=_AZ_AUTHORITY_HOME, client_credential=_AZ_SECRET)
    token_result = app_client.acquire_token_for_client(
        scopes=["https://graph.microsoft.com/.default"])

    if "access_token" not in token_result:
        err = token_result.get("error_description", token_result.get("error", "unknown"))
        return jsonify(error=f"Failed to acquire Graph API token: {err}"), 500

    headers = {
        "Authorization": f"Bearer {token_result['access_token']}",
        "Content-Type": "application/json",
    }

    resp = _http_requests.get(
        f"https://graph.microsoft.com/v1.0/users/{SCADA_MAILBOX}/messages"
        "?$top=200&$orderby=receivedDateTime desc"
        "&$select=subject,receivedDateTime,bodyPreview,body",
        headers=headers,
        timeout=30,
    )

    if resp.status_code == 403:
        return jsonify(
            error="Mail.Read permission not yet granted for the SCADA mailbox."
        ), 403

    if resp.status_code != 200:
        detail = ""
        try:
            detail = resp.json().get("error", {}).get("message", resp.text[:300])
        except Exception:
            detail = resp.text[:300]
        return jsonify(error=f"Graph API error ({resp.status_code}): {detail}"), 502

    messages = resp.json().get("value", [])
    # Both email types inform history (DVI-538): Trigger emails contribute one
    # point-in-time snapshot each, while a single "X-400 - Log" email contributes
    # a full day of 15-min interval rows. Sensor labels are normalized
    # ("K1 Temp(F)" -> "K1 Temp") so the two sources land on the same series.
    series = {}
    for msg in messages:
        kind = scada_subject_kind(msg.get("subject"))
        received = msg.get("receivedDateTime", "")
        time_label = received
        if received:
            try:
                dt = _datetime_type.fromisoformat(received.replace("Z", "+00:00"))
                time_label = dt.strftime("%Y-%m-%d %H:%M")
            except Exception:
                pass
        if kind == "trigger":
            _ts, readings = _parse_scada_body(_scada_message_body(msg))
            point_time = _ts or time_label
            for r in readings:
                key = _scada_norm_sensor(r["label"])
                series.setdefault(key, []).append(
                    {"time": point_time, "value": r["value"]})
        elif kind == "log":
            parsed = _parse_scada_log_email(_scada_log_body_text(msg))
            cols = parsed["columns"]
            sensor_cols = cols[1:-1] if len(cols) >= 2 else []
            for row in parsed["rows"]:
                dt = row.get("_dt")
                point_time = dt.strftime("%Y-%m-%d %H:%M") if dt else time_label
                for col in sensor_cols:
                    val = (row.get(col, "") or "").strip()
                    if not val or val.lower() == "x.x":
                        continue
                    series.setdefault(_scada_norm_sensor(col), []).append(
                        {"time": point_time, "value": val})

    # Order each series ascending by time (sources arrive newest-first and
    # interleave), keeping the chart monotonic.
    for points in series.values():
        points.sort(key=lambda p: p["time"])

    return jsonify(series=series, count=len(messages))


# ---------------------------------------------------------------------------
# SCADA Reporting engine (DVI-482 / DVI-474 ST1)
# ---------------------------------------------------------------------------
# The engine was extracted into the self-contained ``scada_report`` module
# (DVI-486) so the standalone report scheduler can reuse it without importing
# app.py (which starts background loops at import time). The names below are
# imported so the existing /scada/report[/download] call sites are unchanged.
from scada_report import (  # noqa: E402
    _build_scada_report,
    _parse_scada_log_email,
    _scada_log_body_text,
    _scada_norm_sensor,
    _scada_report_workbook,
    scada_subject_kind,
)


def _scada_parse_report_args(req):
    """Parse date_from/date_to (YYYY-MM-DD) and sensors (CSV or repeated) from a
    request. Returns (date_from, date_to, sensors, error)."""
    def parse_date(raw):
        if not raw:
            return None
        return _datetime_type.strptime(raw, "%Y-%m-%d").date()

    try:
        date_from = parse_date(req.args.get("date_from", "").strip())
        date_to = parse_date(req.args.get("date_to", "").strip())
    except ValueError:
        return None, None, None, "Invalid date; expected YYYY-MM-DD."
    if date_from and date_to and date_from > date_to:
        return None, None, None, "date_from must be on or before date_to."

    sensors = req.args.getlist("sensors")
    if len(sensors) == 1 and "," in sensors[0]:
        sensors = sensors[0].split(",")
    sensors = [s.strip() for s in sensors if s.strip()]
    return date_from, date_to, sensors, None


def _scada_report_error_response(err):
    """Map a _build_scada_report error string to a JSON Flask response."""
    if err == "403":
        return jsonify(
            error="Mail.Read permission not yet granted for the SCADA mailbox."
        ), 403
    return jsonify(error=f"SCADA mailbox fetch failed: {err}"), 502


@app.route("/scada/report")
@scada_required
def scada_report():
    """Preview JSON for the SCADA report: per-day tables + available sensors +
    earliest available date, honoring the {date_from, date_to, sensors[]} filter."""
    date_from, date_to, sensors, err = _scada_parse_report_args(request)
    if err:
        return jsonify(error=err), 400
    report = _build_scada_report(date_from, date_to, sensors)
    if report["error"]:
        return _scada_report_error_response(report["error"])
    return jsonify(
        days=report["days"],
        columns=report["columns"],
        available_sensors=report["available_sensors"],
        earliest_date=report["earliest_date"],
        row_count=report["row_count"],
    )


@app.route("/scada/report/download")
@scada_required
def scada_report_download():
    """Stream the SCADA report as an XLSX (one tab per day)."""
    date_from, date_to, sensors, err = _scada_parse_report_args(request)
    if err:
        return jsonify(error=err), 400
    report = _build_scada_report(date_from, date_to, sensors)
    if report["error"]:
        return _scada_report_error_response(report["error"])
    buf = _scada_report_workbook(report)

    def fmt(d):
        return f"{d.month}.{d.day}.{d.strftime('%y')}" if d else "all"
    name = f"{fmt(date_from)} - {fmt(date_to)}.xlsx"
    return send_file(
        buf,
        as_attachment=True,
        download_name=name,
        mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    )


@app.route("/scada/reports")
@scada_required
def scada_reports_list():
    """List produced XLSX report files (written by the report scheduler),
    newest first. Used by the File Browser SCADA tab."""
    reports = []
    if SCADA_REPORTS_DIR.is_dir():
        for p in SCADA_REPORTS_DIR.glob("*.xlsx"):
            if not p.is_file():
                continue
            try:
                st = p.stat()
            except OSError:
                continue
            reports.append({
                "name": p.name,
                "size": st.st_size,
                "modified": _datetime_type.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M"),
                "ts": st.st_mtime,
            })
    reports.sort(key=lambda r: r["ts"], reverse=True)
    return jsonify(reports=reports)


@app.route("/scada/reports/download")
@scada_required
def scada_reports_download():
    """Stream a single persisted XLSX report by filename (path-traversal safe)."""
    name = request.args.get("name", "")
    # Reject any path component — only a bare filename in the reports dir is allowed.
    if not name or name != Path(name).name or not name.lower().endswith(".xlsx"):
        return jsonify(error="Invalid report name."), 400
    path = (SCADA_REPORTS_DIR / name).resolve()
    try:
        path.relative_to(SCADA_REPORTS_DIR.resolve())
    except ValueError:
        return jsonify(error="Invalid report name."), 400
    if not path.is_file():
        return jsonify(error="Report not found."), 404
    return send_file(
        path,
        as_attachment=True,
        download_name=name,
        mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    )


# Data Collector defaults (DVI-465): how often the collector polls the SCADA
# mailbox for new readings, and how long without a new reading before a stale-
# data notification should fire. Both in minutes.
SCADA_COLLECTOR_DEFAULTS = {"check_interval_minutes": 15, "stale_after_minutes": 60}


def _load_scada_config():
    """Return shared SCADA config: {ranges: {label: {low, high}}, enabled: {label: bool}, aliases: {label: str}, reporting: {label: bool}, notify: {label: bool}, collector: {check_interval_minutes, stale_after_minutes}}."""
    defaults = {"ranges": {}, "enabled": {}, "aliases": {}, "reporting": {}, "notify": {},
                "collector": dict(SCADA_COLLECTOR_DEFAULTS)}
    if SCADA_CONFIG_FILE.is_file():
        try:
            saved = json.loads(SCADA_CONFIG_FILE.read_text())
            if isinstance(saved.get("ranges"), dict):
                defaults["ranges"] = saved["ranges"]
            if isinstance(saved.get("enabled"), dict):
                defaults["enabled"] = saved["enabled"]
            if isinstance(saved.get("aliases"), dict):
                defaults["aliases"] = saved["aliases"]
            if isinstance(saved.get("reporting"), dict):
                defaults["reporting"] = saved["reporting"]
            if isinstance(saved.get("notify"), dict):
                defaults["notify"] = saved["notify"]
            if isinstance(saved.get("collector"), dict):
                defaults["collector"].update({
                    k: saved["collector"][k]
                    for k in SCADA_COLLECTOR_DEFAULTS
                    if k in saved["collector"]
                })
        except (json.JSONDecodeError, OSError):
            pass
    return defaults


def _save_scada_config(config):
    """Persist shared SCADA config to disk."""
    SCADA_CONFIG_FILE.write_text(json.dumps(config, indent=2))


@app.route("/scada/config")
@scada_required
def scada_config_get():
    """Return the shared SCADA config (ranges + enabled flags)."""
    return jsonify(config=_load_scada_config())


@app.route("/scada/config", methods=["POST"])
@scada_required
def scada_config_post():
    """Save shared SCADA config (ranges + enabled flags)."""
    body = request.get_json(silent=True) or {}
    config = _load_scada_config()
    # Bulk override (DVI-483): apply one temp/humidity range + notify flag to all
    # discovered sensors, overwriting their individual settings.
    if body.get("bulk_override"):
        labels = body.get("labels")
        if not isinstance(labels, list):
            labels = []
        temp = body.get("temp") if isinstance(body.get("temp"), dict) else {}
        humidity = body.get("humidity") if isinstance(body.get("humidity"), dict) else {}
        notify_enabled = bool(body.get("notify_enabled"))
        ranges = config.get("ranges") or {}
        notify = config.get("notify") or {}
        for label in labels:
            if not isinstance(label, str) or not _scada_is_sensor_label(label):
                continue
            rng = temp if "Temp" in label else humidity
            ranges[label] = {"low": rng.get("low", ""), "high": rng.get("high", "")}
            notify[label] = notify_enabled
        config["ranges"] = ranges
        config["notify"] = notify
        _save_scada_config(config)
        return jsonify(ok=True)
    if "ranges" in body and isinstance(body["ranges"], dict):
        config["ranges"] = body["ranges"]
    if "enabled" in body and isinstance(body["enabled"], dict):
        config["enabled"] = body["enabled"]
    if "aliases" in body and isinstance(body["aliases"], dict):
        config["aliases"] = body["aliases"]
    if "reporting" in body and isinstance(body["reporting"], dict):
        config["reporting"] = body["reporting"]
    if "notify" in body and isinstance(body["notify"], dict):
        config["notify"] = body["notify"]
    if "collector" in body and isinstance(body["collector"], dict):
        collector = config.get("collector") or dict(SCADA_COLLECTOR_DEFAULTS)
        for key in SCADA_COLLECTOR_DEFAULTS:
            if key in body["collector"]:
                try:
                    val = int(body["collector"][key])
                except (TypeError, ValueError):
                    continue
                collector[key] = max(1, val)
        config["collector"] = collector
    _save_scada_config(config)
    return jsonify(ok=True)


# ---------------------------------------------------------------------------
# SCADA report schedules (DVI-484) — saved report definitions that ST4's
# scheduler executes on a cadence and (optionally) emails. Storage is a flat
# list in scada_report_schedules.json; cadence drives the date window the
# scheduler computes at run time, so only the relative window is persisted.
# ---------------------------------------------------------------------------
SCADA_REPORT_CADENCES = ("last_week", "last_month", "custom")


def _load_scada_report_schedules():
    """Return the list of saved report schedules (empty list if none)."""
    if SCADA_REPORT_SCHEDULES_FILE.is_file():
        try:
            saved = json.loads(SCADA_REPORT_SCHEDULES_FILE.read_text())
            if isinstance(saved, list):
                return [s for s in saved if isinstance(s, dict)]
        except (json.JSONDecodeError, OSError):
            pass
    return []


def _save_scada_report_schedules(schedules):
    """Persist the report schedules list to disk."""
    SCADA_REPORT_SCHEDULES_FILE.write_text(json.dumps(schedules, indent=2))


def _scada_schedule_from_body(body, existing=None):
    """Build/validate a schedule dict from a request body. Returns
    (schedule, error). ``existing`` supplies id/created_at when updating."""
    name = (body.get("name") or "").strip()
    if not name:
        return None, "Schedule name is required."
    if len(name) > 120:
        return None, "Schedule name is too long (max 120 characters)."

    cadence = (body.get("cadence") or "last_week").strip()
    if cadence not in SCADA_REPORT_CADENCES:
        return None, f"Invalid cadence; expected one of {', '.join(SCADA_REPORT_CADENCES)}."

    custom_days = None
    if cadence == "custom":
        try:
            custom_days = int(body.get("custom_days"))
        except (TypeError, ValueError):
            return None, "custom_days must be an integer for a custom cadence."
        if custom_days < 1:
            return None, "custom_days must be at least 1."

    sensors = body.get("sensors")
    if sensors is None:
        sensors = []
    if not isinstance(sensors, list):
        return None, "sensors must be a list."
    sensors = [s.strip() for s in sensors if isinstance(s, str) and s.strip()]

    now = _datetime_type.now().isoformat()
    schedule = {
        "id": (existing or {}).get("id") or str(uuid.uuid4()),
        "name": name,
        "cadence": cadence,
        "custom_days": custom_days,
        "sensors": sensors,
        "notify": bool(body.get("notify")),
        "external": bool(body.get("external")),
        "created_at": (existing or {}).get("created_at") or now,
        "updated_at": now,
    }
    return schedule, None


@app.route("/scada/report-schedules")
@scada_required
def scada_report_schedules_list():
    """Return all saved report schedules."""
    return jsonify(schedules=_load_scada_report_schedules())


@app.route("/scada/report-schedules", methods=["POST"])
@scada_required
def scada_report_schedules_create():
    """Create a new report schedule."""
    body = request.get_json(silent=True) or {}
    schedule, err = _scada_schedule_from_body(body)
    if err:
        return jsonify(error=err), 400
    schedules = _load_scada_report_schedules()
    schedules.append(schedule)
    _save_scada_report_schedules(schedules)
    return jsonify(schedule=schedule), 201


@app.route("/scada/report-schedules/<sched_id>", methods=["PUT"])
@scada_required
def scada_report_schedules_update(sched_id):
    """Update an existing report schedule by id."""
    body = request.get_json(silent=True) or {}
    schedules = _load_scada_report_schedules()
    idx = next((i for i, s in enumerate(schedules) if s.get("id") == sched_id), None)
    if idx is None:
        return jsonify(error="Schedule not found."), 404
    schedule, err = _scada_schedule_from_body(body, existing=schedules[idx])
    if err:
        return jsonify(error=err), 400
    schedules[idx] = schedule
    _save_scada_report_schedules(schedules)
    return jsonify(schedule=schedule)


@app.route("/scada/report-schedules/<sched_id>", methods=["DELETE"])
@scada_required
def scada_report_schedules_delete(sched_id):
    """Delete a report schedule by id."""
    schedules = _load_scada_report_schedules()
    remaining = [s for s in schedules if s.get("id") != sched_id]
    if len(remaining) == len(schedules):
        return jsonify(error="Schedule not found."), 404
    _save_scada_report_schedules(remaining)
    return jsonify(ok=True)


# ---------------------------------------------------------------------------
# SCADA report recipients (DVI-487) — two global email lists consumed by the
# ST4 scheduler. Enforced recipients receive every scheduled report; External
# recipients receive a report only when that schedule's `external` toggle is
# on. File shape matches the scheduler's reader: {"recipients": [...]}.
# ---------------------------------------------------------------------------
SCADA_RECIPIENT_FILES = {
    "enforced": SCADA_ENFORCED_RECIPIENTS_FILE,
    "external": SCADA_EXTERNAL_RECIPIENTS_FILE,
}


def _load_scada_recipients(path):
    """Return the email list from a recipient file (flat list or {recipients})."""
    if path.is_file():
        try:
            data = json.loads(path.read_text())
        except (json.JSONDecodeError, OSError):
            data = None
        if isinstance(data, dict):
            data = data.get("recipients", [])
        if isinstance(data, list):
            return [e for e in data if isinstance(e, str) and e.strip()]
    return []


def _save_scada_recipients(path, emails):
    """Persist a recipient list, de-duplicated and lowercased."""
    seen, ordered = set(), []
    for e in emails:
        if not isinstance(e, str):
            continue
        norm = e.strip().lower()
        if norm and norm not in seen:
            seen.add(norm)
            ordered.append(norm)
    path.write_text(json.dumps({"recipients": ordered}, indent=2))


@app.route("/scada/report-recipients/<list_name>", methods=["GET"])
@scada_required
def scada_report_recipients_get(list_name):
    """Return the enforced or external recipient list."""
    path = SCADA_RECIPIENT_FILES.get(list_name)
    if path is None:
        return jsonify(error="Unknown recipient list."), 404
    return jsonify(recipients=_load_scada_recipients(path))


@app.route("/scada/report-recipients/<list_name>", methods=["POST"])
@scada_required
def scada_report_recipients_post(list_name):
    """Add or remove an email in the enforced or external recipient list."""
    path = SCADA_RECIPIENT_FILES.get(list_name)
    if path is None:
        return jsonify(error="Unknown recipient list."), 404
    body = request.get_json(silent=True) or {}
    action = body.get("action", "")
    email = (body.get("email") or "").strip()
    if action not in ("add", "remove"):
        return jsonify(error="Invalid action."), 400
    norm = email.lower()
    if action == "add" and ("@" not in norm or "." not in norm.split("@")[-1]):
        return jsonify(error="A valid email address is required."), 400
    emails = _load_scada_recipients(path)
    if action == "add":
        if norm not in [e.lower() for e in emails]:
            emails.append(norm)
    else:
        emails = [e for e in emails if e.lower() != norm]
    try:
        _save_scada_recipients(path, emails)
    except OSError as exc:
        return jsonify(error=f"Failed to save: {exc}"), 500
    return jsonify(ok=True, recipients=_load_scada_recipients(path))


# ---------------------------------------------------------------------------
# SCADA notification delivery engine (DVI-468)
# ---------------------------------------------------------------------------
# Background collector that polls the SCADA mailbox on the configured
# check_interval and fires the per-user emails that DVI-465 only persisted prefs
# for: stale-data (no new reading within stale_after_minutes) and sensor
# out-of-range. Recipient resolution + send plumbing live in notifications.py;
# this is the wiring that consumes scada_config.json + per-user prefs.

# Tracks which alert conditions have already been emailed so we fire once on the
# transition into the condition and reset on recovery — never spam every poll.
SCADA_ALERT_STATE_FILE = Path(__file__).resolve().parent / "scada_alert_state.json"


def _scada_fetch_messages(top):
    """Fetch up to ``top`` recent SCADA mailbox messages. Returns (messages, error).

    Background-thread safe (returns plain data, not Flask responses). ``error``
    is None on success or a short string describing the failure.
    """
    try:
        app_client = msal.ConfidentialClientApplication(
            _AZ_CLIENT_ID, authority=_AZ_AUTHORITY_HOME, client_credential=_AZ_SECRET)
        token_result = app_client.acquire_token_for_client(
            scopes=["https://graph.microsoft.com/.default"])
        token = token_result.get("access_token")
        if not token:
            return [], token_result.get("error_description", "no access_token")
        resp = _http_requests.get(
            f"https://graph.microsoft.com/v1.0/users/{SCADA_MAILBOX}/messages"
            f"?$top={int(top)}&$orderby=receivedDateTime desc"
            "&$select=subject,receivedDateTime,bodyPreview,body",
            headers={"Authorization": f"Bearer {token}",
                     "Content-Type": "application/json"},
            timeout=30,
        )
        if resp.status_code != 200:
            return [], f"Graph API {resp.status_code}"
        return resp.json().get("value", []), None
    except Exception as exc:  # network / parse errors — treat as transient
        return [], str(exc)


def _scada_is_sensor_label(label):
    """Mirror the UI's _scadaIsSensorLabel: Temp/RH labels are sensors."""
    return "Temp" in label or "RH" in label


def _scada_label_enabled(config, label):
    """Mirror _scadaIsEnabled: enabled unless explicitly set False."""
    enabled = config.get("enabled") or {}
    return enabled.get(label) is not False


def _scada_reporting_disabled(config, label):
    """Mirror _scadaReportingDisabled: reporting[label] === false."""
    reporting = config.get("reporting") or {}
    return reporting.get(label) is False


def _scada_notify_enabled(config, label):
    """Per-sensor out-of-range notify flag (DVI-483): on unless set False."""
    notify = config.get("notify") or {}
    return notify.get(label) is not False


def _scada_value_out_of_range(rng, value):
    """True if numeric ``value`` falls outside the configured ``rng`` (low/high)."""
    if not isinstance(rng, dict):
        return False
    try:
        num = float(value)
    except (TypeError, ValueError):
        return False
    low = rng.get("low")
    high = rng.get("high")
    if low not in (None, "") and num < float(low):
        return True
    if high not in (None, "") and num > float(high):
        return True
    return False


def _load_scada_alert_state():
    """Return {stale: bool, oor: {label: bool}} persisted alert state."""
    state = {"stale": False, "oor": {}}
    if SCADA_ALERT_STATE_FILE.is_file():
        try:
            saved = json.loads(SCADA_ALERT_STATE_FILE.read_text())
            if isinstance(saved, dict):
                state["stale"] = bool(saved.get("stale"))
                if isinstance(saved.get("oor"), dict):
                    state["oor"] = {k: bool(v) for k, v in saved["oor"].items()}
        except (json.JSONDecodeError, OSError):
            pass
    return state


def _save_scada_alert_state(state):
    try:
        SCADA_ALERT_STATE_FILE.write_text(json.dumps(state, indent=2))
    except OSError:
        app.logger.exception("Failed to persist scada_alert_state.json")


def _run_scada_collector():
    """One collector cycle: poll the mailbox, fire stale/out-of-range emails.

    Emails fire only on the transition into a condition (stale onset, sensor
    crossing out of range) and the state resets on recovery, so a sustained
    condition is not re-emailed every poll. Returns a short summary dict.
    """
    config = _load_scada_config()
    collector = config.get("collector") or dict(SCADA_COLLECTOR_DEFAULTS)
    stale_after = int(collector.get("stale_after_minutes",
                                    SCADA_COLLECTOR_DEFAULTS["stale_after_minutes"]))

    # Fetch a small window, not just the newest message: staleness is judged
    # against the most recent email of any type (any arrival means data is
    # flowing), but out-of-range checks must parse the latest Trigger email,
    # since "X-400 - Log" daily tables don't fit the Trigger snapshot parser
    # (DVI-538).
    messages, error = _scada_fetch_messages(25)
    if error:
        app.logger.warning("SCADA collector fetch failed: %s", error)
        return {"error": error}
    if not messages:
        return {"skipped": "no messages"}

    msg = messages[0]
    received_raw = msg.get("receivedDateTime", "")
    received_dt = None
    if received_raw:
        try:
            received_dt = _datetime_type.fromisoformat(received_raw.replace("Z", "+00:00"))
        except ValueError:
            received_dt = None

    state = _load_scada_alert_state()
    fired = {"stale": False, "oor": []}

    # --- Stale-data check (transition-guarded) ---
    age_minutes = None
    is_stale = False
    if received_dt is not None:
        now = _datetime_type.now(_dt_timezone.utc)
        age_minutes = int((now - received_dt).total_seconds() // 60)
        is_stale = age_minutes >= stale_after

    if is_stale and not state["stale"]:
        last_label = received_dt.strftime("%Y-%m-%d %H:%M UTC") if received_dt else None
        notifications.notify_scada(
            notifications.EVENT_SCADA_STALE,
            {"stale_after_minutes": stale_after,
             "age_minutes": age_minutes,
             "last_reading_time": last_label})
        fired["stale"] = True
    state["stale"] = is_stale

    # --- Out-of-range check per enabled, reporting-on sensor ---
    trigger_msg = next(
        (m for m in messages if scada_subject_kind(m.get("subject")) == "trigger"),
        None)
    if trigger_msg is None:
        _save_scada_alert_state(state)
        return fired
    timestamp, readings = _parse_scada_body(_scada_message_body(trigger_msg))
    ranges = config.get("ranges") or {}
    aliases = config.get("aliases") or {}
    seen_labels = set()
    for r in readings:
        label = r.get("label")
        if not label or not _scada_is_sensor_label(label):
            continue
        if not _scada_label_enabled(config, label) or _scada_reporting_disabled(config, label):
            continue
        if not _scada_notify_enabled(config, label):
            continue
        rng = ranges.get(label)
        if not isinstance(rng, dict):
            continue
        seen_labels.add(label)
        oor = _scada_value_out_of_range(rng, r.get("value"))
        prev = state["oor"].get(label, False)
        if oor and not prev:
            notifications.notify_scada(
                notifications.EVENT_SCADA_OUT_OF_RANGE,
                {"sensor": aliases.get(label) or label,
                 "label": label,
                 "value": r.get("value"),
                 "low": rng.get("low"),
                 "high": rng.get("high")})
            fired["oor"].append(label)
        state["oor"][label] = oor

    _save_scada_alert_state(state)
    return fired


# ---------------------------------------------------------------------------
# OneDrive connector (DVI-810): File Browser tab + Winston app-only access
# ---------------------------------------------------------------------------

def _onedrive_graph_token() -> str | None:
    """Acquire an app-only Graph token scoped to the home (iCast) tenant."""
    if not (_AZ_CLIENT_ID and _AZ_SECRET):
        return None
    try:
        cl = msal.ConfidentialClientApplication(
            _AZ_CLIENT_ID, authority=_AZ_AUTHORITY_HOME, client_credential=_AZ_SECRET)
        result = cl.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
        token = result.get("access_token")
        if not token:
            app.logger.error("OneDrive: token request failed: %s", result.get("error_description", result))
        return token
    except Exception:
        app.logger.exception("OneDrive: token acquisition error")
        return None


def _onedrive_list_items(token: str, user_upn: str, item_id: str | None) -> list[dict] | None:
    """List OneDrive items — root if item_id is None, otherwise by item ID."""
    if item_id:
        url = f"{_GRAPH_BASE}/users/{user_upn}/drive/items/{item_id}/children"
    else:
        url = f"{_GRAPH_BASE}/users/{user_upn}/drive/root/children"
    try:
        resp = _http_requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=30)
        if resp.status_code != 200:
            app.logger.error("OneDrive list failed (%s) for %s item=%s: %s",
                             resp.status_code, user_upn, item_id, resp.text[:300])
            return None
        return resp.json().get("value", [])
    except Exception:
        app.logger.exception("OneDrive: list error for %s", user_upn)
        return None


@app.route("/onedrive/ls")
@login_required
def onedrive_ls():
    """List a OneDrive folder for the signed-in user using app-only Graph.

    Query params:
      item_id  (optional) Drive item ID to list; defaults to root.

    Returns JSON {items: [{id, name, type, size, modified}]} or {error: ...}.
    """
    user = session.get("user") or {}
    user_upn = user.get("email", "")
    if not user_upn:
        return jsonify(error="No user session"), 401

    item_id = request.args.get("item_id") or None
    token = _onedrive_graph_token()
    if not token:
        return jsonify(error="OneDrive not available — Graph credentials not configured"), 503

    raw = _onedrive_list_items(token, user_upn, item_id)
    if raw is None:
        return jsonify(error="Could not list OneDrive folder. Check that Files.ReadWrite.All is granted for this tenant."), 502

    items = []
    for it in raw:
        is_folder = "folder" in it
        items.append({
            "id":       it.get("id", ""),
            "name":     it.get("name", ""),
            "type":     "folder" if is_folder else "file",
            "size":     it.get("size"),
            "modified": it.get("lastModifiedDateTime"),
        })
    return jsonify(items=items)


@app.route("/onedrive/download")
@login_required
def onedrive_download():
    """Proxy-download a OneDrive file by item ID for the signed-in user.

    Query params:
      item_id  Drive item ID (required).
      name     Suggested filename for Content-Disposition (optional).
    """
    user = session.get("user") or {}
    user_upn = user.get("email", "")
    if not user_upn:
        return jsonify(error="No user session"), 401

    item_id = request.args.get("item_id", "").strip()
    if not item_id:
        return jsonify(error="item_id required"), 400

    token = _onedrive_graph_token()
    if not token:
        return jsonify(error="OneDrive not available"), 503

    # Fetch the item metadata to get the preauthenticated download URL.
    try:
        meta_resp = _http_requests.get(
            f"{_GRAPH_BASE}/users/{user_upn}/drive/items/{item_id}",
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        if meta_resp.status_code != 200:
            return jsonify(error="Item not found"), 404
        meta = meta_resp.json()
    except Exception:
        app.logger.exception("OneDrive download: metadata fetch failed")
        return jsonify(error="OneDrive error"), 502

    download_url = meta.get("@microsoft.graph.downloadUrl")
    if not download_url:
        return jsonify(error="No download URL — item may be a folder"), 400

    # Stream the file content through to the client.
    filename = request.args.get("name") or meta.get("name", "download")
    content_type = (meta.get("file") or {}).get("mimeType", "application/octet-stream")
    try:
        file_resp = _http_requests.get(download_url, timeout=120, stream=True)
        if file_resp.status_code != 200:
            return jsonify(error="Download failed"), 502

        def generate():
            for chunk in file_resp.iter_content(chunk_size=65536):
                yield chunk

        from flask import Response, stream_with_context
        headers = {
            "Content-Disposition": f'attachment; filename="{filename}"',
            "Content-Type": content_type,
        }
        if "Content-Length" in file_resp.headers:
            headers["Content-Length"] = file_resp.headers["Content-Length"]
        return Response(stream_with_context(generate()), headers=headers)
    except Exception:
        app.logger.exception("OneDrive download: stream failed")
        return jsonify(error="Download error"), 502


# ---------------------------------------------------------------------------
# OneDrive / SharePoint folder-browse endpoint (DVI-841)
# Used by the quote-path picker (DVI-839 P3) to let users navigate their
# personal OneDrive or the shared SharePoint library without leaving the app.
# ---------------------------------------------------------------------------

# Simple in-process TTL cache for the SharePoint drive ID (resolving it costs 2
# Graph round-trips and the ID is stable for the lifetime of the site).
_sp_drive_cache: dict[str, tuple[str, float]] = {}  # site_name → (drive_id, expire_ts)
_SP_DRIVE_CACHE_TTL = 3600  # seconds


def _sp_resolve_drive(token: str, site_name: str) -> str | None:
    """Return the default document-library drive ID for a SharePoint site.

    Caches the result for _SP_DRIVE_CACHE_TTL seconds to avoid redundant lookups.
    Requires Sites.Read.All (or Sites.ReadWrite.All) application permission.
    Returns None on any Graph error.
    """
    import time as _time
    cached = _sp_drive_cache.get(site_name)
    if cached:
        drive_id, expire_ts = cached
        if _time.monotonic() < expire_ts:
            return drive_id

    hostname = _SP_HOSTNAME  # "icastinc.sharepoint.com"

    # Step 1: resolve site ID via /sites/{hostname}:/sites/{site_name}:
    try:
        site_resp = _http_requests.get(
            f"{_GRAPH_BASE}/sites/{hostname}:/sites/{site_name}:",
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        if site_resp.status_code != 200:
            app.logger.error(
                "_sp_resolve_drive: site fetch failed (%s) for %s: %s",
                site_resp.status_code, site_name, site_resp.text[:300],
            )
            return None
        site_id = site_resp.json().get("id", "")
        if not site_id:
            app.logger.error("_sp_resolve_drive: no site id in response for %s", site_name)
            return None
    except Exception:
        app.logger.exception("_sp_resolve_drive: site fetch error for %s", site_name)
        return None

    # Step 2: list drives and pick the default document library (Shared Documents).
    try:
        drives_resp = _http_requests.get(
            f"{_GRAPH_BASE}/sites/{site_id}/drives",
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        if drives_resp.status_code != 200:
            app.logger.error(
                "_sp_resolve_drive: drives fetch failed (%s) for site %s: %s",
                drives_resp.status_code, site_id, drives_resp.text[:300],
            )
            return None
        drives = drives_resp.json().get("value", [])
    except Exception:
        app.logger.exception("_sp_resolve_drive: drives fetch error for site %s", site_id)
        return None

    # Prefer drive named "Documents" / "Shared Documents"; fall back to first documentLibrary.
    preferred_names = {"Documents", "Shared Documents", "General"}
    default_drive = None
    for d in drives:
        if d.get("driveType") == "documentLibrary":
            if d.get("name") in preferred_names:
                default_drive = d
                break
            if default_drive is None:
                default_drive = d

    if not default_drive:
        app.logger.error(
            "_sp_resolve_drive: no documentLibrary drive found for site %s (drives: %d)",
            site_id, len(drives),
        )
        return None

    drive_id = default_drive.get("id", "")
    if drive_id:
        _sp_drive_cache[site_name] = (drive_id, _time.monotonic() + _SP_DRIVE_CACHE_TTL)
    return drive_id or None


def _onedrive_browse_list(token: str, url: str, label: str) -> list[dict] | None:
    """Fetch a Graph /children URL and return the item list, or None on error.

    Selects only the fields needed by the browse response to reduce payload size.
    """
    try:
        resp = _http_requests.get(
            url,
            headers={"Authorization": f"Bearer {token}"},
            params={"$select": "id,name,size,folder,lastModifiedDateTime", "$top": 500},
            timeout=30,
        )
        if resp.status_code != 200:
            app.logger.error(
                "onedrive-browse: list failed (%s) for %s: %s",
                resp.status_code, label, resp.text[:300],
            )
            return None
        return resp.json().get("value", [])
    except Exception:
        app.logger.exception("onedrive-browse: list error for %s", label)
        return None


@app.route("/documents/onedrive-browse")
@login_required
def documents_onedrive_browse():
    """Browse OneDrive/SharePoint folders for the quote-path picker (DVI-841).

    Query params:
      scope  "personal" — signed-in user's OneDrive via /users/{upn}/drive
             "shared"   — SharePoint site "InfrastructurePrecastInc" default library
      path   Relative folder path from the drive root (default: root).
             Empty or "/" means root. Segments must not start/end with "/".

    Response JSON:
      { scope, path, breadcrumb:[{name,path}], folders:[{name,path,id}], files:[{name,path,id}] }

    Errors: 400 bad params, 502 Graph error, 503 credentials unavailable.

    Auth note (personal scope): uses app-only token reading /users/{upn}/drive, which
    requires Files.ReadWrite.All (application permission). If the tenant has not granted
    this, Graph returns 403. Delegated access (OBO flow) would be needed as an alternative
    — but that requires a separate OAuth redirect not currently wired up.
    """
    user = session.get("user") or {}
    scope = request.args.get("scope", "personal").strip()
    if scope not in ("personal", "shared"):
        return jsonify(error="scope must be 'personal' or 'shared'"), 400

    raw_path = request.args.get("path", "").strip().strip("/")

    token = _onedrive_graph_token()
    if not token:
        return jsonify(
            error="Graph credentials not configured — OneDrive/SharePoint browsing unavailable"
        ), 503

    # Build breadcrumb from path segments.
    breadcrumb = [{"name": "Home", "path": ""}]
    if raw_path:
        parts = raw_path.split("/")
        for i, part in enumerate(parts):
            breadcrumb.append({"name": part, "path": "/".join(parts[: i + 1])})

    if scope == "personal":
        user_upn = user.get("email", "")
        if not user_upn:
            return jsonify(error="No authenticated user UPN in session"), 401
        if raw_path:
            url = f"{_GRAPH_BASE}/users/{user_upn}/drive/root:/{raw_path}:/children"
        else:
            url = f"{_GRAPH_BASE}/users/{user_upn}/drive/root/children"
        items = _onedrive_browse_list(token, url, f"personal:{user_upn}/{raw_path or 'root'}")
        if items is None:
            return jsonify(
                error=(
                    "Could not list personal OneDrive folder. "
                    "Ensure Files.ReadWrite.All (application) is granted for this tenant."
                )
            ), 502

    else:  # scope == "shared"
        sp_site_name = "InfrastructurePrecastInc"
        drive_id = _sp_resolve_drive(token, sp_site_name)
        if drive_id is None:
            return jsonify(
                error=(
                    "Could not resolve SharePoint site or drive. "
                    "Ensure Sites.Read.All (or Sites.Selected) is granted for this tenant."
                )
            ), 502
        if raw_path:
            url = f"{_GRAPH_BASE}/drives/{drive_id}/root:/{raw_path}:/children"
        else:
            url = f"{_GRAPH_BASE}/drives/{drive_id}/root/children"
        items = _onedrive_browse_list(token, url, f"shared:{sp_site_name}/{raw_path or 'root'}")
        if items is None:
            return jsonify(
                error=(
                    "Could not list SharePoint folder. "
                    "Ensure Sites.Read.All (or Sites.Selected) is granted for this tenant."
                )
            ), 502

    folders = []
    files = []
    for it in items:
        name = it.get("name", "")
        entry = {
            "name": name,
            "path": f"{raw_path}/{name}" if raw_path else name,
            "id":   it.get("id", ""),
        }
        if "folder" in it:
            folders.append(entry)
        else:
            files.append(entry)

    folders.sort(key=lambda x: x["name"].lower())
    files.sort(key=lambda x: x["name"].lower())

    return jsonify(scope=scope, path=raw_path, breadcrumb=breadcrumb, folders=folders, files=files)


# ---------------------------------------------------------------------------
# Vision tool (DVI-399 / DVI-408): enrollment, check-in, collectors, settings
# ---------------------------------------------------------------------------

# 14 agent-reported fields accepted in every check-in payload.
_VISION_CHECKIN_FIELDS = (
    "machine_id", "hostname", "logged_in_user", "os_version", "agent_version",
    "status", "capture_enabled", "last_capture_ts", "foreground_app",
    "idle_seconds", "cpu_pct", "screen_count", "agent_uptime_s", "ts",
)

# Enrollment tokens expire after 60 minutes — a 15-minute window was too tight
# when the first bootstrap attempt fails (e.g. ZIP download error) and the user
# retries after troubleshooting.
_VISION_ENROLL_TTL_S = 60 * 60

# vision_checkins is unbounded (one row per check-in). Keep raw history for this
# many days; older rows are pruned on insert so the Activity timeline stays bounded.
_VISION_CHECKIN_RETENTION_DAYS = 14

# Collector Phase-3 sparkline (DVI-427): online/offline history bucketed over
# this window so each tile shows a recent uptime trend without a per-tile fetch.
_VISION_SPARK_WINDOW_S = 6 * 3600
_VISION_SPARK_BUCKETS = 24

# Alert thresholds. A machine is "unexpectedly offline" once last_seen is older
# than this (longer than the collector grid's online/offline staleness so we only
# alert on sustained outages, not a single missed beat).
_VISION_OFFLINE_ALERT_SECONDS = 15 * 60
# Vision alert log, separate from the WO idempotency notification_log.json.
VISION_NOTIFICATION_LOG_FILE = Path(__file__).resolve().parent / "vision_notification_log.json"
_vision_notif_lock = threading.Lock()


def _vision_db():
    """Open the Vision SQLite DB, creating tables on first use. Caller must close."""
    conn = sqlite3.connect(str(VISION_DB_PATH), check_same_thread=False)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.executescript("""
        CREATE TABLE IF NOT EXISTS vision_enrollment_tokens (
            token       TEXT PRIMARY KEY,
            machine_id  TEXT,
            issued_at   REAL NOT NULL,
            expires_at  REAL NOT NULL,
            consumed_at REAL
        );
        CREATE TABLE IF NOT EXISTS vision_agents (
            machine_id   TEXT PRIMARY KEY,
            hostname     TEXT,
            agent_token  TEXT UNIQUE NOT NULL,
            enrolled_at  REAL NOT NULL,
            last_seen_at REAL,
            revoked      INTEGER NOT NULL DEFAULT 0,
            status       TEXT NOT NULL DEFAULT 'active'
        );
        CREATE TABLE IF NOT EXISTS vision_kv (
            key        TEXT PRIMARY KEY,
            value      TEXT,
            updated_at REAL
        );
        CREATE TABLE IF NOT EXISTS vision_checkins (
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
            machine_id      TEXT NOT NULL,
            received_at     REAL NOT NULL,
            hostname        TEXT,
            logged_in_user  TEXT,
            os_version      TEXT,
            agent_version   TEXT,
            status          TEXT,
            capture_enabled INTEGER,
            last_capture_ts REAL,
            foreground_app  TEXT,
            idle_seconds    REAL,
            cpu_pct         REAL,
            screen_count    INTEGER,
            agent_uptime_s  REAL,
            ts              REAL,
            FOREIGN KEY (machine_id) REFERENCES vision_agents (machine_id)
        );
        CREATE TABLE IF NOT EXISTS vision_commands (
            id            TEXT PRIMARY KEY,
            machine_id    TEXT NOT NULL,
            action        TEXT NOT NULL,
            params_json   TEXT,
            created_at    REAL NOT NULL,
            delivered_at  REAL,
            acked_at      REAL,
            acked_status  TEXT,
            error_message TEXT,
            FOREIGN KEY (machine_id) REFERENCES vision_agents (machine_id)
        );
        CREATE TABLE IF NOT EXISTS vision_agent_logs (
            machine_id    TEXT NOT NULL,
            file          TEXT NOT NULL,
            content       TEXT,
            received_at   REAL NOT NULL,
            PRIMARY KEY (machine_id, file)
        );
        CREATE INDEX IF NOT EXISTS idx_vision_checkins_machine_time
            ON vision_checkins (machine_id, received_at);
    """)
    _vision_migrate(conn)
    return conn


# Enrollment status model (DVI-436). `status` is authoritative:
#   pending  — self-enrolled (NULL-machine_id token or open-enroll secret); quarantined.
#   active   — quick-enrolled (admin machine-bound token) or approved; surfaces normally.
#   revoked  — token rejected; check-ins refused.
# The legacy `revoked` INTEGER is kept in sync (1 iff status='revoked') so existing
# `revoked = 0` queries continue to admit both pending and active rows.
_VISION_AGENT_STATUSES = ("pending", "active", "revoked")


def _vision_migrate(conn):
    """Idempotent schema upgrades for older Vision DBs (DVI-436, DVI-726)."""
    # vision_agents.status: add + backfill from the legacy revoked flag.
    agent_cols = {r["name"] for r in conn.execute("PRAGMA table_info(vision_agents)")}
    if "status" not in agent_cols:
        conn.execute(
            "ALTER TABLE vision_agents ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"
        )
        conn.execute("UPDATE vision_agents SET status = 'revoked' WHERE revoked = 1")
        conn.commit()
    # vision_enrollment_tokens.machine_id: drop the NOT NULL constraint so admins can
    # issue a token without a pre-known machine (agent self-supplies the id at register).
    tok_info = list(conn.execute("PRAGMA table_info(vision_enrollment_tokens)"))
    mid = next((r for r in tok_info if r["name"] == "machine_id"), None)
    if mid is not None and mid["notnull"] == 1:
        conn.executescript("""
            CREATE TABLE vision_enrollment_tokens_new (
                token       TEXT PRIMARY KEY,
                machine_id  TEXT,
                issued_at   REAL NOT NULL,
                expires_at  REAL NOT NULL,
                consumed_at REAL
            );
            INSERT INTO vision_enrollment_tokens_new
                (token, machine_id, issued_at, expires_at, consumed_at)
            SELECT token, machine_id, issued_at, expires_at, consumed_at
                FROM vision_enrollment_tokens;
            DROP TABLE vision_enrollment_tokens;
            ALTER TABLE vision_enrollment_tokens_new
                RENAME TO vision_enrollment_tokens;
        """)
        conn.commit()
    # DVI-726: SSH public key fields.
    if "ssh_public_key" not in agent_cols:
        conn.execute("ALTER TABLE vision_agents ADD COLUMN ssh_public_key TEXT")
        conn.commit()
    if "ssh_key_written" not in agent_cols:
        conn.execute(
            "ALTER TABLE vision_agents ADD COLUMN ssh_key_written INTEGER NOT NULL DEFAULT 0"
        )
        conn.commit()


_VISION_OPEN_ENROLL_KEY = "open_enroll_secret"


def _vision_write_ssh_key_to_rsync(public_key):
    """Append public_key to authorized_keys on the rsync server (DVI-726).

    Uses ssh to run a one-liner on the remote that appends the key only if it
    is not already present (idempotent).  Returns True on success or a string
    error message on failure.

    Requires _VISION_RSYNC_MGMT_KEY to be set to a private key file that is
    pre-authorized for _VISION_RSYNC_USER@_VISION_RSYNC_HOST.
    """
    if not _VISION_RSYNC_MGMT_KEY:
        return "VISION_RSYNC_MGMT_KEY not configured — cannot push key automatically."
    key_stripped = public_key.strip().replace("'", "'\\''")
    # Idempotent: append only if not already present.
    remote_cmd = (
        f"mkdir -p ~/.ssh && chmod 700 ~/.ssh && "
        f"grep -qxF '{key_stripped}' ~/.ssh/authorized_keys 2>/dev/null || "
        f"echo '{key_stripped}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
    )
    cmd = [
        "ssh",
        "-o", "StrictHostKeyChecking=no",
        "-o", "BatchMode=yes",
        "-o", "ConnectTimeout=10",
        "-p", str(_VISION_RSYNC_PORT),
        "-i", _VISION_RSYNC_MGMT_KEY,
        f"{_VISION_RSYNC_USER}@{_VISION_RSYNC_HOST}",
        remote_cmd,
    ]
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
        if result.returncode == 0:
            return True
        err = (result.stderr or result.stdout or "").strip()
        return err or f"ssh exited {result.returncode}"
    except subprocess.TimeoutExpired:
        return "SSH connection timed out."
    except FileNotFoundError:
        return "ssh not found on server PATH."
    except Exception as exc:  # noqa: BLE001
        return str(exc)


def _vision_kv_get(conn, key):
    row = conn.execute("SELECT value FROM vision_kv WHERE key = ?", (key,)).fetchone()
    return row["value"] if row else None


def _vision_kv_set(conn, key, value):
    conn.execute(
        "INSERT INTO vision_kv (key, value, updated_at) VALUES (?, ?, ?)"
        " ON CONFLICT(key) DO UPDATE SET value = excluded.value,"
        " updated_at = excluded.updated_at",
        (key, value, time.time()),
    )


def _vision_get_open_enroll_secret(conn, create=True):
    """Return the current open-enroll shared secret, minting one on first use."""
    secret = _vision_kv_get(conn, _VISION_OPEN_ENROLL_KEY)
    if not secret and create:
        secret = uuid.uuid4().hex
        _vision_kv_set(conn, _VISION_OPEN_ENROLL_KEY, secret)
        conn.commit()
    return secret


def _vision_prune_checkins(conn):
    """Drop check-in rows older than the retention window. Cheap, index-backed."""
    cutoff = time.time() - _VISION_CHECKIN_RETENTION_DAYS * 86400
    conn.execute("DELETE FROM vision_checkins WHERE received_at < ?", (cutoff,))


def _load_vision_settings():
    """Return Vision settings: {mock_data: bool}. Mock defaults on until first real check-in."""
    defaults = {"mock_data": True}
    if VISION_SETTINGS_FILE.is_file():
        try:
            saved = json.loads(VISION_SETTINGS_FILE.read_text())
            if isinstance(saved.get("mock_data"), bool):
                defaults["mock_data"] = saved["mock_data"]
        except (json.JSONDecodeError, OSError):
            pass
    return defaults


def _save_vision_settings(settings):
    VISION_SETTINGS_FILE.write_text(json.dumps(settings, indent=2))


_vision_names_lock = threading.Lock()


def _load_vision_agent_names():
    """Return {machine_id: friendly_name} display-name overrides (DVI-682)."""
    if not VISION_AGENT_NAMES_FILE.is_file():
        return {}
    try:
        data = json.loads(VISION_AGENT_NAMES_FILE.read_text())
        return {str(k): str(v) for k, v in data.items()} if isinstance(data, dict) else {}
    except (json.JSONDecodeError, OSError):
        return {}


def _save_vision_agent_names(names):
    VISION_AGENT_NAMES_FILE.write_text(json.dumps(names, indent=2))


# --- Capture policy (DVI-427) --------------------------------------------
# Default policy applied to every machine; per-machine overrides live under
# "machines". Delivered to agents via the existing check-in response config_delta.
_VISION_POLICY_DEFAULTS = {
    "capture_enabled": False,          # capture is off until explicitly enabled
    "capture_mode": "screenshot",      # screenshot | video (DVI-727 hybrid backend)
    "capture_fps": 1.0,                # video frame rate; ignored in screenshot mode
    "capture_rate_s": 60,              # seconds between frames (screenshot mode)
    "idle_threshold_s": 300,           # pause capture after this idle window
    "exclusions": [],                  # app names / URL substrings never captured
    "schedule": {"days": [1, 2, 3, 4, 5], "start": "08:00", "end": "17:00"},
    "retention_days": 30,              # surfaced for the footage store's purge job
}

_VISION_CAPTURE_MODES = ("screenshot", "video")


def _load_vision_policy():
    """Return {default: {...}, machines: {machine_id: {...overrides}}}."""
    base = {"default": dict(_VISION_POLICY_DEFAULTS), "machines": {}}
    if VISION_POLICY_FILE.is_file():
        try:
            saved = json.loads(VISION_POLICY_FILE.read_text())
            if isinstance(saved, dict):
                if isinstance(saved.get("default"), dict):
                    base["default"].update(_vision_clean_policy(saved["default"]))
                if isinstance(saved.get("machines"), dict):
                    for mid, ov in saved["machines"].items():
                        if isinstance(ov, dict):
                            base["machines"][mid] = _vision_clean_policy(ov)
        except (json.JSONDecodeError, OSError):
            pass
    return base


def _vision_clean_policy(raw):
    """Coerce a policy dict to known fields/types; drop anything unrecognized."""
    out = {}
    if "capture_enabled" in raw:
        out["capture_enabled"] = bool(raw["capture_enabled"])
    if "capture_mode" in raw:
        mode = str(raw["capture_mode"]).strip().lower()
        if mode in _VISION_CAPTURE_MODES:
            out["capture_mode"] = mode
    if "capture_fps" in raw:
        try:
            fps = float(raw["capture_fps"])
            if fps > 0:
                # Clamp to a sane window: 0.1 fps (1 frame / 10s) up to 30 fps.
                out["capture_fps"] = round(min(30.0, max(0.1, fps)), 3)
        except (TypeError, ValueError):
            pass
    for k in ("capture_rate_s", "idle_threshold_s", "retention_days"):
        if k in raw:
            try:
                out[k] = max(0, int(raw[k]))
            except (TypeError, ValueError):
                pass
    if isinstance(raw.get("exclusions"), list):
        out["exclusions"] = [str(x).strip() for x in raw["exclusions"] if str(x).strip()]
    sched = raw.get("schedule")
    if isinstance(sched, dict):
        clean = {}
        if isinstance(sched.get("days"), list):
            clean["days"] = sorted({int(d) for d in sched["days"]
                                    if isinstance(d, (int, float)) and 0 <= int(d) <= 6})
        for k in ("start", "end"):
            v = sched.get(k)
            if isinstance(v, str) and re.match(r"^\d{1,2}:\d{2}$", v.strip()):
                clean[k] = v.strip()
        out["schedule"] = clean
    return out


def _save_vision_policy(policy):
    VISION_POLICY_FILE.write_text(json.dumps(policy, indent=2))


def _vision_effective_policy(machine_id, policy=None):
    """Merge default + per-machine override into the policy the agent receives."""
    policy = policy or _load_vision_policy()
    eff = dict(_VISION_POLICY_DEFAULTS)
    eff.update(policy.get("default") or {})
    override = (policy.get("machines") or {}).get(machine_id)
    if isinstance(override, dict):
        eff.update(override)
    return eff


_vision_footage_purge_at = 0.0
_VISION_FOOTAGE_PURGE_INTERVAL_S = 3600  # at most one purge sweep per hour


def _vision_purge_footage(now=None):
    """Delete footage older than each machine's effective retention_days.

    Covers screenshot frames, video segments (.mp4), and their thumbnail/poster
    companions alike — anything in the machine's footage dir whose age exceeds
    its retention window. Throttled to one sweep/hour so the check-in path stays
    cheap. retention_days <= 0 disables purging for that machine."""
    global _vision_footage_purge_at
    now = now or time.time()
    if now - _vision_footage_purge_at < _VISION_FOOTAGE_PURGE_INTERVAL_S:
        return
    _vision_footage_purge_at = now
    if not VISION_FOOTAGE_DIR.is_dir():
        return
    policy = _load_vision_policy()
    for mdir in VISION_FOOTAGE_DIR.iterdir():
        if not mdir.is_dir():
            continue
        days = _vision_effective_policy(mdir.name, policy).get("retention_days") or 0
        if days <= 0:
            continue
        cutoff = now - days * 86400
        for p in mdir.iterdir():
            try:
                if p.is_file() and _vision_frame_ts(p) < cutoff:
                    p.unlink()
            except OSError:
                continue


# --- Captured footage store (D3) -----------------------------------------
# Frame ids and machine ids are constrained to this charset so a request can
# never traverse outside the configured footage root.
_VISION_FRAME_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
_VISION_FRAME_EXTS = (".jpg", ".jpeg", ".png", ".webp")
# Segmented H.264 footage (DVI-727 video backend). A segment <stem>.mp4 ships
# with poster companions (<stem>.jpg full + <stem>.thumb.jpg thumb) extracted
# agent-side, so the existing image-serving path renders the tile/poster.
_VISION_VIDEO_EXTS = (".mp4",)


def _vision_footage_machine_dir(machine_id):
    return VISION_FOOTAGE_DIR / machine_id


def _vision_machine_active(machine_id):
    """True only for an enrolled, approved (status='active') machine. Pending and
    revoked machines are quarantined from footage/recordings (DVI-436)."""
    conn = _vision_db()
    try:
        row = conn.execute(
            "SELECT status FROM vision_agents WHERE machine_id = ?", (machine_id,)
        ).fetchone()
    finally:
        conn.close()
    return bool(row) and row["status"] == "active"


def _vision_frame_ts(path):
    """Frame timestamp: epoch-second filename stem if present, else mtime."""
    try:
        stem_ts = float(path.stem)
        if stem_ts > 1_000_000_000:
            return stem_ts
    except ValueError:
        pass
    try:
        return path.stat().st_mtime
    except OSError:
        return 0.0


class FootageStore:
    """Backend-agnostic access to captured Vision footage (DVI-743 Phase 1).

    Serving endpoints address footage only by (machine_id, frame_id), so the
    storage backend is swappable behind this seam. Callers enforce the
    `vision_access_required` + `_vision_machine_active` quarantine *before*
    invoking any method here — the store never re-checks access.

    Phase 2 adds an on-demand remote backend implementing this same interface.
    """

    def list_frames(self, machine_id, frm=None, to=None, limit=500):
        """Footage entries for a machine, newest first (frame + video dicts)."""
        raise NotImplementedError

    def frame_bytes(self, machine_id, frame_id, size):
        """Flask response serving the frame image (size 'thumb'|'full'), or
        None if the frame is absent."""
        raise NotImplementedError

    def segment_stream(self, machine_id, frame_id, rng=None):
        """Range-capable Flask response for the H.264 segment, or None if
        absent. The local backend defers Range handling to Flask's conditional
        send_file (which reads the request's Range header); `rng` is the
        forward-compat seam for a remote backend that must forward the range."""
        raise NotImplementedError

    def read_bytes(self, machine_id, frame_id, kind):
        """Raw footage bytes for Phase-4 Assistant ingestion (DVI-751).

        Unlike frame_bytes/segment_stream (which return Flask responses for the
        HTTP serving path), this returns ``(data: bytes, content_type: str)`` or
        None, so a future Assistant/Claude integration can build image blocks or
        upload via the Files API without round-tripping through HTTP. `kind`
        selects the representation: "thumb"|"full" -> frame image (a video
        item's poster keyframe for "full"), "segment" -> raw H.264 .mp4."""
        raise NotImplementedError


class LocalFootageStore(FootageStore):
    """Footage served from the local mirror directory
    (VISION_FOOTAGE_DIR/<machine_id>/...). This is the pre-DVI-743 behavior,
    moved behind the FootageStore seam with no functional change."""

    def _machine_dir(self, machine_id):
        return _vision_footage_machine_dir(machine_id)

    def list_frames(self, machine_id, frm=None, to=None, limit=500):
        """Real footage-store entries for a machine, newest first. Handles both
        screenshot frames (.jpg/.png/…) and video segments (.mp4 + poster), DVI-727.

        Thumbnails (<stem>.thumb.<ext>) are companions, not standalone entries. A
        poster image that shares a stem with a .mp4 segment is surfaced through the
        video entry (as its thumb/full poster), not as its own frame."""
        d = self._machine_dir(machine_id)
        if not d.is_dir():
            return []
        video_stems = set()
        files = []
        for p in sorted(d.iterdir()):
            if not p.is_file() or ".thumb." in p.name:
                continue
            ext = p.suffix.lower()
            if ext in _VISION_VIDEO_EXTS:
                video_stems.add(p.stem)
                files.append((p, "video"))
            elif ext in _VISION_FRAME_EXTS:
                files.append((p, "image"))
        out = []
        for p, kind in files:
            # Skip poster images — they belong to their video segment's entry.
            if kind == "image" and p.stem in video_stems:
                continue
            ts = _vision_frame_ts(p)
            if frm is not None and ts < frm:
                continue
            if to is not None and ts > to:
                continue
            entry = {
                "frame_id": p.stem,
                "ts": int(ts),
                "kind": kind,
                "thumb_url": f"/vision/frames/{machine_id}/{p.stem}?size=thumb",
                "full_url": f"/vision/frames/{machine_id}/{p.stem}?size=full",
            }
            if kind == "video":
                entry["video_url"] = f"/vision/segments/{machine_id}/{p.stem}"
            out.append(entry)
        out.sort(key=lambda f: f["ts"], reverse=True)
        return out[:limit]

    def frame_bytes(self, machine_id, frame_id, size):
        d = self._machine_dir(machine_id)
        candidates = []
        if size == "thumb":
            candidates += sorted(d.glob(frame_id + ".thumb.*"))
        candidates += [p for p in sorted(d.glob(frame_id + ".*")) if ".thumb." not in p.name]
        for p in candidates:
            if p.is_file() and p.suffix.lower() in _VISION_FRAME_EXTS:
                return send_file(str(p))
        return None

    def segment_stream(self, machine_id, frame_id, rng=None):
        d = self._machine_dir(machine_id)
        for p in sorted(d.glob(frame_id + ".*")):
            if p.is_file() and p.suffix.lower() in _VISION_VIDEO_EXTS:
                return send_file(str(p), conditional=True)
        return None

    def read_bytes(self, machine_id, frame_id, kind):
        d = self._machine_dir(machine_id)
        if kind == "segment":
            for p in sorted(d.glob(frame_id + ".*")):
                if p.is_file() and p.suffix.lower() in _VISION_VIDEO_EXTS:
                    return p.read_bytes(), _VISION_CONTENT_TYPES.get(
                        p.suffix.lower(), "video/mp4")
            return None
        candidates = []
        if kind == "thumb":
            candidates += sorted(d.glob(frame_id + ".thumb.*"))
        candidates += [p for p in sorted(d.glob(frame_id + ".*")) if ".thumb." not in p.name]
        for p in candidates:
            if p.is_file() and p.suffix.lower() in _VISION_FRAME_EXTS:
                return p.read_bytes(), _VISION_CONTENT_TYPES.get(
                    p.suffix.lower(), "application/octet-stream")
        return None


_VISION_CONTENT_TYPES = {
    ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
    ".webp": "image/webp", ".mp4": "video/mp4",
}


class _FootageLRUCache:
    """Bounded on-disk LRU hot-cache for remotely-fetched footage (DVI-748, D3=ii).

    Holds recently-viewed thumbs/frames/segments only — never a full mirror.
    Entries are evicted oldest-first once the total cached size exceeds
    ``max_bytes`` or an entry is older than ``ttl_s``. Files are stored under
    ``root`` with their original extension so callers can serve them straight
    through send_file (correct Content-Type + native HTTP Range for video).
    """

    def __init__(self, root, max_bytes, ttl_s):
        self._root = Path(root)
        self._max_bytes = max(int(max_bytes), 0)
        self._ttl = max(int(ttl_s), 0)
        self._lock = threading.Lock()
        # key -> (path, size); insertion order == recency (move_to_end on hit).
        self._entries = _OrderedDict_type()
        self._mtimes = {}
        try:
            self._root.mkdir(parents=True, exist_ok=True)
        except OSError:
            pass

    def _safe_name(self, key):
        # Flatten machine_id/filename into one cache filename; key chars are
        # already restricted by _VISION_FRAME_ID_RE + a fixed extension set.
        return key.replace("/", "__")

    def get(self, key):
        now = time.time()
        with self._lock:
            entry = self._entries.get(key)
            if entry is None:
                return None
            path, _size = entry
            if self._ttl and now - self._mtimes.get(key, 0) > self._ttl:
                self._drop(key)
                return None
            if not path.exists():
                self._drop(key)
                return None
            self._entries.move_to_end(key)
            return path

    def put(self, key, data):
        path = self._root / self._safe_name(key)
        try:
            tmp = path.with_suffix(path.suffix + ".part")
            tmp.write_bytes(data)
            tmp.replace(path)
        except OSError as exc:
            app.logger.warning("Footage cache write failed for %s: %s", key, exc)
            return None
        with self._lock:
            if key in self._entries:
                self._drop(key)
            self._entries[key] = (path, len(data))
            self._mtimes[key] = time.time()
            self._evict_locked()
        return path

    def _drop(self, key):
        entry = self._entries.pop(key, None)
        self._mtimes.pop(key, None)
        if entry:
            try:
                entry[0].unlink()
            except OSError:
                pass

    def _evict_locked(self):
        # Never evict the just-added (newest) entry, even if it alone exceeds the
        # cap — that keeps an oversized item servable until the next put.
        total = sum(sz for _p, sz in self._entries.values())
        while total > self._max_bytes and len(self._entries) > 1:
            old_key, (old_path, old_size) = next(iter(self._entries.items()))
            self._entries.pop(old_key, None)
            self._mtimes.pop(old_key, None)
            try:
                old_path.unlink()
            except OSError:
                pass
            total -= old_size


class _SFTPPool:
    """Tiny thread-safe pool of paramiko SFTP connections (DVI-748).

    Avoids a per-request SSH handshake. Connections are validated cheaply on
    checkout; dead ones are discarded and replaced. paramiko is imported lazily
    so the local backend never pays for it.
    """

    def __init__(self, host, port, user, key_path, max_size):
        self._host, self._port, self._user = host, port, user
        self._key_path = key_path
        self._max_size = max(int(max_size), 1)
        self._idle = []  # list[(transport_owner_client, sftp)]
        self._lock = threading.Lock()

    def _connect(self):
        import paramiko  # lazy: only the remote backend needs it
        client = paramiko.SSHClient()
        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        client.connect(
            hostname=self._host, port=self._port, username=self._user,
            key_filename=self._key_path, timeout=15, banner_timeout=15,
            auth_timeout=15, look_for_keys=False, allow_agent=False,
        )
        sftp = client.open_sftp()
        sftp.get_channel().settimeout(30)
        return client, sftp

    @_contextmanager_type
    def connection(self):
        client = sftp = None
        with self._lock:
            if self._idle:
                client, sftp = self._idle.pop()
        if sftp is not None:
            try:
                sftp.stat(".")  # liveness probe
            except Exception:
                try:
                    client.close()
                except Exception:
                    pass
                client = sftp = None
        if sftp is None:
            client, sftp = self._connect()
        try:
            yield sftp
        except Exception:
            try:
                client.close()
            except Exception:
                pass
            raise
        else:
            with self._lock:
                if len(self._idle) < self._max_size:
                    self._idle.append((client, sftp))
                    return
            try:
                client.close()
            except Exception:
                pass


class RemoteSSHFootageStore(FootageStore):
    """Footage served on demand from the rsync storage server over SFTP
    (DVI-748 Phase 2). No full local copy — a bounded LRU caches only the hot
    set. Remote layout mirrors local: ``<base>/<machine_id>/<frame_id><ext>``
    (+ ``<frame_id>.thumb.<ext>`` companions, ``.mp4`` segments)."""

    def __init__(self):
        self._base = VISION_FOOTAGE_REMOTE_BASE.rstrip("/")
        self._pool = _SFTPPool(
            _VISION_RSYNC_HOST, _VISION_RSYNC_PORT, _VISION_RSYNC_USER,
            VISION_FOOTAGE_SSH_KEY, VISION_FOOTAGE_SSH_POOL_SIZE,
        )
        self._cache = _FootageLRUCache(
            VISION_FOOTAGE_CACHE_DIR, VISION_FOOTAGE_CACHE_MAX_BYTES,
            VISION_FOOTAGE_CACHE_TTL,
        )
        self._listing_ttl = max(VISION_FOOTAGE_LISTING_TTL, 0)
        self._listings = {}  # machine_id -> (expires_at, [(name, mtime), ...])
        self._listing_lock = threading.Lock()

    def _remote_dir(self, machine_id):
        return f"{self._base}/{machine_id}"

    def _list_remote(self, machine_id):
        """[(filename, mtime)] for a machine dir, briefly cached. [] on outage
        or a missing/empty dir (clean UI, never a crash)."""
        now = time.time()
        with self._listing_lock:
            cached = self._listings.get(machine_id)
            if cached and cached[0] > now:
                return cached[1]
        try:
            with self._pool.connection() as sftp:
                attrs = sftp.listdir_attr(self._remote_dir(machine_id))
            files = [(a.filename, float(getattr(a, "st_mtime", 0) or 0)) for a in attrs]
        except FileNotFoundError:
            files = []
        except Exception as exc:
            app.logger.warning("Remote footage listing failed for %s: %s", machine_id, exc)
            return []
        if self._listing_ttl:
            with self._listing_lock:
                self._listings[machine_id] = (now + self._listing_ttl, files)
        return files

    @staticmethod
    def _name_ts(name, mtime):
        stem = name.rsplit(".", 1)[0]
        # A thumb companion's stem is "<frame_id>.thumb"; use the frame_id part.
        if stem.endswith(".thumb"):
            stem = stem[: -len(".thumb")]
        try:
            ts = float(stem)
            if ts > 1_000_000_000:
                return ts
        except ValueError:
            pass
        return mtime

    def list_frames(self, machine_id, frm=None, to=None, limit=500):
        files = self._list_remote(machine_id)
        video_stems = set()
        items = []  # (name, ext, kind, ts)
        for name, mtime in files:
            if ".thumb." in name:
                continue
            dot = name.rfind(".")
            if dot < 0:
                continue
            ext = name[dot:].lower()
            stem = name[:dot]
            if ext in _VISION_VIDEO_EXTS:
                video_stems.add(stem)
                items.append((name, ext, stem, "video", self._name_ts(name, mtime)))
            elif ext in _VISION_FRAME_EXTS:
                items.append((name, ext, stem, "image", self._name_ts(name, mtime)))
        out = []
        for name, ext, stem, kind, ts in items:
            if kind == "image" and stem in video_stems:
                continue  # poster belongs to its video entry
            if frm is not None and ts < frm:
                continue
            if to is not None and ts > to:
                continue
            entry = {
                "frame_id": stem,
                "ts": int(ts),
                "kind": kind,
                "thumb_url": f"/vision/frames/{machine_id}/{stem}?size=thumb",
                "full_url": f"/vision/frames/{machine_id}/{stem}?size=full",
            }
            if kind == "video":
                entry["video_url"] = f"/vision/segments/{machine_id}/{stem}"
            out.append(entry)
        out.sort(key=lambda f: f["ts"], reverse=True)
        return out[:limit]

    def _resolve(self, machine_id, frame_id, exts, prefer_thumb=False):
        """Pick the best matching remote filename for a frame, or None."""
        files = [n for n, _m in self._list_remote(machine_id)]
        candidates = []
        if prefer_thumb:
            candidates += sorted(
                n for n in files
                if n.startswith(frame_id + ".thumb.") and n[n.rfind("."):].lower() in exts
            )
        candidates += sorted(
            n for n in files
            if ".thumb." not in n and n.startswith(frame_id + ".")
            and n[n.rfind("."):].lower() in exts
        )
        return candidates[0] if candidates else None

    def _fetch(self, machine_id, name):
        """Return a local cache path for a remote file, fetching via SFTP on a
        miss. None on outage/missing file."""
        cache_key = f"{machine_id}/{name}"
        hit = self._cache.get(cache_key)
        if hit is not None:
            return hit
        remote_path = f"{self._remote_dir(machine_id)}/{name}"
        try:
            with self._pool.connection() as sftp:
                with sftp.open(remote_path, "rb") as fh:
                    fh.prefetch()
                    data = fh.read()
        except FileNotFoundError:
            return None
        except Exception as exc:
            app.logger.warning("Remote footage fetch failed for %s: %s", remote_path, exc)
            return None
        return self._cache.put(cache_key, data)

    def frame_bytes(self, machine_id, frame_id, size):
        name = self._resolve(machine_id, frame_id, _VISION_FRAME_EXTS,
                              prefer_thumb=(size == "thumb"))
        if not name:
            return None
        path = self._fetch(machine_id, name)
        if path is None:
            return None
        ctype = _VISION_CONTENT_TYPES.get(path.suffix.lower())
        return send_file(str(path), mimetype=ctype)

    def segment_stream(self, machine_id, frame_id, rng=None):
        name = self._resolve(machine_id, frame_id, _VISION_VIDEO_EXTS)
        if not name:
            return None
        path = self._fetch(machine_id, name)
        if path is None:
            return None
        # Whole segment is cached locally (H.264 segments are small, ~10s at low
        # bitrate), so Flask's conditional send_file serves HTTP Range / 206 for
        # scrubbing natively off the cache file.
        return send_file(str(path), mimetype="video/mp4", conditional=True)

    def read_bytes(self, machine_id, frame_id, kind):
        if kind == "segment":
            name = self._resolve(machine_id, frame_id, _VISION_VIDEO_EXTS)
        else:
            name = self._resolve(machine_id, frame_id, _VISION_FRAME_EXTS,
                                  prefer_thumb=(kind == "thumb"))
        if not name:
            return None
        path = self._fetch(machine_id, name)
        if path is None:
            return None
        default_ct = "video/mp4" if kind == "segment" else "application/octet-stream"
        ctype = _VISION_CONTENT_TYPES.get(path.suffix.lower(), default_ct)
        return path.read_bytes(), ctype


def _make_footage_store():
    """Select the footage backend from VISION_FOOTAGE_BACKEND (DVI-743).
    "remote" (DVI-748) streams on demand over SFTP; anything else (incl. an
    unknown value) falls back to the local-disk mirror so a misconfigured env
    never takes footage offline. A failure constructing the remote backend also
    degrades to local rather than crashing startup."""
    if VISION_FOOTAGE_BACKEND == "remote":
        try:
            store = RemoteSSHFootageStore()
            app.logger.info(
                "Vision footage backend = remote SFTP (%s@%s:%s/%s)",
                _VISION_RSYNC_USER, _VISION_RSYNC_HOST, _VISION_RSYNC_PORT,
                VISION_FOOTAGE_REMOTE_BASE,
            )
            return store
        except Exception as exc:
            app.logger.error(
                "Remote footage backend unavailable (%s); falling back to local.", exc
            )
            return LocalFootageStore()
    if VISION_FOOTAGE_BACKEND not in ("", "local"):
        app.logger.warning(
            "VISION_FOOTAGE_BACKEND=%r is not recognized; using local backend.",
            VISION_FOOTAGE_BACKEND,
        )
    return LocalFootageStore()


_footage_store = _make_footage_store()


def footage_bytes(machine_id, frame_id, kind="full"):
    """Phase-4 Assistant-ingestion resolver (DVI-751 — groundwork, NOT wired).

    The single server-side seam mapping a canonical Recordings item
    (machine_id, frame_id) to raw footage bytes for a future Assistant/Claude
    "analyze this capture" action. Returns ``(data: bytes, content_type: str)``
    or None. Backed by the active FootageStore, so it behaves identically for
    the local-mirror and remote-SFTP backends.

    Ingestion mapping (the contract Vision Phase 4 will build on):
      - "full"   -> full frame image. A screenshot item maps 1:1 to a Claude
                    image block (base64). A video item yields its poster /
                    keyframe image, the cheap representative still for a clip.
      - "thumb"  -> small frame image (low-token preview / triage).
      - "segment"-> raw H.264 .mp4 bytes. The caller samples keyframes (or
                    uploads via the Files API) — frame extraction is future
                    Phase-4 work, deliberately out of scope here.

    Access: callers MUST enforce vision_access_required + _vision_machine_active
    before invoking; this resolver never re-checks access (same rule as the
    FootageStore serving methods).

    NOT YET WIRED: no Assistant tool registration, no Claude call, no UI, no
    endpoint. This locks the contract so the streaming refactor (DVI-743) is a
    clean base for Vision Phase 4."""
    return _footage_store.read_bytes(machine_id, frame_id, kind)


def _vision_mock_frames(machine_id, frm=None, to=None, count=24):
    """Synthetic frame list so Recordings/thumbnails are demonstrable pre-capture."""
    now = int(time.time())
    out = []
    for i in range(count):
        ts = now - i * 900  # one mock frame every 15 minutes
        if frm is not None and ts < frm:
            continue
        if to is not None and ts > to:
            continue
        fid = f"mock-{ts}"
        out.append({
            "frame_id": fid,
            "ts": ts,
            "kind": "image",
            "thumb_url": f"/vision/frames/{machine_id}/{fid}?size=thumb",
            "full_url": f"/vision/frames/{machine_id}/{fid}?size=full",
        })
    return out


def _vision_latest_frame(machine_id, mock):
    frames = (_vision_mock_frames(machine_id, count=1) if mock
              else _footage_store.list_frames(machine_id, limit=1))
    return frames[0] if frames else None


def _vision_mock_frame_svg(machine_id, frame_id, size):
    """A self-describing placeholder image for mock footage (no binary deps)."""
    w, h = (240, 150) if size == "thumb" else (960, 600)
    try:
        ts = int(frame_id.split("-", 1)[1])
        when = _datetime_type.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
    except (ValueError, IndexError):
        when = frame_id
    fs1 = 16 if size == "thumb" else 34
    fs2 = 11 if size == "thumb" else 20
    return (
        f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" '
        f'viewBox="0 0 {w} {h}">'
        f'<rect width="{w}" height="{h}" fill="#1f2937"/>'
        f'<rect x="0" y="0" width="{w}" height="{h}" fill="none" '
        f'stroke="#0d9488" stroke-width="2"/>'
        f'<text x="50%" y="42%" fill="#5eead4" font-family="monospace" '
        f'font-size="{fs1}" text-anchor="middle">{_h(machine_id)}</text>'
        f'<text x="50%" y="58%" fill="#9ca3af" font-family="monospace" '
        f'font-size="{fs2}" text-anchor="middle">{_h(when)}</text>'
        f'<text x="50%" y="74%" fill="#6b7280" font-family="monospace" '
        f'font-size="{fs2}" text-anchor="middle">mock frame</text>'
        f'</svg>'
    )


def _h(s):
    """Minimal XML/HTML text escape for SVG bodies."""
    return (str(s).replace("&", "&amp;").replace("<", "&lt;")
            .replace(">", "&gt;").replace('"', "&quot;"))


def _vision_mock_collectors():
    """Sample machine tiles so the Collector UI is demonstrable pre-agent.
    Includes Phase-3 fields (sparkline/uptime/restarts/thumbnail)."""
    now = int(time.time())
    full = [1] * _VISION_SPARK_BUCKETS
    gappy = [1] * _VISION_SPARK_BUCKETS
    for i in (3, 4, 11, 18):
        gappy[i] = 0
    offline_tail = [1] * (_VISION_SPARK_BUCKETS - 6) + [0] * 6
    return [
        {"machine_id": "mock-1", "hostname": "ICAST-CNC-01", "logged_in_user": "jdoe",
         "agent_version": "0.1.0", "last_seen": now - 20, "online": True,
         "status": "active", "capture_enabled": True, "foreground_app": "Mastercam",
         "idle_seconds": 5, "cpu_pct": 37, "agent_uptime_s": 86400 * 3 + 1200,
         "restarts": 0, "sparkline": full,
         "thumb_url": f"/vision/frames/mock-1/mock-{now}?size=thumb",
         "full_url": f"/vision/frames/mock-1/mock-{now}?size=full"},
        {"machine_id": "mock-2", "hostname": "ICAST-PRESS-02", "logged_in_user": "msmith",
         "agent_version": "0.1.0", "last_seen": now - 95, "online": True,
         "status": "idle", "capture_enabled": True, "foreground_app": "Outlook",
         "idle_seconds": 840, "cpu_pct": 8, "agent_uptime_s": 7200,
         "restarts": 2, "sparkline": gappy,
         "thumb_url": f"/vision/frames/mock-2/mock-{now}?size=thumb",
         "full_url": f"/vision/frames/mock-2/mock-{now}?size=full"},
        {"machine_id": "mock-3", "hostname": "ICAST-WELD-03", "logged_in_user": "—",
         "agent_version": "0.1.0", "last_seen": now - 4200, "online": False,
         "status": "error", "capture_enabled": False, "foreground_app": None,
         "idle_seconds": None, "cpu_pct": None, "agent_uptime_s": None,
         "restarts": 1, "sparkline": offline_tail,
         "thumb_url": None, "full_url": None},
    ]


@app.route("/vision/agents/enroll", methods=["POST"])
@togen_admin_required
def vision_agents_enroll():
    """Issue a short-lived enrollment token.

    machine_id is optional (DVI-436): supplying it pre-binds the token to a known
    box → quick-enroll, agent lands `active`. Omitting it stores machine_id NULL →
    the agent self-supplies a generated id at register time and lands `pending`
    (admin approval required).
    """
    body = request.get_json(silent=True) or {}
    machine_id = (body.get("machine_id") or "").strip() or None
    token = str(uuid.uuid4())
    now = time.time()
    conn = _vision_db()
    try:
        conn.execute(
            "INSERT INTO vision_enrollment_tokens (token, machine_id, issued_at, expires_at)"
            " VALUES (?, ?, ?, ?)",
            (token, machine_id, now, now + _VISION_ENROLL_TTL_S),
        )
        conn.commit()
    finally:
        conn.close()
    record_history(
        "vision-agents", "enroll-token-issued",
        actor=(session.get("user") or {}).get("name"),
        summary=f"Enrollment token issued for {machine_id or 'self-enroll'}",
        ref=machine_id or "",
    )
    return jsonify(enrollment_token=token,
                   machine_id=machine_id,
                   mode="quick" if machine_id else "self")


@app.route("/vision/agents/register", methods=["POST"])
def vision_agent_register():
    """Machine presents a credential; receives a permanent agent_token.

    Three trust paths (DVI-436), all via the `enrollment_token` field so the
    existing agent needs no change:
      - machine-bound token  → effective machine_id from the token, status active.
      - NULL-machine token    → agent self-supplies machine_id, status pending.
      - open-enroll secret    → agent self-supplies machine_id, status pending.
    The open-enroll secret may also be passed explicitly as `open_enroll_secret`.
    """
    body = request.get_json(silent=True) or {}
    auth = request.headers.get("Authorization", "")
    credential = (body.get("enrollment_token") or
                  (auth[7:].strip() if auth.startswith("Bearer ") else ""))
    open_secret_body = (body.get("open_enroll_secret") or "").strip()
    machine_id_body = (body.get("machine_id") or "").strip()
    hostname = (body.get("hostname") or "").strip() or None
    ssh_public_key = (body.get("ssh_public_key") or "").strip() or None
    if not credential and not open_secret_body:
        return jsonify(error="enrollment_token or open_enroll_secret required."), 400

    now = time.time()
    conn = _vision_db()
    try:
        open_secret = _vision_get_open_enroll_secret(conn, create=False)
        via_open = bool(open_secret) and (
            open_secret_body == open_secret or credential == open_secret
        )

        if via_open:
            # Self-enroll via shared secret → pending, agent-supplied machine_id.
            if not machine_id_body:
                return jsonify(error="machine_id required for self-enroll."), 400
            effective_machine_id, status = machine_id_body, "pending"
        else:
            row = conn.execute(
                "SELECT * FROM vision_enrollment_tokens WHERE token = ?", (credential,)
            ).fetchone()
            if not row:
                return jsonify(error="Invalid enrollment_token."), 401
            if row["consumed_at"] is not None:
                return jsonify(error="enrollment_token already used."), 401
            if now > row["expires_at"]:
                return jsonify(error="enrollment_token expired."), 401
            if row["machine_id"]:
                # Machine-bound quick-enroll → active.
                if machine_id_body and machine_id_body != row["machine_id"]:
                    return jsonify(error="machine_id does not match enrollment."), 403
                effective_machine_id, status = row["machine_id"], "active"
            else:
                # NULL-machine token → agent self-supplies id, lands pending.
                if not machine_id_body:
                    return jsonify(error="machine_id required for self-enroll."), 400
                effective_machine_id, status = machine_id_body, "pending"
            conn.execute(
                "UPDATE vision_enrollment_tokens SET consumed_at = ? WHERE token = ?",
                (now, credential),
            )

        existing = conn.execute(
            "SELECT agent_token, status, revoked FROM vision_agents WHERE machine_id = ?",
            (effective_machine_id,),
        ).fetchone()
        if existing and not existing["revoked"]:
            agent_token = existing["agent_token"]
            status = existing["status"]  # re-register keeps prior disposition
            if ssh_public_key:
                conn.execute(
                    "UPDATE vision_agents SET ssh_public_key = ? WHERE machine_id = ?",
                    (ssh_public_key, effective_machine_id),
                )
        elif existing:
            # Revoked machine re-enrolling with a fresh valid token: treat as a new
            # enrollment so an admin can re-approve. Issue a new agent_token (the old
            # one was revoked) and clear the revoked flag with the freshly computed status.
            agent_token = str(uuid.uuid4())
            conn.execute(
                "UPDATE vision_agents SET agent_token = ?, hostname = COALESCE(?, hostname),"
                " enrolled_at = ?, status = ?, revoked = ?,"
                " ssh_public_key = COALESCE(?, ssh_public_key) WHERE machine_id = ?",
                (agent_token, hostname, now, status,
                 1 if status == "revoked" else 0, ssh_public_key, effective_machine_id),
            )
        else:
            agent_token = str(uuid.uuid4())
            conn.execute(
                "INSERT INTO vision_agents"
                " (machine_id, hostname, agent_token, enrolled_at, status, revoked, ssh_public_key)"
                " VALUES (?, ?, ?, ?, ?, ?, ?)",
                (effective_machine_id, hostname, agent_token, now, status,
                 1 if status == "revoked" else 0, ssh_public_key),
            )
        conn.commit()
    finally:
        conn.close()
    record_history(
        "vision-agents", "registered",
        actor=hostname or effective_machine_id,
        summary=f"Agent {effective_machine_id} registered (status: {status})",
        ref=effective_machine_id,
    )
    return jsonify(agent_token=agent_token, machine_id=effective_machine_id, status=status)


@app.route("/vision/agents/ssh-key", methods=["POST", "PUT"])
def vision_agent_ssh_key():
    """Agent reports its SSH public key for rsync authorization (DVI-726).

    Bearer auth via existing agent_token.  Stores the key so the admin's
    Approve action can push it to the rsync server's authorized_keys.
    Safe to call at any time (re-run upgrade.ps1, key rotation, etc.).
    """
    auth = request.headers.get("Authorization", "")
    token = auth[7:].strip() if auth.startswith("Bearer ") else ""
    if not token:
        return jsonify(error="Missing bearer agent_token."), 401
    body = request.get_json(silent=True) or {}
    public_key = (body.get("ssh_public_key") or "").strip()
    if not public_key:
        return jsonify(error="ssh_public_key required."), 400
    conn = _vision_db()
    try:
        row = conn.execute(
            "SELECT machine_id, status FROM vision_agents WHERE agent_token = ? AND revoked = 0",
            (token,),
        ).fetchone()
        if not row:
            return jsonify(error="Invalid or revoked agent_token."), 401
        conn.execute(
            "UPDATE vision_agents SET ssh_public_key = ? WHERE machine_id = ?",
            (public_key, row["machine_id"]),
        )
        conn.commit()
    finally:
        conn.close()

    # For already-active agents, push the updated key to the rsync server immediately.
    # (Pending agents get their key pushed at approval time.)
    key_pushed = False
    key_push_error = None
    if row["status"] == "active":
        result = _vision_write_ssh_key_to_rsync(public_key)
        if result is True:
            key_pushed = True
            conn2 = _vision_db()
            try:
                conn2.execute(
                    "UPDATE vision_agents SET ssh_key_written = 1 WHERE machine_id = ?",
                    (row["machine_id"],),
                )
                conn2.commit()
            finally:
                conn2.close()
        else:
            key_push_error = result

    return jsonify(ok=True, key_pushed=key_pushed, key_push_error=key_push_error)


@app.route("/vision/checkin", methods=["POST"])
def vision_checkin():
    """Agent -> server check-in. Validates bearer agent_token, stores all 14 fields."""
    auth = request.headers.get("Authorization", "")
    token = auth[7:].strip() if auth.startswith("Bearer ") else ""
    if not token:
        return jsonify(error="Missing bearer agent_token."), 401

    conn = _vision_db()
    try:
        agent = conn.execute(
            "SELECT machine_id, status, revoked FROM vision_agents WHERE agent_token = ?",
            (token,),
        ).fetchone()
        if not agent:
            return jsonify(error="Invalid agent_token."), 401

        machine_id = agent["machine_id"]
        agent_status = agent["status"]

        # Revoked agents: deliver any pending uninstall command so the agent can
        # self-uninstall cleanly, then signal the revoked status back.
        if agent["revoked"]:
            cmd_row = conn.execute(
                "SELECT id, action, params_json FROM vision_commands"
                " WHERE machine_id = ? AND acked_at IS NULL"
                " ORDER BY created_at ASC LIMIT 1",
                (machine_id,),
            ).fetchone()
            if cmd_row:
                conn.execute(
                    "UPDATE vision_commands SET delivered_at = COALESCE(delivered_at, ?)"
                    " WHERE id = ?",
                    (time.time(), cmd_row["id"]),
                )
                conn.commit()
                params = None
                if cmd_row["params_json"]:
                    try:
                        params = json.loads(cmd_row["params_json"])
                    except (json.JSONDecodeError, TypeError):
                        params = None
                command = {"id": cmd_row["id"], "action": cmd_row["action"], "params": params}
            else:
                command = None
            return jsonify(ack=True, agent_status="revoked", command=command)
        body = request.get_json(silent=True) or {}
        now = time.time()

        conn.execute(
            """INSERT INTO vision_checkins
               (machine_id, received_at, hostname, logged_in_user, os_version, agent_version,
                status, capture_enabled, last_capture_ts, foreground_app, idle_seconds,
                cpu_pct, screen_count, agent_uptime_s, ts)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
            (
                machine_id, now,
                body.get("hostname"),
                body.get("logged_in_user"),
                body.get("os_version"),
                body.get("agent_version"),
                body.get("status"),
                body.get("capture_enabled"),
                body.get("last_capture_ts"),
                body.get("foreground_app"),
                body.get("idle_seconds"),
                body.get("cpu_pct"),
                body.get("screen_count"),
                body.get("agent_uptime_s"),
                body.get("ts"),
            ),
        )
        conn.execute(
            "UPDATE vision_agents SET last_seen_at = ?, hostname = COALESCE(?, hostname)"
            " WHERE machine_id = ?",
            (now, body.get("hostname"), machine_id),
        )

        # Dequeue oldest pending command for this machine (acked_at IS NULL).
        cmd_row = conn.execute(
            "SELECT id, action, params_json FROM vision_commands"
            " WHERE machine_id = ? AND acked_at IS NULL"
            " ORDER BY created_at ASC LIMIT 1",
            (machine_id,),
        ).fetchone()
        if cmd_row:
            conn.execute(
                "UPDATE vision_commands SET delivered_at = COALESCE(delivered_at, ?)"
                " WHERE id = ?",
                (now, cmd_row["id"]),
            )
        _vision_prune_checkins(conn)
        conn.commit()
    finally:
        conn.close()

    # First real check-in auto-disables mock mode.
    settings = _load_vision_settings()
    if settings.get("mock_data"):
        settings["mock_data"] = False
        _save_vision_settings(settings)

    # Retention sweep over the footage store (throttled to ~1/hour internally).
    _vision_purge_footage(now)

    command = None
    if cmd_row:
        params = None
        if cmd_row["params_json"]:
            try:
                params = json.loads(cmd_row["params_json"])
            except (json.JSONDecodeError, TypeError):
                params = None
        command = {"id": cmd_row["id"], "action": cmd_row["action"], "params": params}

    # Push the machine's effective capture policy (DVI-427) so the agent applies
    # capture rate / idle threshold / exclusion list / schedule without a new channel.
    # Also return agent_status so the agent can detect pending→active transitions
    # without a separate status endpoint (DVI-438).
    config_delta = _vision_effective_policy(machine_id)
    return jsonify(ack=True, next_interval_s=60, command=command, config_delta=config_delta,
                   agent_status=agent_status)


_VISION_COMMAND_ACTIONS = frozenset({
    "pause_capture", "resume_capture", "set_interval",
    "collect_screenshot", "restart_agent", "upload_log",
    "start_capture_task", "update_agent", "cleanup_legacy",
    "uninstall",
})
# Fallback used only when the deployed ZIP is missing/unreadable. The live value
# is read dynamically from vision_agent.py inside togen-vision-agent.zip so a new
# ZIP deploy advertises its version without an app.py edit (DVI-723).
_LATEST_VISION_AGENT_VERSION_FALLBACK = "3.2.15"
_AGENT_VERSION_RE = re.compile(r'^_AGENT_VERSION\s*=\s*["\']([^"\']+)["\']', re.MULTILINE)
# (version, source-zip-mtime) cache so we re-read only when the ZIP changes.
_latest_vision_agent_version_cache = {"version": None, "mtime": None}


def _latest_vision_agent_version():
    """Read _AGENT_VERSION from vision_agent.py inside the deployed agent ZIP.

    Cached against the ZIP's mtime so a redeploy is picked up without a restart.
    Falls back to _LATEST_VISION_AGENT_VERSION_FALLBACK if the ZIP is absent or
    the version line cannot be parsed.
    """
    path = VISION_AGENT_DIST_DIR / VISION_AGENT_ZIP_NAME
    try:
        mtime = path.stat().st_mtime
    except OSError:
        return _LATEST_VISION_AGENT_VERSION_FALLBACK
    cache = _latest_vision_agent_version_cache
    if cache["version"] is not None and cache["mtime"] == mtime:
        return cache["version"]
    version = _LATEST_VISION_AGENT_VERSION_FALLBACK
    try:
        with zipfile.ZipFile(str(path)) as zf:
            source = zf.read("vision_agent.py").decode("utf-8", "replace")
        match = _AGENT_VERSION_RE.search(source)
        if match:
            version = match.group(1)
    except (zipfile.BadZipFile, KeyError, OSError):
        pass
    cache["version"] = version
    cache["mtime"] = mtime
    return version


@app.route("/vision/commands", methods=["POST"])
@togen_admin_required
def vision_commands_create():
    """Admin enqueues a remote command for a collector machine."""
    body = request.get_json(silent=True) or {}
    machine_id = (body.get("machine_id") or "").strip()
    action = (body.get("action") or "").strip()
    params = body.get("params")

    if not machine_id:
        return jsonify(error="machine_id required."), 400
    if action not in _VISION_COMMAND_ACTIONS:
        return jsonify(error=f"action must be one of: {', '.join(sorted(_VISION_COMMAND_ACTIONS))}."), 400

    cmd_id = "cmd-" + uuid.uuid4().hex[:8]
    now = time.time()
    params_json = json.dumps(params) if params is not None else None

    conn = _vision_db()
    try:
        agent = conn.execute(
            "SELECT machine_id FROM vision_agents WHERE machine_id = ? AND revoked = 0",
            (machine_id,),
        ).fetchone()
        if not agent:
            return jsonify(error="machine_id not found or revoked."), 404
        conn.execute(
            "INSERT INTO vision_commands (id, machine_id, action, params_json, created_at)"
            " VALUES (?, ?, ?, ?, ?)",
            (cmd_id, machine_id, action, params_json, now),
        )
        conn.commit()
    finally:
        conn.close()
    return jsonify(cmd_id=cmd_id), 201


# Max stored log size per (machine, file). Logs are tails for diagnostics, not archives.
_VISION_LOG_MAX_CHARS = 200_000


@app.route("/vision/agent-log", methods=["POST"])
def vision_agent_log_upload():
    """Agent uploads a log file's content (response to an upload_log command).
    Authenticated by bearer agent_token. Body: {file, content}. The latest upload
    per (machine, file) is retained for the admin log viewer (DVI-705 #2)."""
    auth = request.headers.get("Authorization", "")
    token = auth[7:].strip() if auth.startswith("Bearer ") else ""
    if not token:
        return jsonify(error="Missing bearer agent_token."), 401
    body = request.get_json(silent=True) or {}
    file = (body.get("file") or "").strip()
    content = body.get("content")
    if not file:
        return jsonify(error="file is required."), 400
    if not isinstance(content, str):
        return jsonify(error="content must be a string."), 400
    if len(content) > _VISION_LOG_MAX_CHARS:
        content = content[-_VISION_LOG_MAX_CHARS:]

    conn = _vision_db()
    try:
        agent = conn.execute(
            "SELECT machine_id FROM vision_agents WHERE agent_token = ? AND revoked = 0",
            (token,),
        ).fetchone()
        if not agent:
            return jsonify(error="Invalid or revoked agent_token."), 401
        conn.execute(
            "INSERT INTO vision_agent_logs (machine_id, file, content, received_at)"
            " VALUES (?, ?, ?, ?)"
            " ON CONFLICT(machine_id, file) DO UPDATE SET content = excluded.content,"
            " received_at = excluded.received_at",
            (agent["machine_id"], file, content, time.time()),
        )
        conn.commit()
    finally:
        conn.close()
    return jsonify(ok=True), 201


@app.route("/vision/agent-log")
@togen_admin_required
def vision_agent_log_get():
    """Admin log viewer (DVI-705 #2). With ?machine_id only, lists files that have
    uploaded content. With ?machine_id&file, returns that file's content + timestamp."""
    machine_id = (request.args.get("machine_id") or "").strip()
    file = (request.args.get("file") or "").strip()
    if not machine_id:
        return jsonify(error="machine_id required."), 400
    conn = _vision_db()
    try:
        if file:
            row = conn.execute(
                "SELECT content, received_at FROM vision_agent_logs"
                " WHERE machine_id = ? AND file = ?",
                (machine_id, file),
            ).fetchone()
            if not row:
                return jsonify(machine_id=machine_id, file=file, content=None, received_at=None)
            return jsonify(machine_id=machine_id, file=file,
                           content=row["content"], received_at=row["received_at"])
        rows = conn.execute(
            "SELECT file, received_at FROM vision_agent_logs WHERE machine_id = ?"
            " ORDER BY file ASC",
            (machine_id,),
        ).fetchall()
    finally:
        conn.close()
    return jsonify(machine_id=machine_id,
                   files=[{"file": r["file"], "received_at": r["received_at"]} for r in rows])


@app.route("/vision/agent-logs")
@togen_admin_required
def vision_agent_logs_list():
    """DVI-742: list every uploaded/requested log across all agents for the Vision
    Logs tab. Joins vision_agent_logs with vision_agents for the hostname and
    overlays the friendly-name overrides. Newest received first."""
    names = _load_vision_agent_names()
    conn = _vision_db()
    try:
        rows = conn.execute(
            "SELECT l.machine_id, l.file, l.received_at, a.hostname"
            " FROM vision_agent_logs l"
            " LEFT JOIN vision_agents a ON a.machine_id = l.machine_id"
            " ORDER BY l.received_at DESC"
        ).fetchall()
    finally:
        conn.close()
    logs = []
    for r in rows:
        mid = r["machine_id"]
        logs.append({
            "machine_id": mid,
            "hostname": r["hostname"] or None,
            "display_name": names.get(mid) or None,
            "file": r["file"],
            "received_at": r["received_at"],
        })
    return jsonify(logs=logs)


@app.route("/vision/assistant-config")
@vision_access_required
def vision_assistant_config_get():
    """Return the assistant models available to the Vision user, split into
    System Providers (org-configured in Togen Admin → Assistant) and User Models
    (the user's own connection in User Settings → Assistant), plus the user's
    saved Vision selection. Option values are namespaced "source:modelId"."""
    from assistant import load_assistant_settings, get_user_assistant, ASSISTANT_PROVIDERS
    email = session["user"]["email"]
    user_cfg = _load_user_settings(email)
    global_cfg = load_assistant_settings()
    ua = get_user_assistant(user_cfg)

    system_providers = []
    if global_cfg.get("api_key"):
        for prov in ASSISTANT_PROVIDERS.values():
            for m in prov.get("models", []):
                system_providers.append({
                    "value": "system:" + m["id"],
                    "label": prov["label"] + " — " + m["label"],
                })

    user_models = []
    if ua.get("api_key") and ua.get("model"):
        model_id = ua["model"]
        label = model_id
        provider_label = ""
        for prov in ASSISTANT_PROVIDERS.values():
            for m in prov.get("models", []):
                if m["id"] == model_id:
                    label = m["label"]
                    provider_label = prov["label"]
                    break
        user_models.append({
            "value": "user:" + model_id,
            "label": (provider_label + " — " + label) if provider_label else label,
        })

    selected = (user_cfg.get("vision") or {}).get("assistant_model", "")
    valid = {o["value"] for o in system_providers} | {o["value"] for o in user_models}
    if selected not in valid:
        selected = ""
    return jsonify(
        system_providers=system_providers,
        user_models=user_models,
        selected=selected,
    )


@app.route("/vision/assistant-config", methods=["POST"])
@vision_access_required
def vision_assistant_config_post():
    """Persist the Vision user's assistant model selection. Validates the value
    against the models currently available to the user."""
    from assistant import load_assistant_settings, get_user_assistant, ASSISTANT_PROVIDERS
    email = session["user"]["email"]
    body = request.get_json(silent=True) or {}
    model = str(body.get("model", "")).strip()

    user_cfg = _load_user_settings(email)
    global_cfg = load_assistant_settings()
    ua = get_user_assistant(user_cfg)

    valid = set()
    if global_cfg.get("api_key"):
        for prov in ASSISTANT_PROVIDERS.values():
            for m in prov.get("models", []):
                valid.add("system:" + m["id"])
    if ua.get("api_key") and ua.get("model"):
        valid.add("user:" + ua["model"])

    if model and model not in valid:
        return jsonify(error="Selected model is not available."), 400

    vision_cfg = dict(user_cfg.get("vision") or {})
    vision_cfg["assistant_model"] = model
    user_cfg["vision"] = vision_cfg
    _save_user_settings(email, user_cfg)
    return jsonify(ok=True)


@app.route("/vision/commands/<cmd_id>/ack", methods=["POST"])
def vision_command_ack(cmd_id):
    """Agent acknowledges execution of a delivered command."""
    auth = request.headers.get("Authorization", "")
    token = auth[7:].strip() if auth.startswith("Bearer ") else ""
    if not token:
        return jsonify(error="Missing bearer agent_token."), 401

    body = request.get_json(silent=True) or {}
    status = (body.get("status") or "").strip()
    if status not in ("ok", "error"):
        return jsonify(error="status must be 'ok' or 'error'."), 400
    error_message = body.get("error_message") or None

    now = time.time()
    conn = _vision_db()
    try:
        # Allow revoked agents to ack their final commands (e.g. uninstall) so the
        # command history shows "acked" rather than stuck in "delivered".
        agent = conn.execute(
            "SELECT machine_id FROM vision_agents WHERE agent_token = ?",
            (token,),
        ).fetchone()
        if not agent:
            return jsonify(error="Invalid agent_token."), 401

        cmd = conn.execute(
            "SELECT id FROM vision_commands WHERE id = ? AND machine_id = ?",
            (cmd_id, agent["machine_id"]),
        ).fetchone()
        if not cmd:
            return jsonify(error="Command not found."), 404

        conn.execute(
            "UPDATE vision_commands SET acked_at = ?, acked_status = ?, error_message = ?"
            " WHERE id = ?",
            (now, status, error_message, cmd_id),
        )
        conn.commit()
    finally:
        conn.close()
    return jsonify(ack=True)


def _vision_compute_sparkline(checkin_times, now):
    """Bucket recent check-in timestamps into online(1)/offline(0) buckets over
    the sparkline window. A bucket is online if any check-in fell within it."""
    n = _VISION_SPARK_BUCKETS
    window = _VISION_SPARK_WINDOW_S
    start = now - window
    bucket_s = window / n
    spark = [0] * n
    for t in checkin_times:
        if t < start or t > now:
            continue
        idx = int((t - start) / bucket_s)
        if idx >= n:
            idx = n - 1
        spark[idx] = 1
    return spark


@app.route("/vision/collectors")
@vision_access_required
def vision_collectors():
    """Return collector machine list. Online/offline derived from last_seen_at vs
    staleness. Phase-3 (DVI-427) adds per-machine sparkline, uptime/restart
    indicator, and the latest captured thumbnail/full-frame URLs."""
    settings = _load_vision_settings()
    if settings.get("mock_data"):
        return jsonify(collectors=_vision_mock_collectors(), mock_data=True,
                       latest_agent_version=_latest_vision_agent_version())
    now = time.time()
    spark_start = now - _VISION_SPARK_WINDOW_S
    conn = _vision_db()
    try:
        rows = conn.execute(
            """SELECT va.machine_id, va.hostname, va.last_seen_at,
                      vc.logged_in_user, vc.agent_version, vc.status,
                      vc.capture_enabled, vc.foreground_app, vc.idle_seconds,
                      vc.cpu_pct, vc.agent_uptime_s
               FROM vision_agents va
               LEFT JOIN (
                   SELECT machine_id, logged_in_user, agent_version, status,
                          capture_enabled, foreground_app, idle_seconds, cpu_pct,
                          agent_uptime_s
                   FROM vision_checkins
                   WHERE id IN (
                       SELECT MAX(id) FROM vision_checkins GROUP BY machine_id
                   )
               ) vc ON vc.machine_id = va.machine_id
               WHERE va.status = 'active'
               ORDER BY va.last_seen_at DESC"""
        ).fetchall()
        # History within the sparkline window for trend + restart detection.
        hist = conn.execute(
            "SELECT machine_id, received_at, agent_uptime_s FROM vision_checkins"
            " WHERE received_at >= ? ORDER BY machine_id, received_at ASC",
            (spark_start,),
        ).fetchall()
    finally:
        conn.close()

    times_by_machine = {}
    uptimes_by_machine = {}
    for h in hist:
        times_by_machine.setdefault(h["machine_id"], []).append(h["received_at"])
        uptimes_by_machine.setdefault(h["machine_id"], []).append(h["agent_uptime_s"])

    names = _load_vision_agent_names()
    policy = _load_vision_policy()
    out = []
    for r in rows:
        mid = r["machine_id"]
        last_seen = r["last_seen_at"]
        cap = r["capture_enabled"]
        # Restart count = times agent_uptime_s dropped vs the prior check-in.
        ups = [u for u in uptimes_by_machine.get(mid, []) if u is not None]
        restarts = sum(1 for a, b in zip(ups, ups[1:]) if b < a)
        latest = _vision_latest_frame(mid, mock=False)
        # Capture mode/fps from the machine's effective policy so tiles can show a
        # video-vs-screenshot badge; latest_kind is the actual newest captured type.
        eff_pol = _vision_effective_policy(mid, policy)
        out.append({
            "machine_id": mid,
            "hostname": r["hostname"],
            "display_name": names.get(mid) or None,
            "logged_in_user": r["logged_in_user"],
            "agent_version": r["agent_version"],
            "last_seen": int(last_seen) if last_seen else None,
            "online": bool(last_seen) and (now - last_seen) <= VISION_STALE_SECONDS,
            "status": r["status"],
            "capture_enabled": None if cap is None else bool(cap),
            "foreground_app": r["foreground_app"],
            "idle_seconds": r["idle_seconds"],
            "cpu_pct": r["cpu_pct"],
            "agent_uptime_s": r["agent_uptime_s"],
            "restarts": restarts,
            "sparkline": _vision_compute_sparkline(times_by_machine.get(mid, []), now),
            "thumb_url": latest["thumb_url"] if latest else None,
            "full_url": latest["full_url"] if latest else None,
            "capture_mode": eff_pol.get("capture_mode"),
            "capture_fps": eff_pol.get("capture_fps"),
            "latest_kind": latest.get("kind") if latest else None,
        })
    return jsonify(collectors=out, mock_data=False,
                   latest_agent_version=_latest_vision_agent_version())


@app.route("/vision/agent-name", methods=["POST"])
@togen_admin_required
def vision_agent_name_post():
    """Set or clear a friendly display-name override for a machine (DVI-682).
    Body: {machine_id, name}. An empty name clears the override (reverts to hostname)."""
    body = request.get_json(silent=True) or {}
    mid = (body.get("machine_id") or "").strip()
    name = (body.get("name") or "").strip()
    if not mid:
        return jsonify(error="machine_id is required."), 400
    if len(name) > 80:
        return jsonify(error="Name must be 80 characters or fewer."), 400
    with _vision_names_lock:
        names = _load_vision_agent_names()
        if name:
            names[mid] = name
        else:
            names.pop(mid, None)
        _save_vision_agent_names(names)
    return jsonify(ok=True, machine_id=mid, name=name or None)


@app.route("/togen-admin/vision-settings")
@togen_admin_required
def vision_settings_get():
    return jsonify(settings=_load_vision_settings())


@app.route("/togen-admin/vision-settings", methods=["POST"])
@togen_admin_required
def vision_settings_post():
    body = request.get_json(silent=True) or {}
    settings = _load_vision_settings()
    if "mock_data" in body:
        settings["mock_data"] = bool(body["mock_data"])
    _save_vision_settings(settings)
    return jsonify(ok=True, settings=settings)


@app.route("/togen-admin/vision-policy")
@togen_admin_required
def vision_policy_get():
    """Return the capture policy (default + per-machine overrides) plus the
    field defaults so the UI can render an empty/reset form."""
    return jsonify(policy=_load_vision_policy(), defaults=_VISION_POLICY_DEFAULTS)


@app.route("/togen-admin/vision-policy", methods=["POST"])
@togen_admin_required
def vision_policy_post():
    """Persist the capture policy. Body: {default?: {...}, machines?: {id: {...}}}.
    A machine override of null/{} removes that override."""
    body = request.get_json(silent=True) or {}
    policy = _load_vision_policy()
    if isinstance(body.get("default"), dict):
        merged = dict(policy["default"])
        merged.update(_vision_clean_policy(body["default"]))
        policy["default"] = merged
    if isinstance(body.get("machines"), dict):
        for mid, ov in body["machines"].items():
            mid = str(mid).strip()
            if not mid:
                continue
            if ov in (None, {}):
                policy["machines"].pop(mid, None)
            elif isinstance(ov, dict):
                policy["machines"][mid] = _vision_clean_policy(ov)
    _save_vision_policy(policy)
    return jsonify(ok=True, policy=policy)


@app.route("/vision/frames/<machine_id>/<frame_id>")
@vision_access_required
def vision_frame(machine_id, frame_id):
    """Serve a captured frame (D2). ?size=thumb|full. Mock frames (mock mode or
    mock- ids) are rendered as self-describing SVG placeholders; real frames are
    read from the configurable footage store, thumbnail companion preferred."""
    machine_id = (machine_id or "").strip()
    frame_id = (frame_id or "").strip()
    size = (request.args.get("size") or "full").strip().lower()
    if size not in ("thumb", "full"):
        size = "full"
    if not _VISION_FRAME_ID_RE.match(machine_id) or not _VISION_FRAME_ID_RE.match(frame_id):
        return jsonify(error="Invalid identifier."), 400

    if _load_vision_settings().get("mock_data") or frame_id.startswith("mock-"):
        svg = _vision_mock_frame_svg(machine_id, frame_id, size)
        return svg, 200, {"Content-Type": "image/svg+xml",
                          "Cache-Control": "no-store"}

    # Quarantine: never serve footage for a machine that isn't approved/active.
    if not _vision_machine_active(machine_id):
        return jsonify(error="Frame not found."), 404

    resp = _footage_store.frame_bytes(machine_id, frame_id, size)
    if resp is None:
        return jsonify(error="Frame not found."), 404
    return resp


@app.route("/vision/segments/<machine_id>/<frame_id>")
@vision_access_required
def vision_segment(machine_id, frame_id):
    """Serve a captured H.264 video segment (DVI-727). Same identifier/quarantine
    guards as frame serving; send_file honors HTTP Range so the Phase-3 HTML5
    <video> player can seek. (Phase 4 may front this with nginx range-serving.)"""
    machine_id = (machine_id or "").strip()
    frame_id = (frame_id or "").strip()
    if not _VISION_FRAME_ID_RE.match(machine_id) or not _VISION_FRAME_ID_RE.match(frame_id):
        return jsonify(error="Invalid identifier."), 400
    # Quarantine: never serve footage for a machine that isn't approved/active.
    if not _vision_machine_active(machine_id):
        return jsonify(error="Segment not found."), 404
    resp = _footage_store.segment_stream(machine_id, frame_id, rng=request.range)
    if resp is None:
        return jsonify(error="Segment not found."), 404
    return resp


@app.route("/vision/recordings/<machine_id>")
@vision_access_required
def vision_recordings(machine_id):
    """Searchable screenshot gallery listing for a machine (D2/D3). Optional
    ?from= & ?to= epoch/ISO bounds. Returns thumbnail + full URLs only — full
    images are never inlined into the grid."""
    machine_id = (machine_id or "").strip()
    if not machine_id or not _VISION_FRAME_ID_RE.match(machine_id):
        return jsonify(error="Invalid machine_id."), 400
    frm = _vision_parse_since(request.args.get("from"))
    to = _vision_parse_since(request.args.get("to"))
    mock = bool(_load_vision_settings().get("mock_data"))
    if mock:
        frames = _vision_mock_frames(machine_id, frm=frm, to=to)
    elif _vision_machine_active(machine_id):
        frames = _footage_store.list_frames(machine_id, frm=frm, to=to)
    else:
        # Quarantine: pending/revoked machines surface no recordings.
        frames = []
    return jsonify(machine_id=machine_id, frames=frames, mock_data=mock)


def _vision_parse_since(raw):
    """Parse the ?since= filter: epoch seconds (int/float) or ISO-8601. None if absent/bad."""
    raw = (raw or "").strip()
    if not raw:
        return None
    try:
        return float(raw)
    except ValueError:
        pass
    try:
        cleaned = raw.replace("Z", "+00:00")
        return _datetime_type.fromisoformat(cleaned).timestamp()
    except ValueError:
        return None


@app.route("/vision/checkins/<machine_id>")
@vision_access_required
def vision_checkins(machine_id):
    """Per-machine check-in history (pure telemetry) for the Activity timeline.

    Ordered by received_at ascending so the client can derive foreground-app
    durations and active/idle spans. Optional ?since=<iso_or_epoch> filter.
    """
    machine_id = (machine_id or "").strip()
    if not machine_id:
        return jsonify(error="machine_id required."), 400
    since = _vision_parse_since(request.args.get("since"))
    conn = _vision_db()
    try:
        params = [machine_id]
        sql = ("SELECT received_at, status, capture_enabled, last_capture_ts,"
               " foreground_app, idle_seconds, cpu_pct, agent_version, logged_in_user"
               " FROM vision_checkins WHERE machine_id = ?")
        if since is not None:
            sql += " AND received_at >= ?"
            params.append(since)
        sql += " ORDER BY received_at ASC"
        rows = conn.execute(sql, params).fetchall()
    finally:
        conn.close()
    out = []
    for r in rows:
        cap = r["capture_enabled"]
        out.append({
            "received_at": r["received_at"],
            "status": r["status"],
            "capture_enabled": None if cap is None else bool(cap),
            "last_capture_ts": r["last_capture_ts"],
            "foreground_app": r["foreground_app"],
            "idle_seconds": r["idle_seconds"],
            "cpu_pct": r["cpu_pct"],
            "agent_version": r["agent_version"],
            "logged_in_user": r["logged_in_user"],
        })
    return jsonify(machine_id=machine_id, checkins=out)


@app.route("/vision/agents")
@togen_admin_required
def vision_agents_list():
    """List enrolled agents with last-seen + status. Pending agents include the
    fingerprint from their latest check-in so the Enroll tab can render an
    approval card (hostname, OS, logged-in user, agent version)."""
    now = time.time()
    conn = _vision_db()
    try:
        rows = conn.execute(
            """SELECT va.machine_id, va.hostname, va.enrolled_at, va.last_seen_at,
                      va.revoked, va.status, va.ssh_public_key, va.ssh_key_written,
                      vc.os_version, vc.logged_in_user, vc.agent_version,
                      vc.hostname AS checkin_hostname
               FROM vision_agents va
               LEFT JOIN (
                   SELECT machine_id, os_version, logged_in_user, agent_version, hostname
                   FROM vision_checkins
                   WHERE id IN (SELECT MAX(id) FROM vision_checkins GROUP BY machine_id)
               ) vc ON vc.machine_id = va.machine_id
               ORDER BY va.enrolled_at DESC"""
        ).fetchall()
    finally:
        conn.close()
    names = _load_vision_agent_names()
    agents = []
    for r in rows:
        last_seen = r["last_seen_at"]
        agents.append({
            "machine_id": r["machine_id"],
            "hostname": r["hostname"] or r["checkin_hostname"],
            "display_name": names.get(r["machine_id"]) or None,
            "enrolled_at": r["enrolled_at"],
            "last_seen": int(last_seen) if last_seen else None,
            "online": bool(last_seen) and (now - last_seen) <= VISION_STALE_SECONDS,
            "status": r["status"],
            "revoked": bool(r["revoked"]),
            "has_ssh_key": bool(r["ssh_public_key"]),
            "ssh_key_written": bool(r["ssh_key_written"]),
            "fingerprint": {
                "hostname": r["checkin_hostname"] or r["hostname"],
                "os_version": r["os_version"],
                "logged_in_user": r["logged_in_user"],
                "agent_version": r["agent_version"],
            },
        })
    return jsonify(agents=agents)


@app.route("/vision/agents/<machine_id>/revoke", methods=["POST"])
@togen_admin_required
def vision_agent_revoke(machine_id):
    """Revoke a machine's agent_token (status=revoked); also enqueues an uninstall
    command so the agent self-uninstalls on its next check-in."""
    machine_id = (machine_id or "").strip()
    conn = _vision_db()
    try:
        cur = conn.execute(
            "UPDATE vision_agents SET status = 'revoked', revoked = 1 WHERE machine_id = ?",
            (machine_id,),
        )
        if cur.rowcount == 0:
            conn.commit()
            return jsonify(error="machine_id not found."), 404
        # Enqueue uninstall command so the agent self-destructs on its next check-in.
        cmd_id = "cmd-" + uuid.uuid4().hex[:8]
        conn.execute(
            "INSERT INTO vision_commands (id, machine_id, action, params_json, created_at)"
            " VALUES (?, ?, 'uninstall', NULL, ?)",
            (cmd_id, machine_id, time.time()),
        )
        conn.commit()
    finally:
        conn.close()
    record_history(
        "vision-agents", "revoked",
        actor=(session.get("user") or {}).get("name"),
        summary=f"Agent {machine_id} revoked (uninstall enqueued)",
        ref=machine_id,
    )
    return jsonify(ok=True, machine_id=machine_id, status="revoked", revoked=True)


@app.route("/vision/agents/<machine_id>/approve", methods=["POST"])
@togen_admin_required
def vision_agent_approve(machine_id):
    """Approve a self-enrolled agent (pending → active) and push its SSH key (DVI-726)."""
    machine_id = (machine_id or "").strip()
    conn = _vision_db()
    try:
        row = conn.execute(
            "SELECT status, ssh_public_key FROM vision_agents WHERE machine_id = ?",
            (machine_id,),
        ).fetchone()
        if not row:
            return jsonify(error="machine_id not found."), 404
        if row["status"] != "pending":
            return jsonify(error=f"machine is '{row['status']}', not pending."), 409
        ssh_public_key = row["ssh_public_key"]
        conn.execute(
            "UPDATE vision_agents SET status = 'active', revoked = 0 WHERE machine_id = ?",
            (machine_id,),
        )
        conn.commit()
    finally:
        conn.close()

    # Push the SSH public key to the rsync server so this machine can transfer footage.
    ssh_key_written = False
    ssh_key_error = None
    if ssh_public_key:
        result = _vision_write_ssh_key_to_rsync(ssh_public_key)
        if result is True:
            ssh_key_written = True
            conn2 = _vision_db()
            try:
                conn2.execute(
                    "UPDATE vision_agents SET ssh_key_written = 1 WHERE machine_id = ?",
                    (machine_id,),
                )
                conn2.commit()
            finally:
                conn2.close()
        else:
            ssh_key_error = result
    else:
        ssh_key_error = (
            "No SSH public key on file — run upgrade.ps1 on the machine and then re-approve, "
            "or add the key manually."
        )

    record_history(
        "vision-agents", "approved",
        actor=(session.get("user") or {}).get("name"),
        summary=f"Agent {machine_id} approved (pending → active)",
        ref=machine_id,
    )
    return jsonify(
        ok=True,
        machine_id=machine_id,
        status="active",
        ssh_key_written=ssh_key_written,
        ssh_key_error=ssh_key_error,
    )


@app.route("/vision/agents/<machine_id>/deny", methods=["POST"])
@togen_admin_required
def vision_agent_deny(machine_id):
    """Deny a self-enrolled agent (pending → revoked); check-ins are refused."""
    machine_id = (machine_id or "").strip()
    conn = _vision_db()
    try:
        row = conn.execute(
            "SELECT status FROM vision_agents WHERE machine_id = ?", (machine_id,)
        ).fetchone()
        if not row:
            return jsonify(error="machine_id not found."), 404
        if row["status"] != "pending":
            return jsonify(error=f"machine is '{row['status']}', not pending."), 409
        conn.execute(
            "UPDATE vision_agents SET status = 'revoked', revoked = 1 WHERE machine_id = ?",
            (machine_id,),
        )
        conn.commit()
    finally:
        conn.close()
    record_history(
        "vision-agents", "denied",
        actor=(session.get("user") or {}).get("name"),
        summary=f"Agent {machine_id} denied (pending → revoked)",
        ref=machine_id,
    )
    return jsonify(ok=True, machine_id=machine_id, status="revoked")


@app.route("/vision/agents/<machine_id>", methods=["DELETE"])
@togen_admin_required
def vision_agent_delete(machine_id):
    """Delete a revoked vision agent and its orphan rows (check-ins/commands).
    Only revoked agents are removable (409 otherwise) so a live agent can't be
    silently dropped out from under an active check-in loop."""
    machine_id = (machine_id or "").strip()
    conn = _vision_db()
    try:
        row = conn.execute(
            "SELECT revoked FROM vision_agents WHERE machine_id = ?", (machine_id,)
        ).fetchone()
        if not row:
            return jsonify(error="machine_id not found."), 404
        if not row["revoked"]:
            return jsonify(error="agent is not revoked; revoke it before deleting."), 409
        conn.execute("DELETE FROM vision_checkins WHERE machine_id = ?", (machine_id,))
        conn.execute("DELETE FROM vision_commands WHERE machine_id = ?", (machine_id,))
        conn.execute("DELETE FROM vision_agents WHERE machine_id = ?", (machine_id,))
        conn.commit()
    finally:
        conn.close()
    return jsonify(ok=True, machine_id=machine_id, deleted=True)


@app.route("/vision/enroll-secret")
@togen_admin_required
def vision_enroll_secret_get():
    """Return the current open-enroll shared secret (minted on first read)."""
    conn = _vision_db()
    try:
        secret = _vision_get_open_enroll_secret(conn, create=True)
    finally:
        conn.close()
    return jsonify(open_enroll_secret=secret)


@app.route("/vision/enroll-secret/rotate", methods=["POST"])
@togen_admin_required
def vision_enroll_secret_rotate():
    """Rotate the open-enroll secret. Prior secret stops working immediately;
    already-enrolled agents keep their agent_token (unaffected)."""
    new_secret = uuid.uuid4().hex
    conn = _vision_db()
    try:
        _vision_kv_set(conn, _VISION_OPEN_ENROLL_KEY, new_secret)
        conn.commit()
    finally:
        conn.close()
    return jsonify(open_enroll_secret=new_secret)


def _vision_server_base_url():
    """Public base URL the agent should call, honoring proxy headers."""
    proto = request.headers.get("X-Forwarded-Proto") or request.scheme
    host = request.headers.get("X-Forwarded-Host") or request.host
    return f"{proto}://{host}"


@app.route("/enroll/bootstrap.ps1")
def vision_enroll_bootstrap():
    """Render a PowerShell onboarding script that downloads the agent bundle and
    enrolls it. ?token=<enrollment_token> embeds a per-machine token (quick-enroll);
    otherwise the current open-enroll secret is embedded (self-enroll → pending).

    Auth mirrors /enroll/print-bootstrap.ps1: the quick-enroll path authenticates
    via the ?token= enrollment token (the bearer credential /vision/agents/register
    already trusts), since the script runs on a sessionless machine. The token-less
    self-enroll path embeds the open-enroll secret and stays gated behind a Togen
    Admin session. (Unconditional admin auth 302-redirects to Microsoft SSO, whose
    HTML then gets piped into `iex` — see DVI-674.)

    The generated script:
      1. Downloads /enroll/agent.zip (no auth required — the ZIP has no embedded secrets).
      2. Verifies the download is a valid ZIP (size check before Expand-Archive).
      3. Extracts to a temp directory.
      4. Calls install.ps1 from the bundle, which handles enrollment + Scheduled Task.
    """
    base = _vision_server_base_url()
    token = (request.args.get("token") or "").strip()
    if token:
        credential, mode = token, "quick"
    else:
        if not (session.get("user") and session["user"].get("togen_admin")):
            return (
                "Write-Host 'ERROR: This bootstrap URL needs a ?token= enrollment "
                "token (generate one via Togen Admin -> Agents -> Vision -> Enroll), "
                "or download it from an authenticated Togen Admin session.' "
                "-ForegroundColor Red\nexit 1\n",
                403,
                {"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store"},
            )
        conn = _vision_db()
        try:
            credential = _vision_get_open_enroll_secret(conn, create=True)
        finally:
            conn.close()
        mode = "self"
    zip_url = f"{base}/enroll/agent.zip"
    # PowerShell single-quoted strings escape ' as ''.
    ps_cred = credential.replace("'", "''")
    ps_base = base.replace("'", "''")
    script = f"""# Togen Vision agent bootstrap ({mode}-enroll) — generated {time.strftime('%Y-%m-%d %H:%M:%S')}
# Run as Administrator.  Downloads the agent bundle and delegates to install.ps1.
$ErrorActionPreference = 'Stop'
$Server     = '{ps_base}'
$Credential = '{ps_cred}'
$Zip        = Join-Path $env:TEMP 'togen-vision-agent.zip'
$ExtractDir = Join-Path $env:TEMP 'togen-vision-agent'

# -- Download (no auth required; ZIP contains no secrets) -------------------
Write-Host 'Downloading Togen Vision agent...'
try {{
    Invoke-WebRequest -Uri '{zip_url}' -OutFile $Zip -UseBasicParsing -TimeoutSec 600
}} catch {{
    Write-Host "ERROR: Download failed: $_" -ForegroundColor Red
    Write-Host 'Ensure the agent bundle has been built and deployed. Contact your IT admin.'
    exit 1
}}
$ZipSize = (Get-Item $Zip -ErrorAction SilentlyContinue).Length
if (-not $ZipSize -or $ZipSize -lt 1048576) {{
    Write-Host "ERROR: Agent bundle too small ($ZipSize bytes) - download likely truncated." -ForegroundColor Red
    Write-Host 'The Windows build step must be run first, or retry on a faster connection. Contact your IT admin.'
    Remove-Item $Zip -Force -ErrorAction SilentlyContinue
    exit 1
}}

# -- Extract ----------------------------------------------------------------
if (Test-Path $ExtractDir) {{ Remove-Item -Recurse -Force $ExtractDir }}
Expand-Archive -Path $Zip -DestinationPath $ExtractDir -Force
Remove-Item $Zip -Force -ErrorAction SilentlyContinue

# -- Install (enroll + Scheduled Task via install.ps1 from the bundle) ------
$Installer = Join-Path $ExtractDir 'install.ps1'
if (-not (Test-Path $Installer)) {{
    Write-Host 'ERROR: install.ps1 missing from agent bundle.' -ForegroundColor Red
    exit 1
}}
Write-Host 'Running installer...'
& powershell.exe -ExecutionPolicy Bypass -NonInteractive -File $Installer -Server $Server -Enroll $Credential
Write-Host ''
Write-Host 'Bootstrap complete.' -ForegroundColor Green
Write-Host 'If this machine registered as PENDING, visit the Togen admin panel'
Write-Host '  -> Settings -> Vision -> Enroll tab -> Approve this machine.'
"""
    return script, 200, {
        "Content-Type": "text/plain; charset=utf-8",
        "Content-Disposition": "attachment; filename=bootstrap.ps1",
        "Cache-Control": "no-store",
    }


@app.route("/enroll/upload-ssh-key.ps1")
def vision_enroll_upload_ssh_key():
    """Serve a self-contained PowerShell script that uploads the machine's SSH
    public key to the Togen server (DVI-769).

    No session auth required — the machine authenticates via its existing HKLM
    AgentToken (Bearer cred for POST /vision/agents/ssh-key).  After running
    this script the admin can re-approve the machine in the Enroll tab and the
    key will be written to the rsync server automatically.

    Usage on the target machine (run as Administrator):
        iwr '<server>/enroll/upload-ssh-key.ps1' -UseBasicParsing | iex
    """
    base = _vision_server_base_url()
    ps_base = base.replace("'", "''")
    script = f"""# Togen Vision - Upload machine SSH public key — generated {time.strftime('%Y-%m-%d %H:%M:%S')}
# Run as Administrator on an already-enrolled Vision agent machine.
# Usage: iwr '{ps_base}/enroll/upload-ssh-key.ps1' -UseBasicParsing | iex
$ErrorActionPreference = 'Stop'
$Server = '{ps_base}'

function Write-Step {{ param([string]$Msg) Write-Host ">> $Msg" -ForegroundColor Cyan }}
function Write-OK   {{ param([string]$Msg) Write-Host "   OK: $Msg" -ForegroundColor Green }}
function Write-Warn {{ param([string]$Msg) Write-Host "   WARN: $Msg" -ForegroundColor Yellow }}

# 1. Find or generate SSH key
Write-Step 'Checking machine SSH key'
$TogenDataDir = Join-Path $env:ProgramData 'Togen'
$SshKeyPath   = Join-Path $TogenDataDir 'id_togen_vision'
New-Item -ItemType Directory -Path $TogenDataDir -Force | Out-Null
$SshKeygen = Get-Command ssh-keygen -ErrorAction SilentlyContinue
if (-not $SshKeygen) {{
    if (-not (Test-Path $SshKeyPath)) {{
        Write-Host 'ERROR: ssh-keygen not found and no existing key.' -ForegroundColor Red
        Write-Host 'Install OpenSSH Client: Settings -> Optional Features -> OpenSSH Client' -ForegroundColor Yellow
        exit 1
    }}
    Write-OK "Key exists (ssh-keygen absent, using existing key): $SshKeyPath"
}} elseif (-not (Test-Path $SshKeyPath)) {{
    Write-Step 'Generating machine SSH key'
    & $SshKeygen -t ed25519 -f $SshKeyPath -N '""'
    Write-OK "Generated: $SshKeyPath"
}} else {{
    Write-OK "Key exists: $SshKeyPath"
}}
# DVI-804: reset ACL then set SYSTEM full + Authenticated Users read.
# /reset removes DENY ACEs that ssh-keygen sets for private key hardening; the
# plain /grant can't override an explicit DENY. PowerShell Set-Acl ensures it.
& icacls $SshKeyPath /reset 2>$null | Out-Null
& icacls $SshKeyPath /grant 'NT AUTHORITY\\SYSTEM:(F)' 2>$null | Out-Null
& icacls $SshKeyPath /grant '*S-1-5-11:(R)' 2>$null | Out-Null
try {{
    $acl0 = Get-Acl $SshKeyPath
    $acl0.SetAccessRuleProtection($true, $false)
    foreach ($ace in @($acl0.Access)) {{ $acl0.RemoveAccessRule($ace) | Out-Null }}
    $sysAcc0 = New-Object System.Security.AccessControl.FileSystemAccessRule(
        'NT AUTHORITY\\SYSTEM', 'FullControl', 'Allow')
    $authSid0 = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-11')
    $authAcc0 = New-Object System.Security.AccessControl.FileSystemAccessRule(
        $authSid0, 'Read', 'Allow')
    $acl0.AddAccessRule($sysAcc0)
    $acl0.AddAccessRule($authAcc0)
    Set-Acl $SshKeyPath $acl0
}} catch {{ Write-Warn "PS ACL reset failed (install): $_" }}
Write-OK 'SSH key ACL: SYSTEM full + Authenticated Users read (DVI-804)'

# 2. Read agent token
Write-Step 'Reading agent token from registry'
$AgentToken = $null
try {{
    $AgentToken = (Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\Togen\\Vision' -Name 'AgentToken' -ErrorAction Stop).AgentToken
}} catch {{
    Write-Host 'ERROR: No AgentToken found in HKLM:\\SOFTWARE\\Togen\\Vision.' -ForegroundColor Red
    Write-Host 'This machine may not be enrolled. Run bootstrap.ps1 first.' -ForegroundColor Yellow
    exit 1
}}
Write-OK 'Agent token found'

# 3. Upload public key
Write-Step 'Uploading SSH public key to Togen server'
$PubKeyContent = (Get-Content "$SshKeyPath.pub" -Raw).Trim()
$Body = @{{ ssh_public_key = $PubKeyContent }} | ConvertTo-Json -Compress
try {{
    $null = Invoke-RestMethod -Uri "$Server/vision/agents/ssh-key" `
        -Method POST `
        -Headers @{{ Authorization = "Bearer $AgentToken"; 'Content-Type' = 'application/json' }} `
        -Body $Body -UseBasicParsing
    Write-OK 'SSH public key uploaded successfully'
}} catch {{
    Write-Host "ERROR: Failed to upload: $_" -ForegroundColor Red
    exit 1
}}

Write-Host ''
Write-Host 'Done.' -ForegroundColor Green
Write-Host 'Re-approve this machine in Togen Admin -> Vision -> Enroll tab.' -ForegroundColor Green
Write-Host 'The approval will now write the key to the rsync server automatically.' -ForegroundColor Green
"""
    return script, 200, {
        "Content-Type": "text/plain; charset=utf-8",
        "Content-Disposition": "attachment; filename=upload-ssh-key.ps1",
        "Cache-Control": "no-store",
    }


@app.route("/enroll/upgrade.ps1")
def vision_enroll_upgrade():
    """Render an upgrade-in-place PowerShell script for already-enrolled machines.

    Stops the scheduled tasks, replaces the agent files from the latest ZIP,
    generates the machine-level SSH key if absent (DVI-693 Phase 3C), and
    restarts the tasks.  No enrollment token required — the existing HKLM token
    and registry machine_id are preserved.

    Auth: manual session check (not @togen_admin_required) so that unauthenticated
    requests receive a parseable PowerShell error instead of an SSO redirect whose
    HTML gets executed as script — see DVI-674 for the same fix on bootstrap.ps1.
    """
    # Auth: accept (a) Togen Admin browser session, (b) ?token= matching the
    # open-enroll secret (RMM / headless admin deployment), or (c) Authorization:
    # Bearer <agent_token> from an already-enrolled, non-revoked machine — this
    # lets the update_agent command self-download the script without needing an
    # admin credential or the open-enroll secret.
    token = (request.args.get("token") or "").strip()
    authed = bool(session.get("user") and session["user"].get("togen_admin"))
    if not authed and token:
        conn = _vision_db()
        try:
            secret = _vision_get_open_enroll_secret(conn, create=False)
        finally:
            conn.close()
        authed = bool(secret and token == secret)
    if not authed:
        auth_hdr = request.headers.get("Authorization", "")
        bearer = auth_hdr[7:].strip() if auth_hdr.startswith("Bearer ") else ""
        if bearer:
            conn_ag = _vision_db()
            try:
                row = conn_ag.execute(
                    "SELECT 1 FROM vision_agents WHERE agent_token = ? AND revoked = 0",
                    (bearer,),
                ).fetchone()
                authed = bool(row)
            finally:
                conn_ag.close()
    if not authed:
        # Return HTTP 200 so `iwr ... | iex` doesn't throw a WebException before
        # the PS body reaches iex.  The script itself writes the error and exits 1.
        return (
            "Write-Host 'ERROR: upgrade.ps1 requires a Togen Admin session or "
            "?token=<open-enroll-secret>. In your browser log into Togen then go to "
            "Settings -> Vision -> Enroll tab and click Download upgrade.ps1, or pass "
            "the open-enroll secret as ?token= for headless/RMM use.' "
            "-ForegroundColor Red\nexit 1\n",
            200,
            {"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store"},
        )
    # Capture bearer token for embedding in the script so sections 0 and 1a can
    # POST diagnostics without a registry lookup (token may be in keyring, not HKLM).
    _embed_token = ""
    _auth_hdr2 = request.headers.get("Authorization", "")
    if _auth_hdr2.startswith("Bearer "):
        _embed_token = _auth_hdr2[7:].strip()
    base = _vision_server_base_url()
    ps_base = base.replace("'", "''")
    # DVI-798: record every upgrade.ps1 fetch to a manager-readable file so we can
    # confirm whether the (frozen v3.2.91) .exe's update_agent actually downloads
    # the server-rendered script. Token prefix identifies the requesting machine.
    try:
        _auth_hdr = request.headers.get("Authorization", "")
        _bearer = _auth_hdr[7:].strip() if _auth_hdr.startswith("Bearer ") else ""
        _who = (request.args.get("token") and "open-enroll-token") or (_bearer[:8] if _bearer else "admin-session")
        with open("/var/www/html/togen/upgrade_fetch.log", "a") as _fh:
            _fh.write("%s fetch who=%s ip=%s\n" % (
                time.strftime("%Y-%m-%d %H:%M:%S"), _who, request.remote_addr))
    except Exception:
        pass
    script = f"""# Togen Vision agent upgrade script - generated {time.strftime('%Y-%m-%d %H:%M:%S')}
# Upgrades an already-enrolled Vision agent to the latest version in-place.
# Run as Administrator.  No enrollment token is required.
#Requires -RunAsAdministrator

# DVI-804: Apply key ACL fix immediately — before any try/catch or ZIP download —
# so the interactive-session capture worker can load id_togen_vision via paramiko
# even if the rest of this upgrade fails or takes time.  S-1-5-11=Authenticated Users.
$_kp = Join-Path $env:ProgramData 'Togen\\id_togen_vision'
if (Test-Path $_kp) {{
    & icacls $_kp /grant:r '*S-1-5-11:(R)' 2>&1 | Out-Null
    Write-Host "DVI-804: icacls grant applied to $_kp" -ForegroundColor Green
}}

$ErrorActionPreference = 'Stop'
$Server       = '{ps_base}'
$MachineToken = '{_embed_token}'
$TaskName    = 'TogenVisionAgent'
$CapTask     = 'TogenVisionAgentCapture'
$InstallDir  = Join-Path $env:ProgramFiles 'Togen\\Vision'
$LogDir      = Join-Path $env:ProgramData  'Togen\\Vision\\Logs'

function Write-Step {{ param([string]$Msg) Write-Host ">> $Msg" -ForegroundColor Cyan }}
function Write-OK   {{ param([string]$Msg) Write-Host "   OK: $Msg" -ForegroundColor Green }}
function Write-Warn {{ param([string]$Msg) Write-Host "   WARN: $Msg" -ForegroundColor Yellow }}

# 0. DVI-798 push connectivity self-test. Positioned FIRST, before any step that
#    can abort under $ErrorActionPreference='Stop' (e.g. Expand-Archive on WDAC
#    boxes), so the diagnostic always fires. Runs as SYSTEM (can read the HKLM
#    token); POSTs results to /vision/agent-log as push_diag.txt rather than
#    appending to agent.log (the running check-in process holds agent.log open;
#    the baked .exe's upload_log only fetches agent.log). Diagnoses: OpenSSH
#    client presence, the machine key's ACL (a too-permissive private-key ACL
#    makes Windows OpenSSH reject the key before any TCP connect), and live
#    auth/connect to the footage host. Wrapped so it can never abort the upgrade.
try {{
    $DiagKey = Join-Path $env:ProgramData 'Togen\\id_togen_vision'
    $Diag = New-Object System.Collections.ArrayList
    $scpCmd = (Get-Command scp.exe -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source)
    $sshCmd = (Get-Command ssh.exe -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source)
    [void]$Diag.Add('ts=' + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))
    [void]$Diag.Add('host=' + $env:COMPUTERNAME + ' activeUser=' + (try {{ (Get-CimInstance Win32_ComputerSystem).UserName }} catch {{ '?' }}))
    [void]$Diag.Add('scp.exe=' + $scpCmd)
    [void]$Diag.Add('ssh.exe=' + $sshCmd)
    [void]$Diag.Add('key_exists=' + (Test-Path $DiagKey) + ' path=' + $DiagKey)
    if (Test-Path $DiagKey) {{ try {{ [void]$Diag.Add('key_acl=' + ((icacls $DiagKey 2>&1) -join ' || ')) }} catch {{ }} }}
    if ($sshCmd) {{
        try {{
            $out = & $sshCmd -p 4423 -i $DiagKey -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=15 'aiv@165.188.108.55' 'echo SYSTEM_SSH_OK' 2>&1
            [void]$Diag.Add('ssh_test_rc=' + $LASTEXITCODE + ' out=' + (($out | Out-String).Trim()))
        }} catch {{ [void]$Diag.Add('ssh_test_exc=' + $_.Exception.Message) }}
    }} else {{ [void]$Diag.Add('ssh.exe NOT FOUND on SYSTEM PATH') }}
    # Force TLS 1.2 — older Windows PowerShell defaults to TLS 1.0/1.1 which the
    # HTTPS endpoint rejects, silently failing the POST below.
    try {{ [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 }} catch {{ }}
    # Use the token embedded by the server at script generation time so this POST
    # works even when the registry key (HKLM:\\Software\\Togen\\Vision\\AgentToken) is
    # absent (token may live only in Windows Credential Manager / keyring).
    $dToken = $MachineToken
    if (-not $dToken) {{
        # Fallback: registry (legacy boxes that had token written there explicitly).
        $dToken = (Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\Togen\\Vision' -Name 'AgentToken' -ErrorAction SilentlyContinue).AgentToken
    }}
    if (-not $dToken) {{ $dToken = (Get-ItemProperty -Path 'HKCU:\\SOFTWARE\\Togen\\Vision' -Name 'AgentToken' -ErrorAction SilentlyContinue).AgentToken }}
    if ($dToken) {{ [void]$Diag.Add('token_src=present') }} else {{ [void]$Diag.Add('token_src=MISSING') }}
    if ($dToken) {{
        $dBody = @{{ file = 'push_diag.txt'; content = ($Diag -join \"`n\") }} | ConvertTo-Json -Compress
        $null = Invoke-RestMethod -Uri \"$Server/vision/agent-log\" -Method POST `
            -Headers @{{ Authorization = \"Bearer $dToken\"; 'Content-Type' = 'application/json' }} `
            -Body $dBody -UseBasicParsing
        Write-OK 'Push self-test posted to server'
    }}
}} catch {{ Write-Warn \"Push self-test failed (non-fatal): $_\" }}

# 1a. DVI-804 early icacls fix — pure icacls approach, no PS ACL API.
#     Runs BEFORE any section that may exit early (section 1 exe-check, section 2
#     download).  Idempotent: re-running is harmless.
try {{
    $EarlyKeyPath = Join-Path $env:ProgramData 'Togen\\id_togen_vision'
    $EarlyLogPath = Join-Path $env:ProgramData 'Togen\\Vision\\agent.log'
    if (Test-Path $EarlyKeyPath) {{
        # /reset  — remove all explicit non-inherited ACEs (clears ssh-keygen DENY/owner ACEs)
        # /inheritance:r — remove inherited ACEs + disable inheritance (parent DENY can't creep back)
        # /grant:r — REPLACE (not append) permissions for each principal (locale-independent SID)
        $r1 = & icacls $EarlyKeyPath /reset 2>&1;          $e1 = $LASTEXITCODE
        $r2 = & icacls $EarlyKeyPath /inheritance:r 2>&1;  $e2 = $LASTEXITCODE
        $r3 = & icacls $EarlyKeyPath /grant:r 'NT AUTHORITY\\SYSTEM:(F)' 2>&1;  $e3 = $LASTEXITCODE
        $r4 = & icacls $EarlyKeyPath /grant:r '*S-1-5-11:(R)' 2>&1;             $e4 = $LASTEXITCODE
        $r5 = (& icacls $EarlyKeyPath 2>&1) -join ' | '
        # POST diagnostic to server (Add-Content to agent.log fails — Python FileHandler holds it open).
        $diagContent = "ts=$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') host=$env:COMPUTERNAME whoami=$(whoami) rc=$e1/$e2/$e3/$e4`nr1=$r1`nr2=$r2`nr3=$r3`nr4=$r4`nacl=[$r5]"
        if ($MachineToken) {{
            try {{
                $diagBody = @{{ file = 'acl_diag.txt'; content = $diagContent }} | ConvertTo-Json -Compress
                $null = Invoke-RestMethod -Uri "$Server/vision/agent-log" -Method POST `
                    -Headers @{{ Authorization = "Bearer $MachineToken"; 'Content-Type' = 'application/json' }} `
                    -Body $diagBody -UseBasicParsing -ErrorAction SilentlyContinue
            }} catch {{ }}
        }}
        Write-OK "SSH key ACL fixed (1a): SYSTEM full + Authenticated Users read; rc=$e1/$e2/$e3/$e4"
    }} else {{
        Write-Warn 'SSH key not found at early ACL stage; will be generated in section 5.'
    }}
}} catch {{ Write-Warn "Early SSH key ACL grant failed (non-fatal): $_" }}

# ── COPY-FIRST UPGRADE (DVI-804 v3.2.95) ─────────────────────────────────────
# All upgrade work (verify + download + extract + replace exe + re-register task)
# happens BEFORE stopping TogenVisionAgent.  Windows allows renaming a running
# .exe (Move-Item) while the process keeps its open handle.  A one-shot
# TogenVisionRestart task is registered first; Stop-ScheduledTask then closes
# the Job Object that kills this script (expected).  TogenVisionRestart fires
# ~30 s later and starts TogenVisionAgent with the new exe.
# No Phase-2 process launch needed: no WMI, no schtasks /sd locale issue.
# Clean up stale Phase-2 lock/script from previous WMI approach (idempotent).
Remove-Item (Join-Path $env:TEMP 'togen-upgrade-p2.lock') -Force -ErrorAction SilentlyContinue
Remove-Item (Join-Path $env:TEMP 'togen-upgrade-p2.ps1')  -Force -ErrorAction SilentlyContinue

# 1. Verify existing installation
Write-Step 'Checking existing installation'
$DestExe = Join-Path $InstallDir 'vision_agent.exe'
if (-not (Test-Path $DestExe)) {{
    Write-Host 'ERROR: No existing Vision agent found. Run bootstrap.ps1 for a fresh install.' -ForegroundColor Red
    exit 1
}}
Write-OK "Found: $DestExe"

# 2. Download latest agent ZIP
Write-Step 'Downloading latest agent bundle'
$Zip = Join-Path $env:TEMP 'togen-vision-agent-upgrade.zip'
try {{
    Invoke-WebRequest -Uri "$Server/enroll/agent.zip" -OutFile $Zip -UseBasicParsing -TimeoutSec 300
}} catch {{
    Write-Host "ERROR: Download failed: $_" -ForegroundColor Red; exit 1
}}
$ZipSize = (Get-Item $Zip -ErrorAction SilentlyContinue).Length
if (-not $ZipSize -or $ZipSize -lt 4096) {{
    Write-Host 'ERROR: Agent bundle not available or too small.' -ForegroundColor Red
    Remove-Item $Zip -Force -ErrorAction SilentlyContinue; exit 1
}}
Write-OK "Downloaded: $([math]::Round($ZipSize/1MB, 1)) MB"

# 2b. Extract ZIP
Write-Step 'Extracting agent bundle'
$ExtractDir = Join-Path $env:TEMP 'togen-vision-agent-upgrade'
try {{
    if (Test-Path $ExtractDir) {{ Remove-Item -Recurse -Force $ExtractDir }}
    Expand-Archive -Path $Zip -DestinationPath $ExtractDir -Force
    Remove-Item $Zip -Force -ErrorAction SilentlyContinue
    Write-OK "Extracted to $ExtractDir"
}} catch {{
    Write-Host "ERROR: Extraction failed: $_" -ForegroundColor Red; exit 1
}}

# 2c. Stop capture worker (BUILTIN\\Users AtLogOn task — does NOT close our Job Object)
Stop-ScheduledTask -TaskName $CapTask -ErrorAction SilentlyContinue
try {{
    Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
        Where-Object {{ $_.CommandLine -and $_.CommandLine -match '--capture-only' }} |
        ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}
}} catch {{ }}

# 2d. Rename running exe → .bak; copy new exe to original path.
#     Windows allows renaming a running .exe: the live process keeps its handle
#     while the new file occupies the original path for the next launch.
Write-Step 'Installing updated agent files (copy-first)'
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
New-Item -ItemType Directory -Path $LogDir     -Force | Out-Null
$BackupExe = "$DestExe.bak"
Remove-Item $BackupExe -Force -ErrorAction SilentlyContinue
try {{
    Move-Item $DestExe $BackupExe -Force -ErrorAction Stop
    Write-OK 'Renamed old exe to vision_agent.exe.bak'
}} catch {{ Write-Warn "Rename old exe failed (will try overwrite): $_" }}
$SrcExe = Join-Path $ExtractDir 'vision_agent.exe'
if (Test-Path $SrcExe) {{
    try {{
        Copy-Item $SrcExe $DestExe -Force -ErrorAction Stop
        Unblock-File $DestExe -ErrorAction SilentlyContinue
        Write-OK 'New vision_agent.exe installed'
    }} catch {{ Write-Warn "Could not copy new exe: $_" }}
}}
foreach ($f in @('install.ps1','vision_agent.py')) {{
    $src = Join-Path $ExtractDir $f
    if (Test-Path $src) {{
        Copy-Item $src (Join-Path $InstallDir $f) -Force -ErrorAction SilentlyContinue
        Unblock-File (Join-Path $InstallDir $f) -ErrorAction SilentlyContinue
    }}
}}
Remove-Item -Recurse -Force $ExtractDir -ErrorAction SilentlyContinue
Write-OK 'Agent files updated'

# 2d-diag: POST rename/copy result so admin can diagnose failures.
$_2dBakExists  = (Test-Path $BackupExe)
$_2dDestExists = (Test-Path $DestExe)
$_2dDestSz     = if ($_2dDestExists) {{ (Get-Item $DestExe).Length }} else {{ 0 }}
$_2dBakSz      = if ($_2dBakExists) {{ (Get-Item $BackupExe).Length }} else {{ 0 }}
$_2dDiag = "ts=$(Get-Date -Format 'yyyy-MM-ddTHH:mm:ss') host=$env:COMPUTERNAME whoami=$(whoami)`nbak_exists=$_2dBakExists bak_sz=$_2dBakSz`ndest_exists=$_2dDestExists dest_sz=$_2dDestSz`nextract_dir_exists=$(Test-Path $ExtractDir)"
if ($MachineToken) {{
    try {{
        $null = Invoke-RestMethod -Uri "$Server/vision/agent-log" -Method POST -UseBasicParsing `
            -Headers @{{ Authorization = "Bearer $MachineToken"; 'Content-Type' = 'application/json' }} `
            -Body (@{{ file = 'upgrade_diag.txt'; content = $_2dDiag }} | ConvertTo-Json -Compress) `
            -ErrorAction SilentlyContinue
    }} catch {{ }}
}}

# 2e. SSH key ACL — same icacls sequence as section 1a; repeated here in case
#     ssh-keygen ran on a previous bootstrap and the key was re-created as SYSTEM-only.
$SshKeyPath2e = Join-Path $env:ProgramData 'Togen\\id_togen_vision'
if (Test-Path $SshKeyPath2e) {{
    & icacls $SshKeyPath2e /reset 2>&1 | Out-Null
    & icacls $SshKeyPath2e /inheritance:r 2>&1 | Out-Null
    & icacls $SshKeyPath2e /grant:r 'NT AUTHORITY\\SYSTEM:(F)' 2>&1 | Out-Null
    & icacls $SshKeyPath2e /grant:r '*S-1-5-11:(R)' 2>&1 | Out-Null
    Write-OK 'SSH key ACL refreshed (2e)'
}}

# 2f. Re-register TogenVisionAgent task so it picks up the new exe path on restart.
Write-Step 'Re-registering TogenVisionAgent task'
$LogPath2f   = Join-Path $LogDir 'agent.log'
$ErrPath2f   = Join-Path $LogDir 'agent-err.log'
$PsExe2f     = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
if (-not (Test-Path $PsExe2f)) {{ $PsExe2f = 'powershell.exe' }}
$RunScript2f = Join-Path $InstallDir 'run_agent.ps1'
"Start-Process -FilePath '$DestExe' -ArgumentList @('--server', '$Server') -RedirectStandardOutput '$LogPath2f' -RedirectStandardError '$ErrPath2f' -NoNewWindow -Wait" |
    Set-Content $RunScript2f -Encoding UTF8
$Action2f    = New-ScheduledTaskAction -Execute $PsExe2f `
                 -Argument "-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$RunScript2f`"" `
                 -WorkingDirectory $InstallDir
$Trigger2f   = New-ScheduledTaskTrigger -AtStartup
$Settings2f  = New-ScheduledTaskSettingsSet `
    -ExecutionTimeLimit ([TimeSpan]::Zero) `
    -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 1) `
    -StartWhenAvailable -MultipleInstances IgnoreNew
$Principal2f = New-ScheduledTaskPrincipal -UserID 'NT AUTHORITY\\SYSTEM' -LogonType ServiceAccount -RunLevel Highest
try {{
    Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
    Register-ScheduledTask -TaskName $TaskName -Trigger $Trigger2f -Action $Action2f `
        -Settings $Settings2f -Principal $Principal2f -Force | Out-Null
    Write-OK "Task '$TaskName' re-registered"
}} catch {{ Write-Warn "Task re-registration failed (non-fatal): $_" }}

# 2g. Register TogenVisionRestart — one-shot SYSTEM task, fires 30 s from now.
#     Runs outside TogenVisionAgent's Job Object.  When Stop-ScheduledTask (2h)
#     closes the Job Object and kills this script, TogenVisionRestart fires and
#     starts TogenVisionAgent with the newly placed exe.
#     Uses Register-ScheduledTask to avoid schtasks.exe locale/date-format issues.
Write-Step 'Registering restart task'
$RestartTaskName = 'TogenVisionRestart'
Unregister-ScheduledTask -TaskName $RestartTaskName -Confirm:$false -ErrorAction SilentlyContinue
$RestartAt       = (Get-Date).AddSeconds(30)
$RestartAction   = New-ScheduledTaskAction -Execute $PsExe2f `
    -Argument "-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -Command `"Start-ScheduledTask -TaskName '$TaskName'`""
$RestartTrigger  = New-ScheduledTaskTrigger -Once -At $RestartAt
$RestartSettings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 5) -StartWhenAvailable
$RestartPrincipal = New-ScheduledTaskPrincipal -UserID 'NT AUTHORITY\\SYSTEM' -LogonType ServiceAccount -RunLevel Highest
try {{
    Register-ScheduledTask -TaskName $RestartTaskName -Action $RestartAction `
        -Trigger $RestartTrigger -Settings $RestartSettings -Principal $RestartPrincipal -Force | Out-Null
    Write-OK "TogenVisionRestart registered (fires at $($RestartAt.ToString('HH:mm:ss')))"
}} catch {{ Write-Warn "Could not register restart task (non-fatal): $_" }}

# 2h. Stop TogenVisionAgent — closes Job Object → kills this script (expected).
#     All critical work is done above.  TogenVisionRestart fires in ~30 s.
Write-Host ''
Write-Host '  Stopping TogenVisionAgent — new exe in place, restart in ~30 s.' -ForegroundColor Cyan
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Get-Process -Name vision_agent -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
# Fallback: if Job Object did not kill this script (task was already stopped or
# no kill-on-close limit on this box), wait for restart task then start directly.
Start-Sleep -Seconds 35
Unregister-ScheduledTask -TaskName $RestartTaskName -Confirm:$false -ErrorAction SilentlyContinue
try {{ Start-ScheduledTask -TaskName $TaskName }} catch {{ Write-Warn "Direct start failed: $_" }}
exit 0
# ─────────────────────────────────────────────────────────────────────────────

# 1. Verify existing installation
Write-Step 'Checking existing installation'
$DestExe = Join-Path $InstallDir 'vision_agent.exe'
if (-not (Test-Path $DestExe)) {{
    Write-Host 'ERROR: No existing Vision agent found. Run bootstrap.ps1 for a fresh install.' -ForegroundColor Red
    exit 1
}}
Write-OK "Found: $DestExe"

# 2. Download latest agent ZIP
Write-Step 'Downloading latest agent bundle'
$Zip = Join-Path $env:TEMP 'togen-vision-agent-upgrade.zip'
try {{
    Invoke-WebRequest -Uri "$Server/enroll/agent.zip" -OutFile $Zip -UseBasicParsing -TimeoutSec 300
}} catch {{
    Write-Host "ERROR: Download failed: $_" -ForegroundColor Red; exit 1
}}
$ZipSize = (Get-Item $Zip -ErrorAction SilentlyContinue).Length
if (-not $ZipSize -or $ZipSize -lt 4096) {{
    Write-Host 'ERROR: Agent bundle not available or too small.' -ForegroundColor Red
    Remove-Item $Zip -Force -ErrorAction SilentlyContinue; exit 1
}}
Write-OK "Downloaded: $([math]::Round($ZipSize/1MB, 1)) MB"

# 3. Stop scheduled tasks (stop all TogenVision* except the Phase 2 upgrade task
#    itself, which is the caller when running as an independent schtasks job).
Write-Step 'Stopping scheduled tasks'
Get-ScheduledTask -TaskName 'TogenVision*' -ErrorAction SilentlyContinue |
    Where-Object {{ $_.TaskName -ne 'TogenVisionUpgradeP2' }} |
    Stop-ScheduledTask -ErrorAction SilentlyContinue
# Stop-ScheduledTask is async; the exe/python may still be running when Copy-Item fires.
# Kill vision_agent.exe processes (SYSTEM check-in task).
Get-Process -Name vision_agent -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
# Kill python.exe processes running vision_agent.py (capture task).
try {{
    Get-WmiObject Win32_Process -Filter "name='python.exe'" |
        Where-Object {{ $_.CommandLine -like '*vision_agent*' }} |
        ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }}
}} catch {{ }}
$deadline = (Get-Date).AddSeconds(15)
while ((Get-Process -Name vision_agent -ErrorAction SilentlyContinue) -and (Get-Date) -lt $deadline) {{
    Start-Sleep -Milliseconds 500
}}
Write-OK 'Tasks stopped'

# 4. Extract and overwrite agent files
Write-Step 'Installing updated agent files'
$ExtractDir = Join-Path $env:TEMP 'togen-vision-agent-upgrade'
if (Test-Path $ExtractDir) {{ Remove-Item -Recurse -Force $ExtractDir }}
Expand-Archive -Path $Zip -DestinationPath $ExtractDir -Force
Remove-Item $Zip -Force -ErrorAction SilentlyContinue

New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
New-Item -ItemType Directory -Path $LogDir     -Force | Out-Null

foreach ($f in @('vision_agent.exe','install.ps1','vision_agent.py')) {{
    $src = Join-Path $ExtractDir $f
    if (Test-Path $src) {{
        Copy-Item $src (Join-Path $InstallDir $f) -Force
        Unblock-File (Join-Path $InstallDir $f) -ErrorAction SilentlyContinue
    }}
}}
Remove-Item -Recurse -Force $ExtractDir -ErrorAction SilentlyContinue
Write-OK 'Agent files updated'

# 5. Generate machine-level SSH key and upload public key to Togen server (DVI-693 / DVI-726)
Write-Step 'Checking machine SSH key'
$TogenDataDir = Join-Path $env:ProgramData 'Togen'
$SshKeyPath   = Join-Path $TogenDataDir 'id_togen_vision'
New-Item -ItemType Directory -Path $TogenDataDir -Force | Out-Null
# Guard: ssh-keygen may be absent on machines without OpenSSH Client.
# A CommandNotFoundException under $ErrorActionPreference=Stop would abort the script.
$SshKeygen = Get-Command ssh-keygen -ErrorAction SilentlyContinue
if (-not $SshKeygen) {{
    if (-not (Test-Path $SshKeyPath)) {{
        Write-Warn 'ssh-keygen not found and no existing key - key generation skipped.'
        Write-Warn 'Install OpenSSH Client (Settings -> Optional Features -> OpenSSH Client),'
        Write-Warn 'then re-run upgrade.ps1 to generate the machine key.'
    }} else {{
        Write-OK 'Machine SSH key already exists (ssh-keygen absent, generation skipped)'
    }}
}} else {{
    if (-not (Test-Path $SshKeyPath)) {{
        & $SshKeygen -t ed25519 -f $SshKeyPath -N '""'
        Write-OK "Machine SSH key generated: $SshKeyPath"
    }} else {{
        Write-OK 'Machine SSH key already exists'
    }}
}}
# DVI-804: grant Authenticated Users read on the private key so the interactive-session
# capture worker (running as the logged-in user, not SYSTEM) can load it via paramiko.
# ssh-keygen sets SYSTEM-only ACL when run under SYSTEM; without this grant paramiko
# gets [Errno 13] Permission denied and all three push paths (paramiko/scp/rsync) fail.
# Applied unconditionally when the key exists — regardless of ssh-keygen availability,
# since the key may have been generated on a previous run. S-1-5-11 = Authenticated
# Users (locale-independent SID).
if (Test-Path $SshKeyPath) {{
    # Same icacls-only approach as section 1a.
    $s1 = & icacls $SshKeyPath /reset 2>&1;          $se1 = $LASTEXITCODE
    $s2 = & icacls $SshKeyPath /inheritance:r 2>&1;  $se2 = $LASTEXITCODE
    $s3 = & icacls $SshKeyPath /grant:r 'NT AUTHORITY\\SYSTEM:(F)' 2>&1;  $se3 = $LASTEXITCODE
    $s4 = & icacls $SshKeyPath /grant:r '*S-1-5-11:(R)' 2>&1;             $se4 = $LASTEXITCODE
    Write-OK "SSH key ACL (section 5): SYSTEM full + Authenticated Users read rc=$se1/$se2/$se3/$se4"
}}
# Upload the public key to the Togen server so admin approval can auto-authorize it.
    $PubKeyFile = "$SshKeyPath.pub"
    if (Test-Path $PubKeyFile) {{
        # Read agent token from HKLM (DVI-676).
        $AgentToken = $null
        try {{
            $AgentToken = (Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\Togen\\Vision' -Name 'AgentToken' -ErrorAction SilentlyContinue).AgentToken
        }} catch {{ }}
        if ($AgentToken) {{
            $PubKeyContent = (Get-Content $PubKeyFile -Raw).Trim()
            $Body = @{{ ssh_public_key = $PubKeyContent }} | ConvertTo-Json -Compress
            try {{
                $null = Invoke-RestMethod -Uri "$Server/vision/agents/ssh-key" `
                    -Method POST `
                    -Headers @{{ Authorization = "Bearer $AgentToken"; 'Content-Type' = 'application/json' }} `
                    -Body $Body -UseBasicParsing
                Write-OK 'SSH public key uploaded to Togen server'
            }} catch {{
                Write-Warn "Could not upload SSH public key to server: $_"
                Write-Host "  Public key: $PubKeyFile" -ForegroundColor Yellow
            }}
        }} else {{
            Write-Warn 'No agent token found in registry - SSH public key not uploaded.'
            Write-Host "  Public key: $PubKeyFile" -ForegroundColor Yellow
        }}
    }}
}}

# 6. Re-create scheduled tasks from scratch so renamed tasks, missing tasks, and
#    config changes are all fixed — not just start-if-already-registered.
Write-Step 'Registering scheduled tasks'
# Unregister all existing TogenVision* tasks except TogenVisionUpgradeP2 (the running Phase 2 task).
Get-ScheduledTask -TaskName 'TogenVision*' -ErrorAction SilentlyContinue |
    Where-Object {{ $_.TaskName -ne 'TogenVisionUpgradeP2' }} |
    ForEach-Object {{ Unregister-ScheduledTask -TaskName $_.TaskName -Confirm:$false -ErrorAction SilentlyContinue }}

New-Item -ItemType Directory -Path $LogDir -Force | Out-Null

# Check-in task: SYSTEM, at startup — hidden-window launcher (no blank cmd.exe in taskbar)
$LogPath  = Join-Path $LogDir 'agent.log'
$ErrPath  = Join-Path $LogDir 'agent-err.log'
$PsExe    = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
if (-not (Test-Path $PsExe)) {{ $PsExe = 'powershell.exe' }}
$RunScript = Join-Path $InstallDir 'run_agent.ps1'
"Start-Process -FilePath '$DestExe' -ArgumentList @('--server', '$Server') -RedirectStandardOutput '$LogPath' -RedirectStandardError '$ErrPath' -NoNewWindow -Wait" |
    Set-Content $RunScript -Encoding UTF8
$Action   = New-ScheduledTaskAction -Execute $PsExe `
              -Argument "-NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$RunScript`"" `
              -WorkingDirectory $InstallDir
$Trigger  = New-ScheduledTaskTrigger -AtStartup
$Settings = New-ScheduledTaskSettingsSet `
    -ExecutionTimeLimit ([TimeSpan]::Zero) `
    -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 1) `
    -StartWhenAvailable -MultipleInstances IgnoreNew
$Principal = New-ScheduledTaskPrincipal -UserID 'NT AUTHORITY\\SYSTEM' -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName $TaskName -Trigger $Trigger -Action $Action -Settings $Settings -Principal $Principal -Force | Out-Null
Write-OK "Task '$TaskName' registered (startup, SYSTEM)"

# Capture task: BUILTIN\\Users, at logon — prefers signed python.exe for WDAC/Smart App Control
$CapLog  = Join-Path $LogDir 'capture.log'
$CapErr  = Join-Path $LogDir 'capture-err.log'
$DestPy  = Join-Path $InstallDir 'vision_agent.py'
$PyExe   = $null
if (Test-Path $DestPy) {{
    $cand = Get-Command python -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
    if ($cand -and (Test-Path $cand)) {{
        try {{
            $sig = Get-AuthenticodeSignature $cand -ErrorAction Stop
            if ($sig -and $sig.Status -eq 'Valid') {{ $PyExe = $cand }}
        }} catch {{ Write-Warn "Skipped python candidate (signature check failed): $cand" }}
    }}
}}
$PywExe = $null
if ($PyExe) {{
    $pywCand = $PyExe -replace '\\\\python\\.exe$', '\\pythonw.exe'
    if (Test-Path $pywCand) {{ $PywExe = $pywCand }}
}}
if ($PywExe) {{
    $CapAction = New-ScheduledTaskAction -Execute $PywExe `
                    -Argument "`"$DestPy`" --capture-only --server `"$Server`"" `
                    -WorkingDirectory $InstallDir
    Write-OK "Capture will run via pythonw.exe (no console window): $PywExe"
}} elseif ($PyExe) {{
    # wscript.exe VBScript launcher: creates child with SW_HIDE from the very first frame,
    # eliminating the PowerShell flash seen on Windows 22H2 Task Scheduler.
    $VbsScript = Join-Path $InstallDir 'run_capture.vbs'
@"
Set oShell = CreateObject("WScript.Shell")
oShell.Run Chr(34) & "$PyExe" & Chr(34) & " " & Chr(34) & "$DestPy" & Chr(34) & " --capture-only --server " & Chr(34) & "$Server" & Chr(34), 0, True
"@ | Set-Content $VbsScript -Encoding ASCII
    $CapAction = New-ScheduledTaskAction -Execute 'wscript.exe' `
                    -Argument "`"$VbsScript`"" `
                    -WorkingDirectory $InstallDir
    Write-OK "Capture will run via wscript.exe VBScript (python.exe, no window): $PyExe"
}} else {{
    $VbsScript = Join-Path $InstallDir 'run_capture.vbs'
@"
Set oShell = CreateObject("WScript.Shell")
oShell.Run Chr(34) & "$DestExe" & Chr(34) & " --capture-only --server " & Chr(34) & "$Server" & Chr(34), 0, True
"@ | Set-Content $VbsScript -Encoding ASCII
    $CapAction = New-ScheduledTaskAction -Execute 'wscript.exe' `
                    -Argument "`"$VbsScript`"" `
                    -WorkingDirectory $InstallDir
    Write-OK "Capture will run via wscript.exe VBScript (exe, no window)"
}}
& icacls $LogDir /grant '*S-1-5-32-545:(OI)(CI)M' /T 2>$null | Out-Null
$CapTrigger   = New-ScheduledTaskTrigger -AtLogOn
$CapSettings  = New-ScheduledTaskSettingsSet `
    -ExecutionTimeLimit ([TimeSpan]::Zero) `
    -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 1) `
    -StartWhenAvailable -MultipleInstances IgnoreNew
$CapPrincipal = New-ScheduledTaskPrincipal -GroupId 'BUILTIN\\Users' -RunLevel Limited
Register-ScheduledTask -TaskName $CapTask -Trigger $CapTrigger -Action $CapAction -Settings $CapSettings -Principal $CapPrincipal -Force | Out-Null
Write-OK "Task '$CapTask' registered (at-logon, BUILTIN\\Users)"

# 7. Start tasks now without waiting for next boot/logon (DVI-798)
Write-Step 'Starting tasks'
# Check-in task runs as SYSTEM — Start-ScheduledTask resolves cleanly.
try {{ Start-ScheduledTask -TaskName $TaskName }} catch {{ Write-Warn "Could not start ${{TaskName}}: $_" }}

# Capture task: Start-ScheduledTask on the BUILTIN\\Users group AtLogOn task does
# NOT resolve an interactive token unless a real logon event fires (DVI-798).
# The worker then silently never runs, so no frames are captured and no
# footage/<machine_id> directory is ever created on the rsync server, even though
# SYSTEM check-in keeps reporting the agent healthy. Launch the worker in the
# current desktop session via a transient task bound to the active console user
# with LogonType Interactive, which resolves their existing token (verified to
# run in the user's session). The AtLogOn group task still covers future logons.
# Kill any stale --capture-only worker first so we never double-launch.
$capWorkers = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
    Where-Object {{ $_.CommandLine -and $_.CommandLine -match '--capture-only' }}
foreach ($w in $capWorkers) {{ Stop-Process -Id $w.ProcessId -Force -ErrorAction SilentlyContinue }}
$ActiveUser = $null
try {{ $ActiveUser = (Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).UserName }} catch {{ }}
if ($ActiveUser) {{
    $NowTask = "${{CapTask}}Now"
    Unregister-ScheduledTask -TaskName $NowTask -Confirm:$false -ErrorAction SilentlyContinue
    $NowPrincipal = New-ScheduledTaskPrincipal -UserId $ActiveUser -LogonType Interactive -RunLevel Limited
    Register-ScheduledTask -TaskName $NowTask -Action $CapAction -Settings $CapSettings -Principal $NowPrincipal -Force | Out-Null
    try {{ Start-ScheduledTask -TaskName $NowTask; Write-OK "Capture worker launched now in the session of $ActiveUser" }}
    catch {{ Write-Warn "Could not start capture worker: $_" }}
}} else {{
    Write-Warn 'No interactive user logged on - capture cannot run (screen capture needs a desktop). It will start at the next logon (AtLogOn).'
}}
Write-OK 'Tasks started'

# Phase 2 cleanup: remove the one-shot upgrade task registered by Phase 1.
& "$env:SystemRoot\\System32\\schtasks.exe" /delete /tn TogenVisionUpgradeP2 /f 2>&1 | Out-Null

Write-Host ''
Write-Host 'Upgrade complete.' -ForegroundColor Green
Write-Host "  Install dir: $InstallDir"
Write-Host "  Logs:        $LogDir"
"""
    return script, 200, {
        "Content-Type": "text/plain; charset=utf-8",
        "Content-Disposition": "attachment; filename=upgrade.ps1",
        "Cache-Control": "no-store",
    }


@app.route("/enroll/agent.zip")
def vision_enroll_agent_zip():
    """Serve the frozen agent bundle (exe + installer) dropped by the Windows
    Engineer packaging task. 404 with guidance until the artifact is present.

    No auth required: the ZIP contains only vision_agent.exe + install.ps1 with no
    embedded secrets. An enrollment credential is still required to register the agent.
    This allows bootstrap.ps1 to download the bundle on the target machine without
    needing to forward admin session cookies.
    """
    path = VISION_AGENT_DIST_DIR / VISION_AGENT_ZIP_NAME
    if not path.is_file():
        return jsonify(
            error="Agent bundle not available yet.",
            detail=f"Expected {VISION_AGENT_ZIP_NAME} in VISION_AGENT_DIST_DIR "
                   f"({VISION_AGENT_DIST_DIR}); run build_agent.ps1 on a Windows host "
                   f"to produce the bundle, then deploy to the server.",
        ), 404
    return send_file(str(path), as_attachment=True,
                     download_name=VISION_AGENT_ZIP_NAME)


@app.route("/vision/commands")
@togen_admin_required
def vision_commands_list():
    """Recent commands for a machine with per-command state (queued/delivered/acked)."""
    machine_id = (request.args.get("machine_id") or "").strip()
    if not machine_id:
        return jsonify(error="machine_id required."), 400
    conn = _vision_db()
    try:
        rows = conn.execute(
            "SELECT id, action, params_json, created_at, delivered_at, acked_at,"
            " acked_status, error_message FROM vision_commands"
            " WHERE machine_id = ? ORDER BY created_at DESC LIMIT 20",
            (machine_id,),
        ).fetchall()
    finally:
        conn.close()
    cmds = []
    for r in rows:
        if r["acked_at"]:
            state = "acked"
        elif r["delivered_at"]:
            state = "delivered"
        else:
            state = "queued"
        params = None
        if r["params_json"]:
            try:
                params = json.loads(r["params_json"])
            except (json.JSONDecodeError, TypeError):
                params = None
        cmds.append({
            "id": r["id"], "action": r["action"], "params": params,
            "created_at": r["created_at"], "delivered_at": r["delivered_at"],
            "acked_at": r["acked_at"], "acked_status": r["acked_status"],
            "error_message": r["error_message"], "state": state,
        })
    return jsonify(machine_id=machine_id, commands=cmds)


def _load_vision_notifications():
    if not VISION_NOTIFICATION_LOG_FILE.is_file():
        return []
    try:
        data = json.loads(VISION_NOTIFICATION_LOG_FILE.read_text())
        return data if isinstance(data, list) else []
    except (json.JSONDecodeError, OSError):
        return []


def _vision_evaluate_alerts():
    """Run lightweight server-side alert rules on already-computed signals and append
    new (deduped) alerts to the Vision notification log. Returns the recent log slice.

    Rules: machine unexpectedly offline (last_seen staleness), agent self-reported
    status=error, or a new enrollment. No external paging (Phase 3).
    """
    now = time.time()
    conn = _vision_db()
    try:
        agents = conn.execute(
            "SELECT machine_id, hostname, enrolled_at, last_seen_at FROM vision_agents"
            " WHERE revoked = 0"
        ).fetchall()
        latest = conn.execute(
            "SELECT machine_id, status, received_at FROM vision_checkins"
            " WHERE id IN (SELECT MAX(id) FROM vision_checkins GROUP BY machine_id)"
        ).fetchall()
    finally:
        conn.close()
    latest_by_machine = {r["machine_id"]: r for r in latest}

    candidates = []
    for a in agents:
        mid = a["machine_id"]
        host = a["hostname"] or mid
        last_seen = a["last_seen_at"]
        # New enrollment — fires once per enrollment timestamp.
        candidates.append({
            "key": f"enroll:{mid}:{int(a['enrolled_at'])}",
            "type": "enrollment", "machine_id": mid,
            "message": f"New Vision agent enrolled: {host}",
        })
        # Unexpectedly offline — only for agents that have checked in at least once.
        if last_seen and (now - last_seen) > _VISION_OFFLINE_ALERT_SECONDS:
            candidates.append({
                "key": f"offline:{mid}:{int(last_seen)}",
                "type": "offline", "machine_id": mid,
                "message": f"{host} is unexpectedly offline "
                           f"(last seen {int((now - last_seen) / 60)} min ago).",
            })
        # Agent self-reported error on its latest check-in.
        lc = latest_by_machine.get(mid)
        if lc and lc["status"] == "error":
            candidates.append({
                "key": f"error:{mid}:{int(lc['received_at'])}",
                "type": "error", "machine_id": mid,
                "message": f"{host} reported status=error.",
            })

    with _vision_notif_lock:
        log = _load_vision_notifications()
        seen = {e.get("key") for e in log}
        appended = False
        for c in candidates:
            if c["key"] in seen:
                continue
            c["created_at"] = _datetime_type.now(_dt_timezone.utc).isoformat()
            log.append(c)
            seen.add(c["key"])
            appended = True
        if appended:
            log = log[-500:]
            try:
                VISION_NOTIFICATION_LOG_FILE.write_text(json.dumps(log, indent=2))
            except OSError:
                app.logger.exception("Failed to persist vision_notification_log.json")
    return log


@app.route("/vision/alerts")
@vision_access_required
def vision_alerts():
    """Evaluate alert rules and return the most recent non-dismissed alerts (newest first)."""
    log = _vision_evaluate_alerts()
    recent = [e for e in reversed(log[-50:]) if not e.get("dismissed")]
    return jsonify(alerts=recent)


@app.route("/vision/alerts/dismiss", methods=["POST"])
@vision_access_required
def vision_alerts_dismiss():
    """Dismiss alerts so they stop surfacing (DVI-682). Body: {key} for one, or
    {all: true} to clear every current alert. Dismissal is persisted on the log entry."""
    body = request.get_json(silent=True) or {}
    key = (body.get("key") or "").strip()
    clear_all = bool(body.get("all"))
    if not key and not clear_all:
        return jsonify(error="Provide a key or all=true."), 400
    with _vision_notif_lock:
        log = _load_vision_notifications()
        changed = False
        for e in log:
            if clear_all or e.get("key") == key:
                if not e.get("dismissed"):
                    e["dismissed"] = True
                    changed = True
        if changed:
            try:
                VISION_NOTIFICATION_LOG_FILE.write_text(json.dumps(log, indent=2))
            except OSError:
                app.logger.exception("Failed to persist vision_notification_log.json")
    return jsonify(ok=True)


# ---------------------------------------------------------------------------
# Background scheduler for recurring work orders and date-based reminders
# ---------------------------------------------------------------------------

def _process_wor_date_reminders():
    """Check all non-completed work orders for upcoming/overdue dates and send admin emails."""
    from datetime import timedelta
    today = _datetime_type.now().date()
    tomorrow = (today + timedelta(days=1)).strftime("%Y-%m-%d")
    today_str = today.strftime("%Y-%m-%d")

    reminder_lines = []
    overdue_lines = []

    for wor_type in ("maintenance", "mobilemachine", "safety", "preventive"):
        dirs = _wor_dest_dirs(wor_type)
        for status_key in ("new", "in_progress"):
            directory = dirs[status_key]
            if not directory.is_dir():
                continue
            for json_file in directory.glob("*.json"):
                try:
                    data = json.loads(json_file.read_text())
                except (json.JSONDecodeError, OSError):
                    continue
                pdf_name = json_file.with_suffix(".pdf").name

                if wor_type in ("maintenance", "safety", "preventive"):
                    date_field = data.get("desired_completion_date", "")
                    label = "Desired Completion Date"
                    name = data.get("requestor_name", "Unknown")
                else:
                    date_field = data.get("date_promised", "")
                    label = "Date Promised"
                    name = data.get("machine", "Unknown")

                wo_type_label = {"maintenance": "Maintenance", "safety": "Safety", "preventive": "Preventative Maintenance"}.get(wor_type, "Mobile Machine")
                if date_field:
                    if date_field == tomorrow:
                        reminder_lines.append(
                            f"  - [{wo_type_label}] {pdf_name}\n"
                            f"    {label}: {date_field} | {name} | Status: {status_key}")
                    elif date_field < today_str:
                        overdue_lines.append(
                            f"  - [{wo_type_label}] {pdf_name}\n"
                            f"    {label}: {date_field} (OVERDUE) | {name} | Status: {status_key}")

    # Check recurring next_run dates
    registry = _load_recurring_registry()
    for source_filename, entry in registry.items():
        if not entry.get("enabled", True):
            continue
        next_run = entry.get("next_run", "")
        if next_run == tomorrow:
            freq = entry.get("frequency_days", "?")
            reminder_lines.append(
                f"  - [Recurring] {source_filename}\n"
                f"    Next Run: {next_run} | Every {freq} days")
        elif next_run and next_run < today_str:
            freq = entry.get("frequency_days", "?")
            overdue_lines.append(
                f"  - [Recurring] {source_filename}\n"
                f"    Next Run: {next_run} (OVERDUE) | Every {freq} days")

    if reminder_lines:
        body = (
            "The following work orders have dates coming up tomorrow:\n\n"
            + "\n".join(reminder_lines)
            + "\n\nThis notification was sent from the ICAST Togen application."
        )
        _send_wor_admin_email(
            f"Work Order Reminder — {len(reminder_lines)} upcoming date(s) for {tomorrow}",
            body,
        )

    if overdue_lines:
        body = (
            "The following work orders have passed their target dates without being completed:\n\n"
            + "\n".join(overdue_lines)
            + "\n\nThis notification was sent from the ICAST Togen application."
        )
        _send_wor_admin_email(
            f"Work Order OVERDUE — {len(overdue_lines)} past-due work order(s)",
            body,
        )

    return len(reminder_lines), len(overdue_lines)


_last_reminder_date = ""


def _recurring_scheduler_loop():
    """Background thread: hourly recurring WO check + daily date-based reminders."""
    global _last_reminder_date
    import time
    while True:
        time.sleep(3600)
        try:
            with app.app_context():
                created = _process_recurring_work_orders()
                if created:
                    app.logger.info("Recurring scheduler created %d work order(s): %s", len(created), created)

                today_str = _datetime_type.now().strftime("%Y-%m-%d")
                if today_str != _last_reminder_date:
                    _last_reminder_date = today_str
                    reminders, overdue = _process_wor_date_reminders()
                    if reminders or overdue:
                        app.logger.info("WOR reminders sent: %d upcoming, %d overdue", reminders, overdue)
        except Exception:
            app.logger.exception("Recurring scheduler error")

_recurring_thread = threading.Thread(target=_recurring_scheduler_loop, daemon=True)
_recurring_thread.start()


def _news_scheduler_loop():
    """Background thread: publish due scheduled News posts on time.

    Without this, scheduled posts only flip to published (and email subscribers)
    when an admin next loads the News feed, so an evening-scheduled post could
    sit until the next morning's page view (DVI-755). Polling each minute fires
    posts at their scheduled instant. publish_at is stored in UTC, derived from
    the chosen Central time, so this stays aligned with US Central (CT/CST).
    """
    import time
    while True:
        time.sleep(60)
        try:
            with app.app_context():
                _promote_scheduled_posts()
        except Exception:
            app.logger.exception("News scheduler error")


_news_scheduler_thread = threading.Thread(target=_news_scheduler_loop, daemon=True)
_news_scheduler_thread.start()

_winston_scheduler_thread = _winston_sched_mod.start_loop()

# One-time startup prune for history.db (DVI-789).
threading.Thread(target=_prune_history_db, daemon=True).start()
# Periodic prune for bot_diagnostics.db (DVI-824).
_diag_mod.start_periodic_prune(interval_seconds=3600)


def _scheduled_update_loop():
    """Background thread: apply a scheduled app update at 2:00 AM (DVI-768)."""
    import time
    while True:
        time.sleep(60)
        try:
            with app.app_context():
                _maybe_run_scheduled_update()
        except Exception:
            app.logger.exception("Scheduled update scheduler error")


_scheduled_update_thread = threading.Thread(target=_scheduled_update_loop, daemon=True)
_scheduled_update_thread.start()


def _scada_collector_loop():
    """Background thread: poll the SCADA mailbox on the configured interval."""
    import time
    while True:
        try:
            config = _load_scada_config()
            interval = int(
                (config.get("collector") or {}).get(
                    "check_interval_minutes",
                    SCADA_COLLECTOR_DEFAULTS["check_interval_minutes"]))
        except Exception:
            interval = SCADA_COLLECTOR_DEFAULTS["check_interval_minutes"]
        time.sleep(max(1, interval) * 60)
        try:
            with app.app_context():
                result = _run_scada_collector()
                app.logger.info("SCADA collector cycle: %s", result)
        except Exception:
            app.logger.exception("SCADA collector error")


_scada_collector_thread = threading.Thread(target=_scada_collector_loop, daemon=True)
_scada_collector_thread.start()

# One-time backfill: OCR + index any existing manual not yet searchable (DVI-491).
_manuals_backfill_thread = threading.Thread(target=_backfill_manuals_ocr, daemon=True)
_manuals_backfill_thread.start()

# Pre-cache Grid-view thumbnails for existing manuals so the first load is fast (DVI-585).
_manuals_thumb_thread = threading.Thread(target=_backfill_manual_thumbs, daemon=True)
_manuals_thumb_thread.start()


# ---------------------------------------------------------------------------
# File Browser folder-watcher email rotation (DVI-471)
# ---------------------------------------------------------------------------

FB_WATCHER_INTERVAL_MINUTES = 15


def _fb_diff_snapshots(old: dict, new: dict):
    """Return (added, removed, modified) relative-path lists between snapshots."""
    old = old or {}
    new = new or {}
    added = sorted(p for p in new if p not in old)
    removed = sorted(p for p in old if p not in new)
    modified = sorted(p for p in new if p in old and new[p] != old[p])
    return added, removed, modified


def _fb_watcher_action_html(wid):
    """Render the shared 1-click 'open Togen & remove this watcher' button."""
    return (
        '<p style="margin:18px 0 0;text-align:center;">'
        '<a href="{}" style="display:inline-block;padding:10px 22px;'
        'background:#0d9488;color:#ffffff;font-size:14px;font-weight:600;'
        'text-decoration:none;border-radius:10px;">Open Togen &amp; remove '
        'this watcher</a></p>'
        '<p style="margin:8px 0 0;font-size:12px;color:#9ca3af;text-align:center;">'
        'You will be asked to confirm before the watcher is removed.</p>'
    ).format(_fb_watcher_remove_link(wid))


def _fb_watcher_expired_email(label, rel_path):
    """Render (subject, html) for a watcher that expired and was removed."""
    safe_label = (str(label).replace("&", "&amp;")
                  .replace("<", "&lt;").replace(">", "&gt;"))
    safe_path = (str(rel_path).replace("&", "&amp;")
                 .replace("<", "&lt;").replace(">", "&gt;"))
    subject = f"File Browser: watcher for {label} has expired"
    body_html = (
        '<p style="margin:0;font-size:15px;line-height:1.6;">Your File Browser '
        'folder watcher for <strong>{}</strong> has reached its termination '
        'date and has been removed. You will no longer receive change '
        'notifications for this folder.</p>'
        '<p style="margin:4px 0 0;font-size:13px;color:#6b7280;">{}</p>'
        '<p style="margin:18px 0 0;text-align:center;">'
        '<a href="{}" style="display:inline-block;padding:10px 22px;'
        'background:#0d9488;color:#ffffff;font-size:14px;font-weight:600;'
        'text-decoration:none;border-radius:10px;">Open Togen</a></p>'.format(
            safe_label, safe_path, _FB_APP_URL + "/?open_fb_notifications=1"))
    footer_html = (
        '<hr style="margin:8px 0 14px;border:none;border-top:1px solid #e5e7eb;">'
        '<p style="font-size:12px;color:#9ca3af;text-align:center;margin:0;'
        'line-height:1.6;">You are receiving this because you set up a File '
        'Browser folder watcher with an expiration in your Togen settings.</p>')
    html = notifications.render_email_shell(
        title=subject, body_html=body_html,
        subtitle="File Browser", footer_html=footer_html)
    return subject, html


def _fb_watcher_change_email(label, rel_path, added, removed, modified, wid=None):
    """Render (subject, html) summarizing folder changes for a watcher email."""
    def _section(heading, items):
        if not items:
            return ""
        rows = "".join(
            '<li style="margin:2px 0;color:#111827;font-size:13px;">{}</li>'.format(
                p.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))
            for p in items[:50])
        more = ("<li style=\"margin:2px 0;color:#6b7280;font-size:13px;\">…and "
                "{} more</li>".format(len(items) - 50)) if len(items) > 50 else ""
        return ('<p style="margin:14px 0 4px;font-size:14px;font-weight:600;'
                'color:#374151;">{}</p><ul style="margin:0 0 0 18px;padding:0;">'
                '{}{}</ul>').format(heading, rows, more)

    safe_label = (str(label).replace("&", "&amp;")
                  .replace("<", "&lt;").replace(">", "&gt;"))
    safe_path = (str(rel_path).replace("&", "&amp;")
                 .replace("<", "&lt;").replace(">", "&gt;"))
    subject = f"File Browser: changes in {label}"
    body_html = (
        '<p style="margin:0;font-size:15px;line-height:1.6;">The watched folder '
        '<strong>{}</strong> has changed.</p>'
        '<p style="margin:4px 0 0;font-size:13px;color:#6b7280;">{}</p>'
        '{}{}{}'.format(
            safe_label, safe_path,
            _section("Added", added),
            _section("Modified", modified),
            _section("Removed", removed))
    )
    if wid:
        body_html += _fb_watcher_action_html(wid)
    footer_html = (
        '<hr style="margin:8px 0 14px;border:none;border-top:1px solid #e5e7eb;">'
        '<p style="font-size:12px;color:#9ca3af;text-align:center;margin:0;'
        'line-height:1.6;">You are receiving this because you set up a File '
        'Browser folder watcher in your Togen settings.</p>')
    html = notifications.render_email_shell(
        title=subject, body_html=body_html,
        subtitle="File Browser", footer_html=footer_html)
    return subject, html


def _fb_run_watcher_cycle():
    """Scan every user's enabled watchers; email on folder changes; update state.

    Returns the number of change notifications sent this cycle.
    """
    state = _fb_load_watcher_state()
    live_ids = set()
    sent = 0
    try:
        files = sorted(USER_SETTINGS_DIR.glob("*.json"))
    except OSError:
        files = []

    for fp in files:
        try:
            settings = json.loads(fp.read_text())
        except (json.JSONDecodeError, OSError):
            continue
        if not isinstance(settings, dict):
            continue
        owner = settings.get("email") or fp.stem
        expired_ids = []
        for w in _fb_watchers_from_settings(settings):
            wid = w.get("id")
            rel = w.get("path")
            if not wid or not rel:
                continue
            # Expired watchers are removed and the owner is emailed once.
            if _fb_watcher_expired(w):
                if owner:
                    subject, html = _fb_watcher_expired_email(
                        w.get("label") or Path(rel).name, rel)
                    try:
                        notifications.send_html_email(owner, subject, html)
                        sent += 1
                    except Exception:
                        app.logger.exception(
                            "Folder watcher expiry email failed for %s", owner)
                expired_ids.append(wid)
                state.pop(wid, None)
                continue
            live_ids.add(wid)
            if not w.get("enabled", True):
                continue
            target = _fb_safe_path(rel)
            if target is None or not target.is_dir():
                continue
            new_snap = _fb_snapshot_folder(target)
            prior = state.get(wid) or {}
            old_snap = prior.get("snapshot")
            now_iso = _datetime_type.now().isoformat()
            if old_snap is None:
                # First time we see this watcher (e.g. seeded elsewhere): record
                # the baseline without emailing.
                state[wid] = {"owner": owner, "path": rel,
                              "snapshot": new_snap, "last_checked": now_iso}
                continue
            added, removed, modified = _fb_diff_snapshots(old_snap, new_snap)
            if (added or removed or modified) and owner:
                subject, html = _fb_watcher_change_email(
                    w.get("label") or Path(rel).name, rel, added, removed,
                    modified, wid=wid)
                try:
                    notifications.send_html_email(owner, subject, html)
                    sent += 1
                except Exception:
                    app.logger.exception("Folder watcher email failed for %s", owner)
            state[wid] = {"owner": owner, "path": rel,
                          "snapshot": new_snap, "last_checked": now_iso}

        # Persist removal of any expired watchers for this user.
        if expired_ids:
            settings["fb_watchers"] = [
                w for w in _fb_watchers_from_settings(settings)
                if w.get("id") not in expired_ids]
            try:
                fp.write_text(json.dumps(settings, indent=2))
            except OSError:
                app.logger.exception(
                    "Failed to persist expired watcher removal for %s", owner)

    # Drop snapshots for watchers that no longer exist.
    for stale in [k for k in state if k not in live_ids]:
        state.pop(stale, None)
    _fb_save_watcher_state(state)
    return sent


def _fb_watcher_loop():
    """Background thread: check folder watchers on a 15-minute rotation."""
    import time
    while True:
        time.sleep(FB_WATCHER_INTERVAL_MINUTES * 60)
        try:
            with app.app_context():
                n = _fb_run_watcher_cycle()
                if n:
                    app.logger.info("Folder watcher cycle sent %d notification(s)", n)
        except Exception:
            app.logger.exception("Folder watcher loop error")


_fb_watcher_thread = threading.Thread(target=_fb_watcher_loop, daemon=True)
_fb_watcher_thread.start()


# ---------------------------------------------------------------------------
# Tier-1 assistant tool callbacks (Phase B, DVI-560)
# ---------------------------------------------------------------------------
# These callbacks are registered into assistant.py's _tier1_callbacks registry
# at startup.  Each one receives (args: dict, user: dict) and returns a dict.
# They use Flask's test client to call the actual route, reusing all existing
# validation, session handling, and business logic without code duplication.
#
# Tier-2 routes (togen-admin writes, CSV-schema paths, wor-email, etc.) are
# intentionally absent — they cannot be reached through the agentic path.

def _t1_call_route(endpoint: str, json_body: dict, user: dict) -> dict:
    """Make an authenticated internal POST via Flask test client."""
    with app.test_client() as tc:
        with tc.session_transaction() as s:
            s["user"] = user
        rv = tc.post(endpoint, json=json_body)
        data = rv.get_json() or {}
        if rv.status_code >= 400:
            return {"error": data.get("error", f"Route returned HTTP {rv.status_code}")}
        return data


# ---------------------------------------------------------------------------
# Bot diagnostics REST API (DVI-824)
# Generic observability endpoints; all routes gated togen_admin_required.
# Bot-agnostic: <bot> is any identifier (e.g. "winston").
# ---------------------------------------------------------------------------

@app.route("/diagnostics/<bot>/runs")
@togen_admin_required
def diag_list_runs(bot: str):
    limit = min(int(request.args.get("limit", 50)), 500)
    status = request.args.get("status") or None
    q = request.args.get("q") or None
    return jsonify(runs=_diag_mod.list_runs(bot, limit=limit, status=status, q=q))


@app.route("/diagnostics/<bot>/runs/<run_id>")
@togen_admin_required
def diag_get_run(bot: str, run_id: str):
    run = _diag_mod.get_run(run_id)
    if not run or run.get("bot") != bot:
        return jsonify(error="Not found"), 404
    return jsonify(run=run)


@app.route("/diagnostics/<bot>/runs/<run_id>/logs")
@togen_admin_required
def diag_get_run_logs(bot: str, run_id: str):
    # Verify the run belongs to the requested bot.
    conn = _diag_mod._open_db()
    try:
        row = conn.execute(
            "SELECT bot FROM runs WHERE id=?", (run_id,)
        ).fetchone()
    finally:
        conn.close()
    if not row or row["bot"] != bot:
        return jsonify(error="Not found"), 404
    level = request.args.get("level") or None
    step_id = request.args.get("step") or None
    q = request.args.get("q") or None
    limit = min(int(request.args.get("limit", 2000)), 5000)
    logs = _diag_mod.get_run_logs(run_id, level=level, step_id=step_id, q=q, limit=limit)
    return jsonify(logs=logs)


@app.route("/diagnostics/<bot>/runs/<run_id>/artifacts/<artifact_id>")
@togen_admin_required
def diag_download_artifact(bot: str, run_id: str, artifact_id: str):
    art = _diag_mod.get_artifact(artifact_id)
    if not art or art.get("run_id") != run_id:
        return jsonify(error="Not found"), 404
    # Confirm run belongs to the requested bot.
    conn = _diag_mod._open_db()
    try:
        row = conn.execute("SELECT bot FROM runs WHERE id=?", (run_id,)).fetchone()
    finally:
        conn.close()
    if not row or row["bot"] != bot:
        return jsonify(error="Not found"), 404
    fpath = Path(art["path"])
    if not fpath.is_file():
        return jsonify(error="Artifact file not found on disk"), 404
    return send_file(
        str(fpath),
        mimetype=art.get("content_type", "application/octet-stream"),
        as_attachment=True,
        download_name=art["name"],
    )


@app.route("/diagnostics/<bot>/settings", methods=["GET", "POST"])
@togen_admin_required
def diag_settings(bot: str):
    if request.method == "POST":
        body = request.get_json(force=True, silent=True) or {}
        kwargs: dict = {}
        if "max_runs" in body:
            v = body["max_runs"]
            if not isinstance(v, int) or v < 1:
                return jsonify(error="max_runs must be a positive integer"), 400
            kwargs["max_runs"] = v
        if "max_log_lines" in body:
            v = body["max_log_lines"]
            if not isinstance(v, int) or v < 1:
                return jsonify(error="max_log_lines must be a positive integer"), 400
            kwargs["max_log_lines"] = v
        if "retention_days" in body:
            v = body["retention_days"]
            if v is not None and (not isinstance(v, int) or v < 1):
                return jsonify(error="retention_days must be a positive integer or null"), 400
            kwargs["retention_days"] = v
        updated = _diag_mod.set_settings(bot, **kwargs)
        return jsonify(ok=True, settings=updated)
    return jsonify(settings=_diag_mod.get_settings(bot))


def _register_tier1_callbacks() -> None:
    """Register Tier-1 tool executors into assistant.py's callback registry."""
    from assistant import register_tier1_callback

    def _cb_qr_submit(args: dict, user: dict) -> dict:
        return _t1_call_route("/submit", {"folder": args.get("folder", "")}, user)

    def _cb_duplicate_folder(args: dict, user: dict) -> dict:
        return _t1_call_route("/duplicate-folder", {
            "folder":     args.get("folder", ""),
            "new_folder": args.get("new_folder", ""),
        }, user)

    def _cb_prodrun_submit(args: dict, user: dict) -> dict:
        return _t1_call_route("/prodrun-submit", {"folder": args.get("folder", "")}, user)

    def _cb_scs_save_to_project(args: dict, user: dict) -> dict:
        return _t1_call_route("/scs-save-to-project", {
            "job_id":       args.get("job_id", ""),
            "folder_name":  args.get("folder_name", ""),
            "product_code": args.get("product_code", ""),
            "append_row":   bool(args.get("append_row", False)),
        }, user)

    def _cb_create_carrier(args: dict, user: dict) -> dict:
        """Generate a carrier sheet for an existing upload session (job_id).

        Files must already be present — the assistant cannot upload PDFs.
        We find the shop PDF in the job folder and re-trigger generation with
        any field overrides provided.
        """
        job_id = str(args.get("job_id", "")).strip()
        if not job_id:
            return {"error": "job_id is required."}
        if ".." in job_id:
            return {"error": "Invalid job_id."}
        job_folder = _safe_job_folder(job_id)
        if not job_folder.is_dir():
            return {
                "error": (
                    f"Job session '{job_id}' not found. "
                    "Upload files via the Create Carrier UI first, then use this tool with the returned job_id."
                )
            }
        shop_pdfs = [
            f for f in job_folder.glob("*.pdf") if "CarrierQR" not in f.name
        ]
        if not shop_pdfs:
            return {"error": "No shop drawing PDF found in this session. Upload via the UI first."}
        shop_path = shop_pdfs[0]
        # Field overrides (leave existing values if not supplied)
        fields = {f: "" for f in _CARRIER_FIELDS}
        for k in _CARRIER_FIELDS:
            v = args.get(k, "")
            if v:
                fields[k] = str(v).strip()
        t = threading.Thread(
            target=_run_create_carrier,
            args=(job_folder, shop_path, None, fields),
            daemon=True,
        )
        t.start()
        return {"job_id": job_id, "status": "started", "message": "Carrier sheet generation started."}

    register_tier1_callback("qr_submit",          _cb_qr_submit)
    register_tier1_callback("duplicate_folder",   _cb_duplicate_folder)
    register_tier1_callback("prodrun_submit",      _cb_prodrun_submit)
    register_tier1_callback("scs_save_to_project", _cb_scs_save_to_project)
    register_tier1_callback("create_carrier",      _cb_create_carrier)


_register_tier1_callbacks()


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    port = int(os.environ.get("PORT", 5001))
    app.run(host="0.0.0.0", port=port, debug=False)
