
    5jc                    _  U d Z ddlZddlZddlZddlZddlZddlZddlZddlZddl	Z	ddl
Z
ddlZddlZddlZddlZddlZddlZddlZddlZddlZddlZddlmZmZmZmZ ddlmZ ddlm Z m!Z!m"Z"m#Z# ddl$m%Z%m&Z&m'Z'm(Z(m)Z)m*Z*m+Z+m,Z, ddl-m.Z. ddl/m0Z0 dd	l1m2Z2 ddl3Z3ddl4Z5dd
l6m7Z8 ddl9Z:ddl;m<Z< ddl=m>Z? ddl@mAZB ddl$mCZCmDZDmEZE ej                  j                  d eH eeI      j                         j                  j                               	 ddlLmMZN 	 ddededededz  def
dZO	 ddlLmQZQ ddlSZSddlTZU e%eV      ZWdeWj                  d<   ddlYZZddl[m\Z] ddl=m^Z_ ej                  j                  dd      dk7  Zb ecej                  j                  dd             Zdej                  j                  d! eH eeI      j                         j                  d"z  d#z              Ze e_d$%      ZfdZgebr eZj                  d&      Zgegj                  eZj                         d'eg_k        	  eee      j                  j                  d(d()        e]eed*d+,      Zmemj                   eZj                  d-             egj                  em       eWj                  d/        ZveWj                  d0        ZxeWj                  d1        Zyej                  d2   eWj                  d3<   ej                  j                  d4d5      j                         Z{e{re{eWj                  d6<   ej                  d7   Z|ej                  d8   Z}ej                  d9   Z~e~d:dd;d<d=d>d?d@dAdBdCdDdEdFdFdFdGdCiZdHZdIe~ ZdJdKgZdLZ eej                  j                  dMdN            Z eej                  j                  dOdP            Z eej                  j                  dQdR            ZedSz  ZedTz  ZedUz  ZedVz  ZedWz  ZedTz  ZedUz  ZedVz  ZedXz  ZedTz  ZedUz  ZedVz  ZedYz  ZedTz  ZedUz  ZedVz  ZedZz  Zed[z  Zed\z  Zed]z  Zed^z  Zed_z  Z eeI      j                         j                  d`z  Z eeI      j                         j                  daz  Z eeI      j                         j                  dbz  ZdcZh ddZeZeZ eej                  j                  dedf            Z eej                  j                  dgdh            Z eej                  j                  didj            Z eej                  j                  dkdl            Zedmz  Z eej                  j                  dndo            Z eej                  j                  dpdq            ZdrZej                  j                  dsdt      Zej                  j                  dudv      Z eeI      j                         j                  dwz  Z eeI      j                         j                  dxz  Z eeI      j                         j                  dyz  ZdzZd{Zd|Zd}Zd~ZdefdZd Zd ZddZ eeI      j                         j                  dz  Z eeI      j                         j                  dz  ZdZdZdZdZdZdZdZ eeez         ZdZdZdZdZd}Zd|Zd|ZdZdefdZdefdZdefdZdefdZdeHfdZd Zd Zd Zd Zd ZאddZd Z eeI      j                         j                  dz  Z eeI      j                         j                  dz  ZdZdZdZdZdZdZdZdZdZdZdZdZdZdZdZddddddidddedddd(dd	d_ddedd_dd(dd	dddedddd'dd	dddedddd(dd	gdǜdddddddSddedddd(dd	dWddedddd(dd	dXddedddd(dd	dYddedddd(dd	dddedddd(dd	gdݜddddddddeddddddd'ddddddeddddddd'ddddddeddddddd'ddddddeddddddd'ddddddedd ddddd'ddddddd	edd d
dd"dd'ddddddedd ddddd'dddgddg dddd g ddgg ddddd'ddd ddd'ddddddZd Zd Zd Zd  Zd! Zd" Zd# Zd$ Zd% Zdd&Zd' Zd( Zd) Zd* Zd+ Zd, Zd- Zd. Zd/ Zdd0Zd1 Z d2 Zd3 Zd4 Zd5 Zd6 Zd7 Zd8 Zd9 Zd: Z	d; Z
 eeI      j                         j                  d<z  Z eeI      j                         j                  d=z  Z eeI      j                         j                  d>z  Z eeI      j                         j                  d?z  Z eeI      j                         j                  d@z  Z eeI      j                         j                  dAz  Z eeI      j                         j                  dBz  Z eeI      j                         j                  dCz  Z eeI      j                         j                  dDz  Z eeI      j                         j                  dEz  Z eeI      j                         j                  dFz  Z eeI      j                         j                  dGz  Z eeI      j                         j                  dHz  Z eeI      j                         j                  dIz  Z eeI      j                         j                  dJz  Z eej                  j                  dK eH eeI      j                         j                  dz                    Z eeI      j                         j                  dLz  Z eeI      j                         j                  dMz  Z eeI      j                         j                  dNz  Z eeI      j                         j                  dOz  Z eeI      j                         j                  dPz  ZdQZ  eeI      j                         j                  dRz  Z! eeI      j                         j                  dSz  Z" eeI      j                         j                  dTz  Z# eeI      j                         j                  dUz  Z$ eeI      j                         j                  dVz  Z% eej                  j                  dW      xs$  eeI      j                         j                  dXz        Z&ej                  j                  dY      xs dZj                         jO                         Z(ej                  j                  d[      xs   eH ejR                         d\z  d]z        Z*ej                  j                  d^      xs d_j                         Z+ eej                  j                  d`      xs$  eeI      j                         j                  daz        Z, e-ej                  j                  db      xs	  eHdc            Z. e-ej                  j                  dd      xs de      Z/ e-ej                  j                  df      xs dg      Z0 e-ej                  j                  dh      xs di      Z1 eej                  j                  dj      xs$  eeI      j                         j                  dkz        Z2ej                  j                  dl      xs dmZ3ej                  j                  dndo      Z4 e-ej                  j                  dpdq            Z5ej                  j                  drds      Z6ej                  j                  dtdv      xs dvj                         Z7ej                  j                  dudo      Z8 e-ej                  j                  dvdq            Z9ej                  j                  dwdx      Z:ej                  j                  dydz      Z;ej                  j                  d{d|      Z<ej                  j                  d}d~      Z= eeI      j                         j                  dz  Z> eeI      j                         j                  dz  Z? eeI      j                         j                  dz  Z@ ej                         ZB eeI      j                         j                  dz  ZC eeI      j                         j                  dz  ZD eeI      j                         j                  dz  ZE eeI      j                         j                  dz  ZFd}ZG eej                  j                  d      xs$  eeI      j                         j                  dkz        ZHej                  j                  d      xs dZI eeI      j                         j                  dz  ZJej                  j                  d      xs dZK eeI      j                         j                  dz  ZL eeI      j                         j                  dz  ZM eeI      j                         j                  dz  ZN eeI      j                         j                  dz  ZO eeI      j                         j                  dz  ZP eeI      j                         j                  dz  ZQ eeI      j                         j                  dz  ZR eeI      j                         j                  dz  ZS eeI      j                         j                  dz  ZT eeI      j                         j                  dz  ZUdxdddd+ddZVdZWdZX eeI      j                         j                  dz  ZYdddddddZZdZ[eXdz   Z\ eeI      j                         j                  dz  Z]ddddddZ^d*Z_dZ`d+ZadZbdZcd ZddZedZfd ZgdZhdZid ZjdddddddddgZkdZlg dZmh dZng dZoddddZpg dZq eeI      j                         j                  dz  Zr eeI      j                         j                  dz  Zs eeI      j                         j                  dz  Zt eeI      j                         j                  dz  Zu eeI      j                         j                  dz  Zv eeI      j                         j                  dz  Zw eeI      j                         j                  dz  Zx eeI      j                         j                  dz  Zy eeI      j                         j                  dz  ZzdĐdgg dƢg dǢg dȢdgg dʢdgd̜Z{e{j                         D  ch c]  } | D ]  }|  c}} Z} eeI      j                         j                  dz  Z~g d΢Zg dϢZ ee      g dТg dѢg dҢdӜZdԄ ZdՄ Zdք Zdׄ ZddgiZ eeI      j                         j                  dz  ZdgZdِdiZ eeI      j                         j                  dz  Z eeI      j                         j                  dz  Z eeI      j                         j                  dz  Z eeI      j                         j                  dz  Z eeI      j                         j                  dz  Z eeI      j                         j                  dz  ZdZ eeI      j                         j                  dz  Z eeI      j                         j                  dz  Z eeI      j                         j                  dz  ZdZdddddZdZg dZ eeI      j                         j                  dz  Z eeI      j                         j                  dz  ZdgZeD ch c]  }|d   	 c}Z eeI      j                         j                  dz  Z eeI      j                         j                  dz  Z eeI      j                         j                  dz  Z eeI      j                         j                  dz  ZdZdZd ZddZd Zd Zd Zd Zd Zd Zd ZdedefdZdeHdedefdZdeHdefd ZdeHdefdZdeHdefdZdeHdeHdefdZdeHfdZdZdZeSjl                  feSjn                  eSjp                  feSjr                  feSjt                  feSjv                  feSjx                  fdZdedefd	Zd
edefdZdeHdefdZdefdZdeddfdZdeHdefdZdefdZĐdeddfdZdefdZƐdedefdZǐdeHdefdZȐdedz  defdZɐdedz  defdZʐdedefdZːdeHdefdZdefdZ͐deddfdZΐdeHdefdZАdedefd Zdefd!ZҐd" ZӐdeddfd#ZԐdeHdefd$Z eeI      j                         j                  d%z  Zdefd&Zאddd'Zdefd(Zِdeddfd)Zڐdedefd*Zېdedefd+Zܐd,Zݐd-edefd.Zߐd/edefd0Zd/edefd1Zd/ed-edefd2Zd3Zdefd4Zd5eddfd6Zdd7eHd8eHdz  defd9Zdd:Zd;eHdefd<Zdefd=Zd
eddfd>Zd? Zd@ZdA ZdB ZdxZdCZdD ZdE ZdFZdG ZdH ZdIZdJ ZdK ZdL Zd'ddMg dNdOdvdPZdQ ZdR ZdS ZdT ZddUZdV Z dW ZdX ZdY ZdZ Zd[ Zd\ Zd7eHdeHfd]Zd^eHdeHfd_Zdefd`Z	daeddfdbZ
deHd^eHdceHdeddf
ddZdefdeZdeHdedz  fdfZdeeH   fdgZdeHdefdhZdefdiZdaeddfdjZdkeHdefdlZd^eHdkeHdceHddfdmZd^eHdceHddfdnZd^eHdceHddfdoZddpZh dqZd^eHdefdrZdsedeHdtededz  fduZd^eHdedz  fdvZddwZdx Zdy Zdz Z e	j>                  d{      Z ed|z  Z!d}edeHfd~Z"ddeHdedeeHef   fdZ#eSjn                  eSjr                  eSjp                  dZ$deHdeHd-edefdZ%de-fdZ&de-fdZ'defdZ(d5efdZ)ddeHde-deHdz  deHdz  fdZ*deHde-deHdefdZ+dZ,deHdeHfdZ-deHdeHfdZ.	 	 ddeHdeHdz  dededz  fdZ/h dZ0h dZ1	  e-ej                  j                  dd            Z2dedefdZ4d-edz  dee   fdZ5dededz  de-fdZ6dededdfdZ7ddede-dedz  fdZ8deHdedz  deHfdZ9ddkeHde-deHfdZ:deHdeHdedz  deHfdZ;dedefdZ<deHdefdZ=dededz  fdZ>dedefdZ?dedefdZ@dededdfdZAdedeeH   deeH   deeH   dee   f
dZBdeddfdZCdZDdZEdZFej                  j                  ej                  j                  ej                  j                  eI            d      ZJdZKd ZL eLeD      \  ZDZKd ZMd ZNd ZOd ZPd ZQd ZRdÄ ZSdĄ ZTdń ZUdƄ ZVddǄZWdȄ ZXeWj                  dɫ      eQdʄ               ZZd˄ Z[d̄ Z\d̈́ Z]d΄ Z^dτ Z_dЄ Z`dф ZaddeHdz  dedee   fd҄Zb	 	 ddeHdeHdz  dededz  fdӄZcdeHfdԄZddeHdeHfdՄZeeWj                  d֫      dׄ        ZfeWj                  dث      dل        ZgeWj                  dګ      dۄ        ZheWj                  dܫ      eNd݄               ZieWj                  dސdg      e[d               ZjeWj                  ddg      e[d               ZkeWj                  ddg      e[d               ZleWj                  ddg      e[d               ZmdZndedededefdZoeWj                  ddg      e[d               ZpeWj                  d      e[d               ZqeWj                  d      e[d               ZreWj                  d      e[d               ZseWj                  ddg      e[d               ZtdZu eĐdeuz         ZveWj                  ddg      e[d               ZweWj                  d      e[d               ZxeWj                  d      e[d               ZyeWj                  d      e[d                ZzeWj                  d      e[d               Z{eWj                  ddg      e[d               Z|eWj                  ddg      e[d               Z} eej                  j                  dd            Z~d	Zd
ZdZdZe~dz  ZdefdZdefdZdeddfdZdeHdedefdZe~dz  ZdddddddddZdefdZdeddfdZdeHfd Zd!edeHfd"Ze~d#z  Zd$d%d&d'Zdefd(Zd)eddfd*Zdefd+Zd,eHdeHfd-ZdeHdedz  fd.Zd/eHd0edae-dedz  fd1ZdeHdz  fd2ZdeHd0edae-dedz  fd3ZdeHddfd4ZdeHdeHdedz  fd5Zd6edefd7Zd6edefd8Zd6ededdfd9Zd6ededdfd:Zd;edee   fd<Zd=edee   fd>ZeWj                  d?dg      e\d@               ZeWj                  dAdg      e\dB               ZeWj                  dCdg      e\dD               ZeWj                  dE      e\dF               ZeWj                  dG      e\dH               ZeWj                  dI      e\dJ               ZeWj                  dK       eWdLhdM      dN               ZeWj                  dOdPg      e\dQ               ZeWj                  dOdg      eQdR               ZeWj                  dSdPg      e\dT               ZeWj                  dUdg      e\dV               ZeWj                  dWdg      eQdX               ZeWj                  dYdPg      e\dZ               ZeWj                  d[dg      eQd\               ZeWj                  d]dg      eQd^               ZeWj                  d]d_g      eQd`               ZddedSedz  defdaZddedSedz  defdbZdedefdcZddedXedz  defddZdeHd-edefdeZeWj                  dfdg      eNdg               ZeWj                  dhdg      eNdi               ZeWj                  djdg      eNdk               ZeWj                  dldg      eNdm               ZeWj                  dn      eNdo               ZeWj                  dp      eNdq               ZeWj                  dr      eNds               ZeWj                  dtdg      ePdu               ZeWj                  dv      eNdw               ZeWj                  dx      eNdy               ZeWj                  dz      eNd{               ZeWj                  d|      eNd}               ZeWj                  d~      eNd               ZdedefdZdefdZeWj                  d      eNd               ZeWj                  d      eNd               ZeWj                  d      eNd               ZeWj                  d      eNd               ZeWj                  d      eNd               ZeWj                  d      eNd               ZeWj                  d       eUdd_      d               ZːddeHdeHdz  defdZeWj                  d       eUdd_      d               ZeWj                  d      eNd               ZeWj                  d      eNd               ZeWj                  ddg       eUdd_      d               ZeWj                  ddg       eUdd_      d               ZeWj                  d      eNd               ZeWj                  ddg      eNd               ZeWj                  ddg      eNd;eHfd              ZeWj                  d      eNd;eHfd              ZeWj                  ddg      eNd               ZeWj                  ddg      eNd               ZeWj                  d      ePd               ZeWj                  d      eOd               ZeWj                  ddg      eOd               ZdefdZېdeddfdZܐdeHd7eHddfdZd'd'dZdefdZߐdeddfdZdZdZdg d'dg ddg dddZdefdZdefdZdeddfdZdedeHdefdÄZdedeHdefdĄZddńZ	 ddefdƄZdǄ ZddȄZdɄ ZddʄZ	 	 dd˄ZddeHdeHdz  fd̄Zddefd΄ZddτZdЄ ZdeHdeHdefdӄZeWj                  dԫ      eadՄ               ZeWj                  d֫      eadׄ               ZeWj                  dث      eadل               ZeWj                  dڐdg      eadۄ               ZeWj                  dܐdg      ead݄               ZeWj                  dސdg      ead߄               ZeWj                  ddg      ead               ZeWj                  d      ead               ZeWj                  d      ead               ZeWj                  ddg      ead               ZeWj                  d      ead               ZeWj                  ddg      ead               Z dZdedefdZeWj                  d      eNd               ZeWj                  ddg      eNd               ZeWj                  d      ead               ZeWj                  ddg      eOd               ZeWj                  d      eNd               ZeWj                  ddg      eNd               ZeWj                  ddg      eNd               Z	eWj                  d      eOd               Z
eWj                  ddg      eOd               ZeWj                  ddg      eOd               ZeWj                  ddg      eOd                ZeWj                  ddg      eOd               ZeWj                  ddg      eOd               ZeWj                  ddg      eOd               ZeWj                  ddg      eOd               ZdeeH   fd	Zg d
Zdededddeddf
dZeWj                  ddg      eNd               ZdedeeH   defdZeWj                  ddg      e[d               Zg dZeWj                  ddg      e[d               ZdeHdeHddfdZeWj                  ddg      e[d               ZeWj                  d      e[d               ZeWj                  d      e[d               Zed gz   ZeWj                  d!dg      e[d"               ZdeHddfd#Z d6ededdfd$Z!d6edefd%Z"dd&Z#ddd'd6ed(ed)ed*eHddf
d+Z$eWj                  d,dg      eNd-               Z%eWj                  d.      eNd/               Z&eWj                  d0      eNd1               Z'eWj                  d2      eNd3               Z(eWj                  d2dg      eQd4               Z)ddd'd6ed(ed)ed*eHddf
d5Z*eWj                  d6dg      e[d7               Z+eWj                  d8      eNd9               Z,eWj                  d:      eNd;               Z-eWj                  d<dg      e[d=               Z.eWj                  d>dg      eNd?               Z/eWj                  d@      eNdA               Z0eWj                  dB      eNdC               Z1eWj                  dD      eNdE               Z2eWj                  dF      eNdG               Z3eWj                  dHdPg      eNdI               Z4eWj                  dJdg      eNdK               Z5dedeHdz  dLedeHdz  fdMZ6deHdNee   dOedPeeH   dQeHdeHdz  fdRZ7d;edNee   dSedTeeH   dee   f
dUZ8dedVeHdWeHdXeddf
dYZ9eWj                  dZdg      e[d[               Z: ed\      Z;d]Z<d^Z=dZ>eWj                  d_      e[d`               Z?eWj                  dadg      e[db               Z@eWj                  dcdg      e[dd               ZAeWj                  de      e[df               ZBdgZC eeI      j                         j                  ZDddhdiZEdjZF ej                         ZGddkZHeWj                  dl      eQdm               ZIeWj                  dndg      eQdo               ZJeDdpz  ZKdqZL ej                         ZMdr ZNds ZOdt ZPeWj                  du      eQdv               ZQeWj                  dudg      eQdw               ZRdx ZSdy ZTeWj                  dz      eQd{               ZUeWj                  dzdg      eQd|               ZVeWj                  d}      eQd~               ZWeWj                  d}dg      eQd               ZXeWj                  d      eQd               ZYeWj                  ddg      eQd               ZZeWj                  d      eQd               Z[eWj                  ddg      eQd               Z\ddddddddZ]e]j                         D ci c]  \  }}|dk7  s|| c}}Z_dZ`deHfdZadeHfdZbdeHfdZcdeHfdZdeWj                  d      eNd               ZeeWj                  ddg      eNd               ZfdeHfdZgeWj                  d      eNd               ZheWj                  d      eQd               ZieWj                  ddg      eQd               ZjeWj                  d      eQd               ZkeWj                  ddg      eQd               ZleWj                  d      eQd               ZmeWj                  ddg      eQd               ZneWj                  d      eQd               ZoeWj                  ddg      eQd               ZpeWj                  d      eQd               ZqeWj                  ddg      eQd               ZreWj                  d      eNd               ZseWj                  ddg      eNd               ZteWj                  d      eNd               ZueWj                  ddg      eNd               ZveWj                  dd_g      eNd               ZweWj                  ddg      eNd               ZxeWj                  ddPg      eOd               ZyeWj                  ddg      eOd               ZzeWj                  ddPg      eOd               Z{eWj                  ddg      eOd               Z|eWj                  ddPg      eQd               Z}eWj                  ddg      eQd               Z~h dZdedeeHeHef   fdZdeHd-edz  deeH   fdZdeHdeeH   deHde-fdZdeHde-fdZded0edeސeeH   ef   fdZdedeHddfdĄZdŐedeHdeHfdǄZdeސe-eHf   fdȄZdŐedefdɄZdee   fdʄZdefd˄ZdeHfd̄ZeWj                  d͐dg      eQd΄               ZeWj                  dϐdg      eQdЄ               ZeWj                  dѐdg      eQd҄               ZeWj                  dӐdPg      eQdԄ               ZeWj                  dՐdg      eQdք               ZeWj                  dאdPg      eQd؄               ZeWj                  dِdPg      eQdڄ               ZeWj                  dېdPg      eOd܄               ZeWj                  dېdg      eOd݄               ZeWj                  dސdg      eNd߄               ZeWj                  ddg      eNd               ZeWj                  ddg      eNd               Zg dZd ZeWj                  ddPg      e[d               ZeWj                  ddg      e[d               Zd Zd ZeWj                  ddg      e[d               Zd Zd Zd ZeWj                  ddPg      e[d               Zdd~dZdZd Zd Zd Zd Zd ZeWj                  ddg      e[d               ZeWj                  ddg      e[d               ZdZ e	j>                  d      ZdkeHdeHfd ZdedeHddfdZdedefdZdefdZdeHfdZddefdZdefdZdeddfd	ZeWj                  d
dg      e[d               ZeWj                  ddg      e[d               ZeWj                  ddPg      e[d               ZeWj                  ddg      e[d               ZeWj                  ddg      e[d               ZdZdZdedeHfdZdeHdefdZdedeHfdZeWj                  ddg      e[d               ZeWj                  ddg      e[d               ZeWj                  ddPg      eNd               Z ed      ZeWj                  d       eNd!               ZeZŐdeHdedz  fd"ZeWj                  d#      e[d$               Zǐdedefd%ZȐd& Zɐd'edefd(Zʐd)Zːd*eHdeHfd+ZdeHfd,Z͐dedefd-Zdefd.Zϐdeddfd/ZeWj                  d0      e[d1               ZeWj                  d0dg      e[d2               ZeWj                  d3dg      e[d4               ZeWj                  d5d6g      e[d7               ZeWj                  d5d_g      e[d8               ZeWj                  d9dg      e[d:               ZeWj                  d;      e[d<               ZeWj                  d=dg      e[d>               ZeWj                  d?dg      e[d@               ZeWj                  dA      e[dB               ZڐdC ZeWj                  dD      eNdE               ZeWj                  dDdg      eQdF               ZeWj                  dGd6g      eQdH               ZeWj                  dGd_g      eQdI               ZߐdJ ZdK ZdL ZdMZdN ZdO ZeWj                  dPdPg      eNdQ               ZeWj                  dRdg      eNdS               ZeWj                  dTdg      eNdU               ZeWj                  dV      eNdW               ZeWj                  dVdg      eQdX               ZeWj                  dY      eNdZ               ZeWj                  dYdg      eQd[               ZeWj                  d\      eNd]               ZeWj                  d\dg      eQd^               ZeWj                  d_      eQd`               ZeWj                  dadg      eQdb               ZeWj                  dcdg      eQdd               ZeWj                  de      eQdf               ZeWj                  dgdg      eQdh               ZeWj                  di      eQdj               ZeWj                  dk      eQdl               Zdm ZeWj                  dn      eQdo               ZeWj                  dndg      eQdp               ZeWj                  dq      eQdr               ZeWj                  dqdg      eQds               ZeWj                  dt      eQdu               ZeWj                  dtdg      eQdv               ZeWj                  dw      dx        ZeWj                  dy      dz        ZeWj                  d{      d|        ZeWj                  d}      d~        Z eWj                  d      eQd               Zd Zd Zd Zddvdvdvdvdvdvdg g dvdvdd'dddddg dddZdefdZdefdZd ZdZ	d Z
d ZeWj                  d      eQd               ZeWj                  d      eNd               ZeWj                  ddg      eNd               ZeWj                  ddg      eQd               ZeWj                  d      eQd               ZeWj                  ddg      eQd               ZeWj                  d      eNd               ZeWj                  ddg      eNd               ZeWj                  d      eNd               ZdZeWj                  d      eQd               ZeWj                  d      eQd               ZdZdZdZddlmZ ej<                  Zi Z ee!d<   defdZ"defdZ#defdZ$defdZ%defdZ&ddededz  fdZ'eWj                  d      eQd               Z(eWj                  ddg      eQd               Z)defdZ*d Z+eWj                  d      eQd               Z,eWj                  ddg      eQd               Z-eWj                  ddg      eQd               Z.eWj                  d      eQd               Z/eWj                  ddg      eQdÄ               Z0eWj                  dĐd6g      eQdń               Z1eWj                  dĐd_g      eQdƄ               Z2eWj                  dǫ      eQdȄ               Z3eWj                  dǐdg      eQdɄ               Z4eWj                  dʐdg      eQd˄               Z5eWj                  dʐd_g      eQd̄               Z6eWj                  dͫ      eQd΄               Z7eWj                  dϐdg      eQdЄ               Z8eWj                  dѐdg      eQd҄               Z9deHfdӄZ:eWj                  dԫ      eQdՄ               Z;eWj                  d֫      eQdׄ               Z<dؐZ=dِZ>dڄ Z?dۄ Z@d܄ ZAd݄ ZBdd߄ZC e	j>                  d      ZDdefdZEddvdvdddZFd ZGd ZHd ZId ZJddddddddddddZKd ZLdZMd ZNdZOd ZPd ZQeWj                  d      eXd               ZRd ZSeWj                  d       eWdh      d               ZTeWj                  d        eWdh      d               ZU ej                         ZVdhaWdZXd ZYd ZZd Z[d Z\e[e\fZ]d Z^ddZ_eWj                  d       eWd	h      d
               Z`dddddZadddАddZbd͐ddӐdd3Zcd Zdd Zed ZfddZgdZheWj                  d      eXd               ZieWj                  d       eWdLhdM      d               ZjeWj                  d      eQd               ZkeWj                  d       eWdh      d                ZleWj                  d!       eWdh      d"               ZmeWj                  d#dPg      eQd$               ZneWj                  d#dg      eQd%               ZoeWj                  d&dPg      eQd'               ZpeWj                  d(dg      eQd)               Zqd*Zrd+Zsd,Ztd- Zud. Zvd/ Zwdd0Zxd1 ZyeWj                  d2dg      eQd3               ZzeWj                  d4dg      d5        Z{eWj                  d6dg      d7        Z|eWj                  d8dg      d9        Z}eWj                  d:      eQd;               Z~dd<ZeWj                  d=dg      eQd>               ZeWj                  d?dg      eQd@               ZeWj                  dAdg      eQdB               ZeWj                  dCd_g      eQdD               ZeWj                  dE      eQdF               ZeWj                  dEdg      eQdG               ZeWj                  dH      eQdI               ZeWj                  dJdg      eQdK               Z eej                  j                  dL      xs  e e	j                               dMz        ZeeeedNZh dOZdPZdQ ZdR ZdS ZdT ZdU ZdV ZdW ZeWj                  dX      eNdY               ZeWj                  dZdg      eNd[               ZeWj                  d\      eNd]               ZeWj                  d^      d_        ZeWj                  d`      da        ZeWj                  db      dc        ZeWj                  dd      eQde               ZeWj                  dddg      eQdf               ZeWj                  dg      eNdh               ZeWj                  di      eNdj               ZeWj                  dgdg      eNdk               ZeWj                  dl      eNdm               ZeWj                  dld6g      eNdn               ZeWj                  dodg      eNdp               ZeWj                  dqdg      eNdr               ZeWj                  ds       eWh dt      du               ZeWj                  dvdPg      eQdw               ZeWj                  dvdg      eQdx               ZeWj                  dvd_g      eQdy               ZeWj                  dzdPg      eQd{               ZeWj                  d|dPg      eQd}               ZeWj                  dzdg      eQd~               ZeWj                  dzd_g      eQd               ZeWj                  ddPg      eQd               ZeWj                  ddg      eQd               ZeWj                  dd_g      eQd               Z	 	 ddZeWj                  d       eWdĐdh      d               ZeWj                  d       eWdĐdh      d               ZdddddZeWj                  d       eWdh      d               ZeWj                  d       eWd	h      d               ZeWj                  ddg      eNd               ZeWj                  ddg      eNd               ZdZdZh dZd Zd Zd ZeWj                  d      eNd               ZeWj                  d      eNd               ZeWj                  d      eNd               ZeWj                  ddg      eNd               ZeWj                  ddg      eNd               ZeWj                  ddg      eNd               ZeWj                  d      eNd               Zd ZeWj                  d      eNd               ZeWj                  ddg      eNd               ZeWj                  d      e]d               ZeWj                  d      e]d               ZeWj                  ddg      e]d               ZeWj                  ddg      e]d               ZeWj                  d      e]d               ZeWj                  ddg      e]d               ZeWj                  d      e]d               ZeWj                  ddg      e]d               Z͐dZeWj                  d      e^d               Zϐd ZАdÄ ZѐdĄ ZeWj                  dū      e^dƄ               ZeWj                  dǫ      e^dȄ               ZddɐlՐm֐Z֐mאZאmؐZؐmِZِmڐZڐmېZېmܐZܐmݐZݐmސZސmߐZߐmZ ddlZ e	j                          ddlZddlZddlZ e	j                          ddlZ e	j                          	  e	j                   e۫              ddʄZ e        dːZeWj                  d̫      eNd̈́               ZeWj                  dΫ      eNdτ               ZeWj                  dЫ      eNdф               ZeWj                  dҐdg       eVdh      dԄ               ZeWj                  dի       eVdh      dׄ               ZeWj                  dؐdg       eVdh      dل               ZeWj                  dڐdg       eVdh      dۄ               Zd܄ ZeWj                  dݫ      eNdބ               ZeWj                  dݐdg       eVdh      d               ZeWj                  ddg       eVdh      d               ZeWj                  dd_g       eVdh      d               ZeWj                  d      eNd               ZeWj                  d      eNd               Zd ZeWj                  ddg       eVdh      d               ZeWj                  ddg       eVdh      d               ZeWj                  dd_g       eVdh      d               Zd Zd Z ddZd ZdvddZeWj                  d       eVdh      d               ZeWj                  d       eVdh      d               ZeWj                  d       eVdh      d               ZeWj                  ddg       eVdh      d               ZeWj                  ddg       eVdh      d               ZeWj                  ddg       eVdh      d               Z	eWj                  d       eVdh      d               Z
eWj                  ddg       eVdh      d                ZeWj                  dd_g       eVdh      d               ZeWj                  ddg       eVdh      d               ZeWj                  ddg       eVdh      d               ZeWj                  dd_g       eVdh      d               ZdZd	 Zd
 Zd Zd ZddZeWj                  d       eVdh      d               ZeWj                  d       eVdh      d               ZeWj                  d       eVdh      d               ZdZd ZeWj                  ddg      eNd               ZeWj                  d      eNd               Zh dZeWj                  ddg       eVdh      d               ZeWj                  ddg       eVdh      d               Zddl Z  e 
jB                          ddl"Z" e"
jF                          d Z$d  Z%eZ&d! Z'eWj                  d"       eVe&      d#               Z(eWj                  d$       eVdh      d%               Z)eWj                  d$dg       eVdh      d&               Z*eWj                  d'       eVdh      d(               Z+eWj                  d'dg       eVdh      d)               Z,eWj                  d'd_g       eVd*h      d+               Z-eWj                  d,       eVdh      d-               Z.eWj                  d.       eVdh      d/               Z/d0 Z0eWj                  d1       eVdh      d2               Z1eWj                  d3       eVdh      d4               Z2eWj                  d5       eVd*h      d6               Z3eWj                  d5dg       eVd*h      d7               Z4eWj                  d8d_g       eVd*h      d9               Z5eWj                  d:       eVd*h      d;               Z6eWj                  d:dg       eVd*h      d<               Z7eWj                  d=       eVd*h      d>               Z8eWj                  d=dg       eVd*h      d?               Z9eWj                  d@       eVd*h      dA               Z:eWj                  d@dg       eVd*h      dB               Z;eWj                  dC       eVd*h      dD               Z<eWj                  dCdg       eVd*h      dE               Z=eWj                  dFdg       eVd*h      dG               Z>eWj                  dHdg       eVd*h      dI               Z?eWj                  dHd_g       eVd*h      dJ               Z@eWj                  dK       eVd*h      dL               ZAeWj                  dKdg       eVd*h      dM               ZBeWj                  dNdg       eVd*h      dO               ZCeWj                  dPdg       eVd*h      dQ               ZDeWj                  dPd_g       eVd*h      dR               ZEeWj                  dS       eVd*h      dT               ZFeWj                  dSdg       eVd*h      dU               ZGeWj                  dVdg       eVd*h      dW               ZHeWj                  dXdg       eVd*h      dY               ZIeWj                  dZ       eVd[h      d\               ZJeWj                  dZdg       eVd[h      d]               ZKeWj                  d^       eVd[h      d_               ZLeWj                  d`       eVdh      da               ZMeWj                  d`dg       eVdh      db               ZNeWj                  dcdg       eVdh      dd               ZOde ZPeWj                  dfdg       eVdh      dg               ZQeWj                  dh       eVd*h      di               ZReWj                  dhdg       eVd*h      dj               ZSeWj                  dk       eVe&      dl               ZTeWj                  dkdg       eVe&      dm               ZU eej                  j                  dn eeI      j                         j                  doz              ZV eej                  j                  dp eeI      j                         j                  dqz              ZWdrZX e	j>                  ds      ZYdt ZZdu Z[dv Z\ eej                  j                  dw eeI      j                         j                  dxz              Z]dy Z^dz Z_d{ Z`d| Zad} Zbd~ ZcddZddddddddddZed ZfdZgegdfZhegfdZid Zjd ZkddZlddZmeWj                  d       eVd*h      d               ZneWj                  ddg       eVd*h      d               ZoeWj                  d       eVd*h      d               ZpeWj                  ddg       eVd*h      d               ZqeWj                  d       eVdh      d               ZreWj                  d       eVdd*h      d               ZseWj                  d       eVdh      d               ZteWj                  ddg       eVdh      d               ZueWj                  ddg       eVdd*h      d               ZveWj                  d       eVdd*h      d               ZweWj                  ddg       eVdh      d               ZxeWj                  ddg       eVdd*h      d               ZyeWj                  d       eVdd*h      d               ZzeWj                  ddg       eVdh      d               Z{d Z|d Z}d Z~d Zd ZeWj                  d      e^d               ZeWj                  d      e^d               ZeWj                  d      e^d               ZeWj                  d      e^d               ZeWj                  d      e^d               ZeWj                  d      e^d               Zdd}dZd Zd ZdZd Zd ZdÄ ZdĄ Zdń ZdefdƄZdedefdǄZdeHdefdȄZdedz  defdɄZdʄ Zdd˄Zd̄ Zd̈́ Zd΄ Zdτ ZdЄ Zdф Zd҄ Zdӄ ZdԄ ZdՄ Zdք Zdׄ Zd؄ Zdل ZeWj                  dګ      eNdۄ               ZeWj                  dܐdg      eQd݄               ZeWj                  dސdg      eQd߄               ZeWj                  ddg      eQd               ZeWj                  ddg      eQd               ZdZdefdZdeddfdZdedeHfdZ	 	 ddeHdeHdz  deHdz  fdZd Zd Zd ZdefdZeWj                  ddPg      eNd               ZeWj                  ddg      eNd               ZeWj                  ddg      eNd               ZeWj                  ddg      eNd               ZeWj                  dd_g      eNd               ZdefdZdeddfdZdedeHfdZdefdZ	 dd eHdeHdedefdZd Zd Zd ZeWj                  ddPg      eQd               ZeWj                  ddg      eQd	               ZeWj                  d
dg      eQd               ZeWj                  d
d_g      eQd               Zd Zd ZÐd ZĐd ZeWj                  d       eV eϐed               d               ZeWj                  d       eV eϐed               d               ZeWj                  ddg       eVdh      d               ZeWj                  ddg       eVdh      d               ZeWj                  dd_g       eVdh      d               ZeWj                  d       eV eϐed               d               ZeWj                  ddg       eVdh      d               ZeWj                  ddg       eVdh      d               ZeWj                  dd_g       eVdh      d                ZeWj                  d!       eV eϐed               d"               ZeWj                  d!dg       eVdh      d#               ZeWj                  d$dg       eVdh      d%               ZeWj                  d$d_g       eVdh      d&               ZeWj                  d'       eV eϐed               d(               ZeWj                  d'dg       eVd)h      d*               ZeWj                  d+dg       eVd)h      d,               ZeWj                  d+d_g       eVd)h      d-               Z֐d. Zאd/ Zؐd0 ZeWj                  d1       eV eϐed               d2               ZeWj                  d3       eV eϐed               d4               ZeWj                  d5       eV eϐed               d6               Zܐd7 ZeWj                  d1dg       eVd8h      d9               ZeWj                  d3dg       eVd8h      d:               ZeWj                  d;dg       eVd8h      d<               ZeWj                  d3d_g       eVd8h      d=               ZeWj                  d>       eV eϐed               d?               ZeWj                  d>dg       eVd8h      d@               ZeWj                  dAdg       eVd8h      dB               ZeWj                  dAd_g       eVd8h      dC               Zh dDZh dEZdefdFZdeHfdGZde-fdHZde-fdIZdJdKhZdL ZdefdMZdeHfdNZdeHfdOZdefdPZdeddfdQZd7eHdReHdefdSZdTZdUZdV ZdWedXeHdeHfdYZdZ Zh d[Zh d\Zdefd]Zdefd^Zd_eHdefd`ZdefdaZddededefdbZdc Z deHfddZdefdeZdfe-dge-defdhZdi ZdjeHfdkZdjeHfdlZddmZdefdnZdoedefdpZ	dvddqdrZ
defdsZdededefdtZdededefduZdZdvedeHfdwZdedefdxZdedefdyZdedzedefd{Zd|e-defd}Zd~ Zd Zd Zd Zd Zd Zd Zd Zd Zd Zd Zd Zd Z d Z!d Z"d Z#d Z$d Z%h dZ&h dZ'd$Z(h dZ)ddhZ*h dZ+h dZ,h dZ-dZ.dZ/dZ0drZ1djZ2i Z3i Z4defdZ5deddfdZ6 eeI      j                         j                  dz  Z7d Z8d Z9d Z:d Z;ddZ<defdZ=dededefdZ>defdZ?deHdeHfdZ@deHfdZAdefdZBd ZCd ZDd ZEddZFd ZGd ZHd ZId ZJddedefdZKd ZLd ZMdedefdZNdedefdZOdedefdZPdededzedefdZQdefdZRddddvdddSd͐ddWdАddXdӐddYddgdZSdefdÄZTdefdĄZUdededefdńZVdededefdƄZWdǄ ZXdededefdȄZYdeHfdʄZZdeHdeHdeHfd΄Z[dededefdτZ\dededefdЄZ]eWj                  dѐdPg      eNd҄               Z^dӐedefdԄZ_eWj                  dՐdPg      eQdք               Z`dׄ Zad؄ ZbeWj                  dՐdg      eQdل               ZceWj                  dڐdPg      eQdۄ               ZdeWj                  dܐdg      eQd݄               ZedӐedefdބZfeWj                  dߐdPg      eNd               ZgeWj                  dߐdg      eNd               ZheWj                  ddg      eNd               Zid ZjeWj                  ddg      eNd               ZkeWj                  dd_g      eNd               ZleWj                  dѐdg      eNd               ZmeWj                  ddg      eNd               ZneWj                  ddg      eNd               ZoeWj                  dd_g      eNd               ZpeWj                  ddg      eNd               Zqd$ZreWj                  ddg      eNd               ZseWj                  ddPg      eQd               ZteWj                  ddg      eQd               ZueWj                  ddg      eNd               ZveWj                  ddPg      eQd               ZweWj                  ddg      eQd               ZxeWj                  ddg      eNd               ZyeWj                  ddg      eNd               ZzeWj                  ddg      eN	d                Z{eWj                  	ddg      eN	d               Z|eWj                  	dd_g      eN	d               Z}eWj                  	ddg      eN	d               Z~	deHf	dZeWj                  	ddg      eN	d	               ZeWj                  	d
dg      eN	d               ZeWj                  	d      	d        Zdef	dZdedef	dZdededef	dZdededef	dZ	d Zdededef	dZeWj                  	d      eN	d               ZeWj                  	d      eN	d               ZeWj                  	ddg      eN	d               ZeWj                  	ddg      eQ	d               Z	d Z	d Z	d Z	d Z	d Z	d  Z	d! Z	d" Z	d# Z	d$ Z	d% Zeeeeeeeeee	d&
Z	d' ZeWj                  	d(      eN	d)               Z	d* ZeWj                  	d+      eN	d,               ZeWj                  	d-      eN	d.               Z	d/ Z	d0 Z	d1 Z	d2 ZeWj                  	d3      eN	d4               ZeWj                  	d5      eN	d6               Zdedz  f	d7ZdkeHdedz  f	d8Zdef	d9Zdeddf	d:ZdeHf	d;Z	d<e	d=eHf	d>Z	d?Zd~Zdef	d@Z	dAedeHf	dBZ	dAedef	dCZ	dDZ	dEZd$Zdef	dFZdef	dGZ	dHZ	dIZdPZd$Z	dJZdZ	dKZ	dLZdZ	dM Z	dN Zdde-f	dOZdef	dPZ	dQddddd	dRZ	dSe	dTedef	dUZdef	dVZÐdeddf	dWZdef	dXZŐdeHdef	dYZdv	dZdeHdef	d[Zǐ	d\ZȐ	d]Zɐ	d^dO	d_	d`	da	db	dc	ddZʐd7eHdef	deZː	df Zdef	dgZ͐	dSe	dTedef	dhZdef	diZϐdeddf	djZАdeHdef	dkZdef	dlZҐddeHdef	dmZӐ	dnZԐ	doZՐ	dpZdZא	dqZdef	drZِ	dseH	dteHf	duZڐd	dveHdef	dwZh 	dxZܐ	dAef	dyZݐ	dAef	dzZސ	dseHf	d{Zߐdde	dseH	d|eHdef	d}Z	dAededef	d~Z	deHdef	dZdef	dZdef	dZ	dAedef	dZ	d<e	dseHdeHf	dZ	dAededef	dZ	dAef	dZeWj                  	ddg      eQ	d               Z	dZ	dZ	dZdeHf	dZeWj                  	d      	d        ZdeHf	dZeWj                  	d      	d        ZeWj                  	d      	d        ZeWj                  	d      	d        ZeWj                  	ddg      	d        ZeWj                  	ddg      	d        ZeWj                  	d      eQ	d               ZeWj                  	ddg      eQ	d               ZeWj                  	ddg      eN	d               ZeWj                  	ddg      eQ	d               ZeWj                  	dd_g      eQ	d               ZeWj                  	dg 	d      eN	d               ZeWj                  	d      	d        ZeWj                  	ddPg      eN	d               ZeWj                  	ddg      eN	d               ZeWj                  	ddg      eN	d               ZeWj                  	dd_g      eN	d               ZeWj                  	ddg      eN	d               Z eWj                  	d      eN	d               Z	d ZeWj                  	ddg      eN	d               ZeWj                  	ddg      eN	d               Z	d Z	d Z	d Z	d ZeWj                  	ddg      eN	d               Z		d Z
	d Z	d Z	d ZeWj                  	d      	d        ZeWj                  	dë      	dĄ        Z	 d	dńZ	dƐZeWj                  	dǫ      	dȄ        ZeWj                  	dɫ      eN	dʄ               Z	d˄ ZeWj                  	d̫      eN	d̈́               ZeWj                  	dΫ      	dτ        ZeWj                  	dАdg      eR	dф               ZeWj                  	dҐdg      eR	dӄ               ZeWj                  	dԐdg      eR	dՄ               ZeWj                  	d֐dg      eR	dׄ               ZeWj                  	dؐdg      eR	dل               ZeWj                  	dڐdg      eR	dۄ               ZeWj                  	dܐdg      eR	d݄               ZeWj                  	dސdg      eR	d߄               ZeWj                  	ddg      eR	d               ZeWj                  	ddPg      eQ	d               Z eWj                  	ddg      eQ	d               Z!eWj                  	d      e^	d               Z"eWj                  	ddg      e^	d               Z#ej                  j                  	ddv      Z$dZ%eWj                  	ddg      	d        Z&ej                  j                  	ddv      Z'	dZ( eeI      j                         j                  	dz  Z)	dZ*	dd}iZ+	d Z,	d Z-	d Z.	d Z/	d Z0eWj                  	ddg      	d        Z1eWj                  	d      eQ	d               Z2eWj                  	d      eQ	d               Z3eWj                  	d      eQ	d               Z4eWj                  	d      eQ	d               Z5eWj                  	ddg      eQ	d               Z6eWj                  
d dg      eQ
d               Z7ddl8Z8 eeI      j                         j                  
dz  Z9dvZ:dvZ;
d
d
dZ<
dZ=d~d(
dZ>
dZ?dZ@dZAd}ZBdZCdZD
d	 ZE
d
 ZF
d ZG
d ZH
d ZId
dZJ
d ZK
d ZL ej                         ZMi ZNd
dZOd
dZPd
dZQde?f
dZR
dZS
dZT
dZU e	j>                  
d      ZV e	j>                  
de	j                        ZX e	j>                  
d      ZY e	j>                  
d      ZZ ej                         Z[i Z\
d Z]
d Z^
d Z_
d Z`d
d Za
d! Zbd
d"Zcde@f
d#Zde@f
d$Ze ej                         Zfi Zg
d% Zh
d& Zi
d' Zj
d(
d)
d*
d+
d,Zk
d- Zle@f
d.Zm
d/ Zn
d0 Zo
d1 Zpd
d2Zq ej                         Zrd'ddv
d3Zs
d4 Zt
d5 Zu
d6 Zv
d7 Zw e	j>                  
d8e	j                        Zy e	j>                  
d9      Zz
d: Z{
d; Z|
d< Z}
d= Z~
d> Zdrd	d?
d?Z
d@ Z
dA Z
dB Zd*Z
dCZ
dDZ
dEZdZ ej                         Zi Zi Zi d
dF	dL
dG	dJ
dHd+
dI
dJ
dK
dL
dM
dN
dO
dP
dQ
dR
dS
dT
dU
dV
dU
dW
dX
dY
dZ
d[
d\
d]
d^
d_
d^
d`
da
db
dc
dd
de
de
df
dg
dh
di
dj
dj
dkZ
dl Zd
dmZ
dn Z
do Z
dp Z
dq Z
dr Z
ds Z
dt Zd
duZ
dv ZeWj                  
dw      eQ
dx               ZeWj                  
dwdg      eQ
dy               ZeWj                  
dzdg      eQ
d{               ZeWj                  
d|dg      eQ
d}               ZeWj                  
d~      eQ
d               ZeWj                  
d      eN
d               ZeWj                  
ddg      eN
d               ZeWj                  
ddg      eN
d               ZeWj                  
d      eN
d               Z
dZ
dZ
d Z
d Z
d Z
d Z
d Z
d Z
dZ
d Z
d Z
d Z
dZeD ch c]  \  }}|	 c}}Z
d Z
d Z
dZ
dZ
d Z
d Z
d Z
d ZeWj                  
d       eW eϐe{
d               
d               ZeWj                  
d       eW eϐe{
d               
d               ZeWj                  
d       eW eϐe{
d               
d               ZddlZg 
dZeD ch c]  \  }}|	 c}}Z
d Z
d Z
d Z
d Zh 
dZ
d Z
d ZÐ
d ZĐ
d ZŐ
d ZeWj                  
ddPg      eN
d               ZeWj                  
ddPg      eN
d               ZeWj                  
ddg      eN
d               ZeWj                  
ddg      eN
d               ZeWj                  
dd_g      eN
d               Zː
d ZeWj                  
ddg      eN
d               ZeWj                  
ddg      eN
d               Zΐ
d Zϐ
d ZА
d Zѐ
d ZҐd
dZӐ
d ZԐd
dZՐd
dZeWj                  
ddg      eN
dÄ               ZeWj                  
dĐdPg      eN
dń               ZeWj                  
dƐdPg      eN
dǄ               ZeWj                  
dȐdPg      eN
dɄ               Zڐ
dʄ Zې
d˄ Zܐ
d̄ Zݐ
d̈́ Zސ
d΄ Zߐ
dτ ZddlZ eeI      j                         j                  
dz  Z
dѐZ
dҐZ
dӐZ
dԐZdd(
dZ
dCZdZd}Z
dՄ Z
dք Z
dׄ Zd
d؄Z
dل Z
dڄ Z ej                         Zi Zd
dۄZd
d܄ZdPdef
d݄Z ej                         Zi Z
dބ Z
d߄ Z
d Z
d Zd
dZ ej                         Zd'ddv
d3Z
d Z
d Z 
d Z
d ZeWj                  
d      eQ
d               ZeWj                  
ddg      eQ
d               ZeWj                  
ddg      eQ
d               ZeWj                  
ddg      eQ
d               ZeWj                  
d      eQ
d               ZeWj                  
d      eN
d               ZeWj                  
d      eN
d               Z	dZ

dZ
dZ ej                         Zi Z
d Z
d Z
d Z
d Z
d Z
d Z
d Z
d Z
d Z
d Zd  ZdPZd Zd Zd Zd ZddlZ eeI      j                         j                  dz  Z dZ!dd(
dZ"dZ#dZ$dZ%d}Z&
dZ'd$Z(d	 Z)d
 Z*ddZ+d Z,d Z-d Z.d Z/d Z0d Z1d Z2ddZ3 ej                         Z4i Z5ddZ6e'de$fdZ7ddZ8d Z9 ej                         Z:i Z;d Z<d Z=d Z>ddZ? ej                         Z@d'ddv
d3ZAd ZBd ZCd ZDd ZEeWj                  d       eQd!               ZFeWj                  d dg      eQd"               ZGeWj                  d#dg      eQd$               ZHeWj                  d%dg      eQd&               ZIeWj                  d'      eQd(               ZJeWj                  d)      eNd*               ZKeWj                  d+      eNd,               ZLddlMZM eeI      j                         j                  d-z  ZNd.ZOd/ZPePd0z   ZQd1ZRd2ZSd3ZTd4d(
dZUdZVdZWd}ZXd$ZYdZZd5 Z[d6 Z\dd7Z]d8 Z^d9 Z_d: Z`dd;Za ej                         Zbi Zcdd<ZddeVfd=Zed> ZfdeVeZfd?Zgd@ ZhdA Zi ej                         Zji ZkdB ZldC ZmdD ZnddEZo ej                         Zpd'ddv
d3ZqdF ZrdG ZsdH ZtdI ZuddJZveWj                  dK      eQdL               ZweWj                  dKdg      eQdM               ZxeWj                  dNdg      eQdO               ZyeWj                  dPdg      eQdQ               ZzeWj                  dR      eQdS               Z{eWj                  dT      eQdU               Z|eWj                  dVdg      eQdW               Z}eWj                  dX      eNdY               Z~eWj                  dZ      eNd[               Zd\Zd] Zd^ Zdd_ZeWj                  d`      e^da               ZeWj                  d`dg      e^db               ZeWj                  dcd6g      e^dd               ZeWj                  dcd_g      e^de               Zi Z ej                         Zdf ZeWj                  dgdg      e^dh               ZeWj                  di      e^dj               ZeWj                  dk      e^dl               ZeedmZdn Zdo ZeWj                  dpdPg      e^dq               ZeWj                  dpdg      e^dr               ZeWj                  dsdPg      e^dt               ZeWj                  dsdg      e^du               ZeWj                  dv      e_dw               ZeWj                  dxdg      e_dy               ZeWj                  dzdg      e_d{               Z eeI      j                         j                  d|z  Zd} Zd~ Zd Zd Zd Zd Zd ZddZd Zd Zd Zd Zd ZdeHdz  fdZ	d=eHdeHdeHdz  dee   dz  fdZeWj                  d      eNd               ZeWj                  d      eNd               Zi ZeeHeeHecf   f   e!d<   dZ	d=eHdeHdeHdz  fdZ	d=eHdeHdeHdee   dz  fdZeWj                  d      eNd               ZdZdZdZdސZdZd*Z eeI      j                         j                  dz  Z ej                         Zd Zd,Zd Zd+Zd Zd Zd ZddZd ZdddoddsddZd Zd ZddZÐddZ ej                         ZŐd ZƐd Zd'ddd}djg g ddddddZȐdZɐd Zʐd Zːd Z̐ddZ͐dhaΐdZϐddZ e	j>                  d      ZѐdZҐdZӐd ZԐd ZՐd Z G d d«      Z G dÄ dĐe׫      ZؐdddddŐdƜZ G dǄ dȫ      Z G dɄ dʫ      Z G d˄ d̐e׫      Zܐd̈́ Z eݫ       Zސdd΄ZߐddτZdЄ Z e-ej                  j                  dѫ      xs d      ZdhddҜZ ej                         Zd'adӄ ZdԄ ZdՄ Zdք Zdׄ Zd؄ ZeWj                  dِdg      eQdڄ               ZeWj                  dېdg      d܄        ZeWj                  dݐdߐd6g      dބ        ZeWj                  dߐdg      d        Z eh d      ZdZ e	j>                  de	j                        ZdddZd ZeWj                  ddg      eQd               ZdZeWj                  ddg      d        Z eh d      ZdZeWj                  ddg      d        ZeWj                  d      eQd               ZeWj                  d      eQd               ZeWj                  d      e`d               ZeWj                  ddg      e`d               Z ej                         	Z d 	Zd 	Zd 	Z ej                         	Zd	Zd 	Zd 	ZeWj                  ddPdg      eQd               	Zd}	Z	d~	Z
d+	Zd	Z	dJ	Zd	Zd	Zded|e-dee   fd	Zi 	ZeeHef   e!d<    ej                         	Zd'd deHdedefd	ZeWj                  ddg      e`d               	ZeWj                  d      e`d               	ZeWj                  ddg      e`d               	ZeWj                  ddg      d	        	Zd
 	ZeWj                  d      e`d               	ZeWj                  d      e`d               	ZeWj                  ddg      eQd               	ZeWj                  d      eQd               	ZeWj                  ddg      eQd               	ZeWj                  d      eQd               	ZeWj                  ddg      eQd               	ZeWj                  d      e`d               	Z eWj                  d      e`d               	Z!eWj                  d      e`d               	Z"d 	Z#eWj                  d      e`d               	Z$eWj                  d       e`d!               	Z%eWj                  d"      eQd#               	Z&eWj                  d$dg      eQd%               	Z'eWj                  d&dg      eQd'               	Z(eWj                  d(dg      eQd)               	Z)eWj                  d*d_g      eQd+               	Z*eWj                  d,      eQd-               	Z+eWj                  d.dg      eQd/               	Z,d0 	Z-eWj                  d1      d2        	Z.eWj                  d3      d4        	Z/eWj                  d5      d6        	Z0eWj                  d7      d8        	Z1eWj                  d      eQd9               	Z2d: 	Z3d; 	Z4eWj                  d<      e`d=               	Z5eWj                  d>dg      e`d?               	Z6eWj                  d@       eWdAh      dB               	Z7dC 	Z8dD 	Z9eWj                  dE       eWdAh      dF               	Z:ddG	Z;dH 	Z<eWj                  dIdg      eQdJ               	Z=dK 	Z>dv	a?dL 	Z@ej                  j                  dMd      dk(  	ZA	eAreWj                  j                  dN       neWj                  j                  dO       	eAr* ej                  	e@d(P      	ZD	eDj                          dQ 	ZF	eAr ej                  	eFd(P      	ZG	eGj                          	  eUj                  dR      	ZI	eIreWj                  j                  dS	eI        ej                         	ZL ej                  eBd(P      j                           ej                  eCd(P      j                           eUj                  dU        ej                  e_dVd(id(W      j                          dX 	ZN	eAr* ej                  	eNd(P      	ZO	eOj                          dY 	ZP	eAr* ej                  	ePd(P      	ZQ	eQj                          	eAr& ej                  evd(P      j                          	eAr& ej                  ed(P      j                          	eAr& ej                  eDd(P      j                          	eAr& ej                  etd(P      j                          	eAr& ej                  ed(P      j                          dZ 	ZR	eAr* ej                  	eRd(P      	ZS	eSj                          d[ 	ZTd\ 	ZUd] 	ZV	eAr* ej                  	eVd(P      	ZW	eWj                          d^d'd_d^d'd_d^d'd_d`	ZX ej                         	ZYda 	ZZdb 	Z[eWj                  dcdPdg      eQdd               	Z\eWj                  dedPg      eQdf               	Z]dg 	Z^dh 	Z_	eAr~ ej                  	e_d(P      	Z`	e`j                           ej                  ed(P      	Za	eaj                           ej                  ed(P      	Zb	ebj                          d	Zcdiedjefdk	Zddl 	Zedm 	Zfddn	Zgdo 	Zhdp 	Zi	eAr* ej                  	eid(P      	Zj	ejj                          dqeHdrededefds	ZkeWj                  dt       eWduh      dveHfdw              	ZleWj                  dx       eWduh      dveHdyeHfdz              	ZmeWj                  d{       eWduh      dveHdyeHfd|              	ZneWj                  d}       eWduh      dveHdyeHd~eHfd              	ZoeWj                  ddPdg      eQdveHfd              	Zpd	Zqd	Zr eeI      j                         j                  dz  dz  	Zs e	j>                  d      	Ztdefd	Zudeddfd	ZvdeHdeHdz  fd	ZwdŐedefd	ZxdeHdeHdz  fd	ZydeHddfd	ZzeWj                  d      eQd               	Z{eWj                  ddg      eQd               	Z|eWj                  ddg      eQd               	Z}dd	Z~ 	e~        eVdk(  r? e-ej                  j                  dd            	ZeWj                  d	ed'       yy# eP$ r ddlLmQZR 	 ddededededz  def
dZOY w xY w# eP$ r dZQY w xY w# eq$ r)ZrdZgeWj                  j                  d.er       Y dZr[rdZr[rww xY wc c}} w c c}w # e3$ r dZ2Y Cw xY wc c}}w # e$ r Y S"w xY wc c}}w c c}}w # e$ r! eWj                  j                  dT       Y w xY w(  u  
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)
    N)datedatetime	timedeltatimezone)Path)quoteunquoteurlparseurljoin)Flaskabortgjsonifymake_responserender_template_stringrequest	send_file)FPDF)load_workbook)secure_filename)Imagewraps)OrderedDict)contextmanager)redirectsessionurl_for)
merge_itemcarrier_path	shop_pathoutput_foldersubmittal_pathreturnc           
      8   t        t        t        |       t        |      t        |      |rt        |      nd t                           }|j	                         | j	                         k7  r/| j                  |j                                |j                          | S )N)carrier_sheet_pathshop_drawing_pathr"   r#   base_url)r   _qr_merge_itemstr_get_qr_base_urlresolvewrite_bytes
read_bytesunlink)r    r!   r"   r#   	generateds        /var/www/html/togen/app.py_merge_itemr2   ;   s    "<0!)nm,2@3~.d%'
 	 ,"6"6"88$$Y%9%9%;<    )build_carrier_pdfc                 8   t               }|j                  d       dt        |j                         }| dt        |j                         }|r| dt        |j                         nd}| j	                         }t        |||      }	| j                  |	       | S )N/ )r+   rstripr   namer.   _build_carrier_pdfr-   )
r    r!   r"   r#   	_base_url
folder_urlshop_urlsubmittal_urlcarrier_bytesresult_bytess
             r1   r2   r2   N   s    $&	!((-.am6H6H0I/JK
 \5#8"9:HV:,an.A.A(B'CD\^$//1)-Q  .r3   i  @MAX_CONTENT_LENGTH)RotatingFileHandler)dequeTOGEN_REQUEST_TELEMETRY10TOGEN_SLOW_REQUEST_MS1500TOGEN_SLOW_REQUEST_LOGlogszslow_requests.log   )maxlenztogen.slow_requestFTparentsexist_oki  P    )maxBytesbackupCountz%(asctime)s %(message)sz3Slow-request log unavailable (%s); using app.loggerc                  >    t        j                         t        _        y N)timeperf_counterr   
_req_start r3   r1   _telemetry_mark_startrY      s    ((*r3   c           	      X   t        t        dd       }|t        j                         |z
  dz  }|t        k\  r|ddt
        j                   dt
        j                   dt
        j                  xs d d| j                   	}t        j                  t        j                         t        |d	      t
        j                  t
        j                  t
        j                  | j                  d
       t        t        j                  |       | S t        j                   j#                  d|       | S )NrW   g     @@.0fzms  z -> ?z status=   )tsmsmethodpathendpointstatuszSLOW-REQUEST %s)getattrr   rU   rV   _SLOW_REQUEST_MSr   ra   rb   rc   status_code_recent_slow_requestsappendround_slow_request_loggerinfoapploggerwarning)responsestart
elapsed_mslines       r1   _telemetry_recordrt      s   <.++-5?J--!#&c'..)97<<. I!**1c2(8;O;O:PR  &,,99;eJ.B&~~w||!(!1!1X=Q=QS
 (3(--d3  JJ&&'8$?r3   c                     	 t         j                  j                  d      r| j                  dk(  rd| j                  d<   | S # t
        $ r Y | S w xY w)u   DVI-945: content-hashed /static assets are immutable, so cache them for a
    year. Filenames embed the content hash, so a JS change yields a new URL —
    no stale-cache risk. Flask's static handler already supplies ETag +
    conditional 304; this just extends the freshness lifetime.z/static/rK   z#public, max-age=31536000, immutableCache-Control)r   rb   
startswithrg   headers	Exception)rp   s    r1   _static_immutable_cacherz      sV    <<"":.83G3G33N0UH_- O  Os   =A 	AAFLASK_SECRET_KEY
SECRET_KEYTOGEN_SESSION_COOKIE_DOMAIN.icastinc.comSESSION_COOKIE_DOMAINAZURE_CLIENT_IDAZURE_CLIENT_SECRETAZURE_TENANT_IDiCastz$fed82ffb-eb77-41d4-9f50-59c80f1a942fz$f0e1aca6-fe4f-4f79-9a8b-3ae4bf644db7z$13a096d3-08d9-40e2-a6ba-affd2f181249z$75cb48a6-8d0d-48e5-a90e-75d5f65453b7z$5d5e2955-9b1b-4f16-8e8e-94426158c639z$8da8d55b-f4f7-4801-84e0-ea509eb5b381z$ea998389-b27d-45e4-b23e-966bb47cb772	wor_admintogen_admintakeoff_workflowprod_run	togen_mtstogen_scadatogen_vision)r9   
base_groupgroupsz$5040da8d-ce20-4443-a761-20600db23e5ezVoyage Technologyz$f5a4b007-b23a-4db0-9fc7-2e45a08d45b8)r   r   z/https://login.microsoftonline.com/organizationsz"https://login.microsoftonline.com/z	User.ReadzGroupMember.Read.Allz(https://togen.icastinc.com/auth/callbackQR_WATCH_DIRz/var/www/html/qrQR_SHARED_DIRz/var/www/html/sharedWOR_DIRz/var/www/html/wormaintenancein_progress	completed	cancelledmobilemachinesafety
preventivezrecurring.json_testzwor_test_counter.json_deletedzwor_deletion_log.jsonlmanualszpm_manuals_index.jsonzpm_manuals_text_index.jsonpm_manuals_thumbsi  >   .doc.jpg.pdf.png.docx.jpegSUPPORT_DIRz/var/www/html/support_requestsFEATURE_DIRz/var/www/html/feature_requestsPROJECTBUILDER_DIRz/var/www/html/projectbuilderSTOCK_PRODUCTS_DIRz/var/www/html/stockproductsdrawingsMTS_LABELS_DIRz/var/www/html/mtslabelsOCR_DIRz/var/www/html/ocr   QR_BASE_URLz http://fileshare.icastinc.com/qrAZURE_MAIL_FROMr7   ztool_visibility.jsonztool_order.jsonztool_separators.json)leftcenterright)noner   r   both)autor^      rP            <   
   c                 V    | dk(  xs# t        | t              xr | j                  d      S )z{True for a home-grid separator token (DVI-1337): the legacy 'separator'
    marker (DVI-524) or a unique 'sep_<hex>' token.	separatorsep_
isinstancer*   rw   tids    r1   _is_separator_tokenr   M  s)     +R*S#"6"Q3>>&;QRr3   c                    i }t        | t              s|S |xs g D ch c]  }t        |      s| }}| j                         D ]  \  }}||vst        |t              st	        |j                  dd            j                         dt         }|j                  d      }|t        vrd}|j                  d      }	|	t        vrd}	t        |j                  d            xr |	dk7  }
|j                  d	      }	 t        |      }|t        vrd
}|s|dk(  r|	dk(  r|d
k(  rd|i}|r||d<   |	dk7  r|	|d<   |
rd|d<   |d
k7  r||d	<   |||<   	 |S c c}w # t        t        f$ r Y bw xY w)a_  Sanitize a separator-properties map against the ids present in the owning
    order list (DVI-1337).

    - Drops props whose id is not a separator token present in `order_ids`.
    - Coerces `justify` and `collapse` to their enums (defaults 'center'/'none'),
      `collapsed` to bool, `columns` to auto|1..6 (default 'auto'), and caps
      `label` at 60 chars.
    - `collapse == 'none'` forces `collapsed` off (a non-collapsible bar can't be
      collapsed-by-default).
    - Drops entries with no meaningful properties so the store stays minimal (a
      bare separator simply renders unlabeled).
    labelr7   Njustifyr   collapser   	collapsedcolumnsr   T)r   dictr   itemsr*   getstrip_SEPARATOR_LABEL_MAX_SEPARATOR_JUSTIFY_SEPARATOR_COLLAPSEboolint	TypeError
ValueError_SEPARATOR_COLUMN_CHOICES)raw	order_idsouttallowedsidpropsr   r   r   r   r   entrys                r1   _sanitize_tool_separatorsr   S  s    Cc4 
$?FQ/B1/EqFGFiik "
UgZt%<EIIgr*+1134I5IJ))I&,,G99Z(..H;/0GX5G	 ))I&	'lG 33G'X-(f2Dv%G$"E'Nv (E*!%E+f&E)CE"F JI G$ :& 		s   EE9EE'&E'c                     i } t         j                         r:	 t        j                  t         j	                               }t        |t              r|} t        | t                     S # t        j                  t        f$ r Y /w xY w)zReturn the admin-default separator-properties map (DVI-1337), sanitized
    against the admin tool order so orphaned entries never surface.)TOOL_SEPARATORS_FILEis_filejsonloads	read_textr   r   JSONDecodeErrorOSErrorr   _load_tool_orderr   datas     r1   _load_tool_separatorsr     ss     C##%	::2<<>?D$% %S*:*<== $$g. 		   9A% %B Bc                 X    t         j                  t        j                  | d             y Nr   indent)r   
write_textr   dumpsmappings    r1   _save_tool_separatorsr         ##DJJwq$ABr3   ztool_items.json
tool_icons)webdtiledashref   P   i   )newtabembed)linkglobechartdocwrenchfactorycamerabook),geartoolboxhammerscrewdrivernut_boltthermometerwaterfireboltbatteryplug	lightbulbpackagetruckforklift	clipboardcalendarclock	stopwatchro   checkstopbellchart_up
chart_downlaptopdesktopphoneemailfolderbuildingconstructionsafety_vesthelmetmagnify	test_tubeflask
microscopemagnetpinkeylockrecycleruler   )defaultwidetallr   *c                 H    t        | t              xr | j                  d      S )zETrue for a website item token (DVI-1344): a unique 'web_<hex>' token.web_r   r   s    r1   _is_web_item_tokenr4    s    c3:CNN6$::r3   c                 H    t        | t              xr | j                  d      S )zBTrue for a dashboard-tile item token (DVI-1346 P2): 'dtile_<hex>'.dtile_r   r   s    r1   _is_dashboard_item_tokenr7    s    c3<CNN8$<<r3   c                 H    t        | t              xr | j                  d      S )zITrue for an embedded-dashboard item token (DVI-1347 P3): 'dashref_<hex>'.dashref_r   r   s    r1   _is_dashref_item_tokenr:    s    c3>CNN:$>>r3   c                 L    t        |       xs t        |       xs t        |       S )u   True for any home-grid item token (DVI-1344/1346/1347). Website tiles
    (web_), dashboard tiles (dtile_), and embedded dashboards (dashref_) — all
    ride behind the same token/props/render/sanitize seams.)r4  r7  r:  r   s    r1   _is_tool_item_tokenr<    s+     s# +'?'D +%c*,r3   c                 |   t        | xs d      j                         }|rt        |      t        kD  ry|j	                         }t        j                  d|      ryd|v r#|j                  d      s|j                  d      syd|z   }	 t        |      }|j                  dvs|j                  sy|S # t        $ r Y yw xY w)u4  Return a safe http(s) URL for a website tile, or '' when invalid.

    A tile opens this in a new tab and the favicon fetch dereferences it
    server-side, so anything but http/https (javascript:, data:, file:, …) is
    rejected. A scheme-less value is assumed https so an admin can type a bare
    host.r7   z3^(javascript|data|vbscript|file|about|blob|mailto):://http://https://)httphttps)r*   r   len_WEB_ITEM_URL_MAXlowerrematchrw   r
   r   schemenetloc)r   urllowparseds       r1   _sanitize_web_item_urlrM    s     ciR.


 C#c(..
))+C
 
xxFL|y)S^^J-G3# }}--V]]J	  s   B/ /	B;:B;c           
      L   t        | j                  dd            j                         dt         }t	        | j                  d            }|r|sy| j                  d      }|t
        vrd}| j                  d      }|dk(  rZt        j                  d	dt        | j                  dd            j                               dd
 }|t        v r	d|||d|dS d|||ddS t        j                  ddt        | j                  dd                  dd }|dk(  r|r	d|||d|dS d|||ddS )a  Validate one website tile's props (DVI-1344). Returns the normalized entry
    or None when it can't render (a web tile needs a name + a valid http(s) url).
    icon_mode is 'favicon' (default), 'custom', or 'glyph' (DVI-1355). A 'custom'
    entry keeps its icon id (a filename token in TOOL_ICONS_DIR); a 'glyph' entry
    keeps a curated glyph id from _WEB_ITEM_GLYPH_IDS (an arbitrary string is
    rejected). Either degrades to favicon when its id is missing/unknown so the
    tile always has an icon source.r9   r7   NrJ  moder   	icon_modeglyph
[^a-z0-9_](   r   )typer9   rJ  rO  rP  rQ  favicon)rT  r9   rJ  rO  rP  [^A-Za-z0-9_]icon@   custom)rT  r9   rJ  rO  rP  rW  )
r*   r   r   _WEB_ITEM_NAME_MAXrM  _WEB_ITEM_MODESrF  subrE  _WEB_ITEM_GLYPH_IDS)r   r9   rJ  	open_moderO  rQ  rW  s          r1   _sanitize_web_itemr_    sB    uyy$%++-.A/ABD
 5!1
2Cs		&!I'	99[!Dw}b#eii.D*E*K*K*MNsPRS''!4Y!(5: :tC&( 	(66"BEIIfb,A(BCCRHDxDtC%t5 	54Y"$ $r3   c                 l   t        | j                  dd            j                         }|dk(  s|t               vryt	        || j                  d      xs i       }| j                  d      }|t
        vrd}d|||d	}t        | j                  d
d            j                         dt         }|r||d
<   |S )u  Validate one dashboard-tile home item (DVI-1346 P2). It wraps a dashboards
    tile: {type:'dtile', tile_type, config, size, title?}. The tile_type must be a
    registered dashboard tile type (never 'group' — no nested panels on the home
    grid) and its config is validated through the shared _sanitize_tile_config.
    Returns None when the tile_type is unknown/unsupported. Home sizing is presets
    only (D4): default / wide / tall.	tile_typer7   groupNconfigsizer.  r   )rT  ra  rc  rd  title)r*   r   r   _dashboard_tile_types_sanitize_tile_config_DASHBOARD_ITEM_SIZES_DASHBOARD_ITEM_TITLE_MAX)r   ra  rc  rd  r   re  s         r1   _sanitize_dashboard_itemrj  0  s     EIIk2./557IGy0E0GG"9eii.A.GRHF99VD((9PTUE		'2&'--/0J1JKEgLr3   c           
      ,   t        j                  ddt        | j                  dd                  dd }|sy| j                  d      }|t        vrd}| j                  d      }|t
        vrd	}t        | j                  d
            xr |d	k7  }| j                  d      }|t        vrd}| j                  d      }|t        vrd}d||||d}t        | j                  dd            j                         dt         }|r||d<   |d	k7  r||d<   |rd|d
<   |S )u  Validate one embedded-dashboard home item (DVI-1347 P3). It references a
    whole Dashboard by id and renders as a full-width collapsible section:
    {type:'dashref', dashboard_id, title?, justify, collapse?, collapsed?,
     columns, rows}. The bar follows the Separator model (justify/collapse
     chevrons/collapsed-by-default); columns/rows shape the embedded grid.

    Returns None when there's no dashboard_id (nothing to embed). The referenced
    dashboard's existence + view-gating is resolved at render time (a stale ref
    simply redacts its own section), not here — the props map only carries the
    reference + display options.[^A-Za-z0-9_-]r7   dashboard_idNr   r   r   r   r   r   r   r   rowsr   )rT  rm  r   r   rn  re  T)rF  r\  r*   r   r   r   r   _DASHREF_COLUMN_CHOICES_DASHREF_ROW_CHOICESr   _DASHREF_TITLE_MAX)	r   dash_idr   r   r   r   rn  r   re  s	            r1   _sanitize_dashref_itemrs  E  s.    ff&C		."0M,NOPSQSTGii	"G((yy$H**UYY{+,CV1CIii	"G--99VD''GTCE		'2&'--/0C1CDEg6$j!kLr3   c                 R   i }t        | t              s|S |xs g D ch c]  }t        |      s| }}| j                         D ][  \  }}||vst        |t              st	        |      rt        |      }n"t        |      rt        |      }nt        |      }|W|||<   ] |S c c}w )a  Sanitize an item-properties map against the ids present in the owning
    order list (DVI-1344/1346/1347), mirroring _sanitize_tool_separators.

    - Drops props whose id is not an item token present in `order_ids`.
    - Type is inferred from the token (web_ / dtile_ / dashref_) so an id can
      never masquerade as another item type; each type has its own validator.
    - An entry that can't render (validator returns None) is dropped.
    )	r   r   r<  r   r:  rs  r7  rj  r_  )r   r   r   r   r   iidr   r   s           r1   _sanitize_tool_itemsrv  l  s     Cc4 
$?FQ/B1/EqFGFiik 

UgZt%<!#&*51E%c*,U3E&u-ECH
 J Gs
   B$B$c                     i } t         j                         r:	 t        j                  t         j	                               }t        |t              r|} t        | t                     S # t        j                  t        f$ r Y /w xY w)zReturn the admin-default item-properties map (DVI-1344), sanitized against
    the admin tool order so orphaned entries never surface.)TOOL_ITEMS_FILEr   r   r   r   r   r   r   r   rv  r   r   s     r1   _load_tool_itemsry    sr     C 	::o779:D$%  %5%788 $$g. 		r   c                 X    t         j                  t        j                  | d             y r   )rx  r   r   r   r   s    r1   _save_tool_itemsr{    s    tzz'!<=r3   c                     | rt        |       ni }|j                  d      xs
 t               }t        t	                     }|j                  d      }t        |t              r|j                  |       t        ||      S )a7  Resolve the item-properties map that renders for `email` on the home grid
    (DVI-1344/1346): admin defaults overlaid by the user's own overrides (D3),
    sanitized against the user's effective tool order so orphans are dropped.
    Mirrors the client-side _webItemProps / _dtileItemProps override resolution.
tool_order
tool_items)_load_user_settingsr   r   r   ry  r   updaterv  )r  settingsordermerged
user_itemss        r1   _effective_tool_itemsr    si    
 .3"5)HLL&<*:*<E"$%Fl+J*d#j!..r3   ztool_subpages.jsonztile_surface_subtools.jsona  <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 stroke-linecap="round" stroke-linejoin="round" d="M21 7.5 12 3 3 7.5m18 0-9 4.5m9-4.5v9L12 21m0-9L3 7.5m9 4.5v9M3 7.5v9L12 21"/></svg>a  <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"/></svg>ax  <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 stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26"/></svg>a  <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 stroke-linecap="round" stroke-linejoin="round" d="M9 6.75V15m6-6v8.25m.503 3.498 4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 0 0-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0Z"/></svg>a%  <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>a@  <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>z<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>z<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>aR  <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>z<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="3" width="16" height="18" rx="2"/><path d="M8 8h8"/><path d="M8 12h8"/><path d="M8 16h5"/></svg>a  <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="17" rx="2"/><path d="M3 9h18"/><path d="M8 2v4"/><path d="M16 2v4"/><path d="M8 14l2.5 2.5L15 12"/></svg>z<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 3v11"/><path d="M8 10l4 4 4-4"/><path d="M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg>z<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="3" width="16" height="18" rx="2"/><path d="M8 17V11"/><path d="M12 17V7"/><path d="M16 17v-4"/></svg>a(  <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>a  <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 3H6a2 2 0 00-2 2v14a2 2 0 002 2h12a2 2 0 002-2V9z"/><path d="M14 3v6h6"/><path d="M9 13h6"/><path d="M9 17h4"/></svg>	equipment	Equipmentequipment-manualszem-tiles-gridactivatezemSwitchTab('settings')assetszAssets Inventoryz:Asset registry, models, sub-assemblies, parts & inventory.zemSwitchTab('assets'))	idre  descriptionrW  r  tabaccess_leafopen_defaultextra_accessManualsz6Browse and search equipment repair & operator manuals.zemSwitchTab('manuals')equipment.manualsrpmzRepair Parts Managerz'Parts requests, approvals and tracking.zemSwitchTab('rpm')equipment.rpm
_rpmAccess	oversight	Oversightz*View equipment on the facility floor plan.zemSwitchTab('oversight')equipment.oversight)r  r   tool_id	containerr  catalogwork_order_requestzWork Order Request
work-orderzwor-tiles-list	wor_typesMaintenancez)Facility & equipment maintenance requestszworSelectType('maintenance')Mobile Machinez&Vehicle & mobile equipment work orderszworSelectType('mobilemachine')Safetyz Safety concerns & hazard reportszworSelectType('safety')Preventative MaintenancezScheduled PM tasks & partszworSelectType('preventive')
ithelpdeskzIT Help Deskz'IT support tickets to Voyage TechnologyzitOpenHelpDesk())r  r   r  r  r  visibility_sourcer  qualityQCzqs-tiles-gridbatcheszBatch Ticketsz=Capture daily concrete batch tickets against mixture recipes.qcBatch Productionzqc-tab-batcheszqcSwitchTab('batches')batch_production)r  re  r  rW  target_tooltarget_titlepane_idr  r  r  r  r  rb  dailyzDaily Productionz/Record the day's per-segment production totals.zqc-tab-dailyzqcSwitchTab('daily')	_qc_dailyimportImportz+Import PLC/SCADA batch logs (Pipe Mix Log).zqc-tab-importzqcSwitchTab('import')	qc.importreportsReportsz/Daily + date-range material consumption totals.zqc-tab-reportszqcSwitchTab('reports')
qc.reportskiln_statuszKiln Statusz-Live kiln temperature sensors and monitoring.
scada-viewKiln Metricszscada-panel-statuszscadaSwitchTab('status')rd   	_km_scadakiln_metrics)r  re  r  rW  r  r  r  r  r  r  r  r  render_hookrb  kiln_reportingzKiln Reportingz#View and generate kiln log reports.zscada-panel-logszscadaSwitchTab('logs')kiln_oversightzKiln Oversightz-View kiln sensors on the facility floor plan.zscada-panel-oversightzscadaSwitchTab('oversight'))r  r  r  r  r  r   tiles)r  r  r  )	sep_qcbpr  r  r  r  sep_qckmr  r  r  r   r   r   )r   r   r   r   r   )r  r  )r  
separators)r  r   r  r  r  r  r   default_layout)r  r  r  c                 R    | j                  d       xr | j                  d      dk7  S )us  Whether a surface's catalog visibility/access is managed by the sub-tool
    stores (tile_surface_subtools + Users->Access). False for an external-catalog
    surface (no catalog in the layout) and for a WO-type surface (DVI-1503:
    visibility lives in the WO-type stores — wor_type_visibility + per-user hide +
    the preventive role — NOT tile_surface_subtools).external_catalogr  r  r   )surfaces    r1   _surface_uses_subtool_storer    s2     .// @/0K?Ar3   c                 ,    t         j                  |       S rT   )_TILE_SURFACESr   )
surface_ids    r1   _tile_surfacer    s    j))r3   c                 V    t        |       }|sg S |d   D cg c]  }|d   	 c}S c c}w Nr  r  r  r  r  cs      r1   _surface_catalog_idsr    s1    J'G	$Y/0AdG000s   &c                 X    t        |       }|r|j                  d      rg S t        |       S )u^  Catalog ids that participate in a surface's editable LAYOUT order. Empty for
    an external-catalog surface (DVI-1503): its catalog (e.g. the WOR types) is
    ordered + hidden entirely in its own stores, so catalog tokens never enter
    tool_subpages — the surface layout holds only added items (D-WOR2-B without a
    second order/hide system).r  )r  r   r  )r  r  s     r1   _surface_layout_catalog_idsr    s-     J'Ggkk"45	
++r3   c                 2   t        |       }|r'|j                  d      dk(  rt        t                     S |rNt	        |j                  d      t
              r/|d   j                  d      }t	        |t              rt        |      S t        t        |             S )a  Catalog-seeded default order (D8-A): a surface with no stored admin default
    seeds its order from its catalog, so Equipment works with zero admin setup. An
    external-catalog surface seeds an EMPTY order (added items only). A WO-type
    surface (DVI-1503 board follow-up) seeds from the WO-type order store so the
    deploy-day landing order matches the admin's current wor_type_order; the surface
    layout owns the order thereafter.r  r  r  r  )r  r   list_load_wor_type_orderr   r   r  )r  r  r  s      r1   _surface_default_orderr    s     J'G7;;23{B(*++ :gkk*:;TB()--g6eT";+J788r3   c                    t        |       }|rpt        |j                  d      t              rQ|d   j                  d      }t        |t              r-|j	                         D ci c]  \  }}|t        |       c}}S i S c c}}w )zSeeded separator props for a surface's default_layout (D6). Empty for a
    surface with no default_layout (equipment / work_order_request), so their
    seeding is unchanged.r  r  )r  r   r   r   r   )r  r  sepskvs        r1   _surface_default_separatorsr    sq     J'G:gkk*:;TB'(,,\:dD!+/::<841aAtAwJ88I 9s   $B c                    t        t        |             }t        |t              sg S t               g }}dx}x}x}}|D ]  }	|	|v rt	        |	      r|t
        k\  r|dz  }nVt        |	      r|t        k\  r8|dz  }n;t        |	      r|t        k\  rS|dz  }n t        |	      r|t        k\  rn|dz  }n|	|vry|j                  |	       |j                  |	        |S )u  Validate a surface's order token list. Catalog tokens are validated against
    THIS surface's catalog (a top-level tool id / a foreign catalog token is
    dropped — cross-surface rejection, D9); sep_/web_/dtile_/dashref_ tokens ride
    the same caps as the home order.r   r^   )setr  r   r  r   _MAX_SEPARATORS_PER_LISTr4  _MAX_WEB_ITEMS_PER_LISTr7  _MAX_DASHBOARD_ITEMS_PER_LISTr:  _MAX_DASHREF_ITEMS_PER_LISTaddri   )
r  r   cat_idsseenr   	sep_count	web_countdtile_countdashref_countr   s
             r1   _sanitize_subpage_orderr    s    
 -j9:Gc4 	r#D:;;I;	;K- $;s#44NI$33NI%c*;;1K#C( ;;QM

3-. Jr3   c                     t        |t              r|ni }t        | |j                  d            }t	        |j                  d      xs i |      }t        |j                  d      xs i |      }|||dS )zSanitize one surface's {order, separators, items}. The separator/item maps
    reuse the home validators, scoped to the sanitized order.r  r  r   r  r  r   )r   r   r  r   r   rv  )r  r   r  r  r   s        r1   _sanitize_subpage_layoutr  ?  sj     C&#BC#J0@AE$SWW\%:%@b%HD !1!7R?E$??r3   c                  j   i } t         j                         r:	 t        j                  t         j	                               }t        |t              r|} i }| j                         D ].  \  }}|t        v st        |t              s t        ||      ||<   0 |S # t        j                  t        f$ r Y `w xY w)zReturn the admin-default per-surface layout map (DVI-1496 P1), sanitized +
    self-healing (drop-unknown surface) on load like every sibling store.)TOOL_SUBPAGES_FILEr   r   r   r   r   r   r   r   r   r  r  )r   r   r   r   layouts        r1   _load_tool_subpagesr  I  s     C!!#	::0::<=D$% Cyy{ =V. Z%=/V<CH= J $$g. 		s   9B B21B2c                 X    t         j                  t        j                  | d             y r   )r  r   r   r   r   s    r1   _save_tool_subpagesr  [      !!$**WQ"?@r3   c                    t               j                  |       xs i }|j                  d      xs t        |       }|rt        |      ni }i }|j                  d      }t	        |t
              r$t	        |j                  |       t
              r||    }|j                  d      }t	        |t              r|r|n|}t        |j                  d      xs t        |             }	t	        |j                  d      t
              r|	j                  |d          t        |j                  d      xs i       }
t	        |j                  d      t
              r|
j                  |d          t        | ||	|
d      S )a  Resolve the layout that renders for `email` on a surface's tiles page: the
    admin default (or the catalog seed when none is stored, D8-A) overlaid by the
    user's own overrides (D3 model, same as the home grid), sanitized against the
    resolved order + this surface's catalog.r  tool_subpagesr  r   r  )
r  r   r  r  r   r   r  r  r  r  )r  r  adminadmin_orderr  user_suball_user
user_orderr  r  r   s              r1   _effective_subpager  _  s2   
  !%%j17RE))G$J(>z(JK-2"5)HH||O,H(D!jj1I4&PJ'g&J$Z6:J;E 		,'R+Fz+RSD(,,|,d3H\*+7#)r*E(,,w'.Xg&'#J%t^c0deer3   c                     t               j                  |       xs i }|j                  d      xs t        |       }t        | ||j                  d      xs t	        |       |j                  d      xs i d      S )zThe surface's ADMIN-DEFAULT layout (DVI-1496 P4): the stored admin default,
    or the catalog seed (D8-A) when none is stored. Sanitized. This is what the
    Default-scope editor edits and what a user with no override inherits.r  r  r   r  )r  r   r  r  r  )r  r  r  s      r1   _admin_subpage_layoutr  x  st      !%%j17REIIgD"8"DE#Jii-X1LZ1X7#)r1  r3   c                 d   |rt        |      ni }|j                  d      }i }t        |t              r$t        |j                  |       t              r||    }|j                  d      }t        |t              r|ng }t        | ||j                  d      xs i |j                  d      xs i d      S )zThe surface's RAW per-user override (DVI-1496 P4), sanitized. `order` is
    empty when the user has no custom order (so the client knows to inherit the
    admin default in the My-list scope, mirroring the home tool_order model).r  r  r  r   r  )r  r   r   r   r  r  )r  r  r  r  r  r  s         r1   _user_subpage_layoutr    s     .3"5)H||O,HH(D!jj1I4&PJ'LL!Et,E"E#Jll<06Bg&,"1  r3   c                    t        |       }|sg S |d   D cg c]  }|d   |d   |d   |d   |d   |j                  dd      |j                  d	      |j                  d
      |j                  d      |j                  d      |j                  d      |j                  d      |j                  d      |j                  d      d c}S c c}w )zCatalog metadata handed to the SPA so it can render tiles without a second
    fetch. P1 exposes the display + navigation fields; the P2 access fields
    (access_leaf/open_default) ride along for the client's forward use.r  r  re  r  rW  r  r  Tr  r  r  r  r  r  r  rb  )r  re  r  rW  r  r  r  r  r  r  r  r  r  rb  )r  r   r  s      r1   _surface_catalog_publicr    s     J'G	  Y')  T7QwZ-@PvYqxUU>48EE-0UU>2 EE-0UU>2i(z* EE-0eeGn& ) ) )s   B/C	c           
      "   t        |       }|xs i j                  d      }t        |t              sg S |D cg c]M  }t        |t              r; |j                  d      r)|d   |d   t         |j                  d      xs g       dO c}S c c}w )u   Catalog group metadata (DVI-1522 Amendment 1) — [{id, label, tiles:[ids]}].
    Empty for a surface with no groups (equipment / work_order_request).r   r  r   r  r  )r  r   r   r  r   )r  r  r   r   s       r1   _surface_groups_publicr    s     J'Gm  *Ffd#	Dz!T2uquuT{ T7QwZ$uquuW~?SQS:TU D D Ds   ABc                      t         j                         D  ci c]Q  \  } }| |d   |d   |d   |d   |j                  d      xs i j                  dd      t        |       t	        |       dS c}} S c c}} w )	zDThe whole surface registry as a JS mirror for the SPA (DVI-1496 P1).r  r   r  r  r  r  r7   )r  r   r  r  settings_activater  r   )r  r   r   r  r  )r   ss     r1   _tile_surfaces_publicr	    s     )..02
 Q $!G*9 ~()j(9(?R'D'DZQS'T4S9237	9 9 2 2 2s   AA2c                 L    t        |       }|sy |d   D ]  }|d   |k(  s|c S  y r  r  )r  
subtool_idr  r  s       r1   _surface_catalog_entryr    s<    J'GY T7j H r3   c                      t         j                         D  ci c](  \  } }t        |      r| |d   D ci c]  }|d   d
 c}* }}} }d|v rd|d   v rt               |d   d<   |S c c}w c c}}} w )u  Admin-default visibility for every surface's catalog (N3-A —
    Assets/Manuals/Oversight default enabled). DVI-1496 P3 (§3.6): the Equipment
    RPM sub-tool seeds its default from the retired ``repair-parts-manager``
    Tools-Enabled value so the effective state is unchanged on deploy day (the
    standalone tile defaulted disabled). An explicit rpm entry in
    tile_surface_subtools.json (an admin toggling it post-deploy) overrides this.r  r  Tr  r  )r  r   r  _rpm_legacy_tile_enabled)r   r  r  r   s       r1   _subtool_default_visibilityr    s     (--/S SsA3Nq3Q )514$55 SC Sces;'77":"<KJ	 6 Ss   A,A'A,'A,c                     t               } t        j                         r	 t        j                  t        j                               }t        |t              rQ|j                         D ]>  \  }}|| v st        |t              s| |   D ]  }||v st        ||         | |   |<    @ | S # t        j                  t        f$ r i }Y w xY w)zAdmin-default {surface_id: {subtool_id: bool}}, self-healing / drop-unknown
    on load like wor_type_visibility (missing surface/subtool defaults enabled).)r  TILE_SUBTOOLS_FILEr   r   r   r   r   r   r   r   r   r   )r   savedr   subscids        r1   _load_subtool_visibilityr    s     &
'C!!#	JJ1;;=>E eT""[[] <	T#:*T4"8"3x <$;,0cOCHSM<<
 J $$g. 	E	s   'B* *CCc                 X    t         j                  t        j                  | d             y r   )r  r   r   r   r   s    r1   _save_subtool_visibilityr    r  r3   c                 n    | si S t        |       xs i j                  d      }t        |t              r|S i S )zFPer-user override {surface_id: {subtool_id: bool}} from user settings.subtool_visibility)r  r   r   r   )r  r   s     r1   _user_subtool_visibilityr    s9    	u%+
0
01E
FCS$'3/R/r3   c                 V    t               j                  | i       j                  |d      S NT)r  r   )r  r  s     r1   _subtool_admin_enabledr    s%    #%))*b9==j$OOr3   c                 ~    t        |      j                  |       }t        |t              r||v rt	        ||          S yNF)r  r   r   r   r   )r  r  r  surfs       r1   _subtool_user_hiddenr!    s>    #E*..z:D$*"4Z()))r3   c                    |r|j                  d      ryt        | |      }|sy|j                  dd      ry|j                  d      }|r|t        |xs i       v ry|j                  d      dk(  rt        |xs i       ry|j                  d      dk(  rt	        |xs i       ry|j                  d      d	k(  rt        |xs i       ryy)
a4  Whether *user* is permitted the sub-tool by the access model (independent
    of the admin enable/user-hide toggles). Admin always; an open_default catalog
    entry is open to every logged-in user (N2-A); otherwise the entry's access
    leaf must be held (RPM's _rpmAccess role ORed in for forward-compat).r   TFr  r  r  r  r  r  )r   r  _tool_effective_resources_rpm_role_ok_km_role_ok_qc_daily_access)r  r  usercatleafs        r1   _subtool_access_okr*  
  s    
 '
 Z
8C
ww~t$77=!D1$*"==
ww~,.<
3K ww~+-+djb2I
ww~+-2B4:22Nr3   c                 6    t        | j                  d            S )u   Kiln Metrics (SCADA View) access — the togen_scada role (Togen Admins pass
    at the top of _subtool_access_ok). Mirrors the standalone scada-view tile gate.r   r   r   r'  s    r1   r%  r%  %  s     '((r3   c                 Z    t        t        | xs i       t        t                     z        S )u   Daily Production access — any qc.production.<segment> grant (Togen Admins
    pass earlier). Matches the standalone Daily Production tab gate (DVI-1410).)r   r#  r  _qc_production_leavesr-  s    r1   r&  r&  +  s.     )$*"5+-./ 0 0r3   c                      	 t         j                         rIt        j                  t         j	                               } t        | t              rd| v rt        | d         S y# t        j                  t        f$ r Y yw xY w)up  Read the retired ``repair-parts-manager`` Tools-Enabled value straight from
    the store. DVI-1496 P3 removed it from ``_ALL_TOOL_IDS`` so
    ``_load_tool_visibility`` no longer returns it — this is used ONLY to seed the
    RPM sub-tool's admin-enabled default on deploy day (§3.6) so the effective
    state is unchanged. The standalone tile defaulted disabled.zrepair-parts-managerF)
TOOL_VISIBILITY_FILEr   r   r   r   r   r   r   r   r   )r  s    r1   r  r  2  su    '')JJ3==?@E%&+AU+JE"89::    '* s   AA   A<;A<c                      t        dd      S )u   Whether the RPM sub-tool is admin-enabled — the post-merge replacement for
    the retired ``repair-parts-manager`` Tools-Enabled flag (seeded from it on
    deploy day, then tracks the RPM Tools-Enabled sub-row in
    tile_surface_subtools.json).r  r  )r  rX   r3   r1   _rpm_tool_enabledr3  B  s    
 "+u55r3   c                 r    | j                  d      ryt        | j                  d            xr
 t               S )Nr   Tr   )r   r   r3  r-  s    r1   r$  r$  J  s.    xx%&>+<+>>r3   c                     |r|j                  d      ry|xs i j                  dd      }t        | |      xr t        | ||       xr t        | ||      S )zDEffective visibility of a sub-tool's tile + tab + routes for *user*.r   Tr  r7   )r   r  r!  r*  )r  r  r'  r  s       r1   _subtool_visibler6  P  s`    'ZRWb)E":z: A(ZGGA":z4@Br3   c                     t         j                         D ci c]6  \  }}t        |      r&||d   D ci c]  }|d   t        ||d   |        c}8 c}}}S c c}w c c}}}w )zEffective {surface_id: {subtool_id: bool}} for the SPA (drives tile + tab
    hiding; server routes re-check independently, D9).r  r  )r  r   r  r6  )r'  r   r  r  s       r1   _subtool_visibility_for_userr8  Z  s    
 )..0T TQ4OPQ4R Y<) D'+C4$?? ) ) T T ) Ts   AAAAztoolbar_visibility.jsonzpublic_version.txtzabout_content.mdzsite_text_settings.jsonztool_overrides.jsonzsync_service_state.jsonznews_posts.jsonznews_subscribers.jsonzshared_url_prefix.jsonuser_settingszfb_watcher_state.jsonzscada_config.jsonzscada_report_schedules.jsonzscada_enforced_recipients.jsonzscada_external_recipients.jsonSCADA_REPORTS_DIRzvision_collectors.jsonzvision_settings.jsonzvision_agents.jsonzvision_agent_names.jsonz	vision.db   zvision_policy.jsonzvision_interpret_log.jsonlzvision_watch_criteria.jsonzvision_monitor.jsonzvision_recovery.jsonVISION_FOOTAGE_DIRvision_footageVISION_FOOTAGE_BACKENDlocalVISION_FOOTAGE_SSH_KEYz.sshid_togen_rsync_pullVISION_FOOTAGE_REMOTE_BASEfootageVISION_FOOTAGE_CACHE_DIRvision_footage_cacheVISION_FOOTAGE_CACHE_MAX_BYTESi    VISION_FOOTAGE_CACHE_TTL1800VISION_FOOTAGE_SSH_POOL_SIZE4VISION_FOOTAGE_LISTING_TTL10VISION_AGENT_DIST_DIR
agent_distVISION_AGENT_ZIP_NAMEztogen-vision-agent.zipVISION_RSYNC_HOST165.188.108.55VISION_RSYNC_PORT4423VISION_RSYNC_USERaivVISION_RSYNC_MGMT_KEYBACKUP_RSYNC_HOSTBACKUP_RSYNC_PORTBACKUP_RSYNC_USERtogenBACKUP_RSYNC_KEYz/home/manager/.ssh/id_ed25519BACKUP_RSYNC_SRCz/var/www/html/BACKUP_RSYNC_DESTztogen-backup/zprint.dbz	backup.dbzbackup_schedule.jsonz
history.dbzhistory_settings.jsonhistory_archivezdiag_timeline.dbPRINT_AGENT_DIST_DIRPRINT_AGENT_ZIP_NAMEztogen-print-agent.zipdisplay_agent_winDISPLAY_AGENT_ZIP_NAMEztogen-display-agent.zipzbranding_settings.jsonzdisplay_defaults.jsonzregional_settings.jsonzprinting_settings.jsonzwinston_settings.jsonzwinston_schedules.jsonzwinston_run_records.jsonzcorporate_logo.svgzcorporate_bg_light.svgzcorporate_bg_dark.svgtogen_defaultd   )	logo_modebg_modebg_opacity_lightbg_opacity_darktile_columnstile_side_padding)rZ  	corporate)rc  flatrk  custom_backgrounds	image/png
image/jpegz
image/webpz	image/gifimage/svg+xml)pngjpgjpegwebpgifsvgi   )rY  dashboard_images)rq  rr  rs  rt  rv     	   )gridr  c                     | dk(  ry	 t        |       }t        |cxk  r
t        k  r|S  yy# t        t        f$ r Y yw xY w)zDReturn "unlimited" or an int in [3, 9] for a valid input, else None.	unlimitedN)r   r   r   TILE_COLUMNS_MINTILE_COLUMNS_MAXvaluens     r1   _coerce_tile_columnsr  4  sQ    J 10 00 1	 z" s   + ==rS  c                 t    	 t        |       }t        |cxk  r
t        k  r|S  yy# t        t        f$ r Y yw xY w)zAReturn an int in [0, 40] for a valid percentage input, else None.N)r   r   r   TILE_SIDE_PADDING_MINTILE_SIDE_PADDING_MAXr  s     r1   _coerce_tile_side_paddingr  G  sF    J :%:: ;	 z"    % 77c                 t    	 t        |       }t        |cxk  r
t        k  r|S  yy# t        t        f$ r Y yw xY w)z@Return an int in [40, 100] for a valid opacity input, else None.N)r   r   r   BRANDING_OPACITY_MINBRANDING_OPACITY_MAXr  s     r1   _coerce_bg_opacityr  V  sF    J q8$88 9	 z" r  zhttps://docs.icastinc.comz docs.icastinc.com/shared (HTTPS)r  r   zhttps://fileshare.icastinc.comz%fileshare.icastinc.com/shared (HTTPS)zhttp://fileshare.icastinc.comz$fileshare.icastinc.com/shared (HTTP))project-buildertakeoff-bridgezprod-run-merger  	wor-admin
sync-adminfile-browserz	simple-qrsingle-carrier-sheet	mts-labelr  visionr  r  ocr-toolr  auditorbotsr  visitor-registry
dashboardsr  	logisticsr  >   r  r  r  r  r  r  r  r  )search	assistantr  Search	AssistantCalendar)r   r   r   r   r  zwor_type_visibility.jsonzwor_type_order.jsonzwor_primary.jsonzwor_approvers.jsonzwor_testers.jsonzwor_policy.jsonzwor_equipment.jsonztogen_fqdns.jsonzauditor_access.jsonzassistant.historyzassistant.failures)history.prodrunhistory.printhistory.wor-adminzhistory.rpmzhistory.single-carrier-sheetzhistory.mts-labelzhistory.file-browserzhistory.ocr-toolzhistory.takeoff-bridgezhistory.project-builderzhistory.sync-adminzhistory.togen-adminzhistory.vision-agentszhistory.botszhistory.scada-reportingzhistory.signage-agentszhistory.dashboardszhistory.assetszhistory.datasources)policies.wor-adminpolicies.rpmpolicies.vision)diagnosis.timelinediagnosis.botsdiagnosis.visiondashboards.inspector)reporting.idenciareporting.powerbireporting.assetsreporting.qcreporting.datasourcesreporting.viewreporting.viewerreporting.builderr  )r  historypolicies	diagnosisr  	reportingr  ztool_access.json)r  r  qc.mixturesr  qc.settingsqc.filebrowser)zqc.production.wet_castzqc.production.pipezqc.production.block)r  logistics.loadslogistics.resourceslogistics.settings)r  assets.editassets.catalogassets.inventoryassets.admin)r  r  r  r  zequipment.settings)r  r  r  r  c                      	 t        j                  d      D  cg c]  } d| z   	 c} S c c} w # t        $ r t        t              cY S w xY w)z`qc.production.<id>` leaves for every segment (incl. inactive, so a grant
    for a deactivated segment still validates for historical viewing). Generated
    from the segment registry (DVI-1452 P1 D3).Tinclude_inactiveqc.production.)qc_storesegment_idsry   r  _QC_PRODUCTION_FALLBACKr  s    r1   r/  r/    sV    -!--tDF !1$ F 	F F -+,,-s   . ). . A	A	c                  6    t        t              t               z   S rT   )r  _QC_BASE_LEAVESr/  rX   r3   r1   _qc_access_leavesr    s     #8#:::r3   c                      t         j                         D  ci c]  \  } }| t        |       }} }t               |d<   |S c c}} w )zThe tool->capability-leaf tree with the dynamic qc.production.<id> leaves
    folded into the qc group. Use this (not the static _TOOL_ACCESS_TREE) wherever
    dynamic correctness matters (self-heal, admin grant validation, tile-lighting).r  )_TOOL_ACCESS_TREEr   r  r  )r  r  trees      r1   _tool_access_treer    sB     $5#:#:#<=41aAtAwJ=D="$DJK >s   A c                  l    t               j                         D  ch c]  } | D ]  }|  c}} S c c}} w )zFEvery known capability leaf, including per-segment qc.production.<id>.)r  values)leavesr  s     r1   _tool_access_allr  '  s.    -/668I&&IQAIAIIIs   0ztool_access_groups.jsonz	email-botWinstonzbot_visibility.jsonzbot_order.jsonzrpm_known_parts.jsonzrpm_settings.jsonzrpm_approval.jsonzrpm_tracking.json)r   r   zoversight_config.jsonoversight_planszoversight_editors.jsoni   )rq  rr  rs  rv  location)zSouth Structure F - TrainingzEast Structure E - ShippingzEast Structure D - Break Roomz Northeast Building G - WarehousezNortheast Building H - Coringz Northeast Building J - Warehousez2Northeast Annex - Reinforcement and Styrofoam AreazSouth Hall - Precastz)North Building B - Pipe Equipment StoragezNorth Building A - Besser KilnszCenter High Bay - Pipe PlantzCurtain Hood - Hawkeye CuringzNorth Building C - Maintenancez*West Bay - Box Culvert and Specialty Plantzvisitor_registry.jsonzpersonnel.json)driverDriverzdashboards.jsonzdashboard_sources.jsonzsignage_agents.jsonsignage_agent  x   c                     t         D  ci c]  } | ddd
 }} t        j                         r	 t        j                  t        j                               }t         D ]^  } |j                  |       }t        |t              s%t        |j                  dd            t        |j                  dd            d|| <   ` 	 |S |S c c} w # t        j                  t        f$ r Y |S w xY w)zReturn {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).
    Tadminsusersr  r  )_TOOLBAR_IDSTOOLBAR_VISIBILITY_FILEr   r   r   r   r   r   r   r   r   r   )r   defaultsr  rows       r1   _load_toolbar_visibilityr    s     AMMt44MHM&&(
	JJ6@@BCE# iinc4("&swwx'>"?!%cgggt&<!=%HSM O8O N $$g. 	O	s   C AC ?<C C"!C"c                     |xs
 t               }|j                  | ddd      }|j                  d      ryt        |xr |j                  d            S )zWhether 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.
    Tr  r  r  )r  r   r   )
toolbar_idis_adminvisr  s       r1   _toolbar_enabled_forr    sO     
+)+C
''*=
>C
www.SWWX.//r3   c                  D   t         D  ci c]  } | | t        v }} t        j                         rL	 t	        j
                  t        j                               }t         D ]  } | |v st        ||          || <    	 |S |S c c} w # t        j                  t        f$ r Y |S w xY w)zReturn 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).
    )
_ALL_TOOL_IDS_TOOL_DEFAULT_DISABLEDr1  r   r   r   r   r   r   r   r   r  r  s      r1   _load_tool_visibilityr    s     CPP3S 666PHP##%	JJ3==?@E$ 5%<$(s$4HSM5
 O8O Q $$g. 	O	s   A=4B %B BBc                     t         j                         r	 t        j                  t         j	                               } t        | t              rnt               g }}| D ])  }||vs|j                  |       |j                  |       + t        D ])  }||vs|j                  |       |j                  |       + |S 	 t        t              S t        t              S # t        j                  t        f$ r Y t        t              S w xY w)z6Return deduplicated list of tool IDs in display order.)TOOL_ORDER_FILEr   r   r   r   r   r  r  r  ri   r  r   r   r  r  dedupedr   s       r1   r   r     s     	JJ88:;E%& #rg  ,C$s+, ) ,C$s+,  ' 4 $$g. 		s   AC #1C %C DDc                     i } t         j                         r	 t        j                  t         j	                               }t        |t              r|j                         D ]  \  }}|t        vst        |t              si }t        |j                  dd            j                         }t        |j                  dd            j                         }|r||d<   |r||d<   |s|| |<    | S | S # t        j                  t        f$ r Y | S w xY w)a  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.
    r9   r7   r  )TOOL_OVERRIDES_FILEr   r   r   r   r   r   r   r  r*   r   r   r   r   )r   r  r   ovr   r9   descs          r1   _load_tool_overridesr    s     C""$	JJ2<<>?E%&${{} )GC-/z"d7K Ervvfb1288:DrvvmR89??AD(,f/3m,#(C) J3J $$g. 	J	s   C
C. #C. .D
Dc                  8   t         D  ci c]  } | d }} t        j                         rL	 t        j                  t        j                               }t         D ]  } | |v st        ||          || <    	 |S |S c c} w # t        j                  t        f$ r Y |S w xY w)z9Return dict of {wor_type: bool} for each Work Order type.T)	_WOR_TYPE_IDSWOR_TYPE_VISIBILITY_FILEr   r   r   r   r   r   r   r  s      r1   _load_wor_type_visibilityr    s    %23cT	3H3'')	JJ7AACDE$ 5%<$(s$4HSM5
 O8O 4 $$g. 	O	   
A74A< A< <BBc                      t         j                         r	 t        j                  t         j	                               } t        | t              rwt               g }}| D ]2  }|t        v s||vs|j                  |       |j                  |       4 t        D ])  }||vs|j                  |       |j                  |       + |S 	 t        t              S t        t              S # t        j                  t        f$ r Y t        t              S w xY w)zAReturn deduplicated list of Work Order type IDs in display order.)WOR_TYPE_ORDER_FILEr   r   r   r   r   r  r  r  r  ri   r   r   r  s       r1   r  r  %  s    ""$	JJ2<<>?E%& #rg  ,Cm+4s+, ) ,C$s+,  ' 4 $$g. 		$   AC# 'C# ,1C# %C# #DDc                  8   t         D  ci c]  } | d }} t        j                         rL	 t        j                  t        j                               }t         D ]  } | |v st        ||          || <    	 |S |S c c} w # t        j                  t        f$ r Y |S w xY w)zIReturn dict of {bot_id: bool} for each bot (admin Tools-Enabled submenu).T)	_BOT_IDSBOT_VISIBILITY_FILEr   r   r   r   r   r   r   r  s      r1   _load_bot_visibilityr  :  s    %-.cT	.H.""$	JJ2<<>?E 5%<$(s$4HSM5
 O8O / $$g. 	O	r  c                      t         j                         r	 t        j                  t         j	                               } t        | t              rwt               g }}| D ]2  }|t        v s||vs|j                  |       |j                  |       4 t        D ])  }||vs|j                  |       |j                  |       + |S 	 t        t              S t        t              S # t        j                  t        f$ r Y t        t              S w xY w)z5Return deduplicated list of bot IDs in display order.)BOT_ORDER_FILEr   r   r   r   r   r  r  r   r  ri   r   r   r  s       r1   _load_bot_orderr  H  s    	JJ~779:E%& #rg  ,Ch3d?s+, $ ,C$s+,  ' >4> $$g. 	>	r  r'  c                 h    | xs i } t        | j                  d      xs | j                  d            S )a]  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.
    r   r   r,  r-  s    r1   _user_can_access_pmr  ]  s/     :2D%@-)@AAr3   wor_typec                 z    | dk(  rt        |      S |j                  d      ryt               j                  | d      S )zEWhether *wor_type* is usable by *user*. Togen Admins bypass the gate.r   r   T)r  r   r  )r  r'  s     r1   _wor_type_enabled_forr
  i  s<     <"4((xx$&**8T::r3   r  c                 d    t        j                  dd| j                               }t        | dz  S )z:Return the JSON settings file path for a given user email.[^a-zA-Z0-9@._-]_.json)rF  r\  rE  USER_SETTINGS_DIR)r  safes     r1   _user_settings_pathr  t  s,    66%sEKKM:D$u~--r3   c                     t        |       }|j                         r$	 t        j                  |j	                               S i S # t        j
                  t        f$ r Y i S w xY w)zCReturn per-user settings dict (tool_visibility, tool_order, theme).)r  r   r   r   r   r   r   )r  fps     r1   r  r  z  sZ    	U	#B	zz|	::blln-- I $$g. 	I	s   "A AAr  c                     t         j                  dd       t        |       }|j                  t	        j
                  |d             |j                  t        j                  t        j                  z         y)z"Persist per-user settings to disk.TrM   r   r   N)
r  mkdirr  r   r   r   chmodstatS_IRUSRS_IWUSR)r  r  r  s      r1   _save_user_settingsr    sO    D48	U	#BMM$**Xa01HHT\\DLL()r3   extc                 h    t        j                  dd| j                               }t        | d| z  S )zJReturn the on-disk path for a user's custom background with the given ext.r  r  .)rF  r\  rE  CUSTOM_BG_DIR)r  r  r  s      r1   _custom_bg_pathr    s0    66%sEKKM:DdV1SE?**r3   c                     t        j                  dd| j                               }t        D ]$  }t        | d| z  }|j                         s"|c S  y)zEReturn the user's existing custom-background file (any ext), or None.r  r  r  N)rF  r\  rE  CUSTOM_BG_EXT_MIMESr  r   )r  r  r  r  s       r1   _find_custom_bgr"    sQ    66%sEKKM:D" vQse_,::<I r3   r   r   r   r   )newstatus_changer   due_soonoverdueevh_waitingc           
          | j                  d      xs i }i }t        D ]H  }|j                  |      xs i t        D ci c]  }|t        fdt        |   D                c}||<   J |S c c}w )zADerive the friendly 3x5 grid (all-off default) from stored prefs.wo_notificationsc              3   R   K   | ]  }t        j                  |               y wrT   r,  ).0ev
type_prefss     r1   	<genexpr>z3_notification_grid_from_settings.<locals>.<genexpr>  s     S"T*..,-Ss   $')r   _NOTIFY_GRID_TYPES_NOTIFY_GRID_COLUMNSany_NOTIFY_COLUMN_EVENTS)r  storedrz  wtcolr.  s        @r1    _notification_grid_from_settingsr7    s}    \\,-3FD  
ZZ^)r
 ,
 S8Mc8RSSS
R
 K	
s   #A,rz  c                     i }t         D ]Q  }| j                  |      xs i }i }t        D ]'  }|j                  |      st        |   D ]  }d||<   	 ) |sM|||<   S |S )zTranslate 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.
    T)r0  r   r1  r3  )rz  r   r5  col_valseventsr6  r-  s          r1   _notification_grid_to_storager;    s|     C  88B<%2' 	&C||C /4 &B!%F2J&	& CG Jr3   c                 J    t        t        |             }t        |      |d<   |S )zDLoad per-user settings plus the derived friendly notifications grid.notifications)r   r  r7  )r  r  s     r1   _user_settings_with_gridr>    s&    './H @ JH_Or3   c                     i } t         j                         r:	 t        j                  t         j	                               }t        |t              r|} i }t        D ]I  }| j                  |      }t        |t              r!|D cg c]  }t        |t              s| c}ng ||<   K |S # t        j                  t        f$ r Y qw xY wc c}w )zEReturn Primary email lists keyed by WO type, normalised to all types.)WOR_PRIMARY_FILEr   r   r   r   r   r   r   r   r0  r   r  r*   )r   r   r   r5  emailses         r1   _load_wor_primaryrC    s    D!	**-779:C#t$ C  `"?I&RV?Wf;
1c(:1;]_B` J $$g. 		
 <   9B' CC'CCr   c                 z   i }t         D ]  }t               g }}| j                  |      xs g D ][  }t        |t              s|j                         j                         }|s5||vs:|j                  |       |j                  |       ] |||<    t        j                  t        j                  |d             y)z@Persist Primary lists, de-duplicated and lowercased per WO type.r   r   N)r0  r  r   r   r*   r   rE  r  ri   r@  r   r   r   )r   cleanr5  r  orderedrB  norms          r1   _save_wor_primaryrI    s    E  	rg((2,$" 	%Aa%779??$DD(t$	% b		 

5 ;<r3   c                     | sg S | j                         j                         }t               }t        D cg c]  }||j	                  |g       v s| c}S c c}w )zHWO types for which this email is a Primary recipient (grid enforced on).)r   rE  rC  r0  r   )r  rH  primaryr5  s       r1   _wor_primary_enforced_forrL  	  sL    	;;= D!G+K2tw{{2r7J/JBKKKs   AAc                     i } t         j                         r:	 t        j                  t         j	                               }t        |t              r|} i }t        D ]I  }| j                  |      }t        |t              r!|D cg c]  }t        |t              s| c}ng ||<   K |S # t        j                  t        f$ r Y qw xY wc c}w )z4Return approver email lists keyed by RtS department.)WOR_APPROVERS_FILEr   r   r   r   r   r   r   r   
_RTS_DEPTSr   r  r*   )r   r   r   deptrA  rB  s         r1   _load_wor_approversrQ  	  s    D!!#	**/99;<C#t$ C b$AKFTXAY=1*Q*<Q=_aD	b J $$g. 		
 >rD  c                 z   i }t         D ]  }t               g }}| j                  |      xs g D ][  }t        |t              s|j                         j                         }|s5||vs:|j                  |       |j                  |       ] |||<    t        j                  t        j                  |d             y)zDPersist approver lists, de-duplicated and lowercased per department.r   r   N)rO  r  r   r   r*   r   rE  r  ri   rN  r   r   r   )r   rF  rP  r  rG  rB  rH  s          r1   _save_wor_approversrS  *	  s    E 	rg((4.&B 	%Aa%779??$DD(t$	% d	 !!$**U1"=>r3   c                     t         j                         r	 t        j                  t         j	                               } t        | t              rL| D cg c]@  }t        |t              r.|j                         r|j                         j                         B c}S 	 g S g S c c}w # t        j                  t        f$ r Y g S w xY w)z9Return the list of authorized-tester emails (lowercased).)WOR_TESTERS_FILEr   r   r   r   r   r  r*   r   rE  r   r   r   rB  s     r1   _load_wor_testersrW  =	  s    !	**-779:C#t$36 =a%a-!'') 	) = = %
 I2I	=$$g. 	I	$   ;B# ABB# B# #C ?C rA  c                 :   t               g }}| xs g D ][  }t        |t              s|j                         j	                         }|s5||vs:|j                  |       |j                  |       ] t        j                  t        j                  |d             |S )zNPersist the tester list, de-duplicated and lowercased. Returns the saved list.r   r   )r  r   r*   r   rE  r  ri   rU  r   r   r   rA  r  rG  rB  rH  s        r1   _save_wor_testersr[  J	  s    E2'Dl !!S!wwy D$HHTNNN4 ! 

71 =>Nr3   c                 X    | sy| j                         j                         t               v S )zHTrue if the email is on the Authorized Testers list (per-request check).F)r   rE  rW  r  s    r1   _is_authorized_testerr^  X	  s&    ;;= $5$777r3   c                 h    | xs i } | j                  d      ryt        | j                  dd            S )u'  Whether a user may CREATE and VIEW test WOs (DVI-1082).

    Togen Admins always qualify; other users qualify only if their email is on
    the Authorized Testers list. This is the single gate for test-WO create and
    visibility — never trust a client-supplied ``test`` flag without it.
    r   Tr  r7   )r   r^  r-  s    r1   _wor_can_testr`  _	  s2     :2Dxx '2!677r3   c                     t         j                  j                  d      dv xs! t         j                  j                  d      dv }t	        |      xr t        |       S )zTrue when the current request asks for a test WO AND the user is authorized.

    Fails closed: an unauthorized user who forges ``test=1`` gets a normal
    production WO, never a test one.
    testrE   trueonyes)r   formr   argsr   r`  )r'  flags     r1   _wor_test_requestedrj  l	  sS     <<F#'AA B<<F#'AA 	:----r3   required_deptsc                 d    t               }| D cg c]  }|j                  |      r| c}S c c}w )zIReturn departments in required_depts that have zero authorised approvers.)rQ  r   )rk  	approversds      r1   _wor_approvers_missing_deptsro  w	  s)    #%I%>!Y]]1-=A>>>s   --
user_emailc           	          | sg S t               }| j                         |j                         D cg c]  \  }}t        fd|xs g D              r|! c}}S c c}}w )zTReturn departments the user is authorized to approve (case-insensitive email match).c              3   D   K   | ]  }|j                         k(    y wrT   rE  )r,  rB  email_lcs     r1   r/  z+_wor_user_approver_depts.<locals>.<genexpr>	  s     AQ1779(A    )rQ  rE  r   r2  )rp  rm  rP  rA  rt  s       @r1   _wor_user_approver_deptsrv  }	  sa    	#%I!H%.__%6 C\T6A6<RAA  C C Cs   $Ac                  r   i } t         j                         r:	 t        j                  t         j	                               }t        |t              r|} i }| j                         D ]  \  }}t        |t              rt        |t              s'|j                  d      }t        |t              r|D cg c]  }|t        v s| c}ng }||j                  d      xs d|j                  d      xs dd||j                         j                         <    |S # t        j                  t        f$ r Y w xY wc c}w )zReturn {email(lower): {resources:[...], added_at, added_by}}.

    Unknown resource keys are dropped so the store self-heals if the resource
    tree shrinks in a later release.
    	resourcesadded_atr7   added_byrx  ry  rz  )AUDITOR_ACCESS_FILEr   r   r   r   r   r   r   r   r   r*   r   r  _AUDITOR_ALL_RESOURCESr   rE  )r   r   r   r  recresrs          r1   _load_auditor_accessr  	  s    D""$	**0::<=C#t$ Cjjl 	

s%%ZT-Bggk"AKCQUAV#=Q&<!<q=\^
+1r
+1r&
EKKM!"	
 J $$g. 		 >s   9D /D4=D4D10D1c                 j   i }| xs i j                         D ]  \  }}t        |t              rt        |t              s'|j	                         j                         }|sHt               g }}|j                  d      xs g D ]2  }|t        v s||vs|j                  |       |j                  |       4 |s||j                  d      xs# t        j                         j                  d      |j                  d      xs dd||<    t        j                  t!        j"                  |d             y	)
zPersist the access map, lower-casing emails, de-duping + validating keys.
    Users whose grant list is empty are dropped (revoking every resource removes
    Auditor visibility entirely).rx  ry  %Y-%m-%dT%H:%M:%SZrz  r7   r{  r   r   N)r   r   r*   r   r   rE  r  r   r}  r  ri   _datetime_typeutcnowstrftimer|  r   r   r   )r   rF  r  r~  rH  r  r  r  s           r1   _save_auditor_accessr  	  s    Ezr((* 

s%%ZT-B{{}""$E2c''+&," 	A**q}

1	 
+e~/D/D/F/O/OPd/e
+1r
d
$ ""4::eA#>?r3   c                     | s
t               S t               j                  | j                         j	                               }|rt        |j                  dg             S t               S )z?Granted Auditor resource-key set for a user (case-insensitive).rx  )r  r  r   r   rE  r  r~  s     r1   _auditor_access_forr  	  sM    u

 
$
$U[[]%8%8%:
;C,/3sww{B'(:SU:r3   c                     | r | j                  d      rt        t              S t        | xs i j                  dd            S )uJ   Full granted set for a session user — admins implicitly hold everything.r   r  r7   )r   r  r}  r  r-  s    r1   _auditor_effective_resourcesr  	  s8    ')**
//<==r3   c                     i } t         j                         r:	 t        j                  t         j	                               }t        |t              r|} i }t               }| j                         D ]  \  }}t        |t              rt        |t              s'|j                  d      }t        |t              r|D cg c]	  }||v s| c}ng }t        |      }||j                  d      xs d|j                  d      xs dd||j                         j!                         <    |S # t        j                  t        f$ r Y w xY wc c}w )u   Return {email(lower): {resources:[...], added_at, added_by}} for per-user
    tool grants (DVI-1373). Clone of _load_auditor_access — unknown resource keys
    are dropped so the store self-heals if the tree shrinks in a later release.rx  ry  r7   rz  r{  )TOOL_ACCESS_FILEr   r   r   r   r   r   r   r   r  r   r*   r   r  _tool_access_migrate_leavesr   rE  )r   r   r   
all_leavesr  r~  r  r  s           r1   _load_tool_accessr  	  s#    D!	**-779:C#t$ C!#Jjjl 


s%%ZT-Bggk"5?T5J#1Qjq1PR)#.
+1r
+1r&
EKKM!"

 J $$g. 		 2s   9D& 9	EE&EEc                     t        |       t        j                         D ]e  \  }}|vrt        j	                  |g       D cg c]
  }||k7  s	| }}t        fd|D              rI|D ]  }|vsj                  |        g S c c}w )u  Behavior-preserving upgrade of a Phase-1 grant (DVI-1373 P2). A grant that
    holds a base leaf (e.g. `qc`) but none of its split sibling leaves is a legacy
    grant from before the capability split — fold in the implied leaves (e.g.
    `qc.reports`) so those users keep the access they had. Idempotent: once any
    sibling leaf is present the grant is considered intentional and left alone.c              3   &   K   | ]  }|v  
 y wrT   rX   )r,  r  r   s     r1   r/  z._tool_access_migrate_leaves.<locals>.<genexpr>	  s     *AqCx*   )r  _TOOL_ACCESS_MIGRATEr   r  r   r2  ri   )r  baseimpliedr  siblingsr   s        @r1   r  r  	  s     s)C-335 gs?044T2>L!!t)ALL*** 	A|

1	 J Ms   
BBc                 v   i }t               }| xs i j                         D ]  \  }}t        |t              rt        |t              s'|j                         j                         }|sHt               g }}|j                  d      xs g D ].  }||v s||vs|j                  |       |j                  |       0 |s||j                  d      xs# t        j                         j                  d      |j                  d      xs dd||<    t        j                  t!        j"                  |d             y	)
zPersist the tool-access map, lower-casing emails, de-duping + validating
    keys. Users whose grant list is empty are dropped (revoking every capability
    removes the row entirely).rx  ry  r  rz  r7   r{  r   r   N)r  r   r   r*   r   r   rE  r  r   r  ri   r  r  r  r  r   r   r   )	r   rF  r  r  r~  rH  r  r  r  s	            r1   _save_tool_accessr   
  s    E!#Jzr((* 

s%%ZT-B{{}""$E2c''+&," 	AJ1D=

1	 
+e~/D/D/F/O/OPd/e
+1r
d
$ 

5 ;<r3   c                     | s
t               S t               j                  | j                         j	                               }|rt        |j                  dg             S t               S )z>Granted tool-capability key set for a user (case-insensitive).rx  )r  r  r   r   rE  r  s     r1   _tool_access_forr  
  sM    u


!
!%++-"5"5"7
8C,/3sww{B'(:SU:r3   zuser_directory.jsonc                  >   i } t         j                         r:	 t        j                  t         j	                               }t        |t              r|} i }| j                         D ]  \  }}t        |t              rt        |t              s'|j                  d      xs d|j                  d      xs d|j                  d      xs d|j                  d      xs dd||j                         j                         <    |S # t        j                  t        f$ r Y w xY w)zCReturn {email(lower): {name, given_name, family_name, last_login}}.r9   r7   
given_namefamily_name
last_loginr9   r  r  r  )USER_DIRECTORY_FILEr   r   r   r   r   r   r   r   r   r*   r   r   rE  )r   r   r   r  r~  s        r1   _load_user_directoryr  -
  s    D""$	**0::<=C#t$ Cjjl 

s%%ZT-BGGFO)r'',/5277=17R'',/52	&
EKKM!"
 J $$g. 		s   9D   DDc                    | xs dj                         j                         } | rd| vryt               }|xs dj                         |xs dj                         |xs dj                         t        j                         j                  d      d|| <   t        j                  t        j                  |d             y)zBest-effort upsert of one user's display-name snapshot (DVI-1432 D1-A).
    Callers wrap this in try/except so a write failure never blocks login.r7   @Nr  r  r   r   )
r   rE  r  r  r  r  r  r   r   r   )r  r9   r  r  	directorys        r1   _record_user_directoryr  D
  s     [b!'')ECu$$&I""$!'R..0#)r002$++-667KL	Ie ""4::i#BCr3   c                     i } t         j                         r:	 t        j                  t         j	                               }t        |t              r|} i }t               }| j                         D ]  \  }}t        |t              rt        |t              s'|j                  d      }t        |t              r|D cg c]	  }||v s| c}ng }|j                  d      xs d||j                  d      xs d|j                  d      xs dd||j                         j                         <    |S # t        j                  t        f$ r Y w xY wc c}w )u  Return {group_id(lower): {name, resources:[...], added_at, added_by}} for
    Azure AD group-backed tool grants (DVI-1373 P3). Same self-heal as
    _load_tool_access — unknown resource keys are dropped so the store heals if
    the tree shrinks in a later release.rx  r9   r7   ry  rz  r9   rx  ry  rz  )TOOL_ACCESS_GROUPS_FILEr   r   r   r   r   r   r   r   r  r   r*   r   r  r   rE  )r   r   r   r  gidr~  r  r  s           r1   _load_tool_access_groupsr  T
  s)   
 D&&(	**4>>@AC#t$ C!#JJJL 

S#s#:c4+@ggk"5?T5J#1Qjq1PRGGFO)r
+1r
+1r	$
CIIK 

 J $$g. 		 2s   9D/ 9	EE/E
Ec                    i }t               }| xs i j                         D ]  \  }}t        |t              rt        |t              s(|j                         j                         }|sIt               g }}|j                  d      xs g D ].  }||v s||vs|j                  |       |j                  |       0 |s|j                  d      xs d||j                  d      xs# t        j                         j                  d      |j                  d      xs dd||<    t        j                  t!        j"                  |d	             y
)zPersist the group->capability map, lower-casing group ids, de-duping +
    validating keys. Groups whose grant list is empty are dropped (revoking
    every capability removes the mapping entirely).rx  r9   r7   ry  r  rz  r  r   r   N)r  r   r   r*   r   r   rE  r  r   r  ri   r  r  r  r  r   r   r   )	r   rF  r  r  r~  rH  r  r  r  s	            r1   _save_tool_access_groupsr  q
  s$    E!#JZR&&( 
S#s#:c4+@yy{  "E2c''+&," 	AJ1D=

1	 GGFO)r
+e~/D/D/F/O/OPd/e
+1r	
d
& &&tzz%'BCr3   c                    | xs i j                  d      xs g }|s
t               S t               }|s
t               S |D ch c],  }|st        |      j	                         j                         . }}t               }|j                         D ]+  \  }}||v s|j                  |j                  dg              - |S c c}w )a+  Capability leaves granted to a user via their Azure AD group membership
    (DVI-1373 P3). Unions the leaves of every mapped group present in the user's
    login-snapshotted group set (session["user"]["az_groups"]). Returns an empty
    set when the user has no snapshot or no mapped group matches.	az_groupsrx  )r   r  r  r*   r   rE  r   r  )r'  r   r   r   haver   r  r~  s           r1   _tool_access_group_resourcesr  
  s    
 jbk*0bFu&(Gu,28qaCFLLN  "8D8
%CMMO 1S$;JJsww{B/01 J 9s    B=)B=c                     | r| j                  d      r
t               S t        | xs i j                  dd            t        | xs i       z  S )u   Full granted tool-capability set for a session user — admins implicitly
    hold every capability; otherwise the UNION of the email-allowlist grant and
    any Azure AD group-backed grants (DVI-1373 P3).r   r  r7   )r   r  r  r  r-  s    r1   r#  r#  
  sJ     '!!djb--gr:;*4:267 8r3   )
operationsr   r  sidecar_datac                    | xs i }t        |j                  dd            }|j                  d      xs t        D ci c]  }|d c}}d}d}|j                  d      xs dj                         }|rmt	        |      }t               }	|	j                  |      }
|
rEt        |
j                  dd            }|r(|
j                  d      xs t        D ci c]  }|d c}}|r+|r)dt        D cg c]  }|j                  |d      s| c}fS |r)dt        D cg c]  }|j                  |d      s| c}fS dg fS c c}w c c}w c c}w c c}w )a.  Return (rts_required: bool, required_depts: list) for a WO sidecar.

    Merges WO-level rts_required/rts_departments (from the request form, P3)
    with the equipment-level setting from wor_equipment.json. Equipment-level
    wins if it forces rts_required=True; WO-level can only opt in on top.
    rts_requiredFrts_departmentsTNr  r7   )r   r   rO  r   _normalize_equipment_load_wor_equipment)r  sdwo_rtsrn  wo_deptseq_rtseq_depts	raw_equipr)  registryrecords              r1   _wor_effective_rtsr  
  s:    
	B"&&/0Fvv'(Ij,IQW,IHFH$*113I"9-&(c"&**^U;<F!::&78Yj<YQW<Y(EAx||At/DaEEEEAx||At/DaEEE"9% -J =Z FEs#   
D<
E-EEE0Eevhc                    | j                  d      xs g }| j                  d      xs i }|j                         D ch c]   \  }}|xs i j                  d      dk(  s|" }}}|D cg c]	  }||vs| }}|sg S t        | j                  dd            }d|v r;d|vrdgS |D cg c]  }|dk7  s	||vs| }}|r|S t        D ]  }||v s|gc S  |S t        D cg c]	  }||v s| }	}|	s|S |	d   }
|
|vr|
gS |	d	d
 D cg c]	  }||vs| }}|r|S t        D ]  }||v s|gc S  |S c c}}w c c}w c c}w c c}w c c}w )u  Return departments currently able to sign off on an EVH.

    Sign-off order (board-directed D1): Maintenance is the gating approver
    when required. After Maintenance approves, remaining depts unlock per
    allow_parallel. When Maintenance is not required, the first required dept
    in the fixed order (Operations→Safety→Quality) becomes the gating approver
    with the same allow_parallel option.
    rk  	sign_offsactionapproveallow_parallelFr   r   r^   N)r   r   r   _EVH_NON_MAINT_ORDER)r  requiredr  rn  r  approvedpendingr  	remaining	non_maintgatings              r1   _evh_actionable_deptsr  
  s    ww'(.BH$*I'oo/XdaAG==3Ji3WXHX"8Qax&7q8G8	#''"2E:;N (!?" (U1A,>1HCTQU	U% 	AI~s
	  1BqAMBIBq\FXx%abM?qQh->?I?! 	>3J ? Y8 V C @sA    D1D1*	D74D7(
D<3D<8D< 	E*E	EEc                    | j                  d      xs g }| j                  d      xs i }|j                         D ch c]   \  }}|xs i j                  d      dk(  s|" c}}t        |      xr t        fd|D              S c c}}w )z8Return True when all required departments have approved.rk  r  r  r  c              3   &   K   | ]  }|v  
 y wrT   rX   )r,  rn  r  s     r1   r/  z#_evh_is_complete.<locals>.<genexpr>  s     !BA!x-!Br  )r   r   r   all)r  r  r  rn  r  r  s        @r1   _evh_is_completer  
  sy    ww'(.BH$*I'oo/XdaAG==3Ji3WXH>Bc!B!BBB Ys    B	 B	c                    g }|xs i }|j                  d      xs dj                         }|r|j                  |       | j                  d      | j                  d      fD ]D  }|xs i j                         D ]+  }|xs i j                  d      }|s|j                  |       - F t	               }| j                  d      xs g D ]*  }	|j                  |	g       D ]  }|j                  |        , |S )uJ  Collect every email involved in an EVH approval.

    Guarantees EVH finalized/rejected mail reaches the WO submitter, every
    approver who has signed off (including a cleared rejection set), and all
    currently-configured approvers for the required departments — independent
    of the notification grid/policy config.
    r  r7   r  _rejected_sign_offsapprover_emailrk  )r   r   ri   r  rQ  )
r  r  rA  r  	submitterrb  soaddrrm  rP  s
             r1   _evh_participant_emailsr    s     F		B&B--/Ii ''+&0E(FG $;B&&( 	$BH">>"23Dd#	$$
 $%I)*0b  MM$+ 	 DMM$	   Mr3   r   r  r   r  c                  *    t        j                         S )u  Return equipment registry keyed by normalized asset_id.

    DVI-1472 P2: the registry merged into assets.db, so this is now a thin
    compatibility shim over assets_store.load_equipment_registry() — every
    legacy consumer (RtS/EVH, WO history, Manuals index, Oversight equipment +
    lorawan_mobile, Makdash counts, mm-eas-options) keeps its {norm_name:
    record} view unchanged. The old wor_equipment.json is frozen on disk as a
    rollback archive after the one-time migration (see
    _migrate_equipment_registry_once).)assets_storeload_equipment_registryrX   r3   r1   r  r  ,  s     //11r3   r  c                 .    t        j                  |        y)zDVI-1472 P2 shim: upsert the {norm_name: record} registry back into
    assets.db (auto-seeds an asset + alias for an unknown equipment name).N)r  save_equipment_registryr  s    r1   _save_wor_equipmentr  9  s     ((2r3   r9   
created_atc                     t        |       | j                         |xs# t        j                         j	                  d      dt
        D ci c]  }|d c}dddddd
S c c}w )z8Build a new equipment registry record with RtS defaults.%Y-%m-%dT%H:%M:%SFTr7   )
asset_idr9   r  r  r  include_in_oversightoversight_dev_euiasset_noout_of_serviceoffsite)r  r   r  nowr  rO  )r9   r  rn  s      r1   _make_equipment_recordr  ?  sg     ).

 VN$6$6$8$A$ABU$V-78AtG8 %
   
 9s   	
Ac                     t               } d}t               D ]  }|j                         s|j                  d      D ]  }	 t	        j
                  |j                               }t        |t              s8|j                  d      xs dj                         }|s^t        |      }|sl|| vsqt        |      | |<   d}  |rt        |        yy# t        j                  t        f$ r Y w xY w)a9  Ensure every equipment name seen in WO sidecars exists in the registry.

    Scans all WO type directories for sidecars with a non-empty `equipment`
    field and inserts any unknown names (keyed by normalized asset_id) without
    overwriting existing records. Safe to call on every GET /equipment/registry.
    F*.jsonr  r7   TN)r  _wor_type_dirsis_dirglobr   r   r   r   r   r   r   r   r   r  r  r  )r  changedr  sidecarr   r   r)  s          r1   _seed_equipment_registryr  S  s     #$HG#% 	! ~~h/ 	Gzz'"3"3"56 dD)88K(.B557C&s+Cs(* 6s ;	" H%  (('2 s    #CC,+C,r  c           	         g }g d}|D ]  }t        |      j                         D ]  \  }}|j                         s|j                  d      D ]  }	 t	        j
                  |j                               }t        |t              s9|j                  d      xs dj                         }t        |      | k7  rk|j                  d      j                  }	d}
|j                  d      }|j!                         r<t#        j$                  |j'                         j(                        j+                  d      }
|	t-        |	||      |||
d}|j                  d      }|rt/        |      }|j                  d	      |d
<   ||d<   |j                  d      |d<   |j                  dg       |d<   |j                  di       |d<   t1        |      |d<   |j                  d      |d<   |j                  d      |d<   |j3                  |          |j5                  d d       |S # t        j                  t        f$ r Y w xY w)zReturn WO summary rows for a given equipment asset_id.

    Scans all WO type directories for sidecars whose `equipment` field
    normalizes to `asset_id`. Returns a list of summary dicts sorted by
    date descending.
    r#  r  r  r7   r   r  )filenamere  r  rd   r   _evhrd   
evh_statusevh_actionable_deptsinitialized_atevh_initialized_atrk  evh_required_deptsr  evh_sign_offsevh_zero_approver_deptsevh_pdfreleased_to_production_atevh_released_atc                     | d   S Nr   rX   xs    r1   <lambda>z'_equipment_wo_history.<locals>.<lambda>  s
    qy r3   Tr)  reverse)_wor_dest_dirsr   r  r  r   r   r   r   r   r   r   r   r   r  with_suffixr9   r   r  utcfromtimestampr  st_mtimer  _wor_display_titler  ro  ri   sort)r  matchesr  r  rd   r  r  r   r   pdf_name	mtime_strpdf_pathr   r  
actionables                  r1   _equipment_wo_historyr  q  s?    GHI )& "0!9!?!?!A &	&FI##%$>>(3 #&::g&7&7&9:D "$-xx,299;',8"..v6;;	"..v6##% . ? ? 00!h34  !)/(DI ($% hhv&!6s!;J*-''(*;E,'4>E0125'':J2KE./25'':JB2OE./-0WW["-EE/*7ST^7_E34'*wwy'9E)$/2ww7R/SE+,u%G#&&	&)&T LL($L7NE ,,g6 s   #H""H?	>H?	c                      i } t         j                         r:	 t        j                  t         j	                               }t        |t              r|} t        d| i      S # t        j                  t        f$ r Y (w xY w)zGReturn the friendly Policy grid (all-off default) from wor_policy.json.r*  )
WOR_POLICY_FILEr   r   r   r   r   r   r   r   r7  )r4  r   s     r1   _load_wor_policy_gridr    sp    F 	**_6689C#t$ ,-?,HII $$g. 		s   9A A:9A:c                 j    t         j                  t        j                  t	        |       d             y)zAPersist the friendly Policy grid in the core's event-keyed shape.r   r   N)r  r   r   r   r;  rz  s    r1   _save_wor_policy_gridr    s%    

06qACr3   c                  p    t         j                         r"t         j                         j                         S y)z!Return the public version string.Unknown)PUBLIC_VERSION_FILEr   r   r   rX   r3   r1   _load_public_versionr!    s)    ""$",,.4466r3   u  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.

Why “togen”: Togen is the past participle of the Old English verb “tēon,” which means to draw or pull.

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.

Or its short for TOol GENerator. Built as a platform to build and access tools, chain them into workflows, and impact operations.c                      t         j                         r.	 t         j                         } | j                         r| S 	 t
        S t
        S # t        $ r Y t
        S w xY w)z0Return the editable About popup text (Markdown).)ABOUT_CONTENT_FILEr   r   r   r   DEFAULT_ABOUT_MARKDOWNtexts    r1   _load_about_contentr'    s\    !!#	%//1Dzz|  "!!!  	!!	s   %A	 		AAc                 .    t         j                  |        y rT   )r#  r   r%  s    r1   _save_about_contentr)    s    !!$'r3   zPull it Forwardc                     t         t        }} t        j                         r	 t	        j
                  t        j                               }t        |t              rt        |j                  d      t              r&|d   j                         r|d   j                         } t        |j                  d      t              r&|d   j                         r|d   j                         }| |dS # t        j                  t        f$ r Y  w xY w)zCReturn the editable header title/tagline, falling back to defaults.re  taglinere  r+  )DEFAULT_SITE_TITLEDEFAULT_SITE_TAGLINESITE_TEXT_SETTINGS_FILEr   r   r   r   r   r   r   r*   r   r   r   )re  r+  r  s      r1   _load_site_textr0    s    ')=7E&&(	JJ6@@BCE%&eii0#65>;O;O;Q!'N002Eeii	2C8U9=M=S=S=U#I.446G w// $$g. 		s   CC( (DDc                 ^    t         j                  t        j                  | |dd             y )Nr,  r   r   )r/  r   r   r   r,  s     r1   _save_site_textr2    s#    &&

Uw7Br3   zrclone-bisync.servicec                  ~   	 t        j                  ddt        gddd      } | j                  dk(  S # t        $ r Y nw xY wt
        j                         rj	 t        j                  t
        j                               }t        |t              rd|v rt        |d         S y# t        j                  t        f$ r Y yw xY wy)zReturn 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.).
    	systemctlz	is-activeTr   capture_outputr&  timeoutr   running)
subprocessrun_BISYNC_UNIT
returncodery   SYNC_SERVICE_STATE_FILEr   r   r   r   r   r   r   r   r   )resultr   s     r1   _load_sync_service_runningr?    s    +|4dA

   A%%  &&(	::5??ABD$%)t*;DO,,  $$g. 		s   .1 	==AB B;:B;c                 n    t         j                  t        j                  dt	        |       id             y )Nr8  r   r   )r=  r   r   r   r   r8  s    r1   _save_sync_service_runningrB  '  s%    &&tzz9d7m2LUV'WXr3   ix  c                      t        j                  t        t              d      } t         j                  | _        | j                  d       | j                  d       | S )z@Open backup.db, creating schema on first use. Caller must close.Fcheck_same_threadPRAGMA journal_mode=WALa  
        CREATE TABLE IF NOT EXISTS runs (
            id          TEXT PRIMARY KEY,
            started_at  REAL NOT NULL,
            finished_at REAL,
            status      TEXT NOT NULL DEFAULT 'running',
            exit_code   INTEGER,
            duration_s  REAL,
            log_text    TEXT
        );
        CREATE INDEX IF NOT EXISTS idx_backup_runs_started ON runs (started_at DESC);
    )sqlite3connectr*   BACKUP_DB_PATHRowrow_factoryexecuteexecutescriptconns    r1   
_backup_dbrP  3  sJ    ??3~.%HD{{DLL*+  	 Kr3   c                     ddl } ddl}| j                         j                  }|j                         }t	               }|j                  d||f       |j                          |j                          	 dt         dt         d}ddd	d
d|t        t         dt         dt         g}t        j                  |ddd      }|j                         }|j                   xs d|j"                  xs dz   }	|j$                  dk(  rdnd}
t	               }|j                  d||
|j$                  ||z
  |	|f       |j                          |j                          y# t&        $ rf}|j                         }t	               }|j                  d|||z
  t)        |      |f       |j                          |j                          Y d}~yd}~ww xY w)zAExecute rsync backup; updates backup.db. Runs in a daemon thread.r   NzBINSERT INTO runs (id, started_at, status) VALUES (?, ?, 'running')zssh -p z -i z5 -o StrictHostKeyChecking=accept-new -o BatchMode=yesrsyncz-azz--deletez--statsz-er  :T   r5  r7   okfailedzYUPDATE runs SET finished_at=?, status=?, exit_code=?, duration_s=?, log_text=? WHERE id=?zaUPDATE runs SET finished_at=?, status='failed', exit_code=-1, duration_s=?, log_text=? WHERE id=?)uuidrU   uuid4hexrP  rL  commitclose_BACKUP_RSYNC_PORT_BACKUP_RSYNC_KEY_BACKUP_RSYNC_SRC_BACKUP_RSYNC_USER_BACKUP_RSYNC_HOST_BACKUP_RSYNC_DESTr9  r:  stdoutstderrr<  ry   r*   )_uuid_timerun_idstartedrO  ssh_cmdcmdr>  finishedlogrd   excs               r1   _backup_run_threadrm  G  s   [[]FjjlG<DLLL	 	KKMJJL"()$%   	 UJ	'!"!$6#7q9K8LM	
 DtTR::<}}"v}}':;**a/X|vv00(W2Dc6R	

 	

 	::<|x')3s8V<	

 	

	s   2CE 	GAF<<Gc                     ddl } | j                         }	 t               }|j                  d|t        z
  f      j	                         }|j                  d      j	                         }|j                          |rdddS |r=t        |      }t        |d         |d<   t        |d   xs d      xs d|d<   |d	   |dS 	 d
ddS # t        $ r Y w xY w)uO   Return current backup state from DB — multi-worker safe (no in-process flag).r   Nz`SELECT id FROM runs WHERE status = 'running' AND started_at > ? ORDER BY started_at DESC LIMIT 1z{SELECT id, started_at, finished_at, status, duration_s FROM runs WHERE status != 'running' ORDER BY started_at DESC LIMIT 1r8  )statelast_run
started_atfinished_atrd   idle)	rU   rP  rL  _BACKUP_STALE_SECONDSfetchoner[  r   r   ry   )re  r  rO  active	last_donelrs         r1   _backup_get_statusry  y  s    
**,C|0((*
 (*	 	
 LL0
 (*	 	
 	

&D99iB"2l#34B| #B}$5$: ; CtB}\r::	  ..  s   A'C >>C 	CC02:00r   r^   r   rP   r   r   r   America/Chicagoenabled	frequencyrU   daysr   last_fired_datec                 t    ddl m} 	  || j                  d      xs d      S # t        $ r  |d      cY S w xY w)zGReturn a ZoneInfo for the schedule, defaulting to US Central (CST/CDT).r   ZoneInfor   r|  )zoneinfor  r   ry   )schedr  s     r1   _backup_schedule_tzr    s?    !+		*-B1BCC +)**+s   # 77c                  <   t        t              } 	 t        j                         rHt	        j
                  t        j                               }t        |t               r| j                  |       | S # t        $ r# t        j                  j                  d       Y | S w xY w)z;Return the persisted backup schedule, merged over defaults.zFailed to read backup schedule)r   _BACKUP_SCHEDULE_DEFAULTBACKUP_SCHEDULE_FILEr   r   r   r   r   r  ry   rm   rn   	exception)r  r   s     r1   _load_backup_scheduler    s|    )*E?'')::2<<>?D$%T" L  ?

=>L?s   AA/ /(BBc                     	 t         j                  t        j                  | d             y # t        $ r" t
        j                  j                  d       Y y w xY w)Nr   r   zFailed to write backup schedule)r  r   r   r   r   rm   rn   r  r  s    r1   _save_backup_scheduler    E    @''

5(CD @

>?@   *- (AAc                     	 t        | j                  dd            j                  d      dd \  }}t        |      t        |      }}d|cxk  rdk  rn y	d|cxk  rdk  rn y	||fS y	# t        $ r Y y	w xY w)
zAParse the schedule time into (hour, minute), defaulting to 02:00.rU   rz  rS  Nr   r   r   r   )r   r   )r*   r   splitr   ry   )r  hhmms      r1   _backup_schedule_hmr    s    UYYvw/066s;BQ?BR#b'B<R< 	 LbL  r6M   s   AA) A) $A) )	A54A5c                    | j                  d      syt        |       }t        |       \  }}| j                  dd      }| j                  d      xs t        t	        d            }|r|j                  |      nt        j                  |      }t	        dd      D ]E  }|t        |	      z   j                  ||dd
      }	|	|k  r+|dk(  r|	j                         |vrC|	c S  y)zJReturn the next scheduled run as a tz-aware datetime, or None if disabled.r~  Nr  r  r  r   r      r  hourminutesecondmicrosecondweekly)r   r  r  r  range
astimezoner  r  _timedelta_typereplaceweekday)
r  from_dttzr  r  freqr  r  r  cands
             r1   _backup_schedule_next_runr    s    99Y	U	#B 'FB99['*D99V.U1XD%,7b!.2D2DR2HCQ{ o3//88Bqa 9 93;8d : r3   c                     t         5  t               } | j                  d      s
	 ddd       yt        |       }t	        |       \  }}t        j                  |      }| j                  dd      }| j                  d      xs t        t        d            }|dk(  r|j                         |vr
	 ddd       y|j                  ||dd	      }||k  r
	 ddd       y|j                  d
      }| j                  d      |k(  r
	 ddd       y|| d<   t        |        t               j                  d      dk(  r)t        j                  j!                  d       	 ddd       yt#        j$                  t&        d      j)                          t        j                  j!                  d| j                  d      |||       ddd       y# 1 sw Y   yxY w)zAIf the auto-schedule is due and no backup is running, launch one.r~  Nr  r  r  r   r  r   r  %Y-%m-%dr  ro  r8  zGScheduled backup slot reached but a backup is already running; skippingTtargetdaemonz9Scheduled backup triggered (tz=%s freq=%s time=%02d:%02d)r   )_BACKUP_SCHEDULE_LOCKr  r   r  r  r  r  r  r  r  r  r  r  ry  rm   rn   rl   	threadingThreadrm  rq   )	r  r  r  r  r  r  r  scheduled_today	today_strs	            r1   _maybe_run_scheduled_backupr    s   	 1%'yy#1 1 !'$U+B  $yyg.yy 2DqN8T 91 1 ++2bPQ+R 1 1 LL,	99&'94!1 1& $- e$##G,	9JJOOef/1 10 	 24@FFH

GIIj!4R	131 1 1s+   GA?G5G&GAGAGG
c                     t         j                         rQ	 t        j                  t         j	                               } t        | t              r| j                  d d       | S 	 g S g S # t        j                  t        f$ r Y g S w xY w)z(Return list of news posts, newest first.c                 &    | j                  dd      S Nr   r7   r  ps    r1   r	  z"_load_news_posts.<locals>.<lambda>  s    vr): r3   Tr
  )
	NEWS_FILEr   r   r   r   r   r  r  r   r   postss    r1   _load_news_postsr    s~    	JJy2245E%&

:D
I '
 I2I $$g. 	I	s   AA( (BBc                 X    t         j                  t        j                  | d             y r   )r  r   r   r   r  s    r1   _save_news_postsr    s    E!45r3   c                      t         j                         r=	 t        j                  t         j	                               } t        | t              r| S 	 g S g S # t        j                  t        f$ r Y g S w xY w)z*Return list of subscriber email addresses.)	SUBSCRIBERS_FILEr   r   r   r   r   r  r   r   r  s    r1   _load_subscribersr    sn    !	::.88:;D$% & I2I $$g. 	I	   8A A10A1c                 X    t         j                  t        j                  | d             y r   )r  r   r   r   r  s    r1   _save_subscribersr    s    

4 :;r3   c                  &   t         j                         rT	 t        j                  t         j	                               } t        | t              r| j                  d      r| d   S t        S t        S # t        j                  t        f$ r Y t        S w xY w)z/Return the configured shared URL prefix string.prefix)SHARED_URL_PREFIX_FILEr   r   r   r   r   r   r   r   r   DEFAULT_SHARED_URL_PREFIXr   s    r1   _load_shared_url_prefixr  "  s}    %%'	::4>>@AD$%$((8*<H~% %$$$ $$g. 	$$	s   AA/ /BBc                 \    t         j                  t        j                  d| id             y )Nr  r   r   )r  r   r   r   r  s    r1   _save_shared_url_prefixr  .  s     %%djj(F1CA&NOr3   c                     dj                  | xs dj                         j                         j                               S )zJCase/whitespace-insensitive key for matching Equipment/Asset/System names.r\   r7   )joinr   rE  r  r9   s    r1   r  r  4  s0    88TZR&&(..066899r3   stored_filenamec                 $    t               }| d|  S )z-Document Viewer URL for a stored manual file.z/wor/manuals/r  )r  r  s     r1   _manual_viewer_urlr  9  s    $&FX]?"344r3   c                      t         j                         r=	 t        j                  t         j	                               } t        | t              r| S 	 i S i S # t        j                  t        f$ r Y i S w xY w)z@Return the {normalized-equipment: manual-entry} association map.)	WOR_MANUALS_INDEX_FILEr   r   r   r   r   r   r   r   r  s    r1   _load_manuals_indexr  ?  sn    %%'	::4>>@AD$% & I2I $$g. 	I	r  indexc                 X    t         j                  t        j                  | d             y r   )r  r   r   r   r  s    r1   _save_manuals_indexr  K  s    %%djjq&ABr3   original_namec           	         t        |       }|syt               }||| xs dj                         t        |      t	        j
                         j                  d      |xs i j                  dd      d||<   t        |       y)zFAssociate a manual with an equipment name so future WORs can reuse it.Nr7   r  r9   r  r  r  rJ  
updated_at
updated_by)	r  r  r   r  r  r  r  r   r  )r  r  r  r'  r)  r  s         r1   _manuals_index_setr  O  sy    
y
)C!E#&o2,,.!/2$((*334GHzr&&vr2E#J r3   c                     g } t               j                         D ]  }|j                  d      }|rt        |z  j	                         s.| j                  ||j                  d|      |j                  dd      t        |      |j                  dd      |j                  dd      d        | j                  d d	
       | S )zReturn 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.
    r  r  r  r7   r  r  r  c                 &    | j                  dd      S )Nr  r7   r  r  s    r1   r	  z#_list_all_manuals.<locals>.<lambda>s  s    QUU<4 r3   Tr
  )r  r  r   WOR_MANUALS_DIRr   ri   r  r  )r   r   fns      r1   _list_all_manualsr  `  s     E$&--/ YYz"/B.779"YY;;3%b)))L"5))L"5
 		 
JJ4dJCLr3   c                     t        |       }|syt               j                  |      }|r|j                  d      syt        |d   z  j	                         syt        |      }t        |d         |d<   |S )zMReturn the manual entry previously associated with an equipment name, if any.Nr  rJ  )r  r  r   r  r   r   r  )r  r)  r   s      r1   _manuals_index_lookupr  w  sp    
y
)C!%%c*E		*-eJ//88:KE%eJ&78E%LLr3   c                  .   i dt         ddffd} t        d      D ]v  }|j                         s|j                  d      D ]O  }	 t	        j
                  |j                               }t        |t              s8 | |j                  dd             Q x t               j                         D ]+  }t        |t              s | |j                  dd             - t        j                         d	 
      S # t        j                  t        f$ r Y w xY w)a}  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.
    r   r$   Nc                 d    | xs dj                         }|sy t        |      }|r|vr||<   y y y Nr7   )r   r  )r   r9   r)  r  s      r1   _addz%_collect_pm_eas_options.<locals>._add  s>    	r  ""4(3d?DI #3r3   r   r  r  r7   c                 "    | j                         S rT   rs  r  s    r1   r	  z)_collect_pm_eas_options.<locals>.<lambda>  s    qwwy r3   r)  )r*   r  r  r  r   r   r   r   r   r   r   r   r  r  sorted)r  r  r  r   r   r  s        @r1   _collect_pm_eas_optionsr    s    D# $  $L1 	0	! ~~h/ 	0Gzz'"3"3"56 $%TXXk2./	0	0 %&--/ -eT";+,- $++-%899 (('2 s   #C88DDc                    t         j                  j                  d      }|r#|j                  rt        j
                  j                  |j                        d   j                         }|t        vr%dj                  t        t                    }dd| fS t        j                  dd       t        |j                        xs d|z   }t        j                         j!                  d       d	| }|j#                  t%        t        |z               t'        | ||j                  |       ||j                  t)        |      d
dfS t         j*                  j                  dd      j-                         }|rud|v sd|v sd|v ryt        |z  j/                         rQt         j*                  j                  dd      j-                         xs |}t'        | |||       ||t)        |      d
dfS y)a}  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).
    manualr^   , N'Unsupported manual file type. Allowed: TrM   %Y%m%d_%H%M%Sr  )r  r  rJ  manual_existingr7   r6   \..)NInvalid manual referencemanual_existing_nameNN)r   filesr   r  osrb   splitextrE  ALLOWED_MANUAL_EXTSr  r  r  r  r   r  r  r  saver*   r  r  rg  r   r   )	r  r'  fr  r   r  r4  existingoriginals	            r1   _pm_process_manualr    s    	(#AQZZggqzz*1-335))ii': ;<GB7)LLLdT:qzz*>x#~"&&(11/BC1TFK	s?V+,-9fajj$?"QZZ)&13489 	9 || 126<<>H(?dh.$(2B3h&//1||''(>CIIKWxHy(HdC (8-h79:>? ?r3   c                      t         j                         r=	 t        j                  t         j	                               } t        | t              r| S 	 i S i S # t        j                  t        f$ r Y i S w xY w)z@Return the {stored-filename: {text, pages, ...}} OCR text index.)	WOR_MANUALS_TEXT_INDEX_FILEr   r   r   r   r   r   r   r   r  s    r1   _load_manuals_text_indexr	    sn    "**,	::9CCEFD$% & I2I $$g. 	I	r  c                 X    t         j                  t        j                  | d             y r   )r  r   r   r   r  s    r1   _save_manuals_text_indexr    s    **4::eA+FGr3   r&  c                     g }d}t        | j                  d            D ]2  \  }}|}|t        |      z   }|j                  |dz   ||d       |dz   }4 |S )zPer-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.
    r   r^   )numberrq   end)	enumerater  rC  ri   )r&  pagesposipartrq   r  s          r1   _manuals_page_offsetsr    sh     E
CTZZ-. 4CIoAcBCAg	
 Lr3   c                     t               }| ||t        |      t        j                         j	                  d      d|| <   t        |       y )Nr  )r  r  r&  r  
indexed_at)r	  r  r  r  r  r  )r  r&  r  r  s       r1   _manuals_text_index_setr    sI    $&E#&&t,$((*334GHE/ U#r3   c                 6   	 t         | z  }|j                         r|j                  j                         dk7  ry	 ddlm}m}m} |j                  d      }|j                  d      }	  ||||      }t        j                  t        |      t        |             |j                  d	       t        | |j                   |       y# t        $ r Y yw xY w# |$ r Y yt        $ r' |j                  d	       |j                  d	       Y yw xY w# t        $ r Y yw xY w)
a  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.
    r   Nr   )ocr_pdfOcrEngineUnavailable	SKIP_TEXTz.ocrtmp.pdf.txtrO  T
missing_ok)r  r   suffixrE  
ocr_enginer  r  r  ImportErrorr  ry   r/   r  r  r*   r  r&  )	r  r  srcr  r  r  tmp_outr  r>  s	            r1   _run_manual_ocrr&    s   /{{}

 0 0 2f <	KK //-0%%f-	S'	:F 	

3w<S*$'mL  		 $ 	 	NNdN+NNdN+	  se   6D 
C "D 'C 2AD 	CD CD D	D ,D	D D		D 	DDc                 \    t        j                  t        | |fd      j                          y)zDStart a daemon thread to OCR + index a freshly uploaded manual (D3).Tr  rh  r  N)r  r  r&  rq   )r  r  s     r1   _enqueue_manual_ocrr)    s'    }- egr3   c                     	 t        t               j                               } t        j	                         syi }t               j                         D ]+  }|j                  d      }|s|j                  d|      ||<   - t        j                  d      D ]/  }|j                  }|| v rt        ||j                  ||             1 y# t        $ r Y yw xY w)zOne-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.Nr  r  *.pdf)r  r	  keysr  r  r  r  r   r  r9   r&  ry   )indexednamesr   r  rb   s        r1   _backfill_manuals_ocrr/  (  s    .05578 %%'(*113 	;E:&B!IIor:b		; $((1 	3DBW}B		"b 12		3
  s   5C 0C )AC 	CC>   bmpru  rr  rq  rs  rt  c                     t         | dz   z  S )Nr   )WOR_MANUALS_THUMBS_DIR)r  s    r1   _manual_thumb_pathr3  J  s    !_v%=>>r3   r$  thumbc                    	 t         j                  dd       |dk(  rddl}ddl}|j	                  t        |             }	 |j                  dk  r	 |j                          y|j                  d      }t        t        |j                  j                  |j                  j                  d      z  }|j                  |j                  ||            }t!        j                   |j"                  |j%                  d                  j'                  d	      }	|j                          n7|t(        v r.t!        j                  t        |             j'                  d	      }	ny|	j+                  t        t        f       |j-                  d
      }
|	j/                  t        |
      dd       t1        j2                  t        |
      t        |             |S # |j                          w xY w# t4        $ r Y yw xY w)a  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.
    TrM   pdfr   Nr^   matrixrq  RGBz.tmp.jpgJPEGR   r  )r2  r  fitzioopenr*   
page_countr[  	load_pageMANUAL_THUMB_MAX_PXmaxrectwidthheight
get_pixmapMatrix
_pil_imageBytesIOtobytesconvert_MANUAL_THUMB_IMAGE_EXTS	thumbnailr  r  r  r  ry   )r$  r  r4  r=  r>  r   pagezoompiximgtmps              r1   _generate_manual_thumbrT  N  s|   $$TD$A%<))CH%C>>A% 		 }}Q'*S$))BRBRTU-VVooT[[t-DoE oojbjjU1C&DEMMeT		,,//#c(+33E:C*,?@A
+S62.


3s8SZ( 		  s>   ?G% G G% #B2G AG% A2G% G""G% %	G10G1c                 f   t         | z  }|j                         syt        |       }	 |j                         r7|j                         j                  |j                         j                  k\  r|S |j                  j                         j                  d      }t        |||      S # t
        $ r Y Aw xY w)zReturn 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.
    Nr  )
r  r   r3  r  r  r   r!  rE  lstriprT  )r  r$  r4  r  s       r1   _ensure_manual_thumbrW  q  s     O
+C;;=/E==?uzz|44
8K8KKL **



#
#C
(C!#sE22  s   AB$ $	B0/B0c                  :   	 t         j                         syt         j                         D ]_  } | j                         s| j                  j                         j                  d      }|dk(  s	|t        v sKt        | j                         a y# t        $ r Y yw xY w)zPre-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.Nr  r6  )r  r  iterdirr   r!  rE  rV  rM  rW  r9   ry   )rb   r  s     r1   _backfill_manual_thumbsrZ    s    

%%'#++- 	0D<<>++##%,,S1Ce|s&>>$TYY/	0  s   B AB 6B 	BBc                     t        t              } t        j                         r	 t	        j
                  t        j                               }t        |t               r|j                  d      t        v r|d   | d<   |j                  d      t        v r|d   | d<   t        |j                  d            }dD ].  }t        |j                  |            }|||vr||}|*|| |<   0 t        |j                  d            }||| d<   t        |j                  d            }||| d<   | S | S # t        j                  t        f$ r Y | S w xY w)z9Return branding settings, falling back to Togen defaults.re  rf  
bg_opacityrg  rh  ri  rj  )r   DEFAULT_BRANDING_SETTINGSBRANDING_SETTINGS_FILEr   r   r   r   r   r   BRANDING_LOGO_MODESBRANDING_BG_MODESr  r  r  r   r   )r  r  legacyr)  opacityri  rj  s          r1   _load_branding_settingsrd    sW   -.H%%'	JJ5??ABE%&99[)-@@,1+,>H[)99Y'+<<*/	*:HY' ,EIIl,CDB 0C03@G3e+;@R"(*(/0  4EIIn4MN+/;H^,$=eiiH[>\$]!$04EH01 O8O $$g. 	O	s   B:D. !A	D. .E
Ec                 X    t         j                  t        j                  | d             y r   )r_  r   r   r   r  s    r1   _save_branding_settingsrg    s    %%djj!&DEr3   c                       t               } |  dS )zJReturn the base URL for QR-linked files: shared URL prefix + /shared path.z/sharedr  r  s    r1   r+   r+     s    $&FXWr3   z$^[a-zA-Z0-9][a-zA-Z0-9 _\-\.]{0,79}$zwor_counter.json	file_pathc                     t        | j                               }t        t        j                               |v ryt        t        j                               |v ryt        t        j                               |v ryy)z,Detect wor_type from the resolved file path.r   r   r   r   )r*   r,   WOR_MOBILEMACHINE_DIRWOR_SAFETY_DIRWOR_PREVENTIVE_DIR)ri  r  s     r1   _detect_wor_typern    se    I A
 ((*+q0
>!!#$)
%%'(A-r3   rb  c                    |rt         | z  }||dz  |dz  |dz  dS | dk(  rt        t        t        t        dS | dk(  rt
        t        t        t        dS | dk(  rt        t        t        t        dS t        t        t        t         dS )ak  Return status->directory mapping for a WOR type.

    When ``test`` is set, the mapping points at the segregated ``_test`` subtree
    (DVI-1082) instead of the production tree. The layout mirrors production
    (new = the type root, plus in_progress/completed/cancelled subdirs) so the
    existing status-move plumbing works unchanged inside the test tree.
    r   r   r   r$  r   r   r   r   r   r   )WOR_TEST_ROOTrk  WOR_MM_INPROGRESS_DIRWOR_MM_COMPLETED_DIRWOR_MM_CANCELLED_DIRrl  WOR_SAFETY_INPROGRESS_DIRWOR_SAFETY_COMPLETED_DIRWOR_SAFETY_CANCELLED_DIRrm  WOR_PREVENTIVE_INPROGRESS_DIRWOR_PREVENTIVE_COMPLETED_DIRWOR_PREVENTIVE_CANCELLED_DIRWOR_MAINTENANCE_DIRWOR_MAINT_INPROGRESS_DIRWOR_MAINT_COMPLETED_DIRWOR_MAINT_CANCELLED_DIR)r  rb  r  s      r1   r  r    s     x'-/++	
 	
 ?"(0--	
 	
 8!411	
 	
 <%855	
 	
 #/,,	 r3   )r   r   r   r  c                 X   |xs i }| |j                  d      ||j                  d      |j                  d      |j                  d      |j                  d      |j                  d      |j                  d      |j                  d      |j                  d      |j                  d	      d
S )uU  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.
    _wor_number_notifyr  requestor_namer  r  urgencydesired_completion_dater  )wor_id
wor_numberr  notifysubmitter_emailr  r  r  r  r  r  r  r  )r  r  r  r  s       r1   _wor_event_contextr    s     
	Bff]+&&#66'?&&!12FF:&FF:&66)$#%66*C#Dvvm, r3   c                     t         j                  dd       d} t        j                         r8	 t	        j
                  t        j                               j                  dd      } t        | d      dz   } t        j                  t	        j                  d| i             | S # t        j                  t        f$ r d} Y Xw xY w)z;Atomically increment and return the next work order number.TrM   r   next_numberr^   )r   r  WOR_COUNTER_PATHexistsr   r   r   r   r   r   rC  r   r   counters    r1   _next_work_order_numberr  $  s    MM$M.G 	jj!1!;!;!=>BB=RSTG '1o!G

M7+C DEN	 $$g. 	G	   7B! !B?>B?c                     t         j                  dd       d} t        j                         r8	 t	        j
                  t        j                               j                  dd      } t        | d      dz   } t        j                  t	        j                  d| i             | S # t        j                  t        f$ r d} Y Xw xY w)zAtomically increment and return the next TEST work order number (DVI-1082).

    Uses a counter separate from the production one so test WOs never burn or gap
    production WO numbers. Test WO numbers are surfaced with a ``T`` prefix.
    TrM   r   r  r^   )rq  r  WOR_TEST_COUNTER_PATHr  r   r   r   r   r   r   rC  r   r   r  s    r1   _next_test_work_order_numberr  2  s     t4G##%	jj!6!@!@!BCGGWXYG '1o!G$$TZZ0H%IJN	 $$g. 	G	r  c                      t         j                         r(	 t        j                  t         j	                               S i S # t        j
                  t        f$ r Y i S w xY w)z'Load the recurring work order registry.)WOR_RECURRING_PATHr  r   r   r   r   r   rX   r3   r1   _load_recurring_registryr  D  sW      "	::0::<== I $$g. 	I	s   &? AAc                     t         j                  dd       t        j                  t	        j
                  | d             y)z'Save the recurring work order registry.TrM   r   r   N)r   r  r  r   r   r   r  s    r1   _save_recurring_registryr  N  s+    MM$M.!!$**Xa"@Ar3   
start_datefrequency_days
after_datec                 j   ddl m} 	 t        j                  | d      j	                         }|s"t        j                         j	                         n#t        j                  |d      j	                         } ||      }|}||k  r||z  }||k  r|j                  d      S # t
        t        f$ r Y yw xY w)zPCalculate the next run date from start_date + frequency, on or after after_date.r   r   r  Nr  )	r   r   r  strptimer   r   r   r  r  )r  r  r  r   rq   refdeltanxts           r1   _calc_next_runr  T  s    "''
J?DDF .8.



#
#
%^=T=TU_ak=l=q=q=sC>*E
C
*u *<<
## 	" s   $B   B21B2end_datec                    ddl m} g }	 t        j                  | d      j	                         }|r|dk  r|S d}|r%	 t        j                  |d      j	                         } ||      }	||k  r*||z
  j                  |z  }
||	|
z  z   }||k  r||	z  }||k  r||k  r&|r||kD  r	 |S |j                  |       ||	z  }||k  r&|S # t
        t        f$ r |cY S w xY w# t
        t        f$ r d}Y w xY w)a:  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).
    r   r  r  Nr  )	r   r   r  r  r   r   r   r  ri   )r  r  r  	win_startwin_endr   runscurr  r  skips              r1   _recurring_runs_in_windowr  c  s)    #D%%j*=BBD ^q0
C	 ))(J?DDFC >*E
YC%%7EDL Io5LC Io
.39 K 	Cu	 .
 K- 	"  I& 	C	s"   $C $C CCC0/C0zworkorderadmins@icastinc.comsubject	body_textc                 Z   t         s t        j                  j                  d       y	 t	        j
                  t        t        t              }|j                  dg      }d|vr t        j                  j                  d       y| d|d	d
dt        iigddd}t        j                  dt          dd|d    dd|d      }|j                  dvr.t        j                  j                  d|j                  dd        yy# t         $ r" t        j                  j#                  d       Y yw xY w)zQSend a notification email to WOR admins via Microsoft Graph API. Fire-and-forget.z4Cannot send WOR admin email: AZURE_MAIL_FROM not setN	authorityclient_credential$https://graph.microsoft.com/.defaultscopesaccess_tokenz(WOR admin email: failed to acquire tokenTextcontentTypecontentemailAddressaddressr  bodytoRecipientsfalsemessagesaveToSentItems'https://graph.microsoft.com/v1.0/users/	/sendMailBearer application/jsonAuthorizationContent-Type   rx   r   r7  rK      zWOR admin email failed: %s,  zWOR admin email error)r   rm   rn   ro   msalConfidentialClientApplication_AZ_CLIENT_ID_AZ_AUTHORITY_HOME
_AZ_SECRETacquire_token_for_clienterrorWOR_ADMIN_EMAIL_http_requestspostrg   r&  ry   r  )r  r  
app_clienttoken_result
graph_bodyresps         r1   _send_wor_admin_emailr    s7   

QR677%7:W
!:::; ; =-JJGH #(.9E#i%AB!  '	

 ""5o5FiP#*<+G*H!I 2 
 :-JJ9499Tc?K . 6

456s   AC? A:C? ?(D*)D*c                 `    t        j                  t        | |fd      }|j                          y)zCSend admin email in a background thread to avoid blocking requests.Tr(  N)r  r  r  rq   )r  r  r   s      r1   _send_wor_admin_email_bgr    s&     5Wi<PY]^AGGIr3   include_testc                 :   t        |       j                  d      j                  }|rd|v sd|v ryt        ||      D ]\  }||z  j	                         }t        |      j                  t        |j	                                     s y|j                         sZ|c S  y)zFind the JSON sidecar for a WOR PDF across all directories.

    ``include_test`` (DVI-1082) also searches the segregated ``_test`` subtree;
    default False keeps production reads isolated from test WOs.
    r  r  r6   Nr  )r   r  r9   r  r,   r*   rw   r   )r  r  r  	json_namern  	candidates         r1   _resolve_wor_sidecarr    s     X**7388I	)SI-=H<@ ]++-	9~((QYY[)9: r3   >   r   r   .heicr   .webp>   
attachmentrts_approvalrts_rejectionWOR_PHOTO_MAX_MB15   wor_pathc                 :    | j                   | j                  dz   z  S )z@Return the sibling '<stem>.photos/' directory for a WO PDF path.z.photos)parentstem)r  s    r1   _wor_photos_dirr    s    ??hmmi788r3   c                 T    | xs i j                  d      }t        |t              r|S g S )zDReturn the sidecar 'photos' list, tolerating missing/malformed data.photosr   r   r  )r  r  s     r1   _wor_photo_listr    s+     b%%h/F-6525r3   r  c           	         |xs g D cg c]  }|s|j                   s| }}|sy|xs i }t        dz  dz  }t        |       }|j                  dd       t	        j
                         j                  d      }g }|D ]  }|j                  j                  dt        j                         |j                  j                         }	|j                  j                  d       |	|kD  rit        j                  j                  |j                         d   j                         }
t        |j                         xs	 d|
xs dz   }t	        j
                         j                  d	       d
| }|j!                  t#        ||z               |j%                  |        |sy| j'                  d      }i }|j)                         r$	 t+        j,                  |j/                               }t5        |      }|D ])  }|j%                  ||j7                  dd      |ddd       + ||d<   |j9                  t+        j:                  |d             t=        |      S c c}w # t*        j0                  t2        f$ r i }Y w xY w)a  Save photos/files staged on a Work Order Request form (DVI-1065).

    Mirrors the WO Admin ``/wor-photo-upload`` storage: files land in the
    sibling ``<stem>.photos/`` directory and are appended to the sidecar
    ``photos`` list with context ``attachment``. Any file type is accepted;
    oversize files (> ``WOR_PHOTO_MAX_MB``) are skipped. Returns the count
    actually saved so callers can no-op cleanly when nothing was attached.
    r      TrM   r  r^   photor7   %Y%m%d_%H%M%S_%fr  r  r9   r  fileuploaded_byuploaded_atcontextcaptionr  r   r   )r  r  r  r  r  r  r  streamseekr  SEEK_ENDtellrb   r   rE  r   r  r*   ri   r  r   r   r   r   r   r   r  r   r   r   rC  )r  r  r'  r  uploads	max_bytes
photos_dirr_   r  rd  r  r  r4  r  r  r  s                   r1   _save_wor_request_photosr    s4    !;B<Q1q<G<:2D 4'$.I *JTD1					&	&':	;BE 
	a%xx}}	a)ggqzz*1-335qzz*Ew#)/D"&&(112DEFavN	s:&'(V
 ""7+G	B	G--/0B R F 88FB/#
 	 BxLtzz"Q/0u:Q =6 $$g. 	B	s!   III#I I65I6src_pdfdest_pdfc                     t        |       }t        |      }|j                         rh|j                         |j                         k7  rF|j                  j	                  dd       t        j                  t        |      t        |             yyy)zRelocate a WO's '<stem>.photos/' directory when the WO PDF changes dirs.

    Keeps attachments co-located with the work order across status moves,
    mirroring how the PDF, JSON sidecar, and '.evh.pdf' snapshot are relocated.
    TrM   N)r  r  r,   r  r  shutilmover*   )r	  r
  src_dirdest_dirs       r1   _move_wor_photos_dirr    so     g&Gx(H~~GOO-1A1A1CCdT:CL#h-0 Dr3   img_pathmax_pxc                    | j                   dz  }|| j                  dz   z  }	 |j                         r7|j                         j                  | j                         j                  k\  r|S |j                  dd       t        j                  |       5 }|j                  d      }|j                  ||f       |j                  t        |      dd       d	d	d	       |S # 1 sw Y   |S xY w# t        $ r Y y	w xY w)
a  Return a cached JPEG thumbnail for a WO photo, generating it on demand.

    Thumbnails are cached in a '.thumbs/' subfolder of the photos directory.
    Returns None if the source image cannot be decoded (e.g. HEIC without a
    codec); callers fall back to serving the original.
    z.thumbsr   TrM   r9  r:  r;  r<  N)r  r  r   r  r  r  rI  r?  rL  rN  r  r*   ry   )r  r  	thumb_dir
thumb_pathims        r1   _ensure_wor_photo_thumbr  !  s     )+Ihmmf45J OO%..(--/2J2JJt4__X& 	9"E"BLL&&)*GGC
OVRG8	9 		9  s7   AC+ *(C+ AC	C+ C(#C+ (C+ +	C76C7c                     |xs i j                  d      }t        |t              rd|dS t        j                  d| xs d      }|rdt        |j                  d            dS y)zKReturn a "WO #00042" label from sidecar data, falling back to the filename.r  WO #05d_(\d{4,6})_r7   r^   WO)r   r   r   rF  r  rb  )r  r   numms       r1   _wor_number_labelr  8  se    :2

=
)C#sc#Y
		.(.b1Ac!''!*oc*++r3   limitc                     dj                  | xs dj                               } t        |       |k  r| S | d| j                         dz   S )zECollapse whitespace and truncate request text, appending an ellipsis.r\   r7   N...)r  r  rC  r8   )r&  r   s     r1   _wor_abbrevr#  C  sH    88TZR&&()D
4yE< 5((r3   c                    |s| S t        | |      }|dk(  ra|j                  d      xs i j                  d      xs d}dj                  d |j                  d      |j                  d      fD              }nY|j                  d      xs( |j                  d      xs i j                  d      xs d}t        |j                  d	      xs d      }|g|r|gng z   |r|gng z   }d
j                  |      S )a  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.
    r   r  r  r7   r\   c              3   &   K   | ]	  }|s|  y wrT   rX   r,  r  s     r1   r/  z%_wor_display_title.<locals>.<genexpr>Y  s     XaVWX   machineasset_numberr   - )r  r   r  r#  )r  r  r   wo	requestorr  segmentss          r1   r  r  K  s     	8T	*B?"XXi(.B334DEK	xxXDHHY$7.9Q#RXXXX./ P),2778HIPMO 	488M28b9tv2.9+PRSH::hr3   c           
      	   ddl m}  |t        |             }|j                  r|j                  d   j	                         nd}|j                  d      }i }t        |      D ]  \  }}|j                         }|j                  d      r|dz   t        |      k  s:||dz      j                         }	t        j                  d|	      }
|
r5|
j                  d      |d<   |	d	|
j                          j                         }	t        j                  d
|	      }|rG|j                  d      j                         |d<   |	d	|j                          j                         |d<   |	|d<   |j                  d      r|dz   t        |      k  s'||dz      j                         }	t        j                  d|	      }|r:|j                  d      |d<   |	|j                         d	 j                         |d<   |	|d<   |j                  d      r.|dz   t        |      k  s||dz      j                         |d<   |dk(  r{g }t!        |dz   t        |            D ]:  }||   j                         j                  d      r n|j#                  ||          < dj%                  |      j                         |d<   U|j                  d      r0g }g d}t!        |dz   t'        |dz   t        |                  D ]  }||   }|j                         j                  d      s|j                         j                  d      r nU|D ]N  }d|v s||v s|j)                  d      }|j)                  |      }||k  s5||z
  dk  s>|j#                  |       P  |s_dj%                  ||dz   |dz          }|D ]@  }t        j                  dt        j*                  |      z   |      s0|j#                  |       B ||d<   |dk(  r.|dz   t        |      k  s||dz      j                         |d<   |j                  d       r.|dz   t        |      k  s||dz      j                         |d!<   	|j                  d      st!        |dz   t'        |d"z   t        |                  D ]Q  }d||   v sd#||   j                         d	d k(  s$||   j-                  dd      j                         }|d$v r||d%<      |S )&zNBest-effort extraction of form data from a WOR PDF (fallback when no sidecar).r   )	PdfReaderr7   
Requestor namer^   z[\w.\-+]+@[\w.\-]+\.\w+r  Nz(\d[\d\-\(\)\s\.]{6,}\d)r  r  Date of requestz(\d{4}-\d{2}-\d{2})date_of_requestr  Buildingr  Request descriptionzType of issuer  )
AutomotiveGroundsPlumbing	CustodialCranesConcrete FormsTelehandler/ForkliftMachine
ElectricalTool Repairr  Otherr  z
Best timesUrgencyu   ✔r  u	   [✔4]\s*
issue_typeBest times for service
best_timesDesired completion dater  r   rJ  LowMediumHighr  )pypdfr/  r*   r  extract_textr  r  r   rw   rC  rF  r  rb  rq   rG  r  r  ri   r  minfindescaper  )r  r/  readerr&  linesr   r  rs   r  valemail_matchphone_match
date_matchr  jtypes	all_typesclr   	idx_checkidx_typeblockurgs                          r1   _extract_wor_data_from_pdfr]  b  s   s8}%F-3\\6<<?'')rDJJtEDU# N4JJL<<()1us5z!AEl((* ii(BCH$/$5$5a$8DM2{002399;C ii(CSI$/$5$5a$8$>$>$@DM-01E+2C2C2E-F-L-L-ND)*-0D)*\\+,1us5z!AEl((*XX&<cB
.8.>.>q.AD*+'*:>>+;+<'='C'C'ED$.1D*+\\*%1us5z!#(Q<#5#5#7Z ''D1q5#e*- &8>>#..?E!H%& #'))D/"7"7"9D\\/*EI
 1q5#a!eSZ"89 	,1X88:((6"((*:O:OPY:Z" ,A{qBw$&GGEN	#%771:$x/X	5IR4O!LLO,		, 		%Aq1u"56" (Ayy		!!<eDQ( "'D**1us5z!%*1q5\%7%7%9\"\\341us5z!27A,2D2D2F./\\)$1q5#a!eSZ"89 E!H$uQx~~/?/C(C(**5"5;;=C77*-YSN` Kr3   job_idc                     t         j                  |       rd| v rt        d       t        | z  j	                         }t        |      j                  t        t        j	                                     st        d       |S )z6Return resolved Path for job_id; abort(400) if unsafe.r    )_SAFE_JOB_RErG  r   	WATCH_DIRr,   r*   rw   )r^  r  s     r1   _safe_job_folderrc    s\    f%c
& ))+Fv;!!#i&7&7&9":;c
Mr3   
job_folderc                     | j                         sy | j                         D ]Q  }|j                         sd|j                  j	                         v s1|j
                  j	                         dk(  sO|c S  y )Nr0   r   )r  rY  r   r9   rE  r!  )rd  r  s     r1   _find_generatedrf    s`    ! 99;;!&&,,.8QXX^^=MQW=WH r3   r  c                     | dz  S )N
state.jsonrX   )r  s    r1   _state_pathri    s    L  r3   c                     t        |       }|j                         r#t        j                  |j	                               S i S rT   )ri  r  r   r   r   )r  r  s     r1   _load_staterk    s.    FA()
4::akkm$::r3   ro  c                 b    t        |       j                  t        j                  |d             y r   )ri  r   r   r   )r  ro  s     r1   _save_staterm    s     ""4::eA#>?r3   r   	item_refsitem_cols_ac           
      V   | j                  d      D cg c]  }| }}g }t        |      D ]  \  }}|t        |      k  r||   nd}	|t        |      k  r||   nd}
|}|	|
|fD cg c]1  }|j                         s|j                         j	                         3 }}|	j                         r|	j                         j	                         gng }i }|D ]  }|j
                  j	                         t        |j                  |             }t        fd|D              sLj                  d      r|j                  d|       pj                  d      r|j                  d|       j                  d      s|j                  d|        d|vryd|v ru|rs|D ]n  }|j
                  j	                         j                  d      s/t        fd|D              sD|j                  dt        |j                  |                     n |j                  |        |S c c}w c c}w )	u  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).
    r+  r7   c              3   &   K   | ]  }|v  
 y wrT   rX   r,  ident
stem_lowers     r1   r/  z(_match_files_to_items.<locals>.<genexpr>  s     Duu
*Dr  shop	submittal	carrierqrcarrierc              3   &   K   | ]  }|v  
 y wrT   rX   rr  s     r1   r/  z(_match_files_to_items.<locals>.<genexpr>  s     ;hTYEZ<O;hr  )rglobr  rC  r   rE  r  r*   relative_tor  endswith
setdefaultri   )r  r   rn  ro  r  	pdf_files
auto_filesr  	item_namecol_acol_hcol_cr  identifiers
col_a_onlymatchedr6  relrt  s                     @r1   _match_files_to_itemsr    s   . #LL12q2I2J!%( #9"#c+&6"6AB !C	N 2	! 49%2GUQ1779qwwy(UU16u{{}**,-B
 
	3C)Jcoof-.CDDD""6*""63/$$[1"";4$$[1""9c2
	3 g%&G*;
   XX^^-
&&{3;h]g;h8h&&{C8O4PQ	 	'"?#B I 3 Vs   	H!'H&= H&c                 x   t         ydx}x}}t        | j                               D ]k  }|j                         r|j                  j                         dk7  r1|j                  j                         }d|v rPd|v r||}Yd|v r||}bd|v sg|j|}m |r|r|sy| j                  }t               }|j                  d       dt        |       }| dt        |j                         }	| dt        |j                         }
|j                         }t        ||	|
      }| | dz  }|j                  |       y)	zGClassify uploaded PDFs by filename and run the QR merge (legacy route).Nr   r0   rx  rv  ru  r6    CarrierQR generated.pdf)r4   r  rY  r   r!  rE  r9   r+   r8   r   r.   r-   )rd  rx  rv  ru  r  r9   folder_name_qr_baser<   r>   r=   blank_bytesr@   output_paths                 r1   _run_merge_singler    sO    !%%G%i$J&&() yy{ahhnn.&8vv||~$GD Y%6It^D 	d//K!HOOC()5+=*>?J!l!E)..$9#:;MQuTYY/01H$$&K$[-JL+.FGGKL)r3   uA <!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-bar-alpha: 1; --togen-inset-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; }
  /* DVI-1087: top bar, Search/Assistant surfaces, and tab strips follow the pane
     transparency settings. html.togen-glass is set by applyPaneAlpha() only when
     the transparency gate passes (image bg + desktop + opacity<100), so flat/
     default/mobile users see zero change. --togen-bar-alpha is floored at 0.85
     so typed text on interactive surfaces stays legible at low pane opacity. */
  .togen-topbar { background-color: rgba(255, 255, 255, var(--togen-pane-alpha)); }
  .dark .togen-topbar { background-color: rgba(17, 24, 39, var(--togen-pane-alpha)); }
  .togen-bar-surface { background-color: rgba(255, 255, 255, var(--togen-bar-alpha)); }
  .dark .togen-bar-surface { background-color: rgba(31, 41, 55, var(--togen-bar-alpha)); }
  html.togen-glass .togen-topbar,
  html.togen-glass .togen-bar-surface { -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
  html.togen-glass .togen-tabbar { background-color: rgba(255, 255, 255, var(--togen-pane-alpha)); border-radius: 0.75rem 0.75rem 0 0; -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
  html.togen-glass.dark .togen-tabbar { background-color: rgba(17, 24, 39, var(--togen-pane-alpha)); }
  /* Strips already inside a tented pane keep their bare look (no double tint). */
  html.togen-glass .togen-pane .togen-tabbar { background-color: transparent; border-radius: 0; -webkit-backdrop-filter: none; backdrop-filter: none; }
  /* DVI-1329: backing panels for "ghost" controls and backing-less inset cards so
     they stay legible over a transparent custom background. Both gated on
     html.togen-glass, so default/flat/opaque/mobile users see ZERO change (same
     no-regression gate as the DVI-1087 bar surfaces). .togen-control backs a
     ghost button at --togen-bar-alpha (floored 0.85); the :not(:hover) scope lets
     each button's own hover:bg fill win on hover so feedback is preserved.
     .togen-inset backs a nested card/list row at --togen-inset-alpha (floored
     0.72 — see applyPaneAlpha) so it keeps a readable backing without becoming a
     flat opaque rectangle. */
  html.togen-glass .togen-control:not(:hover) { background-color: rgba(255, 255, 255, var(--togen-bar-alpha)); }
  html.togen-glass.dark .togen-control:not(:hover) { background-color: rgba(31, 41, 55, var(--togen-bar-alpha)); }
  html.togen-glass .togen-control { -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
  html.togen-glass .togen-inset { background-color: rgba(255, 255, 255, var(--togen-inset-alpha)); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
  html.togen-glass.dark .togen-inset { background-color: rgba(17, 24, 39, var(--togen-inset-alpha)); }
  /* DVI-1329 reject follow-up: warn-tinted backing for the amber "Errors Detected"
     callouts. Keeps the yellow tint the board asked to preserve but backs it at the
     inset alpha so it stays legible over a transparent custom background (the bare
     bg-yellow-*/20 went nearly invisible when the callout wasn't nested inside an
     already-backed pane, e.g. Sync Admin). Glass-gated → zero change off-glass. */
  html.togen-glass .togen-inset-warn { background-color: rgba(254, 249, 195, var(--togen-inset-alpha)); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
  html.togen-glass.dark .togen-inset-warn { background-color: rgba(69, 46, 12, var(--togen-inset-alpha)); }
  /* DVI-1329 P3.1 reject follow-up: red-tinted backing for the "failure notification"
     panes (Auditor -> Diagnosis -> Vision fleet: failure-signature + agents-with-errors
     cards). Same idea as togen-inset-warn — keeps the red failure tint but backs it at
     the inset alpha so it stays legible over a transparent custom background instead of
     showing the background straight through. Glass-gated → zero change off-glass. */
  html.togen-glass .togen-inset-danger { background-color: rgba(254, 226, 226, var(--togen-inset-alpha)); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
  html.togen-glass.dark .togen-inset-danger { background-color: rgba(69, 10, 10, var(--togen-inset-alpha)); }
  /* 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; }
  /* DVI-1356 F3: a home-grid section whose separator overrides columns to 1
     (set by _applyTileGridColumns at lg+) must clamp any wide dashboard tile
     (sm:col-span-2) back to a single column so it can't force an implicit 2nd
     column. A dashref's col-span-full is a no-op at 1 column, so this is safe. */
  .tile-grid-section.tile-section-single > * { grid-column: auto; }
</style>
</head>
<body class="bg-gray-50 dark:bg-gray-950 min-h-screen font-sans">

<!-- ===== Separator Edit dialog (DVI-1340 P2) — shared by the Togen Admin and
     User Settings tool editors. Authored at body scope so its fixed overlay is
     never trapped inside a display:none tool pane (the DVI-1255 lesson). ===== -->
<div id="sep-edit-modal" class="hidden fixed inset-0 z-[130] flex items-center justify-center p-4 bg-black/40">
  <div class="togen-pane w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-xl border border-gray-200 dark:border-gray-700 p-6">
    <div class="flex items-center justify-between mb-4">
      <h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Edit separator</h3>
      <button type="button" onclick="_sepEditClose()" title="Close"
        class="inline-flex items-center justify-center h-8 w-8 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 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="space-y-4">
      <label class="block">
        <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Label <span class="text-gray-400 dark:text-gray-500 font-normal">(optional)</span></span>
        <input id="sep-edit-label" type="text" maxlength="60" placeholder="e.g. Maintenance"
          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-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">Label alignment</span>
        <select id="sep-edit-justify"
          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-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal cursor-pointer">
          <option value="left">Left</option>
          <option value="center">Center</option>
          <option value="right">Right</option>
        </select>
      </label>
      <label class="block">
        <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Columns</span>
        <select id="sep-edit-columns"
          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-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal cursor-pointer">
          <option value="auto">Auto (use your column setting)</option>
          <option value="1">1 column</option>
          <option value="2">2 columns</option>
          <option value="3">3 columns</option>
          <option value="4">4 columns</option>
          <option value="5">5 columns</option>
          <option value="6">6 columns</option>
        </select>
        <span class="block mt-1 text-xs text-gray-400 dark:text-gray-500">Overrides how many columns the section below this bar uses on the home grid (wide tiles clamp to 1). List view ignores this.</span>
      </label>
      <label class="block">
        <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Collapse control</span>
        <select id="sep-edit-collapse" onchange="_sepEditSyncCollapsedEnable()"
          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-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal cursor-pointer">
          <option value="none">None (not collapsible)</option>
          <option value="left">Chevron on the left</option>
          <option value="right">Chevron on the right</option>
          <option value="both">Chevron on both sides</option>
        </select>
        <span class="block mt-1 text-xs text-gray-400 dark:text-gray-500">A collapse control lets users show/hide the tools in the section below this bar.</span>
      </label>
      <label class="flex items-center gap-3">
        <input id="sep-edit-collapsed" type="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-700 dark:text-gray-300">Collapsed by default</span>
      </label>
    </div>
    <div class="flex items-center justify-between gap-3 mt-6">
      <button type="button" id="sep-edit-reset" onclick="_sepEditReset()"
        class="hidden px-3 py-2 text-sm font-medium rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer">
        Reset to default
      </button>
      <div class="flex items-center gap-3 ml-auto">
        <button type="button" onclick="_sepEditClose()"
          class="px-4 py-2 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">
          Cancel
        </button>
        <button type="button" onclick="_sepEditSave()"
          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>
</div>

<!-- Web-item (website tile) Edit dialog (DVI-1344). Body-scoped so its fixed
     overlay is never trapped in a display:none tool pane (the DVI-1255 lesson). -->
<div id="web-item-edit-modal" class="hidden fixed inset-0 z-[130] flex items-center justify-center p-4 bg-black/40">
  <div class="togen-pane w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-xl border border-gray-200 dark:border-gray-700 p-6">
    <div class="flex items-center justify-between mb-4">
      <h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Website tile</h3>
      <button type="button" onclick="_webEditClose()" title="Close"
        class="inline-flex items-center justify-center h-8 w-8 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 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="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 id="web-edit-name" type="text" maxlength="80" placeholder="e.g. NetSuite"
          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-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">URL</span>
        <input id="web-edit-url" type="text" maxlength="2048" placeholder="https://example.com" oninput="_webEditRenderIconPreview()"
          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-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">Open in</span>
        <select id="web-edit-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-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal">
          <option value="newtab">New tab</option>
          <option value="embed">Embedded pane</option>
        </select>
        <span class="block mt-1 text-xs text-gray-400 dark:text-gray-500">Embedded shows the site inside Togen. Some sites block embedding for security; if the pane stays blank, use the "Open in new tab" button in its header.</span>
      </label>
      <div>
        <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Icon</span>
        <div class="flex items-start gap-3">
          <div id="web-edit-icon-preview" class="shrink-0 h-12 w-12 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex items-center justify-center text-gray-400 dark:text-gray-500 overflow-hidden"></div>
          <div class="flex-1 space-y-2">
            <label class="flex items-center gap-2 cursor-pointer">
              <input type="radio" name="web-edit-icon-mode" id="web-edit-icon-favicon" value="favicon" onchange="_webEditSyncIconMode();_webEditRenderIconPreview()"
                class="h-4 w-4 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">Use the site's favicon</span>
            </label>
            <label class="flex items-center gap-2 cursor-pointer">
              <input type="radio" name="web-edit-icon-mode" id="web-edit-icon-custom" value="custom" onchange="_webEditSyncIconMode();_webEditRenderIconPreview()"
                class="h-4 w-4 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">Use a custom icon</span>
            </label>
            <label class="flex items-center gap-2 cursor-pointer">
              <input type="radio" name="web-edit-icon-mode" id="web-edit-icon-glyph" value="glyph" onchange="_webEditSyncIconMode();_webEditRenderIconPreview()"
                class="h-4 w-4 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">Choose from library</span>
            </label>
          </div>
        </div>
        <div id="web-edit-glyph-block" class="hidden mt-3">
          <div id="web-edit-glyph-grid" class="grid grid-cols-8 gap-1 max-h-44 overflow-y-auto p-1.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"></div>
        </div>
        <div id="web-edit-custom-block" class="hidden mt-3">
          <div id="web-edit-drop" class="rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 p-4 text-center text-sm text-gray-500 dark:text-gray-400 cursor-pointer hover:border-togen-teal transition-colors"
               onclick="_webEditPickIcon()">
            <span class="block">Drag &amp; drop an image here, or <span class="text-togen-teal font-medium">choose a file</span></span>
            <span class="block mt-1 text-xs text-gray-400 dark:text-gray-500">PNG, JPEG, GIF, WebP or SVG · 512&nbsp;KB max</span>
          </div>
          <input id="web-edit-icon-file" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" class="hidden" onchange="_webEditIconFileChanged(this)" />
          <button type="button" onclick="_webEditClearIcon()" class="mt-2 text-xs text-gray-500 dark:text-gray-400 hover:text-red-500 transition-colors cursor-pointer">Remove custom icon</button>
        </div>
      </div>
      <p id="web-edit-error" class="text-sm text-red-500 min-h-[1.25rem]"></p>
    </div>
    <div class="flex items-center justify-between gap-3 mt-6">
      <button type="button" id="web-edit-reset" onclick="_webEditReset()"
        class="hidden px-3 py-2 text-sm font-medium rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer">
        Reset to default
      </button>
      <div class="flex items-center gap-3 ml-auto">
        <button type="button" onclick="_webEditClose()"
          class="px-4 py-2 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">
          Cancel
        </button>
        <button type="button" onclick="_webEditSave()"
          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>
</div>

<!-- Dashboard-tile home item edit dialog (DVI-1346 P2). Body-scoped so its
     fixed overlay is never trapped in a display:none tool pane (DVI-1255). Sits
     below the dashboards tile picker/config overlays (z-100) so "Configure tile"
     opens them on top. -->
<div id="dash-tile-item-edit-modal" class="hidden fixed inset-0 z-[90] flex items-center justify-center p-4 bg-black/40">
  <div class="togen-pane w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-xl border border-gray-200 dark:border-gray-700 p-6">
    <div class="flex items-center justify-between mb-4">
      <h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Dashboard tile</h3>
      <button type="button" onclick="_dtileEditClose()" title="Close"
        class="inline-flex items-center justify-center h-8 w-8 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 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="space-y-4">
      <label class="block">
        <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title <span class="text-gray-400 font-normal">(optional)</span></span>
        <input id="dash-item-edit-title" type="text" maxlength="80" placeholder="e.g. Open Work Orders"
          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-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">Home size</span>
        <select id="dash-item-edit-size"
          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-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal">
          <option value="default">Default</option>
          <option value="wide">Wide (full width)</option>
          <option value="tall">Tall</option>
        </select>
      </label>
      <div>
        <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tile</span>
        <div class="flex items-center gap-3">
          <button type="button" onclick="_dtileEditConfigure()"
            class="togen-control 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:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer">
            Configure tile…
          </button>
          <span id="dash-item-edit-summary" class="text-xs text-gray-500 dark:text-gray-400 truncate">No tile configured yet.</span>
        </div>
      </div>
      <p id="dash-item-edit-error" class="text-sm text-red-500 min-h-[1.25rem]"></p>
    </div>
    <div class="flex items-center justify-between gap-3 mt-6">
      <button type="button" id="dash-item-edit-reset" onclick="_dtileEditReset()"
        class="hidden px-3 py-2 text-sm font-medium rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer">
        Reset to default
      </button>
      <div class="flex items-center gap-3 ml-auto">
        <button type="button" onclick="_dtileEditClose()"
          class="px-4 py-2 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">
          Cancel
        </button>
        <button type="button" onclick="_dtileEditSave()"
          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>
</div>

<!-- Embedded-dashboard home item edit dialog (DVI-1347 P3). Body-scoped so its
     fixed overlay is never trapped in a display:none tool pane (DVI-1255). -->
<div id="dashref-item-edit-modal" class="hidden fixed inset-0 z-[90] flex items-center justify-center p-4 bg-black/40">
  <div class="togen-pane w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-xl border border-gray-200 dark:border-gray-700 p-6">
    <div class="flex items-center justify-between mb-4">
      <h3 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Embedded dashboard</h3>
      <button type="button" onclick="_dashRefEditClose()" title="Close"
        class="inline-flex items-center justify-center h-8 w-8 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 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="space-y-4">
      <label class="block">
        <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Dashboard to display</span>
        <select id="dashref-edit-dashboard"
          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-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal">
          <option value="">Loading dashboards…</option>
        </select>
      </label>
      <label class="block">
        <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title <span class="text-gray-400 font-normal">(optional — defaults to the dashboard name)</span></span>
        <input id="dashref-edit-title" type="text" maxlength="60" placeholder="e.g. Maintenance Overview"
          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-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal" />
      </label>
      <div class="grid grid-cols-2 gap-3">
        <label class="block">
          <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Columns</span>
          <select id="dashref-edit-columns"
            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-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal">
            <option value="auto">Auto</option><option value="1">1</option><option value="2">2</option>
            <option value="3">3</option><option value="4">4</option><option value="5">5</option><option value="6">6</option>
          </select>
        </label>
        <label class="block">
          <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Rows</span>
          <select id="dashref-edit-rows"
            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-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal">
            <option value="auto">Auto</option><option value="1">1</option><option value="2">2</option>
            <option value="3">3</option><option value="4">4</option><option value="5">5</option><option value="6">6</option>
          </select>
        </label>
      </div>
      <div class="grid grid-cols-2 gap-3">
        <label class="block">
          <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bar alignment</span>
          <select id="dashref-edit-justify"
            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-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal">
            <option value="left">Left</option><option value="center">Center</option><option value="right">Right</option>
          </select>
        </label>
        <label class="block">
          <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Collapse control</span>
          <select id="dashref-edit-collapse" onchange="_dashRefEditSyncCollapsed()"
            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-800 dark:text-gray-100 focus:ring-togen-teal focus:border-togen-teal">
            <option value="none">None</option><option value="left">Chevron left</option>
            <option value="right">Chevron right</option><option value="both">Chevrons both</option>
          </select>
        </label>
      </div>
      <label class="flex items-center gap-2">
        <input id="dashref-edit-collapsed" type="checkbox"
          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">Collapsed by default</span>
      </label>
      <p id="dashref-edit-share-note" class="hidden text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-lg px-3 py-2">
        Embedding a dashboard here makes it viewable by everyone in the organization (it's shared to all).
      </p>
      <p id="dashref-edit-error" class="text-sm text-red-500 min-h-[1.25rem]"></p>
    </div>
    <div class="flex items-center justify-between gap-3 mt-6">
      <button type="button" id="dashref-edit-reset" onclick="_dashRefEditReset()"
        class="hidden px-3 py-2 text-sm font-medium rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer">
        Reset to default
      </button>
      <div class="flex items-center gap-3 ml-auto">
        <button type="button" onclick="_dashRefEditClose()"
          class="px-4 py-2 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">
          Cancel
        </button>
        <button type="button" onclick="_dashRefEditSave()"
          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>
</div>

<!-- ===== TOP NAVBAR ===== -->
<nav class="fixed top-0 left-0 right-0 z-50 togen-topbar 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 togen-bar-surface 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-[min(32rem,calc(100vw-14rem))] max-h-[min(42rem,calc(100vh-6rem))] overflow-y-auto togen-bar-surface 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 togen-bar-surface 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>
      <select id="topbar-asst-model" onchange="assistantModelPick('topbar-asst-model')" title="Model"
              class="hidden mb-1 max-w-[9rem] px-1.5 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 focus:border-togen-teal focus:outline-none flex-shrink-0"></select>
      <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 togen-bar-surface 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 togen-bar-surface 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>

<!-- ===== ONEDRIVE / SHAREPOINT FOLDER PICKER (DVI-842) — reusable, global ===== -->
<div id="od-picker-overlay" class="hidden fixed inset-0 z-[80] flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" onclick="if(event.target===this)odPickerClose()">
  <div class="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 flex flex-col max-h-[80vh]">
    <div class="flex items-center justify-between bg-togen-teal rounded-t-2xl px-6 py-3 shrink-0">
      <h2 id="od-picker-title" class="text-sm font-bold text-white uppercase tracking-wide">Browse OneDrive</h2>
      <button onclick="odPickerClose()" class="text-white/80 hover:text-white transition-colors cursor-pointer" 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-3 border-b border-gray-100 dark:border-gray-700 shrink-0">
      <nav id="od-picker-breadcrumb" class="flex flex-wrap items-center gap-1 text-xs text-gray-600 dark:text-gray-300"></nav>
    </div>
    <div id="od-picker-body" class="flex-1 overflow-y-auto px-3 py-2 min-h-[8rem]">
      <div id="od-picker-loading" class="hidden py-8 text-center text-sm text-gray-400">Loading…</div>
      <div id="od-picker-error" class="hidden py-6 px-3 text-sm text-red-500"></div>
      <div id="od-picker-empty" class="hidden py-8 text-center text-sm text-gray-400">No subfolders here.</div>
      <ul id="od-picker-list" class="space-y-1"></ul>
    </div>
    <div class="flex items-center justify-between gap-3 px-6 py-3 border-t border-gray-100 dark:border-gray-700 shrink-0">
      <span class="text-xs text-gray-500 dark:text-gray-400 truncate">Current: <span id="od-picker-current" class="font-medium text-gray-700 dark:text-gray-200">Home</span></span>
      <div class="flex items-center gap-2 shrink-0">
        <button onclick="odPickerClose()" type="button"
                class="togen-control px-3 py-2 text-sm 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 cursor-pointer">Cancel</button>
        <button onclick="odPickerSelect()" type="button"
                class="px-4 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer">Select this folder</button>
      </div>
    </div>
  </div>
</div>

<!-- ===== 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>
        <div class="flex items-center gap-2">
          <label for="assistant-mobile-model" class="text-xs font-medium text-gray-500 dark:text-gray-400 flex-shrink-0">Model</label>
          <select id="assistant-mobile-model" onchange="assistantModelPick('assistant-mobile-model')"
                  class="flex-1 min-w-0 px-2 py-1.5 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:border-togen-teal focus:outline-none"></select>
        </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>
      <div class="flex items-center gap-2 flex-shrink-0">
        <!-- Home-grid edit-mode toolbar (DVI-1357 F4) — shown only in edit mode -->
        <div id="home-edit-toolbar" class="hidden flex items-center gap-2">
          <span id="home-edit-toast" class="text-xs font-medium" aria-live="polite"></span>
          {% if is_togen_admin %}
          <!-- Default-list / My-list scope switch (admins only) -->
          <div id="home-scope-switch" class="inline-flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden text-xs font-medium" title="Which list you are editing">
            <button type="button" id="home-scope-user" onclick="homeEditSetScope('user')" class="px-2.5 py-1 cursor-pointer bg-togen-teal text-white">My list</button>
            <button type="button" id="home-scope-admin" onclick="homeEditSetScope('admin')" class="px-2.5 py-1 cursor-pointer text-gray-600 dark:text-gray-300">Default</button>
          </div>
          {% endif %}
          <!-- + Add menu -->
          <div class="relative">
            <button type="button" id="home-edit-add-btn" onclick="homeEditToggleAddMenu(event)"
                    class="togen-control inline-flex items-center gap-1 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">
              <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>
              <span>Add</span>
            </button>
            <div id="home-edit-add-menu" class="hidden absolute right-0 mt-1 w-56 z-[20] togen-bar-surface bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-lg py-1">
              <button type="button" onclick="homeEditAddWebsite()" class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Website tile</button>
              <button type="button" onclick="homeEditAddDashTile()" class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Dashboard tile</button>
              <button type="button" onclick="homeEditAddDashRef()" class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Embedded dashboard</button>
              <button type="button" onclick="homeEditAddSeparator()" class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Separator</button>
            </div>
          </div>
          <!-- Reset my layout (DVI-1496 P6, D10-A) — a My-list affordance; clears
               this user's tool_order/tool_separators/tool_items back to the admin
               Default. Hidden in the Default scope by _homeUpdateScopeButtons. -->
          <button type="button" id="home-edit-reset" onclick="homeResetMyLayout()" title="Clear your personal changes and use the default layout"
                  class="togen-control inline-flex items-center gap-1 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">Reset my layout</button>
        </div>
        <!-- Edit toggle (DVI-1357 F4) -->
        <button type="button" id="home-edit-toggle" onclick="homeEditToggle()"
                class="togen-control 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" title="Edit the home page in place">
          <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>
          <span id="home-edit-toggle-label">Edit</span>
        </button>
        <!-- Grid/List view toggle (DVI-512) -->
        <button type="button" id="tile-view-toggle" onclick="toggleTileView()"
                class="togen-control 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>

    <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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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-control 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="togen-control 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="togen-inset 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="togen-control 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-control 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="togen-control 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="togen-control 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="togen-tabbar 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.</p>
          </div>
        </section>
        {% if is_togen_admin %}
        <!-- DVI-959 P2: Approved domains & paths allowlist (admin only) -->
        <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-4 space-y-4">
            <div>
              <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Approved domains &amp; paths</h2>
              <p class="text-sm text-gray-500 dark:text-gray-400">When enabled, Production Run only downloads sources whose host is in the approved list (and, if any path prefixes are set, whose path starts with one). Out-of-policy sources fail with an <span class="text-red-600 dark:text-red-400 font-medium">Outside approved domains/paths</span> reason instead of being fetched. Ships disabled — curate the list first, then enable.</p>
            </div>
            <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
              <input type="checkbox" id="prodrun-approved-enabled" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
              Enforce approved domains/paths on Production Run downloads
            </label>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Approved domains (one per line, e.g. <code>fileshare.icastinc.com</code>)</label>
              <textarea id="prodrun-approved-domains" rows="4" class="w-full 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 p-2 font-mono" placeholder="fileshare.icastinc.com&#10;icastinc.sharepoint.com"></textarea>
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Approved path prefixes (optional, one per line, e.g. <code>/shared/</code>)</label>
              <textarea id="prodrun-approved-prefixes" rows="3" class="w-full 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 p-2 font-mono" placeholder="/shared/"></textarea>
            </div>
            <div class="flex items-center gap-3">
              <button onclick="prodRunSaveApproved()" class="px-4 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white text-sm font-medium rounded-lg transition-colors">Save</button>
              <span id="prodrun-approved-status" class="text-xs"></span>
            </div>
          </div>
        </section>
        <!-- DVI-963 O3.P1: Auto-tuning rules (admin only) — data model + CRUD -->
        <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-4 space-y-4">
            <div>
              <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-1">Auto-tuning rules</h2>
              <p class="text-sm text-gray-500 dark:text-gray-400">Save reusable rules that describe how a recurring source failure should be corrected — rewrite a domain, remap a path prefix, or substitute a specific source URL. Rules can be seeded from a resolved failure (Auditor / History detail). <span class="font-medium text-gray-700 dark:text-gray-300">These rules are not applied yet</span> — a future phase will simulate them (dry-run) and later auto-apply enabled rules behind a gate. New rules are created disabled.</p>
            </div>
            <div id="prodrun-rules-list" class="space-y-2"><p class="text-sm text-gray-400">Loading…</p></div>
            <div class="border-t border-gray-200 dark:border-gray-700 pt-4">
              <p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Add a rule</p>
              <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
                <div>
                  <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Rule type</label>
                  <select id="prodrun-rule-type" class="w-full 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 p-2">
                    <option value="domain_rewrite">Domain rewrite (host → host)</option>
                    <option value="path_remap">Path remap (path prefix → path prefix)</option>
                    <option value="source_substitution">Source substitution (URL → URL)</option>
                  </select>
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Applies to category (optional)</label>
                  <select id="prodrun-rule-category" class="w-full 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 p-2">
                    <option value="">Any category</option>
                    <option value="file_not_found">File not found</option>
                    <option value="outside_approved_domain">Outside approved domains/paths</option>
                    <option value="not_pdf">Not a PDF</option>
                    <option value="http_error">HTTP error</option>
                    <option value="sharepoint_auth">SharePoint access denied</option>
                    <option value="network_error">Network error</option>
                    <option value="merge_error">Merge error</option>
                  </select>
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Match (from)</label>
                  <input id="prodrun-rule-match" type="text" class="w-full 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 p-2 font-mono" placeholder="old.example.com  /  /old/path/  /  https://old/full/url">
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Replacement (to)</label>
                  <input id="prodrun-rule-replacement" type="text" class="w-full 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 p-2 font-mono" placeholder="new.example.com  /  /new/path/  /  https://new/full/url">
                </div>
              </div>
              <div class="mt-3">
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Notes (optional)</label>
                <input id="prodrun-rule-notes" type="text" class="w-full 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 p-2" placeholder="Why this rule exists">
              </div>
              <div class="flex items-center gap-3 mt-3">
                <button onclick="prodRunAddRule()" class="px-4 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white text-sm font-medium rounded-lg transition-colors">Add rule (disabled)</button>
                <span id="prodrun-rule-status" class="text-xs"></span>
              </div>
            </div>
          </div>
        </section>
        {% endif %}
      </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>

        <!-- DVI-1503 (DVI-1496 P7 + board follow-up): Work Order Request Tile
             Surface. The WO-type nav tiles are now first-class catalog tiles
             rendered by JS into #wor-tiles-list (below), mixed + reorderable with
             the Add/Edit stack (Dashboard tiles / embedded dashboards / website
             tiles / separators) over tool_subpages['work_order_request']. WO-type
             VISIBILITY still comes from the WO-type stores (wor_type_visibility +
             per-user hide + the preventive role) via _worCatalogRenderable. -->
        <div id="wor-surface" class="w-full max-w-4xl mt-8">
          <div class="flex items-center justify-end gap-2 flex-wrap mb-4">
            <div id="wor-edit-toolbar" class="hidden flex items-center gap-2 flex-wrap">
              <span id="wor-edit-toast" class="text-xs font-medium" aria-live="polite"></span>
              {% if is_togen_admin %}
              <div id="wor-scope-switch" class="inline-flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden text-xs font-medium" title="Which list you are editing">
                <button type="button" id="wor-scope-user" onclick="worEditSetScope('user')" class="px-2.5 py-1 cursor-pointer bg-togen-teal text-white">My list</button>
                <button type="button" id="wor-scope-admin" onclick="worEditSetScope('admin')" class="px-2.5 py-1 cursor-pointer text-gray-600 dark:text-gray-300">Default</button>
              </div>
              {% endif %}
              <div class="relative">
                <button type="button" id="wor-edit-add-btn" onclick="worEditToggleAddMenu(event)"
                        class="togen-control inline-flex items-center gap-1 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">
                  <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>
                  <span>Add</span>
                </button>
                <div id="wor-edit-add-menu" class="hidden absolute right-0 mt-1 w-56 z-[20] togen-bar-surface bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-lg py-1">
                  <button type="button" onclick="worEditAddWebsite()" class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Website tile</button>
                  <button type="button" onclick="worEditAddDashTile()" class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Dashboard tile</button>
                  <button type="button" onclick="worEditAddDashRef()" class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Embedded dashboard</button>
                  <button type="button" onclick="worEditAddSeparator()" class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Separator</button>
                </div>
              </div>
              <button type="button" id="wor-edit-reset" onclick="worResetMyLayout()" title="Clear your personal changes and use the default layout"
                      class="togen-control inline-flex items-center gap-1 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">Reset my layout</button>
            </div>
            <button id="wor-tiles-edit" onclick="worEditToggle()" title="Add dashboard tiles and links to this page"
                    class="togen-control inline-flex items-center gap-1.5 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 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>
              <span id="wor-tiles-edit-label">Edit</span>
            </button>
          </div>
          <div id="wor-tiles-list" class="hidden grid grid-cols-1 sm:grid-cols-2 gap-5"></div>
        </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="togen-control 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="togen-control 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>
                <div class="relative" id="wor-eas-combo">
                  <input type="text" name="equipment" id="wor-eas-input" autocomplete="off"
                         role="combobox" aria-autocomplete="list" aria-expanded="false"
                         oninput="worEasOnInput()" onfocus="worEasOnFocus()" onblur="setTimeout(worEasHideDropdown,150)"
                         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="wor-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="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>

        <!-- Return-to-Service Approval (DVI-1014) — shown when equipment is filled -->
        <section id="wor-rts-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 space-y-3">
            <label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
              <input type="checkbox" id="wor-rts-required" name="rts_required" value="1"
                     onchange="worRtsToggleDepts()"
                     class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
              Return-to-Service approval required
            </label>
            <p id="wor-rts-forced-note" class="hidden text-xs text-amber-600 dark:text-amber-400">
              Required by equipment record — cannot be unchecked.
            </p>
            <div id="wor-rts-depts" class="hidden pl-5 space-y-2">
              <p class="text-xs font-medium text-gray-500 dark:text-gray-400">Required approving departments</p>
              <div class="grid grid-cols-2 gap-2">
                <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input type="checkbox" id="wor-rts-dept-maintenance" name="rts_dept_maintenance" value="1"
                         class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Maintenance
                </label>
                <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input type="checkbox" id="wor-rts-dept-operations" name="rts_dept_operations" value="1"
                         class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Operations
                </label>
                <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input type="checkbox" id="wor-rts-dept-safety" name="rts_dept_safety" value="1"
                         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" id="wor-rts-dept-quality" name="rts_dept_quality" value="1"
                         class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Quality
                </label>
              </div>
            </div>
          </div>
        </section>

        <!-- Photos / Files (DVI-1065) -->
        <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">Photos</h2>
          </div>
          <div class="px-6 py-4 space-y-3">
            <p class="text-xs text-gray-500 dark:text-gray-400">Attach photos or files to this work order. Drag &amp; drop one or multiple at a time, or click to browse.</p>
            <input type="file" id="worph-mnt-input" name="wor_photos" multiple class="hidden">
            <div id="worph-mnt-zone"
                 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-7 w-7 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 photos or 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">One or multiple at a time. Any file type accepted.</p>
            </div>
            <div id="worph-mnt-list" class="space-y-1"></div>
          </div>
        </section>

        <!-- 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>
          {% if wor_can_test %}
          <button type="button" id="wor-test-submit-btn" onclick="worSubmit(event, true)"
                  class="px-6 py-3 bg-white dark:bg-gray-900 border border-amber-400 dark:border-amber-500 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2"
                  title="Create a segregated test WO (not added to the audit trail)">
            <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="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zM12 2a1 1 0 01.967.744L13.146 4.5a3 3 0 002.354 2.354l1.756.18a1 1 0 010 1.933l-1.756.18A3 3 0 0013.146 11.5l-.179 1.756a1 1 0 01-1.934 0L10.854 11.5A3 3 0 008.5 9.146l-1.756-.18a1 1 0 010-1.933l1.756-.18A3 3 0 0010.854 4.5l.179-1.756A1 1 0 0112 2z" clip-rule="evenodd"/>
            </svg>
            Create Test WO
          </button>
          {% endif %}
          <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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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>

        <!-- Photos / Files (DVI-1065) -->
        <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">Photos</h2>
          </div>
          <div class="px-6 py-4 space-y-3">
            <p class="text-xs text-gray-500 dark:text-gray-400">Attach photos or files to this work order. Drag &amp; drop one or multiple at a time, or click to browse.</p>
            <input type="file" id="worph-sfty-input" name="wor_photos" multiple class="hidden">
            <div id="worph-sfty-zone"
                 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-7 w-7 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 photos or 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">One or multiple at a time. Any file type accepted.</p>
            </div>
            <div id="worph-sfty-list" class="space-y-1"></div>
          </div>
        </section>

        <!-- 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>
          {% if wor_can_test %}
          <button type="button" id="safety-test-submit-btn" onclick="safetySubmit(event, true)"
                  class="px-6 py-3 bg-white dark:bg-gray-900 border border-amber-400 dark:border-amber-500 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2"
                  title="Create a segregated test WO (not added to the audit trail)">
            <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="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zM12 2a1 1 0 01.967.744L13.146 4.5a3 3 0 002.354 2.354l1.756.18a1 1 0 010 1.933l-1.756.18A3 3 0 0013.146 11.5l-.179 1.756a1 1 0 01-1.934 0L10.854 11.5A3 3 0 008.5 9.146l-1.756-.18a1 1 0 010-1.933l1.756-.18A3 3 0 0010.854 4.5l.179-1.756A1 1 0 0112 2z" clip-rule="evenodd"/>
            </svg>
            Create Test WO
          </button>
          {% endif %}
          <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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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 class="relative">
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Machine</label>
              <input type="text" name="machine" id="mm-eas-input" required autocomplete="off"
                     onfocus="mmEasOnFocus()" oninput="mmEasOnInput()" onblur="setTimeout(mmEasHideDropdown, 150)"
                     aria-expanded="false" role="combobox"
                     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="mm-eas-dropdown" class="hidden absolute z-20 left-0 right-0 mt-1 max-h-56 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg text-sm"></div>
            </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" id="mm-asset-input"
                     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="togen-control 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="togen-control 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="togen-control 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="togen-control 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>

        <!-- Photos / Files (DVI-1065) -->
        <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">Photos</h2>
          </div>
          <div class="px-6 py-4 space-y-3">
            <p class="text-xs text-gray-500 dark:text-gray-400">Attach photos or files to this work order. Drag &amp; drop one or multiple at a time, or click to browse.</p>
            <input type="file" id="worph-mm-input" name="wor_photos" multiple class="hidden">
            <div id="worph-mm-zone"
                 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-7 w-7 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 photos or 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">One or multiple at a time. Any file type accepted.</p>
            </div>
            <div id="worph-mm-list" class="space-y-1"></div>
          </div>
        </section>

        <!-- 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>
          {% if wor_can_test %}
          <button type="button" id="mm-test-submit-btn" onclick="mmSubmit(event, true)"
                  class="px-6 py-3 bg-white dark:bg-gray-900 border border-amber-400 dark:border-amber-500 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2"
                  title="Create a segregated test WO (not added to the audit trail)">
            <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="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zM12 2a1 1 0 01.967.744L13.146 4.5a3 3 0 002.354 2.354l1.756.18a1 1 0 010 1.933l-1.756.18A3 3 0 0013.146 11.5l-.179 1.756a1 1 0 01-1.934 0L10.854 11.5A3 3 0 008.5 9.146l-1.756-.18a1 1 0 010-1.933l1.756-.18A3 3 0 0010.854 4.5l.179-1.756A1 1 0 0112 2z" clip-rule="evenodd"/>
            </svg>
            Create Test WO
          </button>
          {% endif %}
          <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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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>

        <!-- Photos / Files (DVI-1065) -->
        <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">Photos</h2>
          </div>
          <div class="px-6 py-4 space-y-3">
            <p class="text-xs text-gray-500 dark:text-gray-400">Attach photos or files to this work order. Drag &amp; drop one or multiple at a time, or click to browse.</p>
            <input type="file" id="worph-pm-input" name="wor_photos" multiple class="hidden">
            <div id="worph-pm-zone"
                 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-7 w-7 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 photos or 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">One or multiple at a time. Any file type accepted.</p>
            </div>
            <div id="worph-pm-list" class="space-y-1"></div>
          </div>
        </section>

        <!-- 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>
          {% if wor_can_test %}
          <button type="button" id="pm-test-submit-btn" onclick="pmSubmit(event, true)"
                  class="px-6 py-3 bg-white dark:bg-gray-900 border border-amber-400 dark:border-amber-500 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 font-semibold rounded-xl text-sm transition-colors cursor-pointer inline-flex items-center gap-2"
                  title="Create a segregated test WO (not added to the audit trail)">
            <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="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zM12 2a1 1 0 01.967.744L13.146 4.5a3 3 0 002.354 2.354l1.756.18a1 1 0 010 1.933l-1.756.18A3 3 0 0013.146 11.5l-.179 1.756a1 1 0 01-1.934 0L10.854 11.5A3 3 0 008.5 9.146l-1.756-.18a1 1 0 010-1.933l1.756-.18A3 3 0 0010.854 4.5l.179-1.756A1 1 0 0112 2z" clip-rule="evenodd"/>
            </svg>
            Create Test WO
          </button>
          {% endif %}
          <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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-tabbar 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-drafts" onclick="winstonSwitchTab('drafts')"
                  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">
            Drafts
          </button>
          <!-- Admin grouping tab (DVI-853): reveals the administrative sub-tab bar -->
          <button id="winston-tab-admin" onclick="winstonSwitchTab('admin')"
                  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 inline-flex items-center gap-1.5">
            Admin
            <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.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
            </svg>
          </button>
        </div>

        <!-- Admin sub-tab bar (DVI-853): hidden until the Admin tab is active -->
        <div id="winston-admin-subbar" class="hidden togen-tabbar 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-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-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-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>
          <!-- Files tab (DVI-943): troubleshooting view of the four quote-file locations -->
          <button id="winston-tab-files" onclick="winstonSwitchTab('files')"
                  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">
            Files
          </button>
          <!-- Email tab (DVI-857): draft-email template controls, right-justified just left of Settings -->
          <button id="winston-tab-email" onclick="winstonSwitchTab('email')"
                  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">
            Email
          </button>
          <button id="winston-tab-settings" onclick="winstonSwitchTab('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>

        <!-- ===== 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 ===== -->
        <!-- ===== EMAIL TAB (DVI-857) ===== -->
        <div id="winston-panel-email" 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-5">
              <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Draft Email Template</h3>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">Controls the subject, body and recipients of the draft emails Winston produces for each quote. Use <code class="font-mono">{token}</code> placeholders (see Available fields) to insert pipeline data. Leave a field blank to use the built-in default.</p>
              <div id="winston-email-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-email-body" class="hidden space-y-4">
                <!-- Per-pipeline templates (DVI-859, Option C) -->
                <div class="flex flex-wrap items-center justify-between gap-3 pb-3 border-b border-gray-100 dark:border-gray-800">
                  <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
                    <input type="checkbox" id="winston-email-perpipeline-toggle" onchange="winstonTogglePerPipeline()"
                           class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal/40" />
                    <span>Per-pipeline templates</span>
                  </label>
                  <div id="winston-email-pipeline-row" class="hidden flex items-center gap-2">
                    <label for="winston-email-pipeline" class="text-sm text-gray-600 dark:text-gray-300">Editing</label>
                    <select id="winston-email-pipeline" onchange="winstonSelectEmailPipeline()"
                            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-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40"></select>
                  </div>
                </div>
                <p id="winston-email-scope-note" class="hidden text-xs text-gray-500 dark:text-gray-400 -mt-2"></p>
                <!-- Send as (mailbox is managed in Settings) -->
                <div>
                  <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Send as</span>
                  <span id="winston-email-mailbox" class="text-sm text-gray-600 dark:text-gray-300 font-mono">(loading…)</span>
                  <p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">The mailbox drafts are created in. Change this in the <button type="button" onclick="winstonSwitchTab('settings')" class="text-togen-teal hover:underline cursor-pointer">Settings</button> tab (Draft mailbox).</p>
                </div>
                <!-- Subject -->
                <label class="block">
                  <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Subject line</span>
                  <input type="text" id="winston-email-subject" placeholder="Quote {quote_number} — iCast Infrastructure"
                         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 font-mono" />
                </label>
                <!-- Body -->
                <label class="block">
                  <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Body (HTML)</span>
                  <textarea id="winston-email-body-html" rows="8"
                            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 font-mono"></textarea>
                  <span class="text-xs text-gray-400 dark:text-gray-500">HTML is sent as the email body. Token values are automatically escaped.</span>
                </label>
                <!-- Recipients -->
                <div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
                  <label class="block">
                    <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">CC</span>
                    <input type="text" id="winston-email-cc" placeholder="a@x.com, b@y.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-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
                    <span class="text-xs text-gray-400 dark:text-gray-500">Comma-separated. Applied to every draft.</span>
                  </label>
                  <label class="block">
                    <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">BCC</span>
                    <input type="text" id="winston-email-bcc" placeholder="archive@x.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-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
                    <span class="text-xs text-gray-400 dark:text-gray-500">Comma-separated.</span>
                  </label>
                  <label class="block">
                    <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Fallback To</span>
                    <input type="text" id="winston-email-fallback"
                           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">Used only when a quote has no To recipient from ClickUp.</span>
                  </label>
                </div>
                <!-- Save + preview actions -->
                <div class="pt-1 flex items-center gap-3">
                  <button type="button" onclick="winstonSaveEmailTemplate()"
                          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 template</button>
                  <button type="button" onclick="winstonPreviewEmailTemplate()"
                          class="px-4 py-2 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">Preview with sample data</button>
                  <button type="button" id="winston-email-reset-btn" onclick="winstonResetPipelineTemplate()"
                          class="hidden px-4 py-2 text-sm font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">Reset to default</button>
                  <span id="winston-email-status" class="text-xs text-gray-500 dark:text-gray-400"></span>
                </div>
              </div>
            </div>
          </section>

          <!-- Available fields legend -->
          <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">Available fields</h3>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Tokens you can use in the Subject and Body. Each is filled from the pipeline's source data for the quote being drafted.</p>
              <div id="winston-email-tokens" class="overflow-x-auto"></div>
            </div>
          </section>

          <!-- Preview -->
          <section id="winston-email-preview-pane" class="hidden 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">Preview (sample data)</h3>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Rendered against representative sample values — no email is sent.</p>
              <div class="mb-2"><span class="text-xs font-medium text-gray-500 dark:text-gray-400">Subject:</span> <span id="winston-email-preview-subject" class="text-sm text-gray-800 dark:text-gray-100 font-mono"></span></div>
              <div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-800/50">
                <div id="winston-email-preview-body" class="text-sm text-gray-800 dark:text-gray-100 prose prose-sm dark:prose-invert max-w-none"></div>
              </div>
            </div>
          </section>
        </div>

        <div id="winston-panel-settings" class="hidden">
          <!-- Dry Run master switch (DVI-908) -->
          <section class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-amber-200 dark:border-amber-800/60 mb-4">
            <div class="px-6 py-5">
              <div class="flex items-start justify-between gap-4">
                <div>
                  <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Dry Run mode</h3>
                  <p class="text-xs text-gray-500 dark:text-gray-400 max-w-xl">When enabled, every Winston run (scheduled or manual) validates the full pipeline &mdash; ClickUp pull, workbook download, PDF conversion &mdash; and saves the generated outputs to Diagnostics, but writes nothing back to ClickUp, the Completed Quotes / Take-off SharePoint paths, or the sales mailbox. Use it to confirm proper operation before enabling write modes.</p>
                </div>
                <label class="flex items-center gap-2 cursor-pointer shrink-0">
                  <input type="checkbox" id="winston-cfg-dryrun" onchange="winstonSaveDryRun()"
                         class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
                  <span class="text-sm font-medium text-gray-700 dark:text-gray-300">Dry Run</span>
                </label>
              </div>
              <span id="winston-cfg-dryrun-status" class="mt-2 inline-block text-xs text-gray-500 dark:text-gray-400"></span>
            </div>
          </section>
          <!-- 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>

          <!-- Tag map (DVI-981) -->
          <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">Tag map</h3>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">Maps ClickUp task tags to quote sheet names. Version suffixes are stripped before lookup (e.g. <code class="font-mono">xv2</code> &rarr; <code class="font-mono">x</code>). <strong>Custom</strong> entries map a base tag to an exact sheet name; <strong>Standard</strong> entries expand to candidates <em>&ldquo;{TAG} Quote 1/2/3&rdquo;</em>.</p>
              <div class="space-y-6">
                <!-- Custom mappings -->
                <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">Custom mappings <span class="text-xs font-normal text-gray-400">(tag &rarr; exact sheet)</span></span>
                    <button type="button" onclick="winstonTagMapAddCustom()"
                            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" title="Add mapping">
                      <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>
                  <div id="winston-tagmap-custom-list" class="space-y-1 min-h-[1.5rem]"></div>
                  <div id="winston-tagmap-custom-add" class="hidden mt-2 flex gap-2 items-center">
                    <input type="text" id="winston-tagmap-custom-key" placeholder="Tag (e.g. X)"
                           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-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40 uppercase" />
                    <span class="text-sm text-gray-400">&rarr;</span>
                    <input type="text" id="winston-tagmap-custom-val" placeholder="Sheet name (e.g. QuoteX)"
                           onkeydown="if(event.key==='Enter'){event.preventDefault();winstonTagMapConfirmCustom();}"
                           class="flex-1 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-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
                    <button type="button" onclick="winstonTagMapConfirmCustom()"
                            class="px-3 py-1.5 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer">Add</button>
                    <button type="button" onclick="winstonTagMapCancelCustom()"
                            class="px-3 py-1.5 text-sm font-medium rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 cursor-pointer">Cancel</button>
                  </div>
                </div>
                <!-- Standard tags -->
                <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">Standard tags <span class="text-xs font-normal text-gray-400">(expand to &ldquo;{TAG} Quote 1/2/3&rdquo;)</span></span>
                    <button type="button" onclick="winstonTagMapAddStd()"
                            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" title="Add tag">
                      <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>
                  <div id="winston-tagmap-std-list" class="flex flex-wrap gap-2 min-h-[2rem]"></div>
                  <div id="winston-tagmap-std-add" class="hidden mt-2 flex gap-2 items-center">
                    <input type="text" id="winston-tagmap-std-input" placeholder="Tag (e.g. SPC)"
                           onkeydown="if(event.key==='Enter'){event.preventDefault();winstonTagMapConfirmStd();}"
                           class="w-36 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-100 focus:outline-none focus:ring-2 focus:ring-togen-teal/40 uppercase" />
                    <button type="button" onclick="winstonTagMapConfirmStd()"
                            class="px-3 py-1.5 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer">Add</button>
                    <button type="button" onclick="winstonTagMapCancelStd()"
                            class="px-3 py-1.5 text-sm font-medium rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 cursor-pointer">Cancel</button>
                  </div>
                </div>
              </div>
              <div class="mt-4 flex items-center gap-3">
                <button type="button" onclick="winstonSaveTagMap()"
                        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 tag map
                </button>
                <span id="winston-tagmap-status" class="text-xs text-gray-500 dark:text-gray-400"></span>
              </div>
            </div>
          </section>

          <!-- Quotes folder paths — read-only mirror of the 4-field model (DVI-842) -->
          <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-4">Where Winston picks up quote take-offs and writes completed quote PDFs. These values are read-only here — edit them where indicated below.</p>

              <div class="grid gap-5 sm:grid-cols-2">
                <!-- Shared group -->
                <div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 p-4">
                  <div class="flex items-center justify-between mb-3">
                    <h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Shared <span class="text-xs font-normal text-gray-400 dark:text-gray-500">(all users)</span></h4>
                    <button type="button" onclick="openTool('togen-admin'); setTimeout(function(){togenadminSetTab('documents');},200)"
                            class="text-xs text-togen-teal hover:underline font-medium cursor-pointer">Edit in Togen Admin &rarr;</button>
                  </div>
                  <div class="space-y-3">
                    <div>
                      <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Quote Take-offs</label>
                      <input id="winston-qp-shared-takeoff" type="text" readonly tabindex="-1"
                             class="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 cursor-default">
                    </div>
                    <div>
                      <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Completed Quotes</label>
                      <input id="winston-qp-shared-completed" type="text" readonly tabindex="-1"
                             class="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 cursor-default">
                    </div>
                  </div>
                </div>
                <!-- Personal group -->
                <div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 p-4">
                  <div class="flex items-center justify-between mb-3">
                    <h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Personal <span class="text-xs font-normal text-gray-400 dark:text-gray-500">(your OneDrive)</span></h4>
                    <button type="button" onclick="openTool('user-settings'); setTimeout(function(){userSettingsSetTab('documents');},200)"
                            class="text-xs text-togen-teal hover:underline font-medium cursor-pointer">Edit in User Settings &rarr;</button>
                  </div>
                  <div class="space-y-3">
                    <div>
                      <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Quote Take-offs</label>
                      <input id="winston-qp-personal-takeoff" type="text" readonly tabindex="-1" placeholder="(not set)"
                             class="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 cursor-default">
                    </div>
                    <div>
                      <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Completed Quotes</label>
                      <input id="winston-qp-personal-completed" type="text" readonly tabindex="-1" placeholder="(not set)"
                             class="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 cursor-default">
                    </div>
                  </div>
                </div>
              </div>
              <p class="text-xs text-gray-400 dark:text-gray-500 mt-3">Winston picks up a quote from the Take-offs path (shared first, then your personal OneDrive) and writes the finished PDF to the Completed Quotes path.</p>
            </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>

        <!-- ===== DRAFTS TAB ===== -->
        <div id="winston-panel-drafts" 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">Drafts</h3>
                  <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Quotes awaiting review in the <span id="winston-drafts-mailbox" class="font-mono">Drafts</span> folder.</p>
                </div>
                <button type="button" onclick="winstonLoadDrafts()"
                        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-drafts-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-drafts-empty" class="hidden text-center py-8">
                <p class="text-sm text-gray-500 dark:text-gray-400">No drafts in the mailbox.</p>
              </div>
              <div id="winston-drafts-error" class="hidden text-center py-8">
                <p id="winston-drafts-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
              </div>
              <div id="winston-drafts-list" class="space-y-3"></div>
            </div>
          </section>
        </div>

        <!-- Drafts attachment preview overlay -->
        <div id="winston-draft-preview-overlay" class="hidden fixed inset-0 z-[60] bg-black/60 flex items-center justify-center p-4" onclick="winstonCloseDraftPreview(event)">
          <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden" onclick="event.stopPropagation()">
            <div class="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700">
              <h4 id="winston-draft-preview-title" class="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate pr-4">Attachment</h4>
              <button type="button" onclick="winstonCloseDraftPreview()" class="shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer" 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>
            <iframe id="winston-draft-preview-frame" class="flex-1 w-full bg-gray-100 dark:bg-gray-800" title="Attachment preview"></iframe>
          </div>
        </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>

        <!-- ===== FILES TAB (DVI-943) ===== -->
        <div id="winston-panel-files" 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">Files</h3>
                <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">File locations relevant to Winston's operation. The shared locations are read with the Logic pipeline's account; the individual locations are read with your own signed-in account. Use this to confirm whether the configured paths can actually be reached.</p>
              </div>

              <!-- Files sub-tab bar -->
              <div id="winston-files-subbar" class="togen-tabbar 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-files-tab-takeoffs" onclick="winstonSwitchFilesTab('takeoffs')"
                        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">
                  Takeoffs
                </button>
                <button id="winston-files-tab-completed" onclick="winstonSwitchFilesTab('completed')"
                        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">
                  Completed
                </button>
                <button id="winston-files-tab-individual_takeoffs" onclick="winstonSwitchFilesTab('individual_takeoffs')"
                        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">
                  Individual Takeoffs
                </button>
                <button id="winston-files-tab-individual_quotes" onclick="winstonSwitchFilesTab('individual_quotes')"
                        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">
                  Individual Quotes
                </button>
              </div>

              <!-- Access metadata (which account / path was queried) -->
              <div id="winston-files-meta" class="hidden mb-3 text-xs text-gray-500 dark:text-gray-400 space-y-0.5"></div>

              <!-- Filter + sort toolbar (DVI-978) -->
              <div id="winston-files-toolbar" class="hidden flex flex-wrap items-center gap-2 mb-3">
                <input id="winston-files-filter" type="text" placeholder="Filter files by name…" oninput="winstonRenderFiles()"
                       class="flex-1 min-w-[12rem] rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal/40">
                <select id="winston-files-sort" onchange="winstonLoadFiles(_winstonFilesTab)"
                        class="shrink-0 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal/40">
                  <option value="modified_desc">Newest first</option>
                  <option value="modified_asc">Oldest first</option>
                  <option value="name_asc">Name (A–Z)</option>
                  <option value="name_desc">Name (Z–A)</option>
                  <option value="size_desc">Largest first</option>
                  <option value="size_asc">Smallest first</option>
                </select>
              </div>

              <!-- Two-column layout: file list + preview pane (mirrors File Browser) -->
              <div class="flex gap-4 items-start">
                <div class="flex-1 min-w-0">
                  <div id="winston-files-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>

                  <!-- Notice box (not configured / access error) -->
                  <div id="winston-files-notice" class="hidden rounded-xl border px-4 py-3 text-sm"></div>

                  <div id="winston-files-empty" class="hidden text-center py-8">
                    <p class="text-sm text-gray-500 dark:text-gray-400">This location is empty.</p>
                  </div>

                  <div id="winston-files-list" class="divide-y divide-gray-100 dark:divide-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden hidden"></div>

                  <div id="winston-files-nomatch" class="hidden text-center py-8">
                    <p class="text-sm text-gray-500 dark:text-gray-400">No files match your filter.</p>
                  </div>

                  <div id="winston-files-more" class="hidden mt-3 text-center">
                    <button id="winston-files-showmore-btn" type="button" onclick="winstonLoadFiles(_winstonFilesTab, true)"
                            class="togen-control text-sm px-4 py-2 rounded-lg border border-togen-teal/40 text-togen-teal hover:bg-togen-teal/10 disabled:opacity-50 cursor-pointer">Show more</button>
                  </div>

                  <p id="winston-files-truncated" class="hidden mt-2 text-center text-xs text-gray-400 dark:text-gray-500"></p>
                </div>

                <!-- Preview pane (hidden until a file is selected) -->
                <div id="winston-files-preview-pane" class="hidden w-96 shrink-0 self-start bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden" style="max-height:calc(100vh - 320px);">
                  <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
                    <span id="winston-files-preview-title" class="text-sm font-semibold text-gray-700 dark:text-gray-200 truncate">Preview</span>
                    <div class="flex items-center gap-2 shrink-0">
                      <a id="winston-files-preview-open" href="#" target="_blank" rel="noopener" class="hidden text-gray-400 hover:text-togen-teal" title="Open in new tab">
                        <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>
                      </a>
                      <button onclick="winstonCloseFilePreview()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" title="Close preview">
                        <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>
                  <div id="winston-files-preview-content" class="flex-1 overflow-auto p-4"></div>
                </div>
              </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>
            <label class="flex items-center gap-2 cursor-pointer">
              <input type="checkbox" id="winston-sched-dryrun" class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-amber-500 focus:ring-amber-500">
              <span class="text-sm text-gray-700 dark:text-gray-300">Dry Run <span class="text-xs text-gray-400 dark:text-gray-500">(test only — no ClickUp / SharePoint / draft writes)</span></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="togen-tabbar 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('approvals')" id="woradmin-tab-approvals"
                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">Approvals</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>
        <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
          <input type="checkbox" id="woradmin-filter-awaiting-rts" checked onchange="woradminApplyFilter()"
                 class="rounded border-gray-300 dark:border-gray-600 text-amber-500 focus:ring-amber-500"> Awaiting Approval
        </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="togen-control 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>
          {% if wor_can_test %}
          <label id="woradmin-testmode-label" class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-amber-300 dark:border-amber-600 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors cursor-pointer"
                 title="Show segregated test work orders (not in the audit trail)">
            <input type="checkbox" id="woradmin-testmode" onchange="woradminToggleTestMode()"
                   class="rounded border-amber-300 dark:border-amber-600 text-amber-500 focus:ring-amber-500">
            Test mode
          </label>
          {% endif %}
          <button onclick="woradminLoadList()" class="togen-control 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="togen-control 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>
              <a id="woradmin-evh-form-link" href="#" target="_blank" rel="noopener"
                 class="togen-control 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="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>
                EVH form
              </a>
              <!-- DVI-1174 P2: Approve/Reject for the current user's actionable RtS
                   department, surfaced right in the preview button row so approvers
                   who also hold WO Admin don't have to hunt for the Approvals tab.
                   Wired to the existing /wor-evh-action route via evhOpenApprove/Reject. -->
              <button id="woradmin-approve-btn" onclick="woradminApproveCurrent()"
                      class="hidden px-3 py-1.5 text-xs font-medium rounded-lg bg-green-600 hover:bg-green-700 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="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>
                Approve
              </button>
              <button id="woradmin-reject-btn" onclick="woradminRejectCurrent()"
                      class="hidden px-3 py-1.5 text-xs font-medium rounded-lg bg-red-600 hover:bg-red-700 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="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>
                Reject
              </button>
              <button id="woradmin-edit-btn" onclick="woradminEditInWorTool()"
                      class="togen-control 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="togen-control 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="togen-control 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="togen-control 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>
              <input type="file" id="woradmin-photo-input" multiple class="hidden">
              <button id="woradmin-attach-photo-btn" onclick="woradminTogglePhotoDropzone()"
                      class="togen-control 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" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.7">
                  <path stroke-linecap="round" stroke-linejoin="round" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
                  <path stroke-linecap="round" stroke-linejoin="round" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
                </svg>
                Attach Photo
              </button>
              <button id="woradmin-photos-btn" onclick="woradminViewPhotos()"
                      class="togen-control 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" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.7">
                  <path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
                </svg>
                <span>Photos</span><span id="woradmin-photos-count" class="text-[11px] font-semibold leading-none"></span>
              </button>
              <button onclick="woradminPrint()"
                      class="togen-control 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="togen-control 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="togen-control 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>
              <!-- Testing regime Phase 2 admin actions (DVI-1084): Togen-Admin only. -->
              <button id="woradmin-convert-test-btn" onclick="woradminConvertTest()"
                      class="hidden px-3 py-1.5 text-xs font-medium rounded-lg border border-amber-300 dark:border-amber-600 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors inline-flex items-center gap-1.5"
                      title="Move this work order into the segregated test tree (removes it from the audit trail)">
                <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.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>
                Convert to Test
              </button>
              <button id="woradmin-convert-prod-btn" onclick="woradminConvertProd()"
                      class="togen-control 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"
                      title="Move this test work order back into the production tree (restores its audit trail)">
                <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="M7.707 3.293a1 1 0 010 1.414L5.414 7H11a7 7 0 017 7v2a1 1 0 11-2 0v-2a5 5 0 00-5-5H5.414l2.293 2.293a1 1 0 11-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/>
                </svg>
                Convert to Prod
              </button>
              <button id="woradmin-delete-btn" onclick="woradminDelete()"
                      class="togen-control hidden px-3 py-1.5 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 inline-flex items-center gap-1.5"
                      title="Soft-delete this work order (quarantined + logged; restorable by an admin)">
                <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="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>
              <button onclick="woradminClosePreview()"
                      class="togen-control 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>
          <!-- DVI-1174 P1: read-only per-department RtS sign-off strip, shown for
               awaiting-approval WOs so the approval workflow is visible in the full
               WO Admin UI (not just the Approvals tab). Sourced from _evh.sign_offs. -->
          <div id="woradmin-approval-strip" class="hidden mb-3"></div>
          <!-- DVI-1065: drag & drop photos/files onto the work order -->
          <div id="woradmin-photo-dropzone-wrap" class="hidden mb-3">
            <div id="woradmin-photo-dropzone" onclick="document.getElementById('woradmin-photo-input').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-7 w-7 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 photos or 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">One or multiple at a time. Any file type accepted.</p>
            </div>
            <p id="woradmin-photo-dropzone-status" class="mt-2 text-xs text-gray-500 dark:text-gray-400"></p>
          </div>
          <div id="woradmin-completion-details" class="hidden mb-3"></div>
          <div id="woradmin-photos-gallery" class="hidden mb-3"></div>
          <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>

      <!-- WO photo lightbox (DVI-1076): in-app gallery viewer, mirrors the Auditor lightbox (DVI-1069) -->
      <div id="woradmin-photos-lightbox" class="hidden fixed inset-0 z-[125] flex items-center justify-center p-4">
        <div onclick="woradminPhotoLightboxClose()" class="absolute inset-0 bg-black/80"></div>
        <div class="relative w-full max-w-4xl max-h-[92vh] flex flex-col items-center">
          <div class="flex items-center justify-between w-full mb-2 gap-2">
            <span id="woradmin-lb-badge" class="px-2.5 py-1 rounded-full bg-white/15 text-white text-xs font-medium"></span>
            <div class="flex items-center gap-2">
              <span id="woradmin-lb-counter" class="text-xs text-gray-300"></span>
              <a id="woradmin-lb-download" href="#" download class="p-1.5 rounded-lg text-gray-200 hover:text-white hover:bg-white/15 transition-colors" title="Download">
                <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 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5 5-5M12 15V3"/></svg>
              </a>
              <button onclick="woradminPhotoLightboxClose()" title="Close" class="p-1.5 rounded-lg text-gray-200 hover:text-white hover:bg-white/15 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 class="relative flex items-center justify-center w-full">
            <div id="woradmin-lb-nav" class="hidden">
              <button onclick="woradminPhotoLightboxNav(-1)" title="Previous" class="absolute left-0 top-1/2 -translate-y-1/2 -ml-2 sm:-ml-12 p-2 rounded-full bg-white/15 text-white hover:bg-white/30 transition-colors cursor-pointer">
                <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="M15 19l-7-7 7-7"/></svg>
              </button>
              <button onclick="woradminPhotoLightboxNav(1)" title="Next" class="absolute right-0 top-1/2 -translate-y-1/2 -mr-2 sm:-mr-12 p-2 rounded-full bg-white/15 text-white hover:bg-white/30 transition-colors cursor-pointer">
                <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="M9 5l7 7-7 7"/></svg>
              </button>
            </div>
            <img id="woradmin-lb-img" src="" alt="" class="max-h-[76vh] max-w-full rounded-lg object-contain bg-black/20">
          </div>
          <div id="woradmin-lb-caption" class="mt-3 text-center"></div>
        </div>
      </div>

      </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>
                    <th class="text-center font-medium px-2 py-2">Awaiting RtS<sup class="text-amber-500">†</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. &nbsp;<sup class="text-amber-500">†</sup> Locked on for users listed as Authorized Approvers.</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>
        <!-- Authorized Approvers pane (DVI-1013) -->
        <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">Authorized Approvers</h2>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Only users listed here may sign a department&rsquo;s Return-to-Service line. A department with no approvers will show a warning when its sign-off is required.</p>
            <div id="woradmin-approvers-lists" class="space-y-5">
              <!-- populated by JS -->
            </div>
            <span id="woradmin-approvers-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 -->

      <!-- Approvals view (DVI-1016): WOs with pending RtS sign-off -->
      <div id="woradmin-approvalsview" class="hidden">
        <div id="woradmin-approvals-zero-warn" class="hidden mb-3 flex items-start gap-2 px-4 py-3 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 text-sm text-amber-800 dark:text-amber-200">
          <svg class="h-4 w-4 mt-0.5 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 id="woradmin-approvals-zero-warn-text"></span>
        </div>
        <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">Awaiting Return-to-Service Approval</h2>
            <div id="woradmin-approvals-list" class="space-y-3">
              <p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
            </div>
          </div>
        </section>
      </div><!-- /woradmin-approvalsview -->

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

    <!-- ===== EVH APPROVE MODAL (DVI-1011) ===== -->
    <div id="evh-approve-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
      <div class="absolute inset-0 bg-black/40" onclick="evhCloseApprove()"></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-1">Approve Return to Service</h3>
        <p class="text-sm text-gray-500 dark:text-gray-400 mb-4"><span id="evh-approve-dept" class="font-medium text-gray-700 dark:text-gray-300"></span> sign-off for <span id="evh-approve-title" class="font-medium text-gray-700 dark:text-gray-300"></span></p>
        <div class="space-y-4">
          <div>
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Notes <span class="text-gray-400 dark:text-gray-500">(optional)</span></label>
            <textarea id="evh-approve-notes" rows="3" placeholder="Any notes about this sign-off…"
                      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 id="evh-approve-parallel-wrap" class="hidden">
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">How should the remaining departments (<span id="evh-approve-parallel-depts" class="font-medium"></span>) approve?</label>
            <div class="space-y-2">
              <label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
                <input type="radio" name="evh-approve-parallel" id="evh-approve-parallel-seq" checked
                       class="mt-0.5 text-togen-teal focus:ring-togen-teal">
                <span><span class="font-medium">Sequential</span> — one department at a time, in order</span>
              </label>
              <label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
                <input type="radio" name="evh-approve-parallel" id="evh-approve-parallel-par"
                       class="mt-0.5 text-togen-teal focus:ring-togen-teal">
                <span><span class="font-medium">Parallel</span> — all remaining departments can approve simultaneously</span>
              </label>
            </div>
          </div>
          <p id="evh-approve-status" class="text-sm"></p>
        </div>
        <div class="flex justify-end gap-3 mt-6">
          <button type="button" onclick="evhCloseApprove()"
                  class="togen-control 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" id="evh-approve-confirm-btn" onclick="evhSubmitApprove()"
                  class="px-4 py-2 text-sm font-medium rounded-xl bg-green-600 hover:bg-green-700 text-white transition-colors">Approve</button>
        </div>
      </div>
    </div>

    <!-- ===== EVH REJECT MODAL (DVI-1011) ===== -->
    <div id="evh-reject-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
      <div class="absolute inset-0 bg-black/40" onclick="evhCloseReject()"></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-1">Reject Return to Service</h3>
        <p class="text-sm text-gray-500 dark:text-gray-400 mb-4"><span id="evh-reject-dept" class="font-medium text-gray-700 dark:text-gray-300"></span> sign-off for <span id="evh-reject-title" class="font-medium text-gray-700 dark:text-gray-300"></span></p>
        <div class="space-y-4">
          <div>
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Reason for rejection <span class="text-red-500">*</span></label>
            <textarea id="evh-reject-reason" rows="3" placeholder="Describe why this equipment cannot be returned to service…"
                      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-red-500"></textarea>
          </div>
          <div>
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Photos <span class="text-gray-400 dark:text-gray-500">(optional)</span></label>
            <input type="file" id="evh-reject-photos" accept="image/*" multiple
                   class="block w-full text-xs text-gray-600 dark:text-gray-300 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border file:border-gray-300 file:dark:border-gray-600 file:text-xs file:font-medium file:bg-white file:dark:bg-gray-800 file:text-gray-700 file:dark:text-gray-300 hover:file:bg-gray-100 dark:hover:file:bg-gray-700">
          </div>
          <p class="text-xs text-gray-500 dark:text-gray-400">Rejecting reopens the work order to In Progress and clears all prior sign-offs. A new completion restarts the approval cycle.</p>
          <p id="evh-reject-status" class="text-sm"></p>
        </div>
        <div class="flex justify-end gap-3 mt-6">
          <button type="button" onclick="evhCloseReject()"
                  class="togen-control 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" id="evh-reject-confirm-btn" onclick="evhSubmitReject()"
                  class="px-4 py-2 text-sm font-medium rounded-xl bg-red-600 hover:bg-red-700 text-white transition-colors">Reject</button>
        </div>
      </div>
    </div>

    <!-- ===== WO COMPLETION DETAILS MODAL (DVI-1033) ===== -->
    <div id="woradmin-complete-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center">
      <div class="absolute inset-0 bg-black/40" onclick="woradminCloseComplete()"></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-1">Complete Work Order</h3>
        <p class="text-sm text-gray-500 dark:text-gray-400 mb-4"><span id="woradmin-complete-title" class="font-medium text-gray-700 dark:text-gray-300"></span></p>
        <div class="space-y-4">
          <div>
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Description of Work Performed <span id="woradmin-complete-wd-req" class="text-red-500 hidden">*</span><span id="woradmin-complete-wd-opt" class="text-gray-400 dark:text-gray-500">(optional)</span></label>
            <textarea id="woradmin-complete-wd" rows="4" placeholder="Describe the work that was performed…"
                      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>
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Outstanding Follow-up Items <span class="text-gray-400 dark:text-gray-500">(optional)</span></label>
            <textarea id="woradmin-complete-fu" rows="3" placeholder="Any outstanding items to follow up on…"
                      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>
            <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Photos <span class="text-gray-400 dark:text-gray-500">(optional)</span></label>
            <input type="file" id="woradmin-complete-photos" accept="image/*" multiple
                   class="block w-full text-xs text-gray-600 dark:text-gray-300 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border file:border-gray-300 file:dark:border-gray-600 file:text-xs file:font-medium file:bg-white file:dark:bg-gray-800 file:text-gray-700 file:dark:text-gray-300 hover:file:bg-gray-100 dark:hover:file:bg-gray-700">
          </div>
          <p id="woradmin-complete-rts-note" class="hidden text-xs text-gray-500 dark:text-gray-400">This work order has Return-to-Service requirements — these details are added to the Equipment Validation Handoff form.</p>
          <p id="woradmin-complete-status" class="text-sm"></p>
        </div>
        <div class="flex justify-end gap-3 mt-6">
          <button type="button" onclick="woradminCloseComplete()"
                  class="togen-control 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" id="woradmin-complete-confirm-btn" onclick="woradminSubmitComplete()"
                  class="px-4 py-2 text-sm font-medium rounded-xl bg-green-600 hover:bg-green-700 text-white transition-colors">Mark Complete</button>
        </div>
      </div>
    </div>

    <!-- ===== 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="togen-control 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="togen-control 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="togen-inset-warn 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="togen-tabbar 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>
        <button id="togenadmin-tab-testing" onclick="togenadminSwitchTab('testing')"
                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">
          Testing
        </button>
        <button id="togenadmin-tab-services" onclick="togenadminSwitchTab('services')"
                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">
          Services
        </button>
        <button id="togenadmin-tab-users" onclick="togenadminSwitchTab('users')"
                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">
          Users
        </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="togen-control 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="togen-control 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>

        <!-- Regional settings pane (DVI-1364 P1): company defaults for time/date/tz/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">Regional settings</h2>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Company defaults for time format, time zone, date format, and location, based on the plant location. Users may override these in their own settings. Defaults follow the Beaver Dam, KY plant standard.</p>
            <div class="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl">
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Time format</label>
                <select id="tga-reg-time-format" 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-200 cursor-pointer">
                  <option value="12h">12-hour (1:30 PM)</option>
                  <option value="24h">24-hour (13:30)</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Date format</label>
                <select id="tga-reg-date-format" 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-200 cursor-pointer">
                  <option value="month_day_year">Aug 9, 2026</option>
                  <option value="month_day">Aug 9</option>
                  <option value="mdy_slash">08/09/2026</option>
                  <option value="dmy_slash">09/08/2026</option>
                  <option value="iso">2026-08-09</option>
                </select>
              </div>
              <div class="sm:col-span-2">
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Time zone</label>
                <select id="tga-reg-timezone" 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-200 cursor-pointer"></select>
              </div>
              <div class="sm:col-span-2">
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Location (for weather)</label>
                <div class="flex gap-2">
                  <input id="tga-reg-loc-search" type="text" maxlength="120" placeholder="Search a city, e.g. Beaver Dam, KY"
                         onkeydown="if(event.key==='Enter'){event.preventDefault();togenadminRegionalSearch();}"
                         class="flex-1 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-200">
                  <button type="button" onclick="togenadminRegionalSearch()" class="px-3 py-2 rounded-lg bg-togen-teal text-white text-sm">Search</button>
                </div>
                <div id="tga-reg-loc-results" class="mt-2 space-y-1"></div>
                <div class="mt-2 flex items-center gap-2 flex-wrap">
                  <span id="tga-reg-loc-current" class="text-xs text-gray-600 dark:text-gray-300"></span>
                </div>
              </div>
            </div>
            <div class="mt-3 flex items-center gap-2">
              <button type="button" onclick="togenadminRegionalSave()" class="px-4 py-2 rounded-lg bg-togen-teal text-white text-sm font-medium">Save defaults</button>
              <span id="tga-reg-status" class="text-xs text-gray-400 dark:text-gray-500"></span>
            </div>
          </div>
        </section>

        <!-- FQDN / custom-domain pane (DVI-1026c) -->
        <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">Domains (FQDN)</h2>
              <button onclick="openAddFqdnModal()"
                      class="togen-control 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 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 FQDN
              </button>
            </div>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Additional fully-qualified domain names served by Togen. Add a domain, create the shown DNS record, then validate to auto-issue an SSL certificate.</p>
            <div class="overflow-x-auto">
              <table class="min-w-full text-sm">
                <thead>
                  <tr class="text-left text-xs font-medium text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
                    <th class="py-2 pr-4">FQDN</th>
                    <th class="py-2 pr-4">Status</th>
                    <th class="py-2 pr-4">DNS Validated</th>
                    <th class="py-2 pr-4">SSL Certificate</th>
                    <th class="py-2 pr-4 text-right">Actions</th>
                  </tr>
                </thead>
                <tbody id="togenadmin-fqdn-tbody">
                  <tr><td colspan="5" class="py-4 text-center text-gray-400 dark:text-gray-500">Loading…</td></tr>
                </tbody>
              </table>
            </div>
          </div>
        </section>
      </div>

      <!-- Add FQDN modal (DVI-1026c) -->
      <div id="add-fqdn-overlay" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onclick="if(event.target===this)closeAddFqdnModal()">
        <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">Add FQDN</h2>
            <button onclick="closeAddFqdnModal()" 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">
            <div id="add-fqdn-step-input">
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Enter the fully-qualified domain name you want Togen to serve (e.g. <code>quotes.example.com</code>).</p>
              <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">FQDN</label>
              <input type="text" id="add-fqdn-input" placeholder="sub.example.com" autocomplete="off"
                     onkeydown="if(event.key==='Enter')submitAddFqdn()"
                     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 id="add-fqdn-status" class="hidden mt-2 text-xs font-medium"></p>
              <div class="flex justify-end mt-4">
                <button id="add-fqdn-submit" onclick="submitAddFqdn()"
                        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 id="add-fqdn-step-dns" class="hidden">
              <p class="text-xs text-gray-600 dark:text-gray-300 mb-3">Domain <span id="add-fqdn-dns-name" class="font-semibold"></span> registered. Create the following DNS record at your registrar, then click <strong>Validate</strong> on its row once it propagates.</p>
              <div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 p-3 space-y-2 text-xs">
                <div class="flex justify-between gap-3"><span class="text-gray-500 dark:text-gray-400">Type</span><span id="add-fqdn-dns-type" class="font-mono text-gray-800 dark:text-gray-100"></span></div>
                <div class="flex justify-between gap-3"><span class="text-gray-500 dark:text-gray-400">Name / Host</span><span id="add-fqdn-dns-host" class="font-mono text-gray-800 dark:text-gray-100 break-all"></span></div>
                <div class="flex items-center justify-between gap-3">
                  <span class="text-gray-500 dark:text-gray-400">Value / Target</span>
                  <span class="flex items-center gap-2">
                    <span id="add-fqdn-dns-target" class="font-mono text-gray-800 dark:text-gray-100 break-all"></span>
                    <button onclick="fqdnCopyText('add-fqdn-dns-target')" title="Copy target"
                            class="text-gray-400 hover:text-togen-teal 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="M7 3a2 2 0 012-2h6a2 2 0 012 2v8a2 2 0 01-2 2H9a2 2 0 01-2-2V3z"/>
                        <path d="M3 7a2 2 0 012-2v10a2 2 0 002 2h6a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"/>
                      </svg>
                    </button>
                  </span>
                </div>
              </div>
              <div class="flex justify-end mt-4">
                <button onclick="closeAddFqdnModal()"
                        class="px-4 py-2 text-sm bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-lg transition-colors">
                  Done
                </button>
              </div>
            </div>
          </div>
        </div>
      </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="togen-control 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="togen-inset-warn 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 SharePoint paths Winston uses for quotes. Individual users can override these with their own OneDrive paths in User Settings &rarr; Documents.</p>

          <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Shared Quote Take-offs Location</label>
          <div class="flex items-center gap-2 mb-2">
            <input id="togenadmin-quote-takeoff-location" type="text" placeholder="e.g. Infrastructure Precast Inc_ - Quote Take-offs"
                   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-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">
            <button type="button" onclick="odPickerOpen('shared','togenadmin-quote-takeoff-location')"
                    class="togen-control px-3 py-2 text-sm 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 whitespace-nowrap cursor-pointer">Browse OneDrive</button>
          </div>
          <p class="text-xs text-gray-400 dark:text-gray-500 mb-4">Folder Winston picks quote source files up from.</p>

          <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Shared Completed Quotes Location</label>
          <div class="flex items-center gap-2 mb-2">
            <input id="togenadmin-quotes-location" type="text" placeholder="e.g. Infrastructure Precast Inc_ - Completed Quotes"
                   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-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">
            <button type="button" onclick="odPickerOpen('shared','togenadmin-quotes-location')"
                    class="togen-control px-3 py-2 text-sm 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 whitespace-nowrap cursor-pointer">Browse OneDrive</button>
          </div>
          <p class="text-xs text-gray-400 dark:text-gray-500 mb-3">Folder Winston writes completed quote PDFs to. 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">
          <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 Enabled</h2>
            <div class="relative shrink-0">
              <button id="togenadmin-tools-add-btn" type="button" onclick="togenadminToggleAddMenu(event)" aria-haspopup="true" aria-expanded="false"
                      class="togen-control 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="togenadmin-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="togenadminAddSeparator()"
                        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>
                <button type="button" onclick="togenadminAddWebsite()"
                        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 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
                    <circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3c2.5 2.5 2.5 15.5 0 18M12 3c-2.5 2.5-2.5 15.5 0 18"/>
                  </svg>
                  Website
                </button>
                <button type="button" onclick="togenadminAddDashTile()"
                        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 d="M3 3a1 1 0 011-1h4a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1V3zm8 0a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1h-4a1 1 0 01-1-1V3zM3 13a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm8-2a1 1 0 011-1h4a1 1 0 011 1v6a1 1 0 01-1 1h-4a1 1 0 01-1-1v-6z"/>
                  </svg>
                  Dashboard Tile
                </button>
                <button type="button" onclick="togenadminAddDashRef()"
                        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="M5 3a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2V5a2 2 0 00-2-2H5zm0 2h10v3H5V5zm0 5h4v5H5v-5zm6 0h4v5h-4v-5z" clip-rule="evenodd"/>
                  </svg>
                  Dashboard
                </button>
              </div>
            </div>
          </div>
          <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. Add a separator to split the home grid into sections, a website link tile, a dashboard tile, or embed a whole dashboard; users may override the layout in their own settings.</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="togen-tabbar 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="togen-inset 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="togen-control 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="togen-control 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="togen-control 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="togen-inset 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-tabbar 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="togen-inset 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="togen-control 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="togen-control 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="togen-control 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="togen-inset 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="togen-control 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="togen-control 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="togen-control 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>

      <!-- Display agents pane (DVI-1198 / DVI-1191 P4a): Linux signage kiosks -->
      <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">Display</h2>
          <div class="flex items-center justify-between mb-1">
            <p class="text-sm text-gray-500 dark:text-gray-400">Signage displays render an assigned dashboard full-screen in kiosk mode. Enroll a display below, then use its Edit button to assign a dashboard flagged <span class="font-medium">&ldquo;Available for signage&rdquo;</span> in the Dashboards tool.</p>
            <div class="ml-3 shrink-0 flex items-center gap-2">
              <!-- DVI-1321 P2: org default display settings -->
              <button onclick="togenadminDisplayDefaultsOpen()"
                      class="togen-control 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-100 dark:hover:bg-gray-700 transition-colors cursor-pointer">Default Display Settings</button>
              <button onclick="togenadminLoadDisplayAgents()"
                      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>

          <!-- Enroll card -->
          <div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 p-4 mt-4 mb-6">
            <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Enroll a display</h3>
            <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Generate a one-time enrollment token, then run the installer on the target device. The token expires in 1 hour and is shown only once.</p>
            <!-- DVI-1227 P1: platform toggle (Linux / Windows) -->
            <div class="mt-3 flex items-center gap-3">
              <span class="text-xs font-medium text-gray-600 dark:text-gray-300">Platform:</span>
              <label class="flex items-center gap-1.5 text-xs text-gray-700 dark:text-gray-200 cursor-pointer">
                <input type="radio" id="tga-display-plat-linux" name="tga-display-platform" value="linux" checked
                       onchange="togenadminDisplayPlatformChange()" class="text-togen-teal focus:ring-togen-teal"/>
                Linux
              </label>
              <label class="flex items-center gap-1.5 text-xs text-gray-700 dark:text-gray-200 cursor-pointer">
                <input type="radio" id="tga-display-plat-windows" name="tga-display-platform" value="windows"
                       onchange="togenadminDisplayPlatformChange()" class="text-togen-teal focus:ring-togen-teal"/>
                Windows
              </label>
            </div>
            <button id="tga-display-enroll-btn" onclick="togenadminDisplayEnroll()"
                    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 enrollment token</button>
            <div id="tga-display-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">
              <!-- Linux install command -->
              <div id="tga-display-enroll-linux">
                <p class="text-xs font-medium text-gray-700 dark:text-gray-200">Install command — run it in a root shell on the Raspberry Pi (SSH or a terminal). It downloads the agent, registers this display and starts the kiosk.</p>
                <div class="mt-2 flex items-start gap-2">
                  <code id="tga-display-install-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-display-install-cmd')" class="togen-control 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 whitespace-nowrap">Copy</button>
                </div>
              </div>
              <!-- Windows install command (DVI-1227 P1) -->
              <div id="tga-display-enroll-windows" class="hidden">
                <p class="text-xs font-medium text-gray-700 dark:text-gray-200">PowerShell command — run it as Administrator on the Windows machine. It downloads the display agent, registers this display and starts the kiosk window.</p>
                <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">To upgrade an already-enrolled Windows display, download <a href="/enroll/display-upgrade.ps1" download="display-upgrade.ps1" class="text-togen-teal underline">display-upgrade.ps1</a> (admin session required) and run it as Administrator.</p>
                <div class="mt-2 flex items-start gap-2">
                  <code id="tga-display-install-cmd-win" 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-display-install-cmd-win')" class="togen-control 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 whitespace-nowrap">Copy</button>
                </div>
              </div>
              <p id="tga-display-token-ttl" class="mt-2 text-xs text-gray-500 dark:text-gray-400"></p>
              <details class="mt-2 text-xs text-gray-500 dark:text-gray-400">
                <summary class="cursor-pointer select-none">Manual / advanced install</summary>
                <div class="mt-1 space-y-0.5">
                  <div>Enrollment token (single use, this is the only time it's shown): <code id="tga-display-token" class="break-all text-gray-700 dark:text-gray-200"></code></div>
                  <div>Kiosk page (opened automatically by the installer): <code id="tga-display-display-url" class="break-all text-gray-700 dark:text-gray-200"></code><span class="text-gray-400">?token=&lt;device token&gt;</span></div>
                  <div class="text-gray-400">The register/checkin endpoints are called by the installer — they are POST-only and will show "Method not allowed" if opened in a browser.</div>
                </div>
              </details>
            </div>
          </div>

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

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

      <!-- Display agent Edit modal (DVI-1198): rename + assign a signage dashboard -->
      <div id="tga-display-edit-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-lg max-h-[90vh] overflow-y-auto">
          <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Edit display</h3>
            <button onclick="togenadminDisplayEditClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none cursor-pointer">&times;</button>
          </div>
          <div class="px-6 py-4 space-y-4">
            <div>
              <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Display name</label>
              <input id="tga-display-edit-name" type="text" maxlength="120"
                     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"/>
            </div>
            <div>
              <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Assigned dashboard</label>
              <select id="tga-display-edit-dashboard"
                      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"></select>
              <p id="tga-display-edit-none-hint" class="hidden mt-1.5 text-xs text-amber-600 dark:text-amber-400">No dashboards are flagged for signage yet. Open the Dashboards tool, create or open a dashboard, and use Share → &ldquo;Available for signage&rdquo;.</p>
            </div>

            <!-- Present gating (DVI-1204 P5) -->
            <div class="pt-2 border-t border-gray-200 dark:border-gray-700">
              <div class="flex items-center gap-2">
                <input id="tga-display-edit-presentable" type="checkbox" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"/>
                <label for="tga-display-edit-presentable" class="text-sm font-medium text-gray-700 dark:text-gray-200 cursor-pointer">Available for &ldquo;Present&rdquo;</label>
              </div>
              <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Lets allowed users temporarily push one of their own dashboards to this display (with a timed or manual revert) from the Dashboards tool.</p>
              <div class="flex items-center gap-2 mt-3">
                <input id="tga-display-edit-publish-anyone" type="checkbox" onchange="togenadminDisplayPublishAnyoneChanged()" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"/>
                <label for="tga-display-edit-publish-anyone" class="text-sm text-gray-700 dark:text-gray-200 cursor-pointer">Allow any logged-in user to present</label>
              </div>
              <label class="block mt-3 text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Allowed presenters (emails)</label>
              <textarea id="tga-display-edit-presenters" rows="3" placeholder="email@icastinc.com&#10;one per line, comma, or space separated"
                        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"></textarea>
              <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Togen Admins can always present to a presentable display.</p>
            </div>

            <!-- Allow Edit delegation (DVI-1322) -->
            <div class="pt-2 border-t border-gray-200 dark:border-gray-700">
              <h4 class="text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Allow Edit</h4>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Users listed here can edit this display&rsquo;s settings, preferences, and splash from User Settings &rarr; Agents &mdash; as if it were their own. They <span class="font-medium">cannot</span> revoke or delete it (admin-only). Multiple editors share one settings record; the last change wins.</p>
              <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Editors (emails)</label>
              <textarea id="tga-display-edit-editors" rows="3" placeholder="email@icastinc.com&#10;one per line, comma, or space separated"
                        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"></textarea>
            </div>

            <!-- Layout options (DVI-1220 P1): fit-to-screen + orientation -->
            <div class="pt-2 border-t border-gray-200 dark:border-gray-700">
              <h4 class="text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">Layout</h4>
              <div class="grid grid-cols-3 gap-3">
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Fit</label>
                  <select id="tga-display-edit-fit"
                          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">
                    <option value="scale">Scale to fit screen</option>
                    <option value="scroll">Scroll (display has input)</option>
                    <option value="clip">Clip (legacy)</option>
                  </select>
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Orientation</label>
                  <select id="tga-display-edit-orientation"
                          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">
                    <option value="auto">Auto (detect)</option>
                    <option value="landscape">Landscape</option>
                    <option value="portrait">Portrait</option>
                  </select>
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Columns</label>
                  <select id="tga-display-edit-columns"
                          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">
                    <option value="auto">Auto (12 / portrait 6)</option>
                  </select>
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Zoom</label>
                  <select id="tga-display-edit-zoom"
                          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">
                    <option value="50">50%</option>
                    <option value="75">75%</option>
                    <option value="90">90%</option>
                    <option value="100">100% (normal)</option>
                    <option value="110">110%</option>
                    <option value="125">125%</option>
                    <option value="150">150%</option>
                    <option value="175">175%</option>
                    <option value="200">200%</option>
                  </select>
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Flow</label>
                  <select id="tga-display-edit-flow"
                          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">
                    <option value="row">Rows (fill across)</option>
                    <option value="column">Columns (fill down)</option>
                  </select>
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Rows</label>
                  <select id="tga-display-edit-rows"
                          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">
                    <option value="auto">Auto</option>
                  </select>
                </div>
              </div>
              <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">&ldquo;Scale to fit&rdquo; sizes tiles so the whole dashboard fills the screen and pages overflowing tile content automatically. &ldquo;Columns&rdquo; sets how many columns wide the grid is (Auto = 12 landscape / 6 portrait); raise it for an ultrawide screen, or set 12 on a portrait wall to place two 2×2 tiles side by side. &ldquo;Zoom&rdquo; scales everything (text, tiles, charts) up or down to tune the display for a larger or smaller physical screen. &ldquo;Flow&rdquo; and &ldquo;Rows&rdquo; apply only to the Scroll/Clip fit modes: Flow &ldquo;Columns (fill down)&rdquo; prioritizes height (tiles fill down a column before wrapping to the next), and Rows caps how many rows tall the grid is before wrapping.</p>
              <p id="tga-display-edit-screen" class="hidden mt-1 text-xs text-gray-500 dark:text-gray-400"></p>
            </div>

            <!-- Windows window options (DVI-1227 P1) — only shown for Windows displays -->
            <div id="tga-display-edit-windows-section" class="hidden pt-2 border-t border-gray-200 dark:border-gray-700">
              <h4 class="text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">Windows display options</h4>
              <div class="space-y-3">
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Window layer</label>
                  <div class="space-y-1.5">
                    <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
                      <input type="radio" name="tga-display-win-layer" value="top" class="text-togen-teal focus:ring-togen-teal"/>
                      Always on top
                    </label>
                    <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
                      <input type="radio" name="tga-display-win-layer" value="bottom" class="text-togen-teal focus:ring-togen-teal"/>
                      Always on bottom
                    </label>
                    <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
                      <input type="radio" name="tga-display-win-layer" value="windowed" class="text-togen-teal focus:ring-togen-teal"/>
                      Windowed
                    </label>
                    <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
                      <input type="radio" name="tga-display-win-layer" value="wallpaper" class="text-togen-teal focus:ring-togen-teal"/>
                      Desktop background <span class="text-xs text-amber-600 dark:text-amber-400">(experimental)</span>
                    </label>
                  </div>
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Monitor</label>
                  <select id="tga-display-edit-win-monitor"
                          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">
                    <option value="primary">Primary monitor</option>
                    <option value="1">Monitor 1</option>
                    <option value="2">Monitor 2</option>
                    <option value="3">Monitor 3</option>
                    <option value="4">Monitor 4</option>
                  </select>
                </div>
                <!-- Windowed geometry — only meaningful when layer=windowed -->
                <div id="tga-display-edit-win-geom-section" class="hidden">
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Window geometry (pixels)</label>
                  <div class="grid grid-cols-2 gap-2">
                    <div>
                      <label class="block text-xs text-gray-500 dark:text-gray-400 mb-0.5">X</label>
                      <input id="tga-display-edit-win-x" type="number" min="0" max="32767" value="0"
                             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 focus:outline-none focus:ring-2 focus:ring-togen-teal"/>
                    </div>
                    <div>
                      <label class="block text-xs text-gray-500 dark:text-gray-400 mb-0.5">Y</label>
                      <input id="tga-display-edit-win-y" type="number" min="0" max="32767" value="0"
                             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 focus:outline-none focus:ring-2 focus:ring-togen-teal"/>
                    </div>
                    <div>
                      <label class="block text-xs text-gray-500 dark:text-gray-400 mb-0.5">Width</label>
                      <input id="tga-display-edit-win-width" type="number" min="100" max="32767" value="800"
                             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 focus:outline-none focus:ring-2 focus:ring-togen-teal"/>
                    </div>
                    <div>
                      <label class="block text-xs text-gray-500 dark:text-gray-400 mb-0.5">Height</label>
                      <input id="tga-display-edit-win-height" type="number" min="100" max="32767" value="600"
                             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 focus:outline-none focus:ring-2 focus:ring-togen-teal"/>
                    </div>
                  </div>
                </div>
              </div>
            </div>

            <!-- Interface options (DVI-1217) — mirrors User Settings → Interface -->
            <div class="pt-2 border-t border-gray-200 dark:border-gray-700">
              <h4 class="text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">Interface</h4>
              <div id="tga-display-edit-iface"></div>
            </div>

            <!-- Active override (DVI-1204 P5) -->
            <div id="tga-display-edit-present" class="hidden rounded-lg border border-togen-teal/30 bg-togen-teal/5 p-3 text-xs">
              <div class="text-gray-700 dark:text-gray-200" id="tga-display-edit-present-info"></div>
              <button onclick="togenadminDisplayForceRevert()" class="togen-control mt-2 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">Force revert now</button>
            </div>
          </div>
          <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-2">
            <button onclick="togenadminDisplayEditClose()"
                    class="togen-control px-4 py-2 text-sm 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 cursor-pointer">Cancel</button>
            <button id="tga-display-edit-save" onclick="togenadminDisplayEditSave()"
                    class="px-4 py-2 text-sm rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white font-medium cursor-pointer">Save</button>
          </div>
        </div>
      </div>

      <!-- Org Default Display Settings modal (DVI-1321 P2) -->
      <div id="tga-display-defaults-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-lg max-h-[90vh] overflow-y-auto">
          <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Default display settings</h3>
            <button onclick="togenadminDisplayDefaultsClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none cursor-pointer">&times;</button>
          </div>
          <div class="px-6 py-4 space-y-4">
            <p class="text-xs text-gray-500 dark:text-gray-400">These defaults apply as the baseline for every Display Agent, Virtual Display Agent, and Fullscreen view. A setting left at &ldquo;Auto&rdquo; / 100% is a no-op — a display's own explicit setting always wins, and a user's personal display settings override these for their own views.</p>
            <div class="grid grid-cols-3 gap-3">
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Fit</label>
                <select id="tga-display-defaults-fit" 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">
                  <option value="scale">Scale to fit screen</option>
                  <option value="scroll">Scroll (display has input)</option>
                  <option value="clip">Clip (legacy)</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Orientation</label>
                <select id="tga-display-defaults-orientation" 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">
                  <option value="auto">Auto (detect)</option>
                  <option value="landscape">Landscape</option>
                  <option value="portrait">Portrait</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Columns</label>
                <select id="tga-display-defaults-columns" 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">
                  <option value="auto">Auto (12 / portrait 6)</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Zoom</label>
                <select id="tga-display-defaults-zoom" 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">
                  <option value="50">50%</option><option value="75">75%</option><option value="90">90%</option>
                  <option value="100">100% (normal)</option><option value="110">110%</option><option value="125">125%</option>
                  <option value="150">150%</option><option value="175">175%</option><option value="200">200%</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Flow</label>
                <select id="tga-display-defaults-flow" 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">
                  <option value="row">Rows (fill across)</option>
                  <option value="column">Columns (fill down)</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Rows</label>
                <select id="tga-display-defaults-rows" 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">
                  <option value="auto">Auto</option>
                </select>
              </div>
            </div>
            <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
              <input id="tga-display-defaults-use-interface" type="checkbox" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal" />
              Default Fullscreen views to &ldquo;use my interface settings&rdquo; (the user's own background + dark/light mode)
            </label>
            <p id="tga-display-defaults-status" class="text-xs"></p>
          </div>
          <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
            <button onclick="togenadminDisplayDefaultsClose()" class="togen-control px-4 py-2 text-sm rounded-lg 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">Cancel</button>
            <button id="tga-display-defaults-save" onclick="togenadminDisplayDefaultsSave()" class="px-4 py-2 text-sm rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white font-medium cursor-pointer">Save</button>
          </div>
        </div>
      </div>

      <!-- Display agent Edit Splash modal (DVI-1217): splash text + its own interface options -->
      <div id="tga-display-splash-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-lg max-h-[90vh] overflow-y-auto">
          <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Edit splash screen</h3>
            <button onclick="togenadminDisplaySplashClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none cursor-pointer">&times;</button>
          </div>
          <div class="px-6 py-4 space-y-4">
            <p class="text-xs text-gray-500 dark:text-gray-400">Shown when no dashboard is selected for this display. Leave the text blank to keep the default &ldquo;Togen Signage&rdquo; splash.</p>
            <div>
              <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Splash text</label>
              <input id="tga-display-splash-text" type="text" maxlength="200" placeholder="e.g. Welcome to iCast — Building 2"
                     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"/>
            </div>
            <div class="pt-2 border-t border-gray-200 dark:border-gray-700">
              <h4 class="text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">Splash interface</h4>
              <div id="tga-display-splash-iface"></div>
            </div>
          </div>
          <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-2">
            <button onclick="togenadminDisplaySplashClose()"
                    class="togen-control px-4 py-2 text-sm 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 cursor-pointer">Cancel</button>
            <button id="tga-display-splash-save" onclick="togenadminDisplaySplashSave()"
                    class="px-4 py-2 text-sm rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white font-medium cursor-pointer">Save</button>
          </div>
        </div>
      </div>

      <!-- 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="togen-control 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 Orders 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 Orders 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>

      <!-- Retention & archival policy (DVI-1045) -->
      <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">Retention &amp; Archival</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Control how long history events are kept in the live database. Pruned rows are rotated to compressed archive files instead of being deleted, unless you turn archiving off. Leave a field blank for unlimited.</p>

          <div class="flex flex-wrap gap-6 mb-4">
            <label class="text-sm text-gray-700 dark:text-gray-300">
              <span class="block font-medium mb-1">Retention window (days)</span>
              <input type="number" id="togenadmin-history-retention-days" min="1" step="1" placeholder="Unlimited"
                     class="w-40 rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-800 text-sm focus:ring-togen-teal focus:border-togen-teal">
              <span class="block text-xs text-gray-500 dark:text-gray-400 mt-1">Events older than this are archived out.</span>
            </label>
            <label class="text-sm text-gray-700 dark:text-gray-300">
              <span class="block font-medium mb-1">Max rows per tool</span>
              <input type="number" id="togenadmin-history-max-rows" min="1" step="1" placeholder="Unlimited"
                     class="w-40 rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-800 text-sm focus:ring-togen-teal focus:border-togen-teal">
              <span class="block text-xs text-gray-500 dark:text-gray-400 mt-1">Oldest rows beyond this cap are archived out.</span>
            </label>
          </div>

          <label class="flex items-start gap-2 mb-4 cursor-pointer max-w-md">
            <input id="togenadmin-history-archive" 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">Archive pruned rows</span>
              <span class="block text-xs text-gray-500 dark:text-gray-400">Write pruned events to <code>history_archive/tool_history_YYYY-MM.jsonl.gz</code> before deleting. Uncheck to delete permanently.</span>
            </span>
          </label>

          <div class="mt-2 flex items-center gap-3">
            <button 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 policy
            </button>
            <button onclick="togenadminHistoryPruneNow()"
                    class="px-4 py-2 text-sm font-medium rounded-xl bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer">
              Run prune now
            </button>
            <span id="togenadmin-history-prune-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 to the TrueNAS server.</p>
            <button id="togenadmin-backup-run-btn" onclick="togenadminBackupRun()"
                    class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white inline-flex items-center gap-2 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 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>
            <p id="togenadmin-backup-run-status" class="text-sm mt-2"></p>
          </div>
        </section>

        <!-- Schedule pane (DVI-966) -->
        <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">Schedule</h2>
              <label class="inline-flex items-center gap-2 cursor-pointer">
                <input id="togenadmin-backup-sched-enabled" type="checkbox" onchange="togenadminBackupSchedToggle()"
                       class="h-4 w-4 rounded border-gray-300 text-togen-teal focus:ring-togen-teal">
                <span class="text-sm text-gray-600 dark:text-gray-300">Enabled</span>
              </label>
            </div>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Automatically run the backup on a recurring schedule. Times use the timezone of the admin who saves the schedule (defaults to Central Time).</p>

            <div id="togenadmin-backup-sched-fields" class="space-y-4 opacity-50 pointer-events-none">
              <div class="flex flex-wrap items-end gap-4">
                <div>
                  <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Frequency</label>
                  <select id="togenadmin-backup-sched-freq" onchange="togenadminBackupSchedFreqChange()"
                          class="px-3 py-2 text-sm rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100">
                    <option value="daily">Daily</option>
                    <option value="weekly">Weekly</option>
                  </select>
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Time</label>
                  <input id="togenadmin-backup-sched-time" type="time" value="02:00"
                         class="px-3 py-2 text-sm rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100">
                </div>
              </div>

              <div id="togenadmin-backup-sched-days-wrap" class="hidden">
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Days</label>
                <div id="togenadmin-backup-sched-days" class="flex flex-wrap gap-1.5"></div>
              </div>

              <div>
                <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Timezone</label>
                <p id="togenadmin-backup-sched-tz" class="text-sm text-gray-700 dark:text-gray-200">America/Chicago</p>
              </div>
            </div>

            <div class="mt-4 flex items-center gap-3">
              <button id="togenadmin-backup-sched-save-btn" onclick="togenadminBackupSchedSave()"
                      class="px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white inline-flex items-center gap-2 transition-colors cursor-pointer">
                Save schedule
              </button>
              <p id="togenadmin-backup-sched-next" class="text-sm text-gray-500 dark:text-gray-400"></p>
            </div>
            <p id="togenadmin-backup-sched-status" class="text-sm mt-2"></p>
          </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">Loading…</p>
            </div>
            <button id="togenadmin-backup-more-btn" onclick="togenadminBackupLoadMore()"
                    class="togen-control 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 keys — one per provider (DVI-952) -->
            <div class="py-3 border-t border-gray-100 dark:border-gray-700 max-w-md">
              <label class="block text-sm font-medium text-gray-800 dark:text-gray-100 mb-1">Company API keys</label>
              <p class="text-xs text-gray-400 dark:text-gray-500 mb-2">Configure a company key for each provider you want available system-wide. Keys are stored securely and never shown again — leave a field blank to keep the current key. Use "Available system-wide" to turn a provider on or off for all users.</p>
              <div id="togenadmin-assistant-keys" class="space-y-3"></div>
            </div>

            <!-- Title-block OCR provider/model (DVI-956) -->
            <div class="py-3 border-t border-gray-100 dark:border-gray-700 max-w-md">
              <label class="block text-sm font-medium text-gray-800 dark:text-gray-100 mb-1">Title-block OCR model</label>
              <p class="text-xs text-gray-400 dark:text-gray-500 mb-2">Vision model used to read engineering drawing title blocks (Take-off / Project Builder OCR). Runs on the company key for the chosen provider. Only providers with a company key above are listed.</p>
              <select id="togenadmin-assistant-ocr-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>

            <!-- 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="togen-control 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 -->

      <!-- Testing panel (DVI-1082) -->
      <div id="togenadmin-panel-testing" 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="p-5">
            <h2 class="text-base font-semibold text-gray-800 dark:text-gray-100">Authorized Testers</h2>
            <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
              Testers may create and view <strong>Test Work Orders</strong> — segregated WOs used to exercise the
              WO Request &rarr; WO Admin &rarr; Approval pipeline without touching the production audit trail.
              Test WOs never appear in WO Admin or the Auditor for non-testers, and fire no notifications or history.
              Togen Admins always have this ability; add non-admin testers by email below.
              (Converting an existing WO to test, and deleting WOs, remain Togen-Admin-only and ship in a later phase.)
            </p>
            <div class="mt-4 flex items-center gap-2 max-w-md">
              <input type="email" id="togenadmin-tester-input" placeholder="email@example.com"
                onkeydown="if(event.key==='Enter'){event.preventDefault();togenadminTesterAdd();}"
                class="flex-1 px-2 py-1 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-1 focus:ring-togen-teal">
              <button type="button" onclick="togenadminTesterAdd()"
                class="px-2.5 py-1 text-sm 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="togenadmin-testers-list" class="mt-3 flex flex-wrap gap-1.5"></div>
            <span id="togenadmin-testers-status" class="block mt-3 text-sm"></span>
          </div>
        </section>

        <!-- Quarantine & Deletion Log (DVI-1084, Phase 2) -->
        <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="p-5">
            <div class="flex items-center justify-between flex-wrap gap-2">
              <h2 class="text-base font-semibold text-gray-800 dark:text-gray-100">Quarantine &amp; Deletion Log</h2>
              <button type="button" onclick="togenadminLoadDeletionAudit()"
                class="togen-control px-2.5 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">Refresh</button>
            </div>
            <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
              Soft-deleted work orders are quarantined here (restorable) and every convert/delete/restore is recorded in an
              append-only, hash-chained deletion log. The chain makes any silent edit or removal <strong>detectable</strong>
              (tamper-<em>evident</em>, not tamper-proof).
            </p>

            <h3 class="mt-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Quarantined (soft-deleted) work orders</h3>
            <div id="togenadmin-deleted-list" class="mt-2 space-y-2 text-sm text-gray-500 dark:text-gray-400">Loading…</div>

            <div class="mt-5 flex items-center gap-2 flex-wrap">
              <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Deletion log</h3>
              <span id="togenadmin-deletion-verify" class="px-2 py-0.5 rounded-full text-xs font-medium"></span>
            </div>
            <div id="togenadmin-deletion-log" class="mt-2 space-y-1.5 text-xs">Loading…</div>
          </div>
        </section>
      </div><!-- /togenadmin-panel-testing -->

      <!-- Services panel (DVI-1136) -->
      <div id="togenadmin-panel-services" class="hidden">

        <!-- Sub-tab bar (one tab per service integration) -->
        <div class="togen-tabbar 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="tga-svc-subtab-thingpark" onclick="togenadminServiceSubtab('thingpark')"
                  class="flex-shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 border-togen-teal text-togen-teal cursor-pointer">
            ThingPark (LoRaWAN)
          </button>
          <button id="tga-svc-subtab-idencia" onclick="togenadminServiceSubtab('idencia')"
                  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 cursor-pointer">
            Idencia
          </button>
          <button id="tga-svc-subtab-powerbi" onclick="togenadminServiceSubtab('powerbi')"
                  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 cursor-pointer">
            Power BI
          </button>
          <button id="tga-svc-subtab-netsuite" onclick="togenadminServiceSubtab('netsuite')"
                  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 cursor-pointer">
            NetSuite
          </button>
          <button id="tga-svc-subtab-paychex" onclick="togenadminServiceSubtab('paychex')"
                  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 cursor-pointer">
            PayChex
          </button>
        </div>

        <div id="tga-svc-panel-thingpark">

          <!-- Ingest status -->
          <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="p-5">
              <div class="flex items-center justify-between flex-wrap gap-2">
                <h2 class="text-base font-semibold text-gray-800 dark:text-gray-100">Uplink Ingest</h2>
                <button type="button" onclick="thingparkLoadHealth()"
                  class="togen-control px-2.5 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">Refresh</button>
              </div>
              <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
                ThingPark's Generic HTTPS connector POSTs each device uplink to
                <code class="text-xs bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded">/thingpark/ingest?token=…</code>.
                Ingest is disabled until <code class="text-xs bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded">THINGPARK_INGEST_TOKEN</code>
                is set in the service environment (the endpoint returns 503 until then).
              </p>
              <div id="thingpark-health" class="mt-4 text-sm text-gray-500 dark:text-gray-400">Loading…</div>
            </div>
          </section>

          <!-- Device registry -->
          <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="p-5">
              <div class="flex items-center justify-between flex-wrap gap-2">
                <h2 class="text-base font-semibold text-gray-800 dark:text-gray-100">Devices</h2>
                <div class="flex items-center gap-2">
                  <button type="button" onclick="thingparkLoadDevices()"
                    class="togen-control px-2.5 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">Refresh</button>
                  <button type="button" onclick="thingparkSaveDevices()"
                    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">Save devices</button>
                </div>
              </div>
              <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
                Assign a friendly name, kind, and location to each device. Devices auto-appear here once they send an uplink;
                <strong>Include in Oversight</strong> exposes a device to the Oversight floor-plan layer (later phase).
              </p>
              <div id="thingpark-devices" class="mt-4 overflow-x-auto text-sm text-gray-500 dark:text-gray-400">Loading…</div>
              <span id="thingpark-devices-status" class="block mt-3 text-sm"></span>
            </div>
          </section>

          <!-- DX-Core API credentials -->
          <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="p-5">
              <h2 class="text-base font-semibold text-gray-800 dark:text-gray-100">DX-Core API (inventory)</h2>
              <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
                OAuth2 client-credentials for the ThingPark DX-Core API — used by Togen to pull device/tag inventory
                (outbound; separate from the inbound uplink token above). The client secret is stored server-side and never
                returned; leave it blank to keep the existing one.
              </p>
              <div class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl">
                <label class="block">
                  <span class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Base URL</span>
                  <input type="text" id="thingpark-dx-base" placeholder="https://dx-api.thingpark.com"
                    class="w-full px-2 py-1 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-1 focus:ring-togen-teal">
                </label>
                <label class="block">
                  <span class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Token URL</span>
                  <input type="text" id="thingpark-dx-tokenurl" placeholder="https://dx-api.thingpark.com/admin/latest/api/oauth/token"
                    class="w-full px-2 py-1 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-1 focus:ring-togen-teal">
                </label>
                <label class="block">
                  <span class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Client ID</span>
                  <input type="text" id="thingpark-dx-clientid" placeholder="sub-000000000/togen"
                    class="w-full px-2 py-1 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-1 focus:ring-togen-teal">
                </label>
                <label class="block">
                  <span class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Client Secret <span id="thingpark-dx-secret-note" class="text-gray-400"></span></span>
                  <input type="password" id="thingpark-dx-secret" placeholder="(unchanged)" autocomplete="new-password"
                    class="w-full px-2 py-1 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-1 focus:ring-togen-teal">
                </label>
              </div>
              <div class="mt-4 flex items-center gap-2 flex-wrap">
                <button type="button" onclick="thingparkSaveDxCore()"
                  class="px-2.5 py-1 text-sm font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 cursor-pointer">Save credentials</button>
                <button type="button" onclick="thingparkDxTest()"
                  class="togen-control px-2.5 py-1 text-sm 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 cursor-pointer">Test connection</button>
                <span id="thingpark-dx-status" class="text-sm"></span>
              </div>
            </div>
          </section>

          <!-- 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="p-5">
              <h2 class="text-base font-semibold text-gray-800 dark:text-gray-100">Settings</h2>
              <div class="mt-3 flex items-center gap-2 flex-wrap">
                <label class="text-sm text-gray-600 dark:text-gray-300">Mark a device stale after</label>
                <input type="number" id="thingpark-stale-min" min="1" step="1"
                  class="w-20 px-2 py-1 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-1 focus:ring-togen-teal">
                <label class="text-sm text-gray-600 dark:text-gray-300">minutes without an uplink.</label>
                <button type="button" onclick="thingparkSaveSettings()"
                  class="px-2.5 py-1 text-sm font-medium rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 cursor-pointer">Save</button>
                <span id="thingpark-settings-status" class="text-sm"></span>
              </div>
            </div>
          </section>

        </div><!-- /tga-svc-panel-thingpark -->

        <!-- Idencia (DVI-1226 P1): org service account + background sync -->
        <div id="tga-svc-panel-idencia" class="hidden">

          <!-- Organization account -->
          <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="p-5">
              <h2 class="text-base font-semibold text-gray-800 dark:text-gray-100">Organization account</h2>
              <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
                The Idencia service account Togen uses for the background sync, signage displays, and any user
                without a personal connection (users connect their own login under User Settings &rarr; Services).
                Use a dedicated read-only Idencia login. The password is stored server-side and never shown again.
              </p>
              <div class="mt-3 max-w-3xl">
                <span class="text-sm font-medium text-gray-700 dark:text-gray-300">Connection mode</span>
                <div class="mt-1 flex items-center gap-4 flex-wrap text-sm text-gray-700 dark:text-gray-300">
                  <label class="inline-flex items-center gap-1.5 cursor-pointer">
                    <input type="radio" name="idencia-mode" id="idencia-mode-api" value="api" onchange="idenciaModeChanged()"
                           class="border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
                    Official API
                  </label>
                  <label class="inline-flex items-center gap-1.5 cursor-pointer">
                    <input type="radio" name="idencia-mode" id="idencia-mode-web" value="web" onchange="idenciaModeChanged()"
                           class="border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
                    Web sign-in <span class="text-xs text-gray-400">(unofficial)</span>
                  </label>
                </div>
                <div id="idencia-web-block" class="mt-2 hidden">
                  <p class="text-xs text-gray-500 dark:text-gray-400">
                    Signs in to the Idencia <strong>website</strong> with the account above and reads the same
                    data the site's Items / Jobs / Shipments pages show — no API access needed. Unofficial:
                    it can stop working when Idencia updates their site, and item <em>status</em> is not
                    available this way (the website does not expose it). Switch to the official API when
                    Idencia provides your API endpoints.
                  </p>
                  <label class="block text-sm mt-2 max-w-md">
                    <span class="text-gray-700 dark:text-gray-300">Idencia web address</span>
                    <input id="idencia-web-url" type="text" autocomplete="off"
                           placeholder="icast.idencia.net"
                           class="mt-1 w-full px-3 py-1.5 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">
                  </label>
                </div>
              </div>
              <div class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-3xl">
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Username</span>
                  <input id="idencia-org-username" type="text" autocomplete="off"
                         class="mt-1 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">
                </label>
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Password</span>
                  <input id="idencia-org-password" type="password" autocomplete="new-password"
                         class="mt-1 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">
                </label>
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Organization ID</span>
                  <input id="idencia-org-id" type="text" autocomplete="off"
                         class="mt-1 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">
                </label>
              </div>
              <details id="idencia-api-block" class="mt-3 max-w-3xl" open>
                <summary class="text-xs text-gray-500 dark:text-gray-400 cursor-pointer">API endpoints (required)</summary>
                <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
                  These are the <strong>backend API</strong> endpoints — <em>not</em> the Idencia web app domain
                  (<span class="font-mono">icast.idencia.net</span>). Obtain them from your Idencia API
                  documentation or by opening the Idencia web app in a browser, opening
                  <strong>DevTools (F12) → Network</strong>, logging in, and copying the URL of the
                  login POST request and the GraphQL POST request. They are typically
                  <span class="font-mono">https://….execute-api.…amazonaws.com/…</span> or
                  <span class="font-mono">https://….appsync-api.…amazonaws.com/graphql</span> style URLs.
                  Leave blank to unset. Users may override these under User Settings &rarr; Services.
                </p>
                <div class="mt-2 grid grid-cols-1 gap-3">
                  <label class="block text-sm">
                    <span class="text-gray-700 dark:text-gray-300">Login URL</span>
                    <input id="idencia-login-url" type="text" autocomplete="off"
                           placeholder="https://….execute-api.…amazonaws.com/…/api/v1/login"
                           class="mt-1 w-full px-3 py-1.5 text-xs 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">
                  </label>
                  <label class="block text-sm">
                    <span class="text-gray-700 dark:text-gray-300">GraphQL URL</span>
                    <input id="idencia-graphql-url" type="text" autocomplete="off"
                           placeholder="https://….appsync-api.…amazonaws.com/graphql"
                           class="mt-1 w-full px-3 py-1.5 text-xs 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">
                  </label>
                </div>
              </details>
              <div class="mt-4 flex items-center gap-2 flex-wrap">
                <button type="button" onclick="idenciaAdminSave()"
                  class="px-3 py-1.5 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal-dark cursor-pointer">Save</button>
                <button type="button" onclick="idenciaAdminTest()"
                  class="togen-control px-3 py-1.5 text-sm 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 cursor-pointer">Test connection</button>
                <span id="idencia-org-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
              </div>
            </div>
          </section>

          <!-- Background sync -->
          <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="p-5">
              <div class="flex items-center justify-between flex-wrap gap-2">
                <h2 class="text-base font-semibold text-gray-800 dark:text-gray-100">Background sync</h2>
                <button type="button" onclick="idenciaAdminLoad()"
                  class="togen-control px-2.5 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 cursor-pointer">Refresh</button>
              </div>
              <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
                Pulls projects, item types, items, and shipments under the organization account into Togen's local
                cache on a cadence, and records snapshot rows for Reporting. Dashboard tiles read live data first and
                fall back to this cache (labeled "Cached") when Idencia is unreachable.
              </p>
              <div class="mt-4 flex items-center gap-4 flex-wrap">
                <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input id="idencia-sync-enabled" type="checkbox" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
                  Sync enabled
                </label>
                <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  Every
                  <input id="idencia-sync-interval" type="number" min="2" max="1440"
                         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-200">
                  minutes
                </label>
                <button type="button" onclick="idenciaSyncNow()"
                  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 cursor-pointer">Sync now</button>
                <span id="idencia-sync-run-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
              </div>
              <div id="idencia-sync-status" class="mt-4 text-sm text-gray-500 dark:text-gray-400">Loading…</div>
            </div>
          </section>

        </div><!-- /tga-svc-panel-idencia -->

        <!-- Power BI (DVI-1243 P1): org service principal + vocab sync -->
        <div id="tga-svc-panel-powerbi" class="hidden">

          <!-- Service principal -->
          <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="p-5">
              <h2 class="text-base font-semibold text-gray-800 dark:text-gray-100">Service principal</h2>
              <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
                The Azure AD app registration Togen uses to read Power BI (app-only, client-credentials).
                Every dashboard tile and report runs under this identity — signage displays can never show
                personal content. The client secret is stored server-side and never shown again.
              </p>
              <details class="mt-2 max-w-3xl">
                <summary class="text-xs text-gray-500 dark:text-gray-400 cursor-pointer">Setup prerequisites (Azure + Power BI tenant)</summary>
                <ol class="mt-2 text-xs text-gray-500 dark:text-gray-400 list-decimal ml-4 space-y-1">
                  <li>Create an Azure AD <strong>app registration</strong> + client secret (no Power BI API permissions needed for app-only access).</li>
                  <li>In the Power BI admin portal &rarr; Tenant settings &rarr; Developer settings, enable
                      <strong>"Allow service principals to use Power BI APIs"</strong> (scope it to a security group containing the app).</li>
                  <li>Add the service principal (or its security group) as <strong>Member or Viewer</strong> on each target workspace.</li>
                  <li>For the Embedded Report tile only: the workspace must sit on a <strong>Fabric F capacity</strong>. The Data tile and Reporting need no capacity.</li>
                  <li>Note: service principals cannot read RLS-protected or SSO-enabled datasets.</li>
                </ol>
              </details>
              <div class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-3xl">
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Tenant ID</span>
                  <input id="powerbi-tenant-id" type="text" autocomplete="off"
                         placeholder="00000000-0000-0000-0000-000000000000"
                         class="mt-1 w-full px-3 py-1.5 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">
                </label>
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Client (application) ID</span>
                  <input id="powerbi-client-id" type="text" autocomplete="off"
                         placeholder="00000000-0000-0000-0000-000000000000"
                         class="mt-1 w-full px-3 py-1.5 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">
                </label>
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Client secret</span>
                  <input id="powerbi-client-secret" type="password" autocomplete="new-password"
                         class="mt-1 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">
                </label>
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Workspace allowlist <span class="text-xs text-gray-400">(optional — names or GUIDs, one per line; blank = all visible)</span></span>
                  <textarea id="powerbi-workspaces" rows="3" autocomplete="off"
                         class="mt-1 w-full px-3 py-1.5 text-xs 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>
                </label>
              </div>
              <div class="mt-4 flex items-center gap-2 flex-wrap">
                <button type="button" onclick="powerbiAdminSave()"
                  class="px-3 py-1.5 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal-dark cursor-pointer">Save</button>
                <button type="button" onclick="powerbiAdminTest()"
                  class="togen-control px-3 py-1.5 text-sm 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 cursor-pointer">Test connection</button>
                <span id="powerbi-org-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
              </div>
            </div>
          </section>

          <!-- Background sync -->
          <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="p-5">
              <div class="flex items-center justify-between flex-wrap gap-2">
                <h2 class="text-base font-semibold text-gray-800 dark:text-gray-100">Background sync</h2>
                <button type="button" onclick="powerbiAdminLoad()"
                  class="togen-control px-2.5 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 cursor-pointer">Refresh</button>
              </div>
              <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
                Caches the workspace / report / dataset inventory on a cadence so tile editors and the
                Reporting query library offer name pickers instead of raw GUIDs. Dashboard tiles read live
                data first and fall back to Togen's local cache (labeled "Cached") when Power BI is unreachable.
              </p>
              <div class="mt-4 flex items-center gap-4 flex-wrap">
                <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input id="powerbi-sync-enabled" type="checkbox" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
                  Sync enabled
                </label>
                <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  Every
                  <input id="powerbi-sync-interval" type="number" min="2" max="1440"
                         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-200">
                  minutes
                </label>
                <button type="button" onclick="powerbiSyncNow()"
                  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 cursor-pointer">Sync now</button>
                <span id="powerbi-sync-run-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
              </div>
              <div id="powerbi-sync-status" class="mt-4 text-sm text-gray-500 dark:text-gray-400">Loading…</div>
            </div>
          </section>

          <!-- Reporting query library (DVI-1243 P3, D3-A) -->
          <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="p-5">
              <div class="flex items-center justify-between flex-wrap gap-2">
                <h2 class="text-base font-semibold text-gray-800 dark:text-gray-100">Reporting query library</h2>
                <button type="button" onclick="powerbiQueryAdd()"
                  class="px-3 py-1.5 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal-dark cursor-pointer">Add query</button>
              </div>
              <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
                Each saved DAX query becomes a runnable report under Auditor &rarr; Reporting &rarr; Power BI
                (table + CSV export) — no code needed per new report. Use <code>&#123;&#123;param&#125;&#125;</code> placeholders
                in the DAX to expose run-time inputs. A built-in "Dataset refresh status" report is always available.
              </p>
              <div id="powerbi-queries-list" class="mt-4 space-y-2 max-w-3xl"></div>
              <div id="powerbi-query-form" class="hidden mt-4 max-w-3xl rounded-xl border border-togen-teal/40 bg-togen-teal/5 p-4">
                <input id="powerbi-q-id" type="hidden">
                <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
                  <label class="block text-sm sm:col-span-2">
                    <span class="text-gray-700 dark:text-gray-300">Report name</span>
                    <input id="powerbi-q-name" type="text" maxlength="100" autocomplete="off"
                           class="mt-1 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">
                  </label>
                  <label class="block text-sm">
                    <span class="text-gray-700 dark:text-gray-300">Workspace</span>
                    <select id="powerbi-q-ws" onchange="powerbiQueryWsChanged('')"
                           class="mt-1 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"></select>
                  </label>
                  <label class="block text-sm">
                    <span class="text-gray-700 dark:text-gray-300">Dataset</span>
                    <select id="powerbi-q-ds"
                           class="mt-1 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"></select>
                  </label>
                  <label class="block text-sm sm:col-span-2">
                    <span class="text-gray-700 dark:text-gray-300">DAX query</span>
                    <textarea id="powerbi-q-dax" rows="5" maxlength="4000" spellcheck="false"
                           placeholder="EVALUATE SUMMARIZECOLUMNS('Table'[Category], &quot;Total&quot;, [Total Sales])"
                           class="mt-1 w-full px-3 py-1.5 text-xs 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>
                  </label>
                  <label class="block text-sm sm:col-span-2">
                    <span class="text-gray-700 dark:text-gray-300">Parameters <span class="text-xs text-gray-400">(optional — <code>key:Label</code> pairs, comma-separated; reference as <code>&#123;&#123;key&#125;&#125;</code> in the DAX)</span></span>
                    <input id="powerbi-q-params" type="text" autocomplete="off" placeholder="category:Category, year:Year"
                           class="mt-1 w-full px-3 py-1.5 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">
                  </label>
                </div>
                <div class="mt-3 flex items-center gap-2">
                  <button type="button" onclick="powerbiQuerySave()"
                    class="px-3 py-1.5 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal-dark cursor-pointer">Save query</button>
                  <button type="button" onclick="powerbiQueryCancel()"
                    class="togen-control px-3 py-1.5 text-sm 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 cursor-pointer">Cancel</button>
                  <span id="powerbi-q-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
                </div>
              </div>
            </div>
          </section>

        </div><!-- /tga-svc-panel-powerbi -->

        <div id="tga-svc-panel-netsuite" class="hidden">

          <!-- Service account -->
          <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="p-5">
              <h2 class="text-base font-semibold text-gray-800 dark:text-gray-100">Service account</h2>
              <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
                The NetSuite integration Togen uses to read the product catalog via SuiteQL (SuiteTalk REST,
                OAuth&nbsp;2.0 client-credentials machine-to-machine). Authentication is a signed JWT — the
                <strong>private key</strong> is stored server-side and never shown again; the certificate ID is the JWT key id.
              </p>
              <details class="mt-2 max-w-3xl">
                <summary class="text-xs text-gray-500 dark:text-gray-400 cursor-pointer">Setup prerequisites (NetSuite)</summary>
                <ol class="mt-2 text-xs text-gray-500 dark:text-gray-400 list-decimal ml-4 space-y-1">
                  <li>Enable <strong>REST Web Services</strong> and <strong>OAuth 2.0</strong> (Setup → Company → Enable Features → SuiteCloud).</li>
                  <li>Create an <strong>Integration record</strong> with <strong>Client Credentials (Machine to Machine)</strong> grant — note the <strong>Client ID (consumer key)</strong>.</li>
                  <li>Generate an RSA/EC key pair; upload the certificate under <strong>Setup → Integration → OAuth 2.0 Client Credentials Setup</strong>, mapping it to the integration + a role — note the <strong>Certificate ID</strong>.</li>
                  <li>The mapped <strong>role</strong> needs the <strong>SuiteQL</strong> (Log in using Access Tokens / REST Web Services) permission and <strong>item record read</strong> access.</li>
                  <li>Paste the matching <strong>private key</strong> (PEM) below. Sandbox account IDs use a suffix, e.g. <code>1234567_SB1</code>.</li>
                </ol>
              </details>
              <div class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-3xl">
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Account ID</span>
                  <input id="netsuite-account-id" type="text" autocomplete="off"
                         placeholder="1234567 (or 1234567_SB1)"
                         class="mt-1 w-full px-3 py-1.5 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">
                </label>
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Client (consumer) ID</span>
                  <input id="netsuite-client-id" type="text" autocomplete="off"
                         class="mt-1 w-full px-3 py-1.5 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">
                </label>
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Certificate ID</span>
                  <input id="netsuite-certificate-id" type="text" autocomplete="off"
                         class="mt-1 w-full px-3 py-1.5 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">
                </label>
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Signing algorithm</span>
                  <select id="netsuite-algorithm"
                         class="mt-1 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">
                    <option value="PS256">PS256 (recommended)</option>
                    <option value="RS256">RS256</option>
                  </select>
                </label>
                <label class="block text-sm sm:col-span-2">
                  <span class="text-gray-700 dark:text-gray-300">Private key <span class="text-xs text-gray-400">(PEM — write-only, leave blank to keep the stored key)</span></span>
                  <textarea id="netsuite-private-key" rows="4" autocomplete="off" spellcheck="false"
                         placeholder="-----BEGIN PRIVATE KEY-----&#10;…&#10;-----END PRIVATE KEY-----"
                         class="mt-1 w-full px-3 py-1.5 text-xs 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>
                </label>
                <label class="block text-sm sm:col-span-2">
                  <span class="text-gray-700 dark:text-gray-300">Item filter <span class="text-xs text-gray-400">(optional SuiteQL WHERE fragment, e.g. <code>itemtype = 'InvtPart'</code>)</span></span>
                  <input id="netsuite-item-filter" type="text" autocomplete="off"
                         class="mt-1 w-full px-3 py-1.5 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">
                </label>
              </div>
              <div class="mt-4 flex items-center gap-2 flex-wrap">
                <button type="button" onclick="netsuiteAdminSave()"
                  class="px-3 py-1.5 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal-dark cursor-pointer">Save</button>
                <button type="button" onclick="netsuiteAdminTest()"
                  class="togen-control px-3 py-1.5 text-sm 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 cursor-pointer">Test connection</button>
                <span id="netsuite-org-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
              </div>
            </div>
          </section>

          <!-- Product-catalog sync -->
          <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="p-5">
              <div class="flex items-center justify-between flex-wrap gap-2">
                <h2 class="text-base font-semibold text-gray-800 dark:text-gray-100">Product-catalog sync</h2>
                <button type="button" onclick="netsuiteAdminLoad()"
                  class="togen-control px-2.5 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 cursor-pointer">Refresh</button>
              </div>
              <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
                Syncs the NetSuite item catalog into Togen on a cadence (nightly by default) so the Logistics
                load picker never blocks on a live query. Data is read live-first and falls back to this local
                copy (labeled "Cached") when NetSuite is unreachable.
              </p>
              <div class="mt-4 flex items-center gap-4 flex-wrap">
                <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input id="netsuite-sync-enabled" type="checkbox" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
                  Sync enabled
                </label>
                <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  Every
                  <input id="netsuite-sync-interval" type="number" min="2" max="10080"
                         class="w-24 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-200">
                  minutes
                </label>
                <button type="button" onclick="netsuiteSyncNow()"
                  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 cursor-pointer">Sync now</button>
                <span id="netsuite-sync-run-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
              </div>
              <div id="netsuite-sync-status" class="mt-4 text-sm text-gray-500 dark:text-gray-400">Loading…</div>
            </div>
          </section>

        </div><!-- /tga-svc-panel-netsuite -->

        <div id="tga-svc-panel-paychex" class="hidden">

          <!-- Service account -->
          <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="p-5">
              <h2 class="text-base font-semibold text-gray-800 dark:text-gray-100">Service account</h2>
              <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
                The Paychex Flex API app Togen uses to read the workers roster, time-off / schedule
                availability, and clock-in status (OAuth&nbsp;2.0 client-credentials machine-to-machine against
                <code>api.paychex.com</code>). The <strong>client secret</strong> is stored server-side and never shown again.
              </p>
              <details class="mt-2 max-w-3xl">
                <summary class="text-xs text-gray-500 dark:text-gray-400 cursor-pointer">Setup prerequisites (Paychex)</summary>
                <ol class="mt-2 text-xs text-gray-500 dark:text-gray-400 list-decimal ml-4 space-y-1">
                  <li>Enroll in the <strong>Paychex Developer</strong> program (developer.paychex.com) and create an <strong>API application</strong>.</li>
                  <li>Have the application <strong>approved for your tenant</strong> — note the <strong>Client ID</strong> and <strong>Client Secret</strong> (client-credentials grant).</li>
                  <li>Grant the app scope to read <strong>Workers</strong> for your company.</li>
                  <li>Note your Paychex <strong>Company ID</strong> (the display id for the company whose workers you want).</li>
                  <li><strong>Clock-in status and schedules require the Paychex Time &amp; Attendance product</strong> — without it Togen still reads the roster; the availability signals show as unavailable.</li>
                </ol>
              </details>
              <div class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-3xl">
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Client ID</span>
                  <input id="paychex-client-id" type="text" autocomplete="off"
                         class="mt-1 w-full px-3 py-1.5 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">
                </label>
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Company ID</span>
                  <input id="paychex-company-id" type="text" autocomplete="off"
                         class="mt-1 w-full px-3 py-1.5 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">
                </label>
                <label class="block text-sm sm:col-span-2">
                  <span class="text-gray-700 dark:text-gray-300">Client secret <span class="text-xs text-gray-400">(write-only, leave blank to keep the stored secret)</span></span>
                  <input id="paychex-client-secret" type="password" autocomplete="new-password"
                         class="mt-1 w-full px-3 py-1.5 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">
                </label>
              </div>
              <div class="mt-4 flex items-center gap-2 flex-wrap">
                <button type="button" onclick="paychexAdminSave()"
                  class="px-3 py-1.5 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal-dark cursor-pointer">Save</button>
                <button type="button" onclick="paychexAdminTest()"
                  class="togen-control px-3 py-1.5 text-sm 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 cursor-pointer">Test connection</button>
                <span id="paychex-org-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
              </div>
            </div>
          </section>

          <!-- Roster sync -->
          <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="p-5">
              <div class="flex items-center justify-between flex-wrap gap-2">
                <h2 class="text-base font-semibold text-gray-800 dark:text-gray-100">Workers roster sync</h2>
                <button type="button" onclick="paychexAdminLoad()"
                  class="togen-control px-2.5 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 cursor-pointer">Refresh</button>
              </div>
              <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
                Syncs the Paychex workers roster (and, where the Time &amp; Attendance product exposes it,
                time-off and clock status) into Togen on a cadence so the driver-availability overlay never
                blocks on a live query. Data is read live-first and falls back to this local copy (labeled
                "Cached") when Paychex is unreachable.
              </p>
              <div class="mt-4 flex items-center gap-4 flex-wrap">
                <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input id="paychex-sync-enabled" type="checkbox" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
                  Sync enabled
                </label>
                <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  Every
                  <input id="paychex-sync-interval" type="number" min="2" max="10080"
                         class="w-24 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-200">
                  minutes
                </label>
                <button type="button" onclick="paychexSyncNow()"
                  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 cursor-pointer">Sync now</button>
                <span id="paychex-sync-run-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
              </div>
              <div id="paychex-sync-status" class="mt-4 text-sm text-gray-500 dark:text-gray-400">Loading…</div>
            </div>
          </section>

          <!-- Driver linkage (D5-A) -->
          <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="p-5">
              <div class="flex items-center justify-between flex-wrap gap-2">
                <h2 class="text-base font-semibold text-gray-800 dark:text-gray-100">Driver linkage</h2>
                <button type="button" onclick="paychexDriverLinksLoad()"
                  class="togen-control px-2.5 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 cursor-pointer">Refresh</button>
              </div>
              <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
                Each synced Paychex worker is matched to a Togen Personnel record by <strong>email</strong>.
                Pick a person to set a manual override, or leave a worker unmatched — Togen never guesses a
                fuzzy match and never silently drops a worker.
              </p>
              <div id="paychex-driver-links" class="mt-4 text-sm text-gray-500 dark:text-gray-400">Loading…</div>
            </div>
          </section>

        </div><!-- /tga-svc-panel-paychex -->
      </div><!-- /togenadmin-panel-services -->

      <!-- Users panel (DVI-1136 Phase 5 — personnel roster + tag assignment) -->
      <div id="togenadmin-panel-users" class="hidden">
        <!-- Users sub-tab bar (DVI-1373): Personnel roster + per-user tool Access. -->
        <div id="tga-users-subtabbar" class="togen-tabbar 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="tga-users-subtab-personnel" onclick="tgaUsersSubtab('personnel')"
                  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">Personnel</button>
          <button id="tga-users-subtab-access" onclick="tgaUsersSubtab('access')"
                  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-200 cursor-pointer">Access</button>
        </div>

        <div id="tga-users-panel-personnel">
        <div class="max-w-3xl space-y-6">
          <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 flex items-center justify-between">
              <h3 class="text-sm font-bold text-white uppercase tracking-wide">Personnel</h3>
              <button onclick="tgaUsersOpenAdd()"
                      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>
                Add Person
              </button>
            </div>
            <div class="px-6 py-4">
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">On-site personnel (floor workers, contractors) and their assigned tracking tags. Decoupled from Microsoft sign-in — a person here need not have a Togen login. Consolidating user permissions here is a future phase.</p>
              <div id="tga-users-list">
                <p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
              </div>
            </div>
          </section>
        </div>

        <!-- Add / edit person modal -->
        <div id="tga-users-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 id="tga-users-modal-title" class="text-sm font-bold text-white uppercase tracking-wide">Add Person</h3>
            </div>
            <div class="px-6 py-4 space-y-3">
              <div class="grid grid-cols-2 gap-3">
                <div>
                  <label for="tga-users-first" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First name <span class="text-red-500">*</span></label>
                  <input type="text" id="tga-users-first" 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="tga-users-last" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last name <span class="text-red-500">*</span></label>
                  <input type="text" id="tga-users-last" 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="tga-users-email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email <span class="text-gray-400 font-normal">(optional)</span></label>
                <input type="email" id="tga-users-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>
                <label for="tga-users-tag" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tag / DevEUI <span class="text-gray-400 font-normal">(optional)</span></label>
                <input type="text" id="tga-users-tag" placeholder="Tracking tag to assign" 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">Live positions activate once tag data is flowing (Phase 7).</p>
              </div>
              <div>
                <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Designations</label>
                <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input type="checkbox" id="tga-users-desig-driver" class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
                  Driver
                </label>
                <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Drivers appear in the Logistics tool's driver roster.</p>
              </div>
              <p id="tga-users-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-2">
              <button onclick="tgaUsersCloseModal()" class="togen-control 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="tga-users-save-btn" onclick="tgaUsersSave()" 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><!-- /tga-users-panel-personnel -->

        <!-- Access sub-tab (DVI-1373): grant tool capabilities AND Auditor
             resource scoping to users by email, in one unified per-user card
             (Phase 4, Q2-A). Tool grants write tool_access.json; the "Auditor
             access" section writes the SAME auditor_access.json that
             Auditor -> Settings -> Access reads (Q1-A single source of truth,
             Q3-A reuse the existing /auditor/access routes). -->
        <div id="tga-users-panel-access" class="hidden">
          <div class="max-w-3xl">
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Tool &amp; Auditor Access</h3>
            <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">Grant tools and Auditor resource scoping to users by email, all in one place. A user with a grant sees only what you enable; Togen Admins always have full access. Enforced server-side — an ungranted user cannot reach the tool or Auditor resource even by URL. Auditor grants here are the same as <span class="font-medium">Auditor → Settings → Access</span> (both edit the same list).</p>

            <!-- Add-user row -->
            <div class="flex items-center gap-2 mb-5">
              <input id="tga-access-email" type="email" placeholder="user@icastinc.com"
                     onkeydown="if(event.key==='Enter')tgaAccessAddUser()"
                     class="flex-1 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 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
              <button onclick="tgaAccessAddUser()"
                      class="px-4 py-2 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">
                Add user
              </button>
            </div>

            <!-- DVI-1373 P4/Q4: read-only context for Azure-group-derived tool
                 grants. Per-user membership isn't resolvable here (it's
                 snapshotted in each user's own session at login), so this
                 surfaces the group->capability map itself so admins understand
                 group-derived access they can't remove per-user. -->
            <div id="tga-access-group-note" class="hidden mb-4 rounded-lg border border-gray-200 dark:border-gray-700 p-3"></div>

            <div id="tga-access-status" class="hidden text-sm mb-3"></div>

            <!-- DVI-1432: live search (R4) + Expand/Collapse all (R2) over the
                 user list. Shown only when there is at least one user. -->
            <div id="tga-access-controls" class="hidden flex items-center gap-2 mb-3 flex-wrap">
              <input id="tga-access-search" type="text" placeholder="Search name or email…" oninput="tgaAccessFilter()"
                     class="flex-1 min-w-[12rem] max-w-xs 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-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
              <button onclick="tgaAccessExpandAll(true)" class="togen-control px-3 py-1.5 text-xs font-medium rounded-lg text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">Expand all</button>
              <button onclick="tgaAccessExpandAll(false)" class="togen-control px-3 py-1.5 text-xs font-medium rounded-lg text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">Collapse all</button>
            </div>

            <div id="tga-access-empty" class="hidden text-sm text-gray-500 dark:text-gray-400 py-6 text-center">No users have been granted tool or Auditor access yet.</div>
            <div id="tga-access-list" class="space-y-4"></div>

            <!-- DVI-1373 P3: Access by Azure AD group -->
            <div class="mt-10 pt-6 border-t border-gray-200 dark:border-gray-700">
              <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">Access by Azure AD group</h3>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">Map an Azure AD group to tool capabilities. Everyone in the group gets these capabilities on their next sign-in — no per-email entry. Changes to this map apply immediately; a change to a person's actual group membership applies on their next login. Enter the group's Object ID (GUID) from the Azure portal (Groups → the group → Overview).</p>

              <div class="flex items-center gap-2 mb-5 flex-wrap">
                <input id="tga-group-id" type="text" placeholder="Group Object ID (GUID)"
                       onkeydown="if(event.key==='Enter')tgaGroupAdd()"
                       class="flex-1 min-w-[16rem] 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 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
                <input id="tga-group-name" type="text" placeholder="Friendly name (optional)"
                       onkeydown="if(event.key==='Enter')tgaGroupAdd()"
                       class="w-40 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 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
                <button onclick="tgaGroupAdd()"
                        class="px-4 py-2 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">
                  Add group
                </button>
              </div>

              <div id="tga-groups-status" class="hidden text-sm mb-3"></div>
              <!-- DVI-1432 R3: search over the group cards (same treatment as the user list). -->
              <div id="tga-groups-search-wrap" class="hidden mb-3">
                <input id="tga-groups-search" type="text" placeholder="Search groups…" oninput="tgaGroupsFilter()"
                       class="w-full max-w-xs 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-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
              </div>
              <div id="tga-groups-empty" class="hidden text-sm text-gray-500 dark:text-gray-400 py-6 text-center">No Azure AD groups mapped yet.</div>
              <div id="tga-groups-list" class="space-y-4"></div>
            </div>
          </div>
        </div><!-- /tga-users-panel-access -->
      </div><!-- /togenadmin-panel-users -->

    </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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-tabbar 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-services" onclick="usSwitchTab('services')"
                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">
          Services
        </button>
        <button id="us-tab-agents" onclick="usSwitchTab('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="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="togen-control 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>

          <!-- Regional settings (DVI-1364 P1): per-user override of the company defaults -->
          <div class="mt-4 p-4 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-1">
              <p class="text-sm font-medium text-gray-800 dark:text-gray-100">Regional settings</p>
              <span id="us-reg-status" class="text-xs text-gray-400 dark:text-gray-500"></span>
            </div>
            <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Override the company defaults for how times, dates, and your location are handled &mdash; useful if you work remotely in a different time zone. Leave a field on &ldquo;Use company default&rdquo; to follow the plant setting.</p>
            <div class="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl">
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Time format</label>
                <select id="us-reg-time-format" 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-200 cursor-pointer">
                  <option value="">Use company default</option>
                  <option value="12h">12-hour (1:30 PM)</option>
                  <option value="24h">24-hour (13:30)</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Date format</label>
                <select id="us-reg-date-format" 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-200 cursor-pointer">
                  <option value="">Use company default</option>
                  <option value="month_day_year">Aug 9, 2026</option>
                  <option value="month_day">Aug 9</option>
                  <option value="mdy_slash">08/09/2026</option>
                  <option value="dmy_slash">09/08/2026</option>
                  <option value="iso">2026-08-09</option>
                </select>
              </div>
              <div class="sm:col-span-2">
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Time zone</label>
                <select id="us-reg-timezone" 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-200 cursor-pointer">
                  <option value="">Use company default</option>
                </select>
              </div>
              <div class="sm:col-span-2">
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Location (for weather)</label>
                <div class="flex gap-2">
                  <input id="us-reg-loc-search" type="text" maxlength="120" placeholder="Search a city, e.g. Nashville, TN"
                         onkeydown="if(event.key==='Enter'){event.preventDefault();usRegionalSearch();}"
                         class="flex-1 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-200">
                  <button type="button" onclick="usRegionalSearch()" class="px-3 py-2 rounded-lg bg-togen-teal text-white text-sm">Search</button>
                  <button type="button" onclick="usRegionalUseMyLocation()" title="Use this device's location" class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 text-sm hover:bg-gray-100 dark:hover:bg-gray-700">&#128205; My location</button>
                </div>
                <div id="us-reg-loc-results" class="mt-2 space-y-1"></div>
                <div class="mt-2 flex items-center gap-2 flex-wrap">
                  <span id="us-reg-loc-current" class="text-xs text-gray-600 dark:text-gray-300"></span>
                  <button type="button" id="us-reg-loc-clear" onclick="usRegionalClearLoc()" class="hidden text-xs text-red-600 dark:text-red-400 underline">Use company default</button>
                </div>
              </div>
            </div>
            <div class="mt-3 flex items-center gap-2">
              <button type="button" onclick="usRegionalSave()" class="px-4 py-2 rounded-lg bg-togen-teal text-white text-sm font-medium">Save</button>
              <button type="button" onclick="usRegionalReset()" class="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 text-sm">Reset to company defaults</button>
            </div>
          </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="togen-control 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>

          <!-- Kiln Metrics notifications (DVI-465) — mirrors Kiln Metrics > 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">Kiln Metrics Notifications</h3>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-3">Email preferences for the Kiln Metrics tool. These mirror the options under Kiln Metrics &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>
                  <th class="text-center font-medium px-2 py-2">Awaiting RtS<sup class="text-amber-500">†</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. &nbsp;<sup class="text-amber-500">†</sup> Locked on for users listed as Authorized Approvers.</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="togen-control 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>
                <button type="button" onclick="usAddWebsite()"
                        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 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
                    <circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3c2.5 2.5 2.5 15.5 0 18M12 3c-2.5 2.5-2.5 15.5 0 18"/>
                  </svg>
                  Website
                </button>
                <button type="button" onclick="usAddDashTile()"
                        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 d="M3 3a1 1 0 011-1h4a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1V3zm8 0a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1h-4a1 1 0 01-1-1V3zM3 13a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm8-2a1 1 0 011-1h4a1 1 0 011 1v6a1 1 0 01-1 1h-4a1 1 0 01-1-1v-6z"/>
                  </svg>
                  Dashboard Tile
                </button>
                <button type="button" onclick="usAddDashRef()"
                        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="M5 3a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2V5a2 2 0 00-2-2H5zm0 2h10v3H5V5zm0 5h4v5H5v-5zm6 0h4v5h-4v-5z" clip-rule="evenodd"/>
                  </svg>
                  Dashboard
                </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="togen-control 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-4 max-w-md">
                <!-- DVI-884: per-provider API keys. One masked field + docs link per provider. -->
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Provider API keys</label>
                  <p class="text-xs text-gray-400 dark:text-gray-500 mb-2">Configure a key for each provider you want to use. Keys are stored securely and never shown again — leave a field blank to keep the current key.</p>
                  <div id="us-assistant-keys" class="space-y-3"></div>
                </div>
                <!-- DVI-884: default Assistant model — may be any model from a configured provider. -->
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Default Assistant 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>
                  <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Used by the Assistant chat. The Vision tool has its own independent model selection.</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>

      <!-- Services pane (DVI-1226 P1): per-user connections to external services,
           one sub-tab per service (mirrors Togen Admin → Services). -->
      <section id="us-pane-services" 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">Services</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Connect your own accounts for external services Togen integrates with.</p>

          <div class="togen-tabbar 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-svc-subtab-idencia" onclick="usServiceSubtab('idencia')"
                    class="flex-shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 border-togen-teal text-togen-teal cursor-pointer">
              Idencia
            </button>
            <button id="us-svc-subtab-powerbi" onclick="usServiceSubtab('powerbi')"
                    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 cursor-pointer">
              Power BI
            </button>
            <button id="us-svc-subtab-netsuite" onclick="usServiceSubtab('netsuite')"
                    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 cursor-pointer">
              NetSuite
            </button>
            <button id="us-svc-subtab-paychex" onclick="usServiceSubtab('paychex')"
                    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 cursor-pointer">
              PayChex
            </button>
          </div>

          <div id="us-svc-panel-idencia">
            <div class="flex items-center gap-2 flex-wrap mb-2">
              <span id="us-idencia-badge"></span>
            </div>
            <p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
              Connect your Idencia login so your dashboard tiles and reports show exactly what your Idencia
              account can see. Without a personal connection, Togen uses the organization account (when configured).
              Your password is stored server-side and never shown again.
            </p>
            <div class="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl">
              <label class="block text-sm">
                <span class="text-gray-700 dark:text-gray-300">Username</span>
                <input id="us-idencia-username" type="text" autocomplete="off"
                       class="mt-1 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">
              </label>
              <label class="block text-sm">
                <span class="text-gray-700 dark:text-gray-300">Password</span>
                <input id="us-idencia-password" type="password" autocomplete="new-password"
                       class="mt-1 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">
              </label>
              <label class="block text-sm sm:col-span-2">
                <span class="text-gray-700 dark:text-gray-300">Default filters <span class="text-xs text-gray-400">(optional — pre-fills new Idencia tiles, e.g. project:"Bridge 12" status:Approved)</span></span>
                <input id="us-idencia-default-filters" type="text" autocomplete="off"
                       class="mt-1 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">
              </label>
              <label class="block text-sm">
                <span class="text-gray-700 dark:text-gray-300">Connection mode</span>
                <select id="us-idencia-mode" onchange="usIdenciaModeChanged()"
                        class="mt-1 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">
                  <option value="">Use organization setting</option>
                  <option value="api">Official API</option>
                  <option value="web">Web sign-in (unofficial)</option>
                </select>
              </label>
              <label id="us-idencia-web-wrap" class="block text-sm hidden">
                <span class="text-gray-700 dark:text-gray-300">Idencia web address</span>
                <input id="us-idencia-web-url" type="text" autocomplete="off"
                       placeholder="Organization setting"
                       class="mt-1 w-full px-3 py-1.5 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">
              </label>
              <p id="us-idencia-web-note" class="hidden sm:col-span-2 text-xs text-gray-500 dark:text-gray-400">
                Web sign-in uses your normal Idencia website login and reads the same data the site's
                Items / Jobs / Shipments pages show. Unofficial — it can stop working when Idencia updates
                their site, and item <em>status</em> is not available this way.
              </p>
            </div>
            <details id="us-idencia-api-block" class="mt-3 max-w-2xl">
              <summary class="text-xs text-gray-500 dark:text-gray-400 cursor-pointer">Advanced: API endpoints</summary>
              <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
                Leave blank to use the organization's endpoint settings. These are the <strong>backend API</strong>
                endpoints — <em>not</em> the Idencia web app domain. Find them in your Idencia API documentation
                or via browser DevTools (F12) → Network while logging in to the Idencia web app.
              </p>
              <div class="mt-2 grid grid-cols-1 gap-3">
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Login URL</span>
                  <input id="us-idencia-login-url" type="text" autocomplete="off"
                         placeholder="Organization setting"
                         class="mt-1 w-full px-3 py-1.5 text-xs 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">
                </label>
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">GraphQL URL</span>
                  <input id="us-idencia-graphql-url" type="text" autocomplete="off"
                         placeholder="Organization setting"
                         class="mt-1 w-full px-3 py-1.5 text-xs 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">
                </label>
              </div>
            </details>
            <div class="mt-4 flex items-center gap-2 flex-wrap">
              <button type="button" onclick="usIdenciaSave()"
                class="px-3 py-1.5 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal-dark cursor-pointer">Save</button>
              <button type="button" onclick="usIdenciaTest()"
                class="togen-control px-3 py-1.5 text-sm 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 cursor-pointer">Test connection</button>
              <button type="button" onclick="usIdenciaDisconnect()"
                class="togen-control px-3 py-1.5 text-sm 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 cursor-pointer">Disconnect</button>
              <span id="us-idencia-status" class="text-sm text-gray-500 dark:text-gray-400"></span>
            </div>
          </div><!-- /us-svc-panel-idencia -->

          <!-- Power BI (DVI-1243 P1, D1-A): read-only status — Togen reads
               Power BI through the organization service principal only.
               Personal connections are a deferred follow-up (D1-B). -->
          <div id="us-svc-panel-powerbi" class="hidden">
            <div class="flex items-center gap-2 flex-wrap mb-2">
              <span id="us-powerbi-badge"></span>
            </div>
            <p class="text-sm text-gray-500 dark:text-gray-400">
              Togen reads Power BI through the organization connection set up by your admin —
              dashboard tiles and reports show data from the organization's shared workspaces.
              Connecting a personal Power BI account (to see your own My-Workspace content) is a
              planned future addition.
            </p>
          </div><!-- /us-svc-panel-powerbi -->

          <!-- NetSuite (DVI-1462 P1, D1-A): read-only status — Togen reads
               NetSuite through the organization service account only. There are
               no per-user NetSuite credentials. -->
          <div id="us-svc-panel-netsuite" class="hidden">
            <div class="flex items-center gap-2 flex-wrap mb-2">
              <span id="us-netsuite-badge"></span>
            </div>
            <p class="text-sm text-gray-500 dark:text-gray-400">
              Togen reads the NetSuite product catalog through the organization service account set up by your
              admin — the Logistics load picker draws its products from the synced catalog. There are no personal
              NetSuite connections.
            </p>
            <p id="us-netsuite-catalog" class="mt-2 text-xs text-gray-400 dark:text-gray-500"></p>
          </div><!-- /us-svc-panel-netsuite -->

          <!-- PayChex (DVI-1462 P2, D1-A): read-only status — Togen reads
               Paychex through the organization service account only. There are
               no per-user Paychex credentials. -->
          <div id="us-svc-panel-paychex" class="hidden">
            <div class="flex items-center gap-2 flex-wrap mb-2">
              <span id="us-paychex-badge"></span>
            </div>
            <p class="text-sm text-gray-500 dark:text-gray-400">
              Togen reads the Paychex workers roster (and, with the Time &amp; Attendance product, time-off and
              clock status) through the organization service account set up by your admin — the driver-availability
              overlay draws from the synced roster. There are no personal Paychex connections.
            </p>
            <p id="us-paychex-roster" class="mt-2 text-xs text-gray-400 dark:text-gray-500"></p>
          </div><!-- /us-svc-panel-paychex -->
        </div>
      </section>

      <!-- Agents pane (DVI-1245): virtual Display Agents for the user's own
           fullscreen Dashboard windows. Configure once here; opening that
           dashboard in Fullscreen (?agent=) then follows these settings and
           makes the window a Present target for other users. -->
      <section id="us-pane-agents" 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">
            <div>
              <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Display Agents</h2>
              <p class="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-2xl">
                A virtual Display Agent turns one of your dashboards into a presentable fullscreen display.
                Open that dashboard in Fullscreen and it follows the interface + presentation settings below —
                and other people you allow can publish their own dashboards to that window.
              </p>
            </div>
            <div class="shrink-0 flex items-center gap-2">
              <!-- DVI-1321 P3: this user's own default display settings -->
              <button type="button" onclick="usDisplaySettingsOpen()"
                class="togen-control 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-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Display settings</button>
              <button type="button" onclick="usAgentOpen()"
                class="px-3 py-1.5 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal-dark cursor-pointer">+ Add display</button>
            </div>
          </div>
          <div id="us-agents-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="us-agents-list" class="grid grid-cols-1 gap-3"></div>

          <!-- DVI-1322: physical displays a Togen Admin has delegated to this
               user via the "Allow Edit" list. Shown only when non-empty. -->
          <div id="us-editable-agents-wrap" class="hidden mt-6 pt-5 border-t border-gray-200 dark:border-gray-700">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Display Agents you can edit</h2>
            <p class="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-2xl">
              A Togen Admin has delegated these managed displays to you. You can change their settings,
              preferences, and splash as if they were your own — revoke and delete stay with admins.
              Any change you make is shared with the other editors of that display.
            </p>
            <div id="us-editable-agents-list" class="grid grid-cols-1 gap-3 mt-3"></div>
          </div>
        </div>
      </section>

      <!-- My default display settings modal (DVI-1321 P3) -->
      <div id="us-display-settings-modal" class="hidden fixed inset-0 z-[120] bg-black/50 flex items-center justify-center 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-lg max-h-[90vh] overflow-y-auto">
          <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">My default display settings</h3>
            <button onclick="usDisplaySettingsClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none cursor-pointer">&times;</button>
          </div>
          <div class="px-6 py-4 space-y-4">
            <p class="text-xs text-gray-500 dark:text-gray-400">Your defaults for opening a dashboard in Fullscreen and for your own Virtual Display Agents. They override the organization defaults for your own views only (never a shared display). A per-dashboard &ldquo;Remember these settings&rdquo; choice still wins for that dashboard.</p>
            <div class="grid grid-cols-3 gap-3">
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Fit</label>
                <select id="us-display-fit" 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">
                  <option value="scale">Scale to fit screen</option>
                  <option value="scroll">Scroll</option>
                  <option value="clip">Clip (legacy)</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Orientation</label>
                <select id="us-display-orientation" 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">
                  <option value="auto">Auto</option>
                  <option value="landscape">Landscape</option>
                  <option value="portrait">Portrait</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Columns</label>
                <select id="us-display-columns" 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">
                  <option value="auto">Auto (12 / portrait 6)</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Zoom</label>
                <select id="us-display-zoom" 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">
                  <option value="50">50%</option><option value="75">75%</option><option value="90">90%</option>
                  <option value="100">100% (normal)</option><option value="110">110%</option><option value="125">125%</option>
                  <option value="150">150%</option><option value="175">175%</option><option value="200">200%</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Flow</label>
                <select id="us-display-flow" 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">
                  <option value="row">Rows (fill across)</option>
                  <option value="column">Columns (fill down)</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Rows</label>
                <select id="us-display-rows" 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">
                  <option value="auto">Auto</option>
                </select>
              </div>
            </div>
            <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
              <input id="us-display-use-interface" type="checkbox" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal" />
              Use my interface settings by default (my own background image + dark/light mode)
            </label>
            <p id="us-display-settings-status" class="text-xs"></p>
          </div>
          <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-between gap-2">
            <button onclick="usDisplaySettingsClear()" class="togen-control px-4 py-2 text-sm 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 cursor-pointer">Reset to org default</button>
            <div class="flex gap-2">
              <button onclick="usDisplaySettingsClose()" class="togen-control px-4 py-2 text-sm rounded-lg 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">Cancel</button>
              <button id="us-display-settings-save" onclick="usDisplaySettingsSave()" class="px-4 py-2 text-sm rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white font-medium cursor-pointer">Save</button>
            </div>
          </div>
        </div>
      </div>

      <!-- Virtual display add/edit modal (DVI-1245) -->
      <div id="us-agent-modal" class="hidden fixed inset-0 z-[120] bg-black/50 flex items-center justify-center 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-lg max-h-[90vh] overflow-y-auto">
          <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
            <h3 id="us-agent-modal-title" class="text-base font-semibold text-gray-800 dark:text-gray-100">Add virtual display</h3>
            <button type="button" onclick="usAgentClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
          </div>
          <div class="px-6 py-4 space-y-4">
            <input type="hidden" id="us-agent-id">
            <!-- DVI-1322: shown only when editing a delegated (managed) physical display. -->
            <div id="us-agent-managed-note" class="hidden text-xs text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2">
              Managed display &mdash; shared with its other editors. You can change its settings and splash, but not revoke or delete it.
            </div>
            <label class="block text-sm">
              <span class="text-gray-700 dark:text-gray-300">Name</span>
              <input id="us-agent-name" type="text" maxlength="120"
                     class="mt-1 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">
            </label>
            <label class="block text-sm">
              <span class="text-gray-700 dark:text-gray-300">Dashboard</span>
              <select id="us-agent-dashboard"
                      class="mt-1 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"></select>
            </label>

            <fieldset class="border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2">
              <legend class="text-xs font-semibold text-gray-500 dark:text-gray-400 px-1">Interface</legend>
              <!-- DVI-1248: shared interface component (theme / background incl.
                   custom image upload / opacity / side padding), same as physical. -->
              <div id="us-agent-iface"></div>
              <div class="grid grid-cols-3 gap-3 mt-3">
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Fit</span>
                  <select id="us-agent-fit" class="mt-1 w-full px-2 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">
                    <option value="scale">Fit to screen</option>
                    <option value="scroll">Scroll</option>
                    <option value="clip">Clip</option>
                  </select>
                </label>
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Orientation</span>
                  <select id="us-agent-orientation" class="mt-1 w-full px-2 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">
                    <option value="auto">Auto</option>
                    <option value="landscape">Landscape</option>
                    <option value="portrait">Portrait</option>
                  </select>
                </label>
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Columns</span>
                  <select id="us-agent-columns" class="mt-1 w-full px-2 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">
                    <option value="auto">Auto</option>
                  </select>
                </label>
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Zoom</span>
                  <select id="us-agent-zoom" class="mt-1 w-full px-2 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">
                    <option value="50">50%</option>
                    <option value="75">75%</option>
                    <option value="90">90%</option>
                    <option value="100">100%</option>
                    <option value="110">110%</option>
                    <option value="125">125%</option>
                    <option value="150">150%</option>
                    <option value="175">175%</option>
                    <option value="200">200%</option>
                  </select>
                </label>
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Flow</span>
                  <select id="us-agent-flow" class="mt-1 w-full px-2 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">
                    <option value="row">Rows (fill across)</option>
                    <option value="column">Columns (fill down)</option>
                  </select>
                </label>
                <label class="block text-sm">
                  <span class="text-gray-700 dark:text-gray-300">Rows</span>
                  <select id="us-agent-rows" class="mt-1 w-full px-2 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">
                    <option value="auto">Auto</option>
                  </select>
                </label>
              </div>
              <p class="mt-1 text-xs text-gray-400 dark:text-gray-500">Flow &amp; Rows apply only to the Scroll/Clip fit modes.</p>
            </fieldset>

            <!-- DVI-1248: Edit Splash — headline text shown when no dashboard is
                 up, plus an optional independent appearance (same as physical). -->
            <fieldset class="border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2">
              <legend class="text-xs font-semibold text-gray-500 dark:text-gray-400 px-1">Splash</legend>
              <label class="block text-sm">
                <span class="text-gray-700 dark:text-gray-300">Splash text <span class="text-xs text-gray-400">(shown when no dashboard is displayed)</span></span>
                <input id="us-agent-splash-text" type="text" maxlength="200" placeholder="e.g. Welcome to iCast — Building 2"
                       class="mt-1 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">
              </label>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 mt-2">
                <input id="us-agent-splash-custom" type="checkbox" onchange="usAgentSplashCustomChanged()"
                       class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
                Give the splash its own appearance
              </label>
              <div id="us-agent-splash-iface" class="mt-2 hidden"></div>
            </fieldset>

            <fieldset class="border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2">
              <legend class="text-xs font-semibold text-gray-500 dark:text-gray-400 px-1">Presentation</legend>
              <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                <input id="us-agent-presentable" type="checkbox" onchange="usAgentPresentableChanged()"
                       class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
                Allow others to publish their dashboards to this display
              </label>
              <div id="us-agent-present-opts" class="mt-2 pl-6 space-y-2 hidden">
                <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input id="us-agent-publish-anyone" type="checkbox" onchange="usAgentPublishAnyoneChanged()"
                         class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
                  Anyone logged in may present
                </label>
                <label class="block text-sm" id="us-agent-presenters-wrap">
                  <span class="text-gray-700 dark:text-gray-300">Allowed presenters <span class="text-xs text-gray-400">(one email per line)</span></span>
                  <textarea id="us-agent-presenters" rows="3"
                            class="mt-1 w-full px-2 py-1.5 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>
                </label>
              </div>
            </fieldset>

            <!-- DVI-1322 (D2): Windows window layer/monitor/geometry — shown only
                 when editing a delegated Windows physical display. -->
            <fieldset id="us-agent-windows-section" class="hidden border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2">
              <legend class="text-xs font-semibold text-gray-500 dark:text-gray-400 px-1">Windows display options</legend>
              <div class="mt-1">
                <span class="block text-sm text-gray-700 dark:text-gray-300 mb-1">Window layer</span>
                <div class="space-y-1.5">
                  <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
                    <input type="radio" name="us-agent-win-layer" value="top" class="text-togen-teal focus:ring-togen-teal"/> Always on top
                  </label>
                  <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
                    <input type="radio" name="us-agent-win-layer" value="bottom" class="text-togen-teal focus:ring-togen-teal"/> Always on bottom
                  </label>
                  <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
                    <input type="radio" name="us-agent-win-layer" value="windowed" class="text-togen-teal focus:ring-togen-teal"/> Windowed
                  </label>
                  <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
                    <input type="radio" name="us-agent-win-layer" value="wallpaper" class="text-togen-teal focus:ring-togen-teal"/> Desktop background <span class="text-xs text-amber-600 dark:text-amber-400">(experimental)</span>
                  </label>
                </div>
              </div>
              <label class="block text-sm mt-3">
                <span class="text-gray-700 dark:text-gray-300">Monitor</span>
                <select id="us-agent-win-monitor" class="mt-1 w-full px-2 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">
                  <option value="primary">Primary monitor</option>
                  <option value="1">Monitor 1</option>
                  <option value="2">Monitor 2</option>
                  <option value="3">Monitor 3</option>
                  <option value="4">Monitor 4</option>
                </select>
              </label>
              <div id="us-agent-win-geom-section" class="hidden mt-3">
                <span class="block text-sm text-gray-700 dark:text-gray-300 mb-1">Window geometry (pixels)</span>
                <div class="grid grid-cols-2 gap-2">
                  <label class="block text-xs text-gray-500 dark:text-gray-400">X
                    <input id="us-agent-win-x" type="number" min="0" max="32767" value="0" class="mt-0.5 w-full px-2 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"/>
                  </label>
                  <label class="block text-xs text-gray-500 dark:text-gray-400">Y
                    <input id="us-agent-win-y" type="number" min="0" max="32767" value="0" class="mt-0.5 w-full px-2 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"/>
                  </label>
                  <label class="block text-xs text-gray-500 dark:text-gray-400">Width
                    <input id="us-agent-win-width" type="number" min="100" max="32767" value="800" class="mt-0.5 w-full px-2 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"/>
                  </label>
                  <label class="block text-xs text-gray-500 dark:text-gray-400">Height
                    <input id="us-agent-win-height" type="number" min="100" max="32767" value="600" class="mt-0.5 w-full px-2 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"/>
                  </label>
                </div>
              </div>
            </fieldset>
          </div>
          <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-2">
            <span id="us-agent-modal-msg" class="text-sm text-red-500 mr-auto"></span>
            <button type="button" onclick="usAgentClose()"
              class="togen-control px-3 py-1.5 text-sm 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 cursor-pointer">Cancel</button>
            <button type="button" onclick="usAgentSave()"
              class="px-3 py-1.5 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal-dark cursor-pointer">Save</button>
          </div>
        </div>
      </div>

      <!-- Quotes pane (DVI-808; renamed from "Documents" in DVI-975 to mirror Togen Admin → Documents → Quotes): 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">Quotes</h2>
          <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Configure where your personal quote documents are stored in OneDrive. Leave a field blank to use the shared company location set by your admin.</p>

          <!-- Personal Quote Take-offs location -->
          <div class="mb-4">
            <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">My Quote Take-offs Location</label>
            <div class="flex items-center gap-2">
              <input id="us-documents-quote-takeoff-location" type="text" placeholder="e.g. My Documents/Quote Take-offs"
                     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-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">
              <button type="button" onclick="odPickerOpen('personal','us-documents-quote-takeoff-location')"
                      class="togen-control px-3 py-2 text-sm 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 whitespace-nowrap cursor-pointer">Browse OneDrive</button>
            </div>
            <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Your personal OneDrive folder Winston can pick quote source files up from (falls back to the shared location).</p>
          </div>

          <!-- Personal Completed Quotes location -->
          <div class="mb-4">
            <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">My Completed Quotes Location</label>
            <div class="flex items-center gap-2">
              <input id="us-documents-quotes-location" type="text" placeholder="e.g. My Documents/Quotes"
                     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-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">
              <button type="button" onclick="odPickerOpen('personal','us-documents-quotes-location')"
                      class="togen-control px-3 py-2 text-sm 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 whitespace-nowrap cursor-pointer">Browse OneDrive</button>
            </div>
            <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="togen-tabbar 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">Kiln Metrics</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_qc_filebrowser %}
        <button id="fb-tab-btn-qc" onclick="fbSwitchTab('qc')"
                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">Batch Production</button>
        {% endif %}
        {% 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>
        <span class="hidden md:inline text-xs text-gray-400 dark:text-gray-500 -ml-1">or drag &amp; drop files into the list</span>

        <button id="fbt-download-btn" onclick="fbDownload()" disabled
                class="togen-control 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="togen-control 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-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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-control 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="togen-control 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="togen-control 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="togen-control 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>

      <!-- FB image lightbox (DVI-1087 Phase 2): in-app gallery viewer, mirrors the WO Admin lightbox (DVI-1076) -->
      <div id="fb-lightbox" class="hidden fixed inset-0 z-[125] flex items-center justify-center p-4">
        <div onclick="fbLightboxClose()" class="absolute inset-0 bg-black/80"></div>
        <div class="relative w-full max-w-4xl max-h-[92vh] flex flex-col items-center">
          <div class="flex items-center justify-between w-full mb-2 gap-2">
            <span id="fb-lb-badge" class="px-2.5 py-1 rounded-full bg-white/15 text-white text-xs font-medium truncate max-w-[50%]"></span>
            <div class="flex items-center gap-2">
              <span id="fb-lb-counter" class="text-xs text-gray-300"></span>
              <a id="fb-lb-download" href="#" download class="p-1.5 rounded-lg text-gray-200 hover:text-white hover:bg-white/15 transition-colors" title="Download">
                <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 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5 5-5M12 15V3"/></svg>
              </a>
              <button onclick="fbLightboxClose()" title="Close" class="p-1.5 rounded-lg text-gray-200 hover:text-white hover:bg-white/15 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 class="relative flex items-center justify-center w-full">
            <div id="fb-lb-nav" class="hidden">
              <button onclick="fbLightboxNav(-1)" title="Previous" class="absolute left-0 top-1/2 -translate-y-1/2 -ml-2 sm:-ml-12 p-2 rounded-full bg-white/15 text-white hover:bg-white/30 transition-colors cursor-pointer">
                <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="M15 19l-7-7 7-7"/></svg>
              </button>
              <button onclick="fbLightboxNav(1)" title="Next" class="absolute right-0 top-1/2 -translate-y-1/2 -mr-2 sm:-mr-12 p-2 rounded-full bg-white/15 text-white hover:bg-white/30 transition-colors cursor-pointer">
                <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="M9 5l7 7-7 7"/></svg>
              </button>
            </div>
            <img id="fb-lb-img" src="" alt="" class="max-h-[76vh] max-w-full rounded-lg object-contain bg-black/20">
          </div>
          <div id="fb-lb-caption" class="mt-3 text-center"></div>
        </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="togen-control 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="togen-control 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="togen-control 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_qc_filebrowser %}
      <!-- QC tab (DVI-1383 P4): the same per-segment SharePoint PDF browser as
           QC → File Browser, mounted here for users with the qc.filebrowser
           grant. Renders through the shared _qcFb component (prefix "fbqc"). -->
      <div id="fb-tab-qc" class="hidden">
        <div class="mb-4">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Batch Production File Browser</h2>
          <p class="text-xs text-gray-500 dark:text-gray-400">SharePoint PDFs available for Batch Production Scan Tickets, per Production Segment.</p>
        </div>
        <!-- one subtab per configured segment -->
        <div id="fbqc-subtabs" class="togen-tabbar flex flex-wrap items-center gap-1 border-b border-gray-200 dark:border-gray-700"></div>
        <div id="fbqc-controls" class="mt-3"></div>
        <div id="fbqc-breadcrumb" class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1 mt-3"></div>
        <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-3">
          <div id="fbqc-listing" class="min-h-[12rem] rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700/60 overflow-hidden"></div>
          <div class="rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden bg-gray-50 dark:bg-gray-800/40 flex flex-col">
            <div class="px-3 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700 truncate" id="fbqc-preview-name">Select a PDF to preview</div>
            <iframe id="fbqc-preview-frame" class="w-full h-[32rem] bg-white" title="PDF preview"></iframe>
          </div>
        </div>
      </div><!-- /fb-tab-qc -->
      {% endif %}

      {% 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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 TOOL CONTENT (DVI-456, DVI-1012) ===== -->
    <div id="tool-equipment-manuals" class="tool-content hidden">

      <!-- ===== Tiles landing page (DVI-1496 P1) — the Equipment sub-home. Shown
           on tool open; no tab bar. Right-aligned control cluster: the gear is
           wired live to Settings; Edit / + Add are visible but stubbed (P4). -->
      <div id="em-tiles-grid" class="hidden">
        <div class="flex items-center justify-between gap-3 mb-5 flex-wrap">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Equipment</h2>
          <div class="flex items-center gap-2 flex-wrap">
            <!-- Edit-mode toolbar (DVI-1496 P4) — shown only in edit mode. Mirrors
                 the home edit toolbar: toast, Default/My-list scope switch (admins),
                 + Add menu, Reset my layout (D10-A). The ⚙ gear stays visible. -->
            <div id="em-edit-toolbar" class="hidden flex items-center gap-2 flex-wrap">
              <span id="em-edit-toast" class="text-xs font-medium" aria-live="polite"></span>
              {% if is_togen_admin %}
              <div id="em-scope-switch" class="inline-flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden text-xs font-medium" title="Which list you are editing">
                <button type="button" id="em-scope-user" onclick="emEditSetScope('user')" class="px-2.5 py-1 cursor-pointer bg-togen-teal text-white">My list</button>
                <button type="button" id="em-scope-admin" onclick="emEditSetScope('admin')" class="px-2.5 py-1 cursor-pointer text-gray-600 dark:text-gray-300">Default</button>
              </div>
              {% endif %}
              <div class="relative">
                <button type="button" id="em-edit-add-btn" onclick="emEditToggleAddMenu(event)"
                        class="togen-control inline-flex items-center gap-1 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">
                  <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>
                  <span>Add</span>
                </button>
                <div id="em-edit-add-menu" class="hidden absolute right-0 mt-1 w-56 z-[20] togen-bar-surface bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-lg py-1">
                  <button type="button" onclick="emEditAddWebsite()" class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Website tile</button>
                  <button type="button" onclick="emEditAddDashTile()" class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Dashboard tile</button>
                  <button type="button" onclick="emEditAddDashRef()" class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Embedded dashboard</button>
                  <button type="button" onclick="emEditAddSeparator()" class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Separator</button>
                </div>
              </div>
              <button type="button" id="em-edit-reset" onclick="emResetMyLayout()" title="Clear your personal changes and use the default layout"
                      class="togen-control inline-flex items-center gap-1 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">Reset my layout</button>
            </div>
            <button id="em-tiles-settings" onclick="emSwitchTab('settings')" title="Settings"
                    class="togen-control inline-flex items-center gap-1.5 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 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="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>
            <button id="em-tiles-edit" onclick="emEditToggle()" title="Edit the Equipment page in place"
                    class="togen-control inline-flex items-center gap-1.5 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 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>
              <span id="em-tiles-edit-label">Edit</span>
            </button>
          </div>
        </div>
        <div id="em-tiles-list" class="grid grid-cols-1 sm:grid-cols-2 gap-5"></div>
        <p id="em-tiles-empty" class="hidden text-sm text-gray-500 dark:text-gray-400 text-center py-10">No tiles yet — click Edit to add some.</p>
      </div>

      <!-- Tab bar (DVI-1496 P1): ◂ Equipment crumb | Assets | Manuals | Oversight ........ [Settings] -->
      <div id="em-tabbar" class="togen-tabbar hidden flex items-center gap-1 border-b border-gray-200 dark:border-gray-700 mb-4">
        <button id="em-tab-crumb" onclick="emShowTiles()" title="Back to Equipment tiles"
                class="px-2 py-2 mr-1 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-togen-teal transition-colors -mb-px inline-flex items-center gap-1 cursor-pointer">
          <span aria-hidden="true">&#9666;</span> Equipment
        </button>
        <button id="em-tab-btn-assets" onclick="emSwitchTab('assets')"
                class="px-4 py-2 text-sm font-medium border-b-2 border-togen-teal text-togen-teal -mb-px">Assets</button>
        <button id="em-tab-btn-manuals" onclick="emSwitchTab('manuals')"
                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">Manuals</button>
        <button id="em-tab-btn-rpm" onclick="emSwitchTab('rpm')"
                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">Repair Parts</button>
        <button id="em-tab-btn-oversight" onclick="emSwitchTab('oversight')"
                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">Oversight</button>
        <button id="em-tab-btn-settings" onclick="emSwitchTab('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>

      <!-- ===== Manuals tab ===== -->
      <div id="em-tab-manuals">
        <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="px-6 py-5">
              <div class="flex items-center justify-between mb-4 gap-3 flex-wrap">
                <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Equipment Manuals</h3>
                <button onclick="emOpenUploadModal()"
                        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-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>
              <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="togen-control 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>
      </div><!-- /em-tab-manuals -->

      <!-- ===== Equipment tab ===== -->
      <div id="em-tab-equipment" class="hidden">
        <div class="max-w-3xl mx-auto space-y-4">
          <div 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 gap-3 flex-wrap">
                <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Equipment Registry</h3>
                <button onclick="eqOpenAddModal()"
                        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-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-xs text-gray-500 dark:text-gray-400 mb-4">All equipment known to the system. Auto-seeded from Work Order names. Configure per-equipment Return-to-Service approval requirements here — Work Orders inherit these defaults.</p>
              <div id="eq-list">
                <p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
              </div>
            </div>
          </div>
        </div>
      </div><!-- /em-tab-equipment -->

      <!-- ===== Assets tab (DVI-1185) — asset inventory, Togen source of truth ===== -->
      <div id="em-tab-assets" class="hidden">
        <div class="max-w-6xl mx-auto space-y-4">
          <div 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-2 gap-3 flex-wrap">
                <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Asset Inventory</h3>
                <div class="flex items-center gap-2">
                  <span id="ast-import-status" class="text-xs text-gray-400 dark:text-gray-500"></span>
                  <button id="ast-report-btn" onclick="assetsShowLastReport()"
                          class="togen-control hidden 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 cursor-pointer">Last import report</button>
                  <button id="ast-add-btn" onclick="assetsEditOpen(null)"
                          class="hidden 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">Add asset</button>
                  <button id="ast-models-btn" onclick="assetModelsOpen()"
                          class="hidden 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 models</button>
                  <button id="ast-parts-btn" onclick="assetsPartsOpen()"
                          class="hidden 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">
                    Parts &amp; inventory <span id="ast-parts-low-badge" class="hidden ml-1 px-1.5 py-0.5 rounded-full bg-red-500/15 text-red-600 dark:text-red-400 text-[10px] font-semibold"></span></button>
                  <button id="ast-import-btn" onclick="assetsImportPick()"
                          class="hidden 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">Import spreadsheet</button>
                  <input type="file" id="ast-import-file" accept=".xlsx" class="hidden" onchange="assetsImportUpload(this)">
                </div>
              </div>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">All plant assets from the Asset File inventory (forms, knockouts, production equipment, tools, mobile equipment). Search by tag, description, manufacturer, model or serial; filter by type, area, form ID, or inventory team sheet.</p>
              <div class="flex items-center gap-2 flex-wrap mb-3">
                <div class="relative flex-1 min-w-[14rem]">
                  <input type="search" id="ast-q" placeholder="Search assets…"
                         oninput="assetsSearchDebounced()" onkeydown="if(event.key==='Enter'){event.preventDefault();assetsLoad(1);}"
                         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>
                </div>
                <select id="ast-f-type" onchange="assetsLoad(1)" class="px-2 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 cursor-pointer"><option value="">All types</option></select>
                <select id="ast-f-area" onchange="assetsLoad(1)" class="px-2 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 cursor-pointer"><option value="">All areas</option></select>
                <select id="ast-f-form" onchange="assetsLoad(1)" class="px-2 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 cursor-pointer"><option value="">All form IDs</option></select>
                <select id="ast-f-sheet" onchange="assetsLoad(1)" class="px-2 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 cursor-pointer"><option value="">All teams</option></select>
                <button onclick="assetsClearFilters()" class="togen-control px-3 py-2 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>
              <div id="ast-list" class="overflow-x-auto">
                <p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
              </div>
              <div class="flex items-center justify-between mt-3 gap-3 flex-wrap">
                <span id="ast-count" class="text-xs text-gray-500 dark:text-gray-400"></span>
                <div class="flex items-center gap-2">
                  <label class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">Per page
                    <select id="ast-per-page" onchange="assetsLoad(1)" 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-100 cursor-pointer">
                      <option value="25">25</option>
                      <option value="50" selected>50</option>
                      <option value="100">100</option>
                      <option value="200">200</option>
                    </select>
                  </label>
                  <button id="ast-prev" onclick="assetsLoad(_astPage-1)" class="togen-control 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 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer" disabled>&larr; Prev</button>
                  <span class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">Page
                    <select id="ast-page-select" onchange="assetsLoad(parseInt(this.value,10))" title="Jump to page" 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-100 cursor-pointer"><option value="1">1</option></select>
                    of <span id="ast-page-total">1</span>
                  </span>
                  <button id="ast-next" onclick="assetsLoad(_astPage+1)" class="togen-control 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 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer" disabled>Next &rarr;</button>
                </div>
              </div>
            </div>
          </div>
        </div>
        <!-- Asset detail popup -->
        <div id="ast-detail-overlay" class="hidden fixed inset-0 z-[120] bg-black/40 flex items-center justify-center p-4" onclick="if(event.target===this)assetsDetailClose()">
          <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] overflow-y-auto">
            <div class="px-6 py-5">
              <div class="flex items-center justify-between mb-3">
                <h3 id="ast-detail-title" class="text-base font-semibold text-gray-800 dark:text-gray-100"></h3>
                <div class="flex items-center gap-3">
                  <button id="ast-detail-edit-btn" onclick="assetsEditFromDetail()" class="hidden togen-control px-3 py-1 text-xs font-medium rounded-lg border border-togen-teal/40 text-togen-teal hover:bg-togen-teal/10 transition-colors cursor-pointer">Edit</button>
                  <button onclick="assetsDetailClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
                </div>
              </div>
              <div id="ast-detail-body" class="text-sm"></div>
            </div>
          </div>
        </div>
        <!-- Asset edit/add popup (DVI-1185 P2) -->
        <div id="ast-edit-overlay" class="hidden fixed inset-0 z-[125] bg-black/40 flex items-center justify-center p-4" onclick="if(event.target===this)assetsEditClose()">
          <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-xl max-h-[90vh] overflow-y-auto">
            <div class="px-6 py-5">
              <div class="flex items-center justify-between mb-4">
                <h3 id="ast-edit-title" class="text-base font-semibold text-gray-800 dark:text-gray-100">Add asset</h3>
                <button onclick="assetsEditClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
              </div>
              <div class="grid grid-cols-2 gap-3 text-sm">
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Asset Tag # *</span>
                  <input id="ast-e-asset_tag" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Asset Type</span>
                  <input id="ast-e-asset_type" list="ast-dl-type" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Model</span>
                  <select id="ast-e-model_id" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal cursor-pointer"><option value="">— none —</option></select></label>
                <label class="block col-span-2"><span class="text-xs text-gray-500 dark:text-gray-400">Description</span>
                  <input id="ast-e-description" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Area</span>
                  <input id="ast-e-area" list="ast-dl-area" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Form ID</span>
                  <input id="ast-e-form_id" list="ast-dl-form" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Length</span>
                  <input id="ast-e-length" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Width</span>
                  <input id="ast-e-width" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Height</span>
                  <input id="ast-e-height" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Manufacturer</span>
                  <input id="ast-e-manufacturer" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Model #</span>
                  <input id="ast-e-model_no" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Serial #</span>
                  <input id="ast-e-serial_no" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Material</span>
                  <input id="ast-e-material" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                <label class="block col-span-2"><span class="text-xs text-gray-500 dark:text-gray-400">Notes</span>
                  <textarea id="ast-e-notes" rows="2" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></textarea></label>
              </div>
              <!-- Photos (board reject on card 1e5f35e8): edit applies immediately, add stages until Save -->
              <div class="mt-3">
                <div class="flex items-center gap-2 mb-1.5">
                  <span class="text-xs text-gray-500 dark:text-gray-400">Photos</span>
                  <button type="button" onclick="document.getElementById('ast-e-photo-file').click()"
                          class="togen-control px-2.5 py-1 text-xs font-medium rounded-lg border border-togen-teal/40 text-togen-teal hover:bg-togen-teal/10 transition-colors cursor-pointer">Attach photos</button>
                  <span id="ast-e-photo-status" class="text-xs text-gray-400 dark:text-gray-500"></span>
                </div>
                <div id="ast-e-photos" class="flex flex-wrap gap-2"></div>
                <input type="file" id="ast-e-photo-file" accept="image/*" multiple class="hidden" onchange="assetsEditPhotosPicked(this)">
              </div>
              <datalist id="ast-dl-type"></datalist>
              <datalist id="ast-dl-area"></datalist>
              <datalist id="ast-dl-form"></datalist>
              <div class="flex items-center justify-end gap-2 mt-4">
                <span id="ast-edit-status" class="text-xs text-red-500 mr-auto"></span>
                <button onclick="assetsEditClose()" class="togen-control px-4 py-2 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 cursor-pointer">Cancel</button>
                <button id="ast-edit-save" onclick="assetsEditSave()" 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>
        </div>
        <!-- Asset models & sub-assemblies catalog (DVI-1472 P3, D6/D7) -->
        <div id="ast-models-overlay" class="hidden fixed inset-0 z-[125] bg-black/40 flex items-center justify-center p-4" onclick="if(event.target===this)assetModelsClose()">
          <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-3xl max-h-[90vh] overflow-y-auto">
            <div class="px-6 py-5">
              <div class="flex items-center justify-between mb-1">
                <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Asset Models</h3>
                <button onclick="assetModelsClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
              </div>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">A model is a named machine type (manufacturer / model no). Assets reference a model, and sub-assemblies attached to a model are inherited by every asset of that model.</p>
              <div class="grid md:grid-cols-2 gap-5">
                <div>
                  <div class="flex items-center justify-between mb-2">
                    <span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Models</span>
                    <button onclick="assetModelEditOpen(null)" class="px-2.5 py-1 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">+ New model</button>
                  </div>
                  <div id="ast-models-list" class="space-y-1.5 max-h-[46vh] overflow-y-auto pr-1"></div>
                </div>
                <div id="ast-model-detail" class="border-l border-gray-200 dark:border-gray-700 md:pl-5">
                  <p class="text-sm text-gray-400 dark:text-gray-500 py-6 text-center">Select a model to edit it and manage its sub-assemblies.</p>
                </div>
              </div>
            </div>
          </div>
        </div>
        <!-- Asset model add/edit dialog -->
        <div id="ast-model-edit-overlay" class="hidden fixed inset-0 z-[130] bg-black/40 flex items-center justify-center p-4" onclick="if(event.target===this)assetModelEditClose()">
          <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="px-6 py-5">
              <div class="flex items-center justify-between mb-4">
                <h3 id="ast-model-edit-title" class="text-base font-semibold text-gray-800 dark:text-gray-100">Add model</h3>
                <button onclick="assetModelEditClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
              </div>
              <div class="space-y-3 text-sm">
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Name *</span>
                  <input id="ast-m-name" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                <div class="grid grid-cols-2 gap-3">
                  <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Manufacturer</span>
                    <input id="ast-m-manufacturer" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                  <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Model no</span>
                    <input id="ast-m-model_no" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                </div>
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Description</span>
                  <textarea id="ast-m-description" rows="2" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></textarea></label>
              </div>
              <div class="flex items-center justify-end gap-2 mt-4">
                <span id="ast-model-edit-status" class="text-xs text-red-500 mr-auto"></span>
                <button onclick="assetModelEditClose()" class="togen-control px-4 py-2 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 cursor-pointer">Cancel</button>
                <button id="ast-model-edit-save" onclick="assetModelEditSave()" 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>
        </div>
        <!-- Sub-assembly add/edit dialog (shared by model + asset scopes) -->
        <div id="ast-sub-edit-overlay" class="hidden fixed inset-0 z-[130] bg-black/40 flex items-center justify-center p-4" onclick="if(event.target===this)assetSubEditClose()">
          <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="px-6 py-5">
              <div class="flex items-center justify-between mb-1">
                <h3 id="ast-sub-edit-title" class="text-base font-semibold text-gray-800 dark:text-gray-100">Add sub-assembly</h3>
                <button onclick="assetSubEditClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
              </div>
              <p id="ast-sub-edit-scope" class="text-xs text-gray-500 dark:text-gray-400 mb-4"></p>
              <div class="space-y-3 text-sm">
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Name *</span>
                  <input id="ast-sub-name" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Description</span>
                  <textarea id="ast-sub-description" rows="2" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></textarea></label>
              </div>
              <div class="flex items-center justify-end gap-2 mt-4">
                <span id="ast-sub-edit-status" class="text-xs text-red-500 mr-auto"></span>
                <button onclick="assetSubEditClose()" class="togen-control px-4 py-2 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 cursor-pointer">Cancel</button>
                <button id="ast-sub-edit-save" onclick="assetSubEditSave()" 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>
        </div>
        <!-- Import report popup -->
        <div id="ast-report-overlay" class="hidden fixed inset-0 z-[120] bg-black/40 flex items-center justify-center p-4" onclick="if(event.target===this)assetsReportClose()">
          <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-2xl max-h-[85vh] overflow-y-auto">
            <div class="px-6 py-5">
              <div class="flex items-center justify-between mb-3">
                <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Import report</h3>
                <button onclick="assetsReportClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
              </div>
              <div id="ast-report-body" class="text-sm"></div>
            </div>
          </div>
        </div>
        <!-- Asset photo lightbox: mirrors the WO Admin photo lightbox (DVI-1076) -->
        <div id="ast-photos-lightbox" class="hidden fixed inset-0 z-[130] flex items-center justify-center p-4">
          <div onclick="assetsPhotoLightboxClose()" class="absolute inset-0 bg-black/80"></div>
          <div class="relative w-full max-w-4xl max-h-[92vh] flex flex-col items-center">
            <div class="flex items-center justify-between w-full mb-2 gap-2">
              <span id="ast-lb-badge" class="px-2.5 py-1 rounded-full bg-white/15 text-white text-xs font-medium"></span>
              <div class="flex items-center gap-2">
                <span id="ast-lb-counter" class="text-xs text-gray-300"></span>
                <a id="ast-lb-download" href="#" download class="p-1.5 rounded-lg text-gray-200 hover:text-white hover:bg-white/15 transition-colors" title="Download">
                  <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 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5 5-5M12 15V3"/></svg>
                </a>
                <button onclick="assetsPhotoLightboxClose()" title="Close" class="p-1.5 rounded-lg text-gray-200 hover:text-white hover:bg-white/15 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 class="relative flex items-center justify-center w-full">
              <div id="ast-lb-nav" class="hidden">
                <button onclick="assetsPhotoLightboxNav(-1)" title="Previous" class="absolute left-0 top-1/2 -translate-y-1/2 -ml-2 sm:-ml-12 p-2 rounded-full bg-white/15 text-white hover:bg-white/30 transition-colors cursor-pointer">
                  <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="M15 19l-7-7 7-7"/></svg>
                </button>
                <button onclick="assetsPhotoLightboxNav(1)" title="Next" class="absolute right-0 top-1/2 -translate-y-1/2 -mr-2 sm:-mr-12 p-2 rounded-full bg-white/15 text-white hover:bg-white/30 transition-colors cursor-pointer">
                  <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="M9 5l7 7-7 7"/></svg>
                </button>
              </div>
              <img id="ast-lb-img" src="" alt="" class="max-h-[76vh] max-w-full rounded-lg object-contain bg-black/20">
            </div>
            <div id="ast-lb-caption" class="mt-3 text-center"></div>
          </div>
        </div>
        <!-- Parts & inventory (DVI-1472 P4, D8-A) — gated by assets.inventory -->
        <div id="ast-parts-overlay" class="hidden fixed inset-0 z-[125] bg-black/40 flex items-center justify-center p-4" onclick="if(event.target===this)assetsPartsClose()">
          <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-4xl max-h-[90vh] overflow-y-auto">
            <div class="px-6 py-5">
              <div class="flex items-center justify-between mb-1">
                <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Parts &amp; Inventory</h3>
                <button onclick="assetsPartsClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
              </div>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Stock levels are moved by Work Order usage and manual adjustments; the movement ledger explains every change. Below-minimum and negative stock are flagged, never blocked.</p>
              <div id="ast-parts-digest" class="hidden mb-3 px-3 py-2 rounded-lg border border-amber-300/60 bg-amber-50/70 dark:bg-amber-900/20 text-xs text-amber-800 dark:text-amber-300"></div>
              <details id="ast-parts-notify" class="mb-3 rounded-lg border border-gray-200 dark:border-gray-700" ontoggle="if(this.open)assetsPartsNotifyLoad()">
                <summary class="px-3 py-2 text-xs font-medium text-gray-700 dark:text-gray-200 cursor-pointer select-none flex items-center gap-2">🔔 Reorder notifications <span id="ast-notify-state" class="text-[10px] px-1.5 py-0.5 rounded-full bg-gray-500/10 text-gray-500 dark:text-gray-400">off</span></summary>
                <div class="px-3 pb-3 pt-1 space-y-2 text-xs">
                  <p class="text-gray-500 dark:text-gray-400">Email (and optionally Teams) a reorder alert when a part reaches its minimum via WO usage or a manual adjustment. A per-part cooldown stops re-alerting on every movement.</p>
                  <label class="flex items-center gap-2 cursor-pointer"><input type="checkbox" id="ast-notify-enabled" class="rounded"> <span class="text-gray-700 dark:text-gray-200">Enable low-stock notifications</span></label>
                  <label class="block"><span class="text-gray-500 dark:text-gray-400">Recipients (comma-separated email addresses)</span>
                    <textarea id="ast-notify-emails" rows="2" placeholder="buyer@icastinc.com, shop@icastinc.com" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></textarea></label>
                  <div class="grid grid-cols-2 gap-2">
                    <label class="block"><span class="text-gray-500 dark:text-gray-400">Cooldown (hours)</span>
                      <input id="ast-notify-cooldown" type="number" min="0" step="1" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                    <label class="block"><span class="text-gray-500 dark:text-gray-400">Teams webhook URL (optional)</span>
                      <input id="ast-notify-teams" type="url" placeholder="https://…/IncomingWebhook/…" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                  </div>
                  <div class="flex items-center gap-2 pt-1">
                    <button onclick="assetsPartsNotifySave()" class="px-2.5 py-1 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">Save</button>
                    <button onclick="assetsPartsNotifyTest()" 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-200 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">Send test</button>
                    <span id="ast-notify-msg" class="text-[11px] text-gray-500 dark:text-gray-400"></span>
                  </div>
                </div>
              </details>
              <div class="grid md:grid-cols-2 gap-5">
                <div>
                  <div class="flex items-center gap-2 mb-2 flex-wrap">
                    <input type="search" id="ast-parts-q" placeholder="Search parts…" oninput="assetsPartsSearchDebounced()" onkeydown="if(event.key==='Enter'){event.preventDefault();assetsPartsLoad();}"
                           class="flex-1 min-w-[8rem] px-2.5 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">
                    <label class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 cursor-pointer"><input type="checkbox" id="ast-parts-low-only" onchange="assetsPartsLoad()" class="rounded"> Low stock</label>
                    <button onclick="assetsPartsPrintSheet()" title="Printable QR label sheet for the parts listed below" class="togen-control 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 cursor-pointer">Print label sheet</button>
                    <button onclick="assetsPartEditOpen(null)" class="px-2.5 py-1 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">+ New part</button>
                  </div>
                  <div id="ast-parts-list" class="space-y-1.5 max-h-[52vh] overflow-y-auto pr-1"></div>
                </div>
                <div id="ast-part-detail" class="border-l border-gray-200 dark:border-gray-700 md:pl-5">
                  <p class="text-sm text-gray-400 dark:text-gray-500 py-6 text-center">Select a part to view its stock, adjust it, and manage links.</p>
                </div>
              </div>
            </div>
          </div>
        </div>
        <!-- Part add/edit dialog -->
        <div id="ast-part-edit-overlay" class="hidden fixed inset-0 z-[130] bg-black/40 flex items-center justify-center p-4" onclick="if(event.target===this)assetsPartEditClose()">
          <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="px-6 py-5">
              <div class="flex items-center justify-between mb-4">
                <h3 id="ast-part-edit-title" class="text-base font-semibold text-gray-800 dark:text-gray-100">Add part</h3>
                <button onclick="assetsPartEditClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
              </div>
              <div class="space-y-3 text-sm">
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Part number *</span>
                  <input id="ast-part-part_no" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Name</span>
                  <input id="ast-part-name" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Description</span>
                  <textarea id="ast-part-description" rows="2" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></textarea></label>
                <div class="grid grid-cols-2 gap-3">
                  <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Minimum qty</span>
                    <input id="ast-part-min_qty" type="number" step="any" min="0" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                  <label class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Location</span>
                    <input id="ast-part-location" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
                </div>
                <label id="ast-part-opening-wrap" class="block"><span class="text-xs text-gray-500 dark:text-gray-400">Opening on-hand (optional)</span>
                  <input id="ast-part-on_hand" type="number" step="any" class="mt-1 w-full px-2.5 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 focus:outline-none focus:ring-2 focus:ring-togen-teal"></label>
              </div>
              <div class="flex items-center justify-end gap-2 mt-4">
                <span id="ast-part-edit-status" class="text-xs text-red-500 mr-auto"></span>
                <button onclick="assetsPartEditClose()" class="togen-control px-4 py-2 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 cursor-pointer">Cancel</button>
                <button id="ast-part-edit-save" onclick="assetsPartEditSave()" 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>
        </div>
      </div><!-- /em-tab-assets -->

      <!-- ===== Oversight tab (DVI-1124) — view-only equipment floor plan ===== -->
      <div id="em-tab-oversight" class="hidden">
        <div id="emov-header" class="flex items-center justify-between gap-3 mb-3 flex-wrap">
          <div class="min-w-0">
            <h3 id="emov-title" class="text-base font-semibold text-gray-800 dark:text-gray-100 truncate">Oversight</h3>
            <div id="emov-legend" class="mt-0.5"></div>
          </div>
        </div>
        <div id="emov-empty" class="hidden togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-8 text-center text-sm text-gray-500 dark:text-gray-400">
          No focused plan configured. <span id="emov-empty-admin" class="hidden">Choose one under <button onclick="emSwitchTab('settings')" class="text-togen-teal underline cursor-pointer">Settings &rarr; Oversight</button>.</span>
        </div>
        <div id="emov-viewer-wrap" class="hidden overflow-hidden relative rounded-lg border border-gray-200 dark:border-gray-700">
          <div id="emov-noimg" class="hidden p-8 text-center text-sm text-gray-500 dark:text-gray-400">This plan has no image yet.</div>
          <div id="emov-stage" class="relative overflow-hidden select-none" style="height:70vh;touch-action:none;">
            <div id="emov-pan" style="position:absolute;left:0;top:0;transform-origin:0 0;">
              <img id="emov-plan-img" alt="Floor plan" style="display:block;max-width:none;pointer-events:none;" />
              <div id="emov-markers" style="position:absolute;left:0;top:0;right:0;bottom:0;"></div>
            </div>
          </div>
          <button id="emov-full-btn" onclick="emovToggleFull()" title="Full view"
                  class="absolute top-2 right-2 z-10 h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer inline-flex items-center justify-center">
            <svg id="emov-full-icon" 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 4a1 1 0 011-1h4a1 1 0 010 2H6.414l3.293 3.293a1 1 0 11-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm14 12a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-3.293-3.293a1 1 0 111.414-1.414L15 13.586V12a1 1 0 012 0v4z" clip-rule="evenodd"/></svg>
          </button>
          <div class="absolute bottom-2 right-2 z-10 flex flex-col gap-1">
            <button onclick="emovZoomBy(1.25)" class="h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer text-lg leading-none">+</button>
            <button onclick="emovZoomBy(0.8)" class="h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer text-lg leading-none">&minus;</button>
            <button onclick="emovZoomReset()" class="h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer text-[10px] leading-none">fit</button>
          </div>
        </div>
        <div id="emov-detail" class="hidden fixed z-[125] w-64 rounded-xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700 p-3 text-sm"></div>
      </div><!-- /em-tab-oversight -->
      <div id="em-tab-rpm" class="hidden"><!-- DVI-1496 P3: RPM merged into Equipment as the RPM sub-tool -->
      <!-- Tab bar: All Parts (default) / Mobile Machine / Preventative Maintenance / Settings (right) -->
      <div id="rpm-tabbar" class="togen-tabbar 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 class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
          <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 class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-4">
          <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="togen-pane 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="togen-pane 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="togen-pane 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><!-- /em-tab-rpm -->

      <!-- ===== Settings tab (DVI-1124) ===== -->
      <div id="em-tab-settings" class="hidden">
        <div class="max-w-3xl mx-auto space-y-4">
          <!-- Oversight tile — global focused plan for the Oversight tab (mirrors SCADA View) -->
          <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">Oversight</h3>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">Choose the focused floor plan shown in the Oversight tab. Applies to all users. The plan renders in the light/dark style matching each user's theme.</p>
              <label class="flex items-center justify-between gap-3">
                <span class="text-sm text-gray-700 dark:text-gray-300">Focused plan</span>
                <select id="em-oversight-plan" onchange="emSaveOversightPlan()"
                        class="min-w-[16rem] px-2 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-100 focus:ring-togen-teal focus:border-togen-teal cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed">
                </select>
              </label>
              <div class="mt-3 flex items-center gap-3">
                <span id="em-oversight-status" class="text-xs text-gray-500 dark:text-gray-400"></span>
                <span id="em-oversight-note" class="hidden text-xs text-gray-400 dark:text-gray-500">Only a Togen admin can change the focused plan.</span>
              </div>
              <p class="mt-3 text-xs text-gray-400 dark:text-gray-500">Plans and their placements are managed in the <button onclick="oversightFromEquipment()" class="text-togen-teal underline cursor-pointer">Oversight</button> tool. Position equipment on a plan by enabling the Equipment layer there in edit mode.</p>
            </div>
          </section>
        </div>
      </div><!-- /em-tab-settings -->

      <!-- 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="togen-control 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="togen-control 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>

      <!-- Add Equipment modal -->
      <div id="eq-add-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">Add Equipment</h3>
          </div>
          <div class="px-6 py-4 space-y-4">
            <div>
              <label for="eq-add-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Equipment / Asset / System name</label>
              <input type="text" id="eq-add-name" 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>
            <p id="eq-add-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="eqCloseAddModal()"
                    class="togen-control 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="eq-add-submit" onclick="eqSubmitAdd()"
                    class="px-5 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors">Add</button>
          </div>
        </div>
      </div>

    </div><!-- /tool-equipment-manuals -->

    <!-- ===== VISITOR REGISTRY CONTENT (DVI-1136 Phase 4) ===== -->
    <div id="tool-visitor-registry" class="tool-content hidden">
      <div class="max-w-5xl mx-auto space-y-6">

        <!-- Tabs: List (left) + Oversight (right) -->
        <div id="vr-tabbar" class="togen-tabbar flex items-center gap-1 border-b border-gray-200 dark:border-gray-700">
          <button id="vr-tab-btn-list" onclick="vrSwitchTab('list')"
                  class="px-4 py-2 text-sm font-medium border-b-2 border-togen-teal text-togen-teal -mb-px">Registry</button>
          <button id="vr-tab-btn-oversight" onclick="vrSwitchTab('oversight')"
                  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">Oversight</button>
        </div>

        <!-- ===== Registry (List) tab ===== -->
        <div id="vr-tab-list">
          <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">Visitor Registry</h2>
              <button id="vr-register-btn" onclick="vrOpenRegister()"
                      class="hidden 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>
                Register Visitor
              </button>
            </div>
            <div class="px-6 py-4">
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">Site visitors and their status. A tag is assigned when a visitor is registered onsite; checking out returns the tag to the pool.</p>
              <div id="vr-list">
                <p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
              </div>
            </div>
          </div>
        </div><!-- /vr-tab-list -->

        <!-- ===== Oversight tab — view-only visitor floor plan ===== -->
        <div id="vr-tab-oversight" class="hidden">
          <div id="visov-header" class="flex items-center justify-between gap-3 mb-3 flex-wrap">
            <div class="min-w-0">
              <h3 id="visov-title" class="text-base font-semibold text-gray-800 dark:text-gray-100 truncate">Oversight</h3>
              <div id="visov-legend" class="mt-0.5"></div>
            </div>
            <div id="visov-plan-picker" class="hidden flex items-center gap-2">
              <label for="visov-plan-sel" class="text-xs text-gray-500 dark:text-gray-400">Focused plan</label>
              <select id="visov-plan-sel" onchange="visovSavePlan()"
                      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"></select>
              <span id="visov-plan-status" class="text-xs text-gray-500 dark:text-gray-400"></span>
            </div>
          </div>
          <div id="visov-noaccess" class="hidden togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-8 text-center text-sm text-gray-500 dark:text-gray-400">
            Visitor locations are restricted. A Togen admin can view onsite visitor tags here.
          </div>
          <div id="visov-empty" class="hidden togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-8 text-center text-sm text-gray-500 dark:text-gray-400">
            No focused plan configured. <span id="visov-empty-admin" class="hidden">Choose one with the <strong>Focused plan</strong> selector above.</span>
          </div>
          <div id="visov-viewer-wrap" class="hidden overflow-hidden relative rounded-lg border border-gray-200 dark:border-gray-700">
            <div id="visov-noimg" class="hidden p-8 text-center text-sm text-gray-500 dark:text-gray-400">This plan has no image yet.</div>
            <div id="visov-stage" class="relative overflow-hidden select-none" style="height:70vh;touch-action:none;">
              <div id="visov-pan" style="position:absolute;left:0;top:0;transform-origin:0 0;">
                <img id="visov-plan-img" alt="Floor plan" style="display:block;max-width:none;pointer-events:none;" />
                <div id="visov-markers" style="position:absolute;left:0;top:0;right:0;bottom:0;"></div>
              </div>
            </div>
            <button id="visov-full-btn" onclick="visovToggleFull()" title="Full view"
                    class="absolute top-2 right-2 z-10 h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer inline-flex items-center justify-center">
              <svg id="visov-full-icon" 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 4a1 1 0 011-1h4a1 1 0 010 2H6.414l3.293 3.293a1 1 0 11-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm14 12a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-3.293-3.293a1 1 0 111.414-1.414L15 13.586V12a1 1 0 012 0v4z" clip-rule="evenodd"/></svg>
            </button>
            <div class="absolute bottom-2 right-2 z-10 flex flex-col gap-1">
              <button onclick="visovZoomBy(1.25)" class="h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer text-lg leading-none">+</button>
              <button onclick="visovZoomBy(0.8)" class="h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer text-lg leading-none">&minus;</button>
              <button onclick="visovZoomReset()" class="h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer text-[10px] leading-none">fit</button>
            </div>
          </div>
          <div id="visov-detail" class="hidden fixed z-[125] w-64 rounded-xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700 p-3 text-sm"></div>
        </div><!-- /vr-tab-oversight -->

      </div>

      <!-- Register Visitor modal -->
      <div id="vr-register-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">Register Visitor</h3>
          </div>
          <div class="px-6 py-4 space-y-3">
            <div class="grid grid-cols-2 gap-3">
              <div>
                <label for="vr-first" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First name <span class="text-red-500">*</span></label>
                <input type="text" id="vr-first" 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="vr-last" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last name <span class="text-red-500">*</span></label>
                <input type="text" id="vr-last" 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="vr-company" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Company <span class="text-gray-400 font-normal">(optional)</span></label>
              <input type="text" id="vr-company" 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="vr-reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reason for visit <span class="text-gray-400 font-normal">(optional)</span></label>
              <input type="text" id="vr-reason" 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 id="vr-tag-row" class="hidden">
              <label for="vr-tag" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tag number</label>
              <input type="text" id="vr-tag" placeholder="Tag / DevEUI to assign" 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">The visitor's tracking tag. Live positions activate once tag data is flowing.</p>
            </div>
            <p id="vr-register-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-2 flex-wrap">
            <button onclick="vrCloseRegister()" class="togen-control 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="vr-just-add-btn" onclick="vrSubmitRegister(false)" class="togen-control px-4 py-2 text-sm font-semibold rounded-xl border border-togen-teal text-togen-teal hover:bg-togen-teal/10 transition-colors">Just Add to Registry</button>
            <button id="vr-add-onsite-btn" onclick="vrSubmitRegister(true)" class="px-5 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors">Add and Register Onsite</button>
          </div>
        </div>
      </div>

    </div><!-- /tool-visitor-registry -->

    <!-- ===== 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="togen-tabbar 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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-control 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="togen-control 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="togen-inset 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="togen-inset 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 -->

    <!-- ===== OVERSIGHT TOOL CONTENT (DVI-1100) ===== -->
    <div id="tool-oversight" class="tool-content hidden">
      <!-- Facility tab strip (hidden while there is only a single facility) -->
      <div id="oversight-facility-tabs" class="hidden togen-tabbar flex flex-nowrap overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden border-b border-gray-200 dark:border-gray-700 mb-3"></div>

      <!-- Sub-view navigation breadcrumbs (DVI-1103): Facility ▸ parent ▸ plan -->
      <nav id="oversight-breadcrumb" class="hidden flex flex-wrap items-center gap-1 text-xs text-gray-500 dark:text-gray-400 mb-2"></nav>

      <!-- Plan tabs + toolbar -->
      <div id="oversight-toolbar" class="flex items-center justify-between gap-3 mb-3 flex-wrap">
        <div id="oversight-plan-tabs" class="togen-tabbar flex flex-nowrap overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden flex-1 min-w-0"></div>
        <div class="flex items-center gap-2 flex-shrink-0">
          <div id="oversight-layer-toggles" class="flex items-center gap-2 flex-wrap"></div>
          <select id="oversight-style-select" onchange="oversightSetStyle(this.value)"
                  class="hidden px-2 py-1.5 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 cursor-pointer"></select>
          <button id="oversight-edit-btn" onclick="oversightToggleEdit()"
                  class="hidden 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">
            Edit locations
          </button>
          <button id="oversight-settings-btn" onclick="oversightOpenSettings()"
                  class="togen-control hidden 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 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.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>
      </div>

      <!-- Empty state -->
      <div id="oversight-empty" class="hidden togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-8 text-center text-sm text-gray-500 dark:text-gray-400">
        No floor plans configured yet.
        <span id="oversight-empty-admin" class="hidden">Use <button onclick="oversightOpenSettings()" class="text-togen-teal underline cursor-pointer">Settings</button> to add a facility and upload a plan image.</span>
      </div>

      <!-- Edit-mode toolbar -->
      <div id="oversight-editbar" class="hidden mb-2 flex items-center gap-2 flex-wrap rounded-lg border border-togen-teal/30 bg-togen-teal/5 px-3 py-2 text-xs">
        <span class="font-medium text-togen-teal">Editing locations</span>
        <select id="oversight-add-layer" onchange="oversightAddLayerChanged()" 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-900 dark:text-gray-100"></select>
        <select id="oversight-add-ref" 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-900 dark:text-gray-100 max-w-[16rem]"></select>
        <input id="oversight-add-loc" list="oversight-loc-presets" placeholder="Location name" class="hidden px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 max-w-[18rem]" />
        <datalist id="oversight-loc-presets"></datalist>
        <button onclick="oversightAddMarker()" class="px-2.5 py-1 rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer">Add marker</button>
        <span class="text-gray-500 dark:text-gray-400">Drag to reposition · ✎ rename · × remove.</span>
        <span class="ml-auto flex items-center gap-2">
          <button onclick="oversightSavePlacements()" class="px-3 py-1 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 cursor-pointer">Save</button>
          <button onclick="oversightCancelEdit()" class="togen-control px-3 py-1 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 cursor-pointer">Cancel</button>
        </span>
      </div>

      <!-- Plan viewer stage (DVI-1117/DVI-1133: no backing card or fill — the plan is the surface; thin border only) -->
      <div id="oversight-viewer-wrap" class="hidden overflow-hidden relative rounded-lg border border-gray-200 dark:border-gray-700">
        <div id="oversight-noimg" class="hidden p-8 text-center text-sm text-gray-500 dark:text-gray-400">This plan has no image yet.</div>
        <div id="oversight-stage" class="relative overflow-hidden select-none" style="height:70vh;touch-action:none;">
          <div id="oversight-pan" style="position:absolute;left:0;top:0;transform-origin:0 0;">
            <img id="oversight-plan-img" alt="Floor plan" style="display:block;max-width:none;pointer-events:none;" />
            <div id="oversight-markers" style="position:absolute;left:0;top:0;right:0;bottom:0;"></div>
          </div>
        </div>
        <!-- Full-view toggle (DVI-1117): overlay so it stays visible when the toolbar is hidden -->
        <button id="oversight-full-btn" onclick="oversightToggleFull()" title="Full view"
                class="absolute top-3 right-3 h-8 w-8 flex items-center justify-center rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer">
          <svg id="oversight-full-icon" 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 4a1 1 0 011-1h4a1 1 0 010 2H6.414l3.293 3.293a1 1 0 11-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm14 12a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-3.293-3.293a1 1 0 111.414-1.414L15 13.586V12a1 1 0 012 0v4z" clip-rule="evenodd"/></svg>
        </button>
        <!-- Zoom controls -->
        <div class="absolute bottom-3 right-3 flex flex-col gap-1">
          <button onclick="oversightZoomBy(1.25)" class="h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer text-lg leading-none">+</button>
          <button onclick="oversightZoomBy(0.8)" class="h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer text-lg leading-none">−</button>
          <button onclick="oversightZoomReset()" class="h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer text-[10px] leading-none">fit</button>
        </div>
      </div>

      <!-- Marker detail popup -->
      <div id="oversight-detail" class="hidden fixed z-[125] w-64 rounded-xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700 p-3 text-sm"></div>

      <!-- Settings modal (admin only) -->
      <div id="oversight-settings" class="hidden fixed inset-0 z-[120] bg-black/50 flex items-start justify-center overflow-y-auto p-4">
        <div class="mt-10 w-full max-w-2xl rounded-2xl bg-white dark:bg-gray-900 shadow-xl border border-gray-200 dark:border-gray-700">
          <div class="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700">
            <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Oversight Settings</h3>
            <button onclick="oversightCloseSettings()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
          </div>
          <div id="oversight-settings-body" class="p-5 space-y-6 text-sm"></div>
        </div>
      </div>
    </div><!-- /tool-oversight -->

    <!-- ===== DASHBOARDS TOOL CONTENT (DVI-1192) ===== -->
    <div id="tool-dashboards" class="tool-content hidden">
      <style>
        /* DVI-1256: the Report Viewer (Auditor → Reporting) reuses this exact
           tile grid — its #reporting-viewer-grid rides the same breakpoints. */
        #dashboards-grid, #reporting-viewer-grid { display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap:0.75rem; grid-auto-rows: minmax(120px, auto); grid-auto-flow: row; }
        /* Column span is CSS-driven (not inline) so the mobile media queries below
           can remap sizes; --dash-cols is the tile's 12-based span, clamped by the
           grid's actual column count on each breakpoint (DVI-1192 / DVI-1208). */
        .dash-tile { grid-column: span var(--dash-cols, 6); }
        @media (min-width: 768px)  { #dashboards-grid, #reporting-viewer-grid { grid-template-columns: repeat(8, minmax(0,1fr)); } }
        @media (min-width: 1024px) { #dashboards-grid, #reporting-viewer-grid { grid-template-columns: repeat(12, minmax(0,1fr)); } }
        /* DVI-1208: phone portrait — 2-column grid with sizes remapped so small
           tiles pair up 2-across and larger tiles go full width. */
        @media (max-width: 767px) and (orientation: portrait) {
          #dashboards-grid, #reporting-viewer-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }
          .dash-tile.dash-size-small, .dash-tile.dash-size-tall, .dash-tile.dash-size-list { grid-column: span 1; }
          .dash-tile.dash-size-medium, .dash-tile.dash-size-large, .dash-tile.dash-size-wide, .dash-tile.dash-size-xlarge, .dash-tile.dash-size-custom { grid-column: span 2; }
        }
        /* DVI-1208: phone landscape (short viewport) — back to a 4-column grid.
           max-height keeps tablets/laptops on their width-based breakpoints. */
        @media (orientation: landscape) and (max-height: 500px) {
          #dashboards-grid, #reporting-viewer-grid { grid-template-columns: repeat(4, minmax(0,1fr)); }
          .dash-tile.dash-size-small, .dash-tile.dash-size-tall, .dash-tile.dash-size-list { grid-column: span 1; }
          /* custom spans get a medium-like phone treatment — the 12-col span
             can't be conditionally clamped in CSS, so remap to half width */
          .dash-tile.dash-size-medium, .dash-tile.dash-size-large, .dash-tile.dash-size-custom { grid-column: span 2; }
          .dash-tile.dash-size-wide, .dash-tile.dash-size-xlarge { grid-column: span 4; }
        }
        /* DVI-1257 (DVI-1252 P4): print-optimized Report Viewer layout for
           browser Print-to-PDF (decision D3, zero server dependency). Scoped to
           body.reporting-print (toggled by reportViewerExportPdf around
           window.print()) so it only affects the "Export PDF" action and never
           an accidental Ctrl-P of some other tool. The classic "print only this
           region" trick: hide everything, then reveal #reporting-print-region. */
        @media print {
          body.reporting-print * { visibility: hidden !important; }
          body.reporting-print #reporting-print-region,
          body.reporting-print #reporting-print-region * { visibility: visible !important; }
          body.reporting-print #reporting-print-region {
            position: absolute; left: 0; top: 0; width: 100%;
            margin: 0; padding: 0; background: #fff;
          }
          body.reporting-print #reporting-print-header { display: block !important; }
          /* Ink-friendly: force light rendering regardless of the SPA theme, drop
             shadows/rounded chrome so tiles print as clean bordered blocks. */
          body.reporting-print #reporting-print-region,
          body.reporting-print #reporting-print-region * {
            color: #111 !important; box-shadow: none !important;
            -webkit-print-color-adjust: exact; print-color-adjust: exact;
          }
          /* Stack tiles one-per-row for a clean, deterministic multi-page PDF —
             CSS-grid pagination is inconsistent across browsers, and P1 tiles are
             static inline SVG so a full-width block prints at full fidelity. */
          body.reporting-print #reporting-viewer-grid { display: block !important; }
          body.reporting-print #reporting-viewer-grid .dash-tile {
            display: block !important; width: 100% !important;
            grid-row: auto !important; grid-column: auto !important;
            border: 1px solid #ccc !important; border-radius: 0 !important;
            margin: 0 0 10px 0 !important; overflow: visible !important;
            break-inside: avoid; page-break-inside: avoid;
          }
          body.reporting-print #reporting-viewer-grid .dash-tile-body {
            display: block !important; overflow: visible !important;
            max-height: none !important; min-height: 0 !important;
          }
          /* Very large tables shouldn't be capped/clipped to one tile height —
             let them paginate row-by-row (a tile with a big table opts out of the
             tile-level break-avoid), repeating the header on each page. */
          body.reporting-print #reporting-viewer-grid .dash-tile:has(table) {
            break-inside: auto; page-break-inside: auto;
          }
          body.reporting-print #reporting-viewer-grid .dash-tile table { break-inside: auto; width: 100%; }
          body.reporting-print #reporting-viewer-grid .dash-tile thead { display: table-header-group; }
          body.reporting-print #reporting-viewer-grid .dash-tile tr {
            break-inside: avoid; page-break-inside: avoid;
          }
          @page { margin: 12mm; }
        }
        #reporting-print-header { margin: 0 0 12px 0; }
        .dash-tile.dash-drag-over { outline: 2px dashed var(--togen-teal, #14b8a6); outline-offset: 2px; }
        .dash-tile.dash-dragging { opacity: 0.4; }
        /* DVI-1243 P2: the powerbi-client SDK injects an iframe — fill the tile. */
        .dashpbi-embed iframe { width: 100%; height: 100%; border: 0; }
      </style>

      <!-- Dashboard selector tabs + actions -->
      <div class="flex items-center justify-between gap-3 mb-3 flex-wrap">
        <div id="dashboards-tabs" class="togen-tabbar flex flex-nowrap overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden flex-1 min-w-0 gap-1"></div>
        <div class="flex items-center gap-2 flex-shrink-0">
          <span id="dashboards-shared-badge"
                class="hidden px-2.5 py-1 text-xs font-medium rounded-full bg-togen-teal/10 text-togen-teal border border-togen-teal/30">
            Shared with you &middot; view-only
          </span>
          <span id="dashboards-system-badge"
                class="hidden px-2.5 py-1 text-xs font-medium rounded-full bg-togen-teal/10 text-togen-teal border border-togen-teal/30">
            Default Oversight &middot; view-only
          </span>
          <button id="dashboards-copy-btn" onclick="dashboardsMakeCopy()"
                  class="hidden togen-control px-3 py-1.5 text-xs font-medium rounded-lg border border-togen-teal/40 text-togen-teal hover:bg-togen-teal/10 transition-colors cursor-pointer">
            Make a copy
          </button>
          <button id="dashboards-fullscreen-btn" onclick="dashboardsOpenFullscreen()" title="Open this dashboard full-screen, exactly as a Display Agent renders it"
                  class="hidden togen-control 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">
            &#x26F6; Fullscreen
          </button>
          <button id="dashboards-default-btn" onclick="dashboardsSetDefault()"
                  class="hidden togen-control 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">
            Set as default
          </button>
          <button id="dashboards-rename-btn" onclick="dashboardsRename()"
                  class="hidden togen-control 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">
            Rename
          </button>
          <button id="dashboards-share-btn" onclick="dashboardsShare()"
                  class="hidden togen-control 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">
            Share
          </button>
          <button id="dashboards-present-btn" onclick="dashboardsPresent()"
                  class="hidden togen-control px-3 py-1.5 text-xs font-medium rounded-lg border border-togen-teal/40 text-togen-teal hover:bg-togen-teal/10 transition-colors cursor-pointer">
            Present
          </button>
          <button id="dashboards-delete-btn" onclick="dashboardsDelete()"
                  class="hidden togen-control px-3 py-1.5 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/30 transition-colors cursor-pointer">
            Delete
          </button>
          <button id="dashboards-edit-btn" onclick="dashboardsToggleEdit()"
                  class="hidden togen-control 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">
            Edit
          </button>
          <button id="dashboards-sources-btn" onclick="dashboardsSourcesOpen()" title="Named data sources Stat tiles bind to — SharePoint cells, report values, and manual values you update by hand"
                  class="hidden togen-control 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">
            Data sources
          </button>
          <button id="dashboards-new-btn" onclick="dashboardsCreate()"
                  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">
            <span class="text-base leading-none">+</span> New dashboard
          </button>
        </div>
      </div>

      <!-- Empty state (no dashboards at all) -->
      <div id="dashboards-empty" class="hidden togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-8 text-center text-sm text-gray-500 dark:text-gray-400">
        No dashboards yet.
        <button onclick="dashboardsCreate()" class="text-togen-teal underline cursor-pointer">Create your first dashboard</button> to get started.
      </div>

      <!-- Edit-mode toolbar -->
      <div id="dashboards-editbar" class="togen-inset hidden mb-3 flex items-center gap-2 flex-wrap rounded-lg border border-togen-teal/30 bg-togen-teal/5 px-3 py-2 text-xs">
        <span class="font-medium text-togen-teal">Editing &ldquo;<span id="dashboards-editbar-name"></span>&rdquo;</span>
        <button onclick="dashboardsAddTile()" class="px-2.5 py-1 rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer inline-flex items-center gap-1"><span class="text-sm leading-none">+</span> Add tile</button>
        <span class="text-gray-500 dark:text-gray-400">Drag a tile, or use its ↑ ↓ controls, to reorder. Click <span class="font-medium">Edit</span> again when done.</span>
      </div>

      <!-- Grid -->
      <div id="dashboards-grid"></div>

      <!-- Grid empty state (dashboard has no tiles) -->
      <div id="dashboards-grid-empty" class="hidden togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-dashed border-gray-300 dark:border-gray-700 p-8 text-center text-sm text-gray-500 dark:text-gray-400">
        This dashboard has no tiles yet.
        <button onclick="dashboardsEnterEditAndAdd()" class="text-togen-teal underline cursor-pointer">Add a tile</button> to get started.
      </div>

      <!-- Tile type picker modal -->
      <div id="dashboards-typepicker-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
        <div class="absolute inset-0 bg-black/50" onclick="dashboardsCloseTypePicker()"></div>
        <div class="relative 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 mx-4">
          <div class="flex items-center justify-between px-5 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 a tile</h2>
            <button onclick="dashboardsCloseTypePicker()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
          </div>
          <div id="dashboards-typepicker-body" class="p-5 grid grid-cols-1 gap-2"></div>
        </div>
      </div>

      <!-- Tile editor modal -->
      <div id="dashboards-tile-overlay" class="hidden fixed inset-0 z-[100] flex items-center justify-center">
        <div class="absolute inset-0 bg-black/50" onclick="dashboardsCloseTileEditor()"></div>
        <div class="relative 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 mx-4 max-h-[90vh] overflow-y-auto">
          <div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700">
            <h2 id="dashboards-tile-title" class="text-lg font-semibold text-gray-800 dark:text-gray-100">Edit tile</h2>
            <button onclick="dashboardsCloseTileEditor()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
          </div>
          <div class="p-5 space-y-4 text-sm">
            <div>
              <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Size</label>
              <select id="dashboards-tile-size" onchange="dashboardsSizeChanged()" 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">
                <option value="small">Small (1&times;1)</option>
                <option value="medium">Medium (2&times;1)</option>
                <option value="tall">Tall (1&times;2)</option>
                <option value="large">Large (2&times;2)</option>
                <option value="wide">Wide (full row)</option>
                <option value="list">List (tall column)</option>
                <option value="xlarge">X-Large (full width, tall)</option>
                <option value="custom">Custom&hellip;</option>
              </select>
            </div>
            <!-- DVI-1220 P2 (F3): explicit span for the Custom size -->
            <div id="dashboards-tile-span-wrap" class="hidden grid grid-cols-2 gap-3">
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Width (1&ndash;12 columns)</label>
                <input id="dashboards-tile-span-cols" type="number" min="1" max="12" step="1" 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" />
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Height (1&ndash;6 rows)</label>
                <input id="dashboards-tile-span-rows" type="number" min="1" max="6" step="1" 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" />
              </div>
            </div>
            <!-- DVI-1220 P2 (B1): per-tile overflow behavior -->
            <div>
              <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">When content overflows the tile</label>
              <select id="dashboards-tile-overflow" 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">
                <option value="">Default (scroll here; auto-cycle on signage displays)</option>
                <option value="scroll">Scroll</option>
                <option value="clip">Clip (hide the rest)</option>
                <option value="cycle">Auto-cycle pages (signage displays)</option>
              </select>
            </div>
            <!-- DVI-1275 P2: per-tile zoom (composes with the display zoom) -->
            <div>
              <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Tile zoom</label>
              <select id="dashboards-tile-zoom" 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">
                <option value="50">50%</option>
                <option value="75">75%</option>
                <option value="90">90%</option>
                <option value="100">100% (normal)</option>
                <option value="110">110%</option>
                <option value="125">125%</option>
                <option value="150">150%</option>
                <option value="175">175%</option>
                <option value="200">200%</option>
              </select>
              <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Scales just this tile&rsquo;s contents; stacks on top of any display-level zoom.</p>
            </div>
            <div id="dashboards-tile-config" class="space-y-4"></div>
          </div>
          <div class="flex items-center justify-end gap-2 px-5 py-4 border-t border-gray-200 dark:border-gray-700">
            <span id="dashboards-tile-status" class="text-xs text-gray-500 dark:text-gray-400 mr-auto"></span>
            <button onclick="dashboardsCloseTileEditor()" class="togen-control 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 cursor-pointer">Cancel</button>
            <button onclick="dashboardsSaveTile()" class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer">Save tile</button>
          </div>
        </div>
      </div>

      <!-- DVI-1305 P4: Group / Panel container manager. Lower z-index than the
           tile editor + type picker so the child-tile editor stacks on top. -->
      <div id="dashboards-group-overlay" class="hidden fixed inset-0 z-[95] flex items-center justify-center">
        <div class="absolute inset-0 bg-black/50" onclick="dashboardsGroupClose()"></div>
        <div class="relative 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 mx-4 max-h-[90vh] overflow-y-auto">
          <div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700">
            <h2 id="dashboards-group-title" class="text-lg font-semibold text-gray-800 dark:text-gray-100">Group / Panel</h2>
            <button onclick="dashboardsGroupClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
          </div>
          <div class="p-5 space-y-4 text-sm">
            <div>
              <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Panel title (optional)</label>
              <input id="dashboards-group-name" type="text" maxlength="200" 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" />
            </div>
            <div class="grid grid-cols-2 gap-3">
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Panel size</label>
                <select id="dashboards-group-size" 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">
                  <option value="medium">Medium (2&times;1)</option>
                  <option value="large">Large (2&times;2)</option>
                  <option value="wide">Wide (full row)</option>
                  <option value="list">List (tall column)</option>
                  <option value="xlarge">X-Large (full width, tall)</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Inner columns</label>
                <select id="dashboards-group-columns" 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">
                  <option value="auto">Auto</option>
                  <option value="1">1</option>
                  <option value="2">2</option>
                  <option value="3">3</option>
                  <option value="4">4</option>
                </select>
              </div>
            </div>
            <div>
              <div class="flex items-center justify-between mb-1">
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300">Panel tiles</label>
                <button onclick="dashboardsGroupAddChild()" class="px-2 py-1 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer">+ Add tile</button>
              </div>
              <div id="dashboards-group-children" class="space-y-1"></div>
            </div>
          </div>
          <div class="flex items-center justify-end gap-2 px-5 py-4 border-t border-gray-200 dark:border-gray-700">
            <span id="dashboards-group-status" class="text-xs text-gray-500 dark:text-gray-400 mr-auto"></span>
            <button onclick="dashboardsGroupClose()" class="togen-control 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 cursor-pointer">Cancel</button>
            <button onclick="dashboardsGroupSave()" class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer">Save panel</button>
          </div>
        </div>
      </div>

      <!-- Markdown reference popup -->
      <div id="dashboards-md-help-overlay" class="hidden fixed inset-0 z-[110] flex items-center justify-center">
        <div class="absolute inset-0 bg-black/50" onclick="dashboardsCloseMdHelp()"></div>
        <div class="relative 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 mx-4">
          <div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Markdown reference</h2>
            <button onclick="dashboardsCloseMdHelp()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
          </div>
          <div class="p-5 text-sm text-gray-700 dark:text-gray-200 space-y-2">
            <p><code class="px-1 rounded bg-gray-100 dark:bg-gray-800"># Heading</code> &mdash; headings (<code>#</code> to <code>###</code>)</p>
            <p><code class="px-1 rounded bg-gray-100 dark:bg-gray-800">**bold**</code> &mdash; <strong>bold</strong></p>
            <p><code class="px-1 rounded bg-gray-100 dark:bg-gray-800">*italic*</code> &mdash; <em>italic</em></p>
            <p><code class="px-1 rounded bg-gray-100 dark:bg-gray-800">- item</code> &mdash; bulleted list</p>
            <p><code class="px-1 rounded bg-gray-100 dark:bg-gray-800">1. item</code> &mdash; numbered list</p>
            <p><code class="px-1 rounded bg-gray-100 dark:bg-gray-800">[text](https://url)</code> &mdash; link</p>
            <p><code class="px-1 rounded bg-gray-100 dark:bg-gray-800">| A | B |</code> &mdash; table (add a <code>| --- | --- |</code> row under the header, then one row per line)</p>
            <p class="text-xs text-gray-500 dark:text-gray-400 pt-1">Bare <code>https://</code> links become clickable automatically. HTML is escaped &mdash; only the syntax above is rendered. Use the toolbar buttons to insert any of these.</p>
          </div>
        </div>
      </div>

      <!-- Share modal (DVI-1196 P3) -->
      <div id="dashboards-share-overlay" class="hidden fixed inset-0 z-[110] flex items-center justify-center">
        <div class="absolute inset-0 bg-black/50" onclick="dashboardsCloseShare()"></div>
        <div class="relative 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 mx-4">
          <div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Share &ldquo;<span id="dashboards-share-name"></span>&rdquo;</h2>
            <button onclick="dashboardsCloseShare()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
          </div>
          <div class="p-5 space-y-4 text-sm">
            <div>
              <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Share with (view-only)</label>
              <textarea id="dashboards-share-users" rows="4" placeholder="email@icastinc.com&#10;one per line, comma, or space separated"
                        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"></textarea>
              <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">People you share with can view this dashboard read-only. Each viewer only sees tile data they already have access to in the source tool.</p>
            </div>
            <div class="flex items-center gap-2">
              <input id="dashboards-share-signage" type="checkbox" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal" />
              <label for="dashboards-share-signage" class="text-sm text-gray-700 dark:text-gray-200 cursor-pointer">Available for signage</label>
            </div>
            <p class="text-xs text-gray-500 dark:text-gray-400">Flagging a dashboard for signage lets an administrator assign it to a wall display. Assigning displays is a later phase.</p>
            <!-- DVI-1245: Virtual Display — the user's own fullscreen window as a presentable display -->
            <div class="pt-3 border-t border-gray-200 dark:border-gray-700">
              <div class="text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Fullscreen display</div>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Turn your Fullscreen view of this dashboard into a presentable display — set its interface and let others publish their dashboards to your window. Manage this in User Settings → Agents.</p>
              <button type="button" onclick="dashboardsManageVirtual()"
                class="togen-control 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 cursor-pointer">Set up fullscreen display</button>
            </div>
          </div>
          <div class="flex items-center justify-end gap-2 px-5 py-4 border-t border-gray-200 dark:border-gray-700">
            <span id="dashboards-share-status" class="text-xs text-gray-500 dark:text-gray-400 mr-auto"></span>
            <button onclick="dashboardsCloseShare()" class="togen-control 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 cursor-pointer">Cancel</button>
            <button onclick="dashboardsSaveShare()" class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer">Save</button>
          </div>
        </div>
      </div>

      <!-- Fullscreen settings dialog (DVI-1321 P1): configure how THIS dashboard
           opens in Fullscreen (zoom/columns/flow/fit/orientation/rows + use my
           interface), Remember it, and live-Update an already-open fullscreen tab. -->
      <div id="dashboards-fullscreen-overlay" class="hidden fixed inset-0 z-[110] flex items-center justify-center">
        <div class="absolute inset-0 bg-black/50" onclick="dashboardsFullscreenClose()"></div>
        <div class="relative 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 mx-4 max-h-[90vh] overflow-y-auto">
          <div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Fullscreen &ldquo;<span id="dashboards-fullscreen-name"></span>&rdquo;</h2>
            <button onclick="dashboardsFullscreenClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
          </div>
          <div class="p-5 space-y-4 text-sm">
            <p class="text-xs text-gray-500 dark:text-gray-400">Choose how this dashboard appears full-screen, then Open it. These settings apply only to your own Fullscreen view.</p>
            <div class="grid grid-cols-3 gap-3">
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Fit</label>
                <select id="dashboards-fullscreen-fit" 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">
                  <option value="scale">Scale to fit screen</option>
                  <option value="scroll">Scroll</option>
                  <option value="clip">Clip</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Orientation</label>
                <select id="dashboards-fullscreen-orientation" 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">
                  <option value="auto">Auto (detect)</option>
                  <option value="landscape">Landscape</option>
                  <option value="portrait">Portrait</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Columns</label>
                <select id="dashboards-fullscreen-columns" 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">
                  <option value="auto">Auto (12 / portrait 6)</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Zoom</label>
                <select id="dashboards-fullscreen-zoom" 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">
                  <option value="50">50%</option>
                  <option value="75">75%</option>
                  <option value="90">90%</option>
                  <option value="100">100% (normal)</option>
                  <option value="110">110%</option>
                  <option value="125">125%</option>
                  <option value="150">150%</option>
                  <option value="175">175%</option>
                  <option value="200">200%</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Flow</label>
                <select id="dashboards-fullscreen-flow" 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">
                  <option value="row">Rows (fill across)</option>
                  <option value="column">Columns (fill down)</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Rows</label>
                <select id="dashboards-fullscreen-rows" 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">
                  <option value="auto">Auto</option>
                </select>
              </div>
            </div>
            <p class="text-xs text-gray-500 dark:text-gray-400">&ldquo;Scale to fit&rdquo; sizes tiles so the whole dashboard fills the screen. &ldquo;Columns&rdquo; sets the grid width. &ldquo;Zoom&rdquo; scales everything up or down. &ldquo;Flow&rdquo; and &ldquo;Rows&rdquo; apply only to the Scroll/Clip fit modes.</p>
            <div class="flex items-center gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
              <input id="dashboards-fullscreen-use-interface" type="checkbox" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal" />
              <label for="dashboards-fullscreen-use-interface" class="text-sm text-gray-700 dark:text-gray-200 cursor-pointer">Use my interface settings (background image + dark/light mode)</label>
            </div>
            <p class="text-xs text-gray-500 dark:text-gray-400">On: the Fullscreen view uses your own background and theme instead of the corporate default.</p>
            <div class="flex items-center gap-2">
              <input id="dashboards-fullscreen-remember" type="checkbox" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal" />
              <label for="dashboards-fullscreen-remember" class="text-sm text-gray-700 dark:text-gray-200 cursor-pointer">Remember these settings for this dashboard</label>
            </div>
            <p id="dashboards-fullscreen-open-note" class="hidden text-xs text-togen-teal"></p>
          </div>
          <div class="flex items-center justify-end gap-2 px-5 py-4 border-t border-gray-200 dark:border-gray-700">
            <span id="dashboards-fullscreen-status" class="text-xs text-gray-500 dark:text-gray-400 mr-auto"></span>
            <button onclick="dashboardsFullscreenClose()" class="togen-control 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 cursor-pointer">Close</button>
            <button id="dashboards-fullscreen-update-btn" onclick="dashboardsFullscreenUpdate()" class="togen-control hidden px-3 py-1.5 text-xs font-medium rounded-lg border border-togen-teal/40 text-togen-teal hover:bg-togen-teal/10 cursor-pointer">Update open view</button>
            <button id="dashboards-fullscreen-open-btn" onclick="dashboardsFullscreenOpen()" class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer">Open</button>
          </div>
        </div>
      </div>

      <!-- Present modal (DVI-1204 P5): push this dashboard to a presentable display -->
      <div id="dashboards-present-overlay" class="hidden fixed inset-0 z-[110] flex items-center justify-center">
        <div class="absolute inset-0 bg-black/50" onclick="dashboardsClosePresent()"></div>
        <div class="relative 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 mx-4">
          <div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Present &ldquo;<span id="dashboards-present-name"></span>&rdquo;</h2>
            <button onclick="dashboardsClosePresent()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
          </div>
          <div class="p-5 space-y-4 text-sm">
            <p class="text-xs text-gray-500 dark:text-gray-400">Temporarily show this dashboard on a display. It reverts to its assigned dashboard when the timer ends or you press Stop.</p>
            <div id="dashboards-present-empty" class="togen-inset hidden text-sm text-gray-500 dark:text-gray-400 rounded-lg border border-dashed border-gray-300 dark:border-gray-700 p-4 text-center">
              No displays are available for you to present to. An administrator marks a display as presentable and adds you as an allowed presenter in Togen Admin &rarr; Agents &rarr; Display.
            </div>
            <div id="dashboards-present-form" class="hidden space-y-4">
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Display</label>
                <select id="dashboards-present-display" 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"></select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Duration</label>
                <select id="dashboards-present-duration" 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">
                  <option value="15">15 minutes</option>
                  <option value="30" selected>30 minutes</option>
                  <option value="60">1 hour</option>
                  <option value="0">Until stopped</option>
                </select>
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Zoom</label>
                <select id="dashboards-present-zoom" 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">
                  <option value="0" selected>Keep the display&rsquo;s zoom</option>
                  <option value="50">50%</option>
                  <option value="75">75%</option>
                  <option value="90">90%</option>
                  <option value="100">100%</option>
                  <option value="110">110%</option>
                  <option value="125">125%</option>
                  <option value="150">150%</option>
                  <option value="175">175%</option>
                  <option value="200">200%</option>
                </select>
              </div>
              <div class="grid grid-cols-2 gap-3">
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Columns</label>
                  <select id="dashboards-present-columns" 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">
                    <option value="keep" selected>Keep the display&rsquo;s columns</option>
                    <option value="auto">Auto (12 / portrait 6)</option>
                  </select>
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Flow</label>
                  <select id="dashboards-present-flow" 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">
                    <option value="keep" selected>Keep the display&rsquo;s flow</option>
                    <option value="row">Rows (fill across)</option>
                    <option value="column">Columns (fill down)</option>
                  </select>
                </div>
              </div>
              <label class="flex items-start gap-2 cursor-pointer">
                <input type="checkbox" id="dashboards-present-myiface" class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
                <span class="text-xs text-gray-600 dark:text-gray-300">Use my interface settings
                  <span class="block text-gray-400 dark:text-gray-500">Your theme, background and padding replace the display&rsquo;s for the duration of the present.</span>
                </span>
              </label>
              <label class="flex items-start gap-2 cursor-pointer">
                <input type="checkbox" id="dashboards-present-remember" class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
                <span class="text-xs text-gray-600 dark:text-gray-300">Remember these settings
                  <span class="block text-gray-400 dark:text-gray-500">Next time, Present starts on this display immediately with these settings.</span>
                </span>
              </label>
              <div id="dashboards-present-remember-note" class="hidden text-xs rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 p-2"></div>
            </div>
            <div id="dashboards-present-active" class="hidden rounded-lg border border-togen-teal/30 bg-togen-teal/5 p-3 space-y-2 text-xs"></div>
          </div>
          <div class="flex items-center justify-end gap-2 px-5 py-4 border-t border-gray-200 dark:border-gray-700">
            <span id="dashboards-present-status" class="text-xs text-gray-500 dark:text-gray-400 mr-auto"></span>
            <button onclick="dashboardsClosePresent()" class="togen-control 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 cursor-pointer">Close</button>
            <button id="dashboards-present-go" onclick="dashboardsStartPresent()" class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer">Present</button>
          </div>
        </div>
      </div>

      <!-- Data Sources admin panel (DVI-1312 P2): admin-configured sources a
           Stat tile row can bind to. Each becomes an entry in the Source picker. -->
      <div id="dashboards-sources-overlay" class="hidden fixed inset-0 z-[110] flex items-center justify-center">
        <div class="absolute inset-0 bg-black/50" onclick="dashboardsSourcesClose()"></div>
        <div class="relative togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col">
          <div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700">
            <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Data sources</h2>
            <button onclick="dashboardsSourcesClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
          </div>
          <!-- DVI-1370: tab bar (Sources = admin CRUD; Manual = tab-through value entry for everyone) -->
          <div class="flex gap-1 px-5 pt-3 togen-tabbar border-b border-gray-200 dark:border-gray-700">
            <button id="dashboards-sources-tab-sources" type="button" onclick="dashboardsSourcesTab('sources')"
                    class="px-3 py-2 text-xs font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 cursor-pointer">Sources</button>
            <button id="dashboards-sources-tab-manual" type="button" onclick="dashboardsSourcesTab('manual')"
                    class="px-3 py-2 text-xs font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 cursor-pointer">Manual</button>
          </div>
          <div id="dashboards-sources-pane-sources" class="p-5 space-y-4 text-sm overflow-y-auto">
            <p class="text-xs text-gray-500 dark:text-gray-400">Named data sources become selectable in a Stat / Metric tile row&rsquo;s <span class="font-medium">Source</span> picker (grouped under &ldquo;Data sources&rdquo;). A <span class="font-medium">SharePoint cell</span> source captures one value from a specific spreadsheet cell; a <span class="font-medium">report cell</span> source reduces a registered Reporting report to one value; a <span class="font-medium">manual</span> source is a value a person updates on the Manual tab. Both external types refresh on a cache interval.</p>
            <div id="dashboards-sources-list" class="space-y-2"></div>
            <button type="button" onclick="dashboardsSourceAdd()" class="text-xs text-togen-teal hover:underline cursor-pointer">+ Add a data source</button>

            <!-- Editor (add / edit one source) -->
            <div id="dashboards-source-form" class="hidden rounded-lg border border-gray-200 dark:border-gray-700 p-4 space-y-3 bg-gray-50 dark:bg-gray-800/40">
              <input type="hidden" id="dashboards-source-id" />
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Name (shown in the Source picker)</label>
                <input id="dashboards-source-name" maxlength="100" placeholder="e.g. Monthly production target"
                       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" />
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Source type</label>
                <select id="dashboards-source-type" onchange="dashboardsSourceTypeChanged()"
                        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">
                  <option value="sharepoint_cell">SharePoint spreadsheet cell</option>
                  <option value="report_cell">Report-derived value</option>
                  <option value="manual">Manual value (typed in by a person)</option>
                </select>
              </div>

              <!-- Manual value fields (DVI-1370) -->
              <div id="dashboards-source-manual-fields" class="hidden space-y-3">
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Starting value (optional)</label>
                  <input id="dashboards-source-value" maxlength="200" placeholder="e.g. 1200 or On track"
                         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" />
                  <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">A person updates this value on the <span class="font-medium">Manual</span> tab. A number is used for Stat color rules; any text is shown as-is.</p>
                </div>
              </div>

              <!-- SharePoint cell fields -->
              <div id="dashboards-source-sp-fields" class="space-y-3">
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">SharePoint file link</label>
                  <input id="dashboards-source-url" maxlength="2000" placeholder="Paste a sharing link to the .xlsx"
                         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" />
                  <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Use the file&rsquo;s &ldquo;Copy link&rdquo; sharing URL. The value is read read-only via Microsoft Graph.</p>
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Data selection</label>
                  <select id="dashboards-source-selectmode" onchange="dashboardsSourceSelectModeChanged()"
                          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 focus:ring-togen-teal">
                    <option value="fixed">Fixed cell</option>
                    <option value="lookup">By date logic</option>
                  </select>
                  <p class="text-xs text-gray-500 dark:text-gray-400 mt-1"><span class="font-medium">By date logic</span> computes which cell to read (today&rsquo;s / yesterday&rsquo;s row&hellip;) from a value column &mdash; no need to re-point the source each day.</p>
                </div>
                <div class="grid grid-cols-2 gap-3">
                  <div>
                    <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Sheet name</label>
                    <input id="dashboards-source-sheet" maxlength="100" placeholder="Sheet1"
                           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" />
                  </div>
                  <div id="dashboards-source-cell-wrap">
                    <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Cell</label>
                    <input id="dashboards-source-cell" maxlength="60" placeholder="B4"
                           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" />
                  </div>
                </div>

                <!-- By-date-logic fields (DVI-1319) -->
                <div id="dashboards-source-lookup-fields" class="hidden space-y-3 rounded-lg border border-gray-200 dark:border-gray-700 p-3 bg-white/50 dark:bg-gray-800/30">
                  <div class="grid grid-cols-2 gap-3">
                    <div>
                      <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Value column</label>
                      <input id="dashboards-source-valuecol" maxlength="100" placeholder="header, letter (B), or 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" />
                    </div>
                    <div id="dashboards-source-datecol-wrap">
                      <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Date column</label>
                      <input id="dashboards-source-datecol" maxlength="100" placeholder="header, letter (A), or 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" />
                    </div>
                  </div>
                  <div class="grid grid-cols-2 gap-3">
                    <div>
                      <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Which row</label>
                      <select id="dashboards-source-daterule" onchange="dashboardsSourceDateRuleChanged()"
                              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 focus:ring-togen-teal">
                        <option value="on_or_before">Most recent on or before&hellip;</option>
                        <option value="on_date">On a specific day&hellip;</option>
                        <option value="latest">Latest populated row</option>
                        <option value="this_week">This week</option>
                        <option value="last_n_days">Last N days</option>
                        <option value="month_to_date">Month to date</option>
                      </select>
                    </div>
                    <div id="dashboards-source-anchor-wrap">
                      <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Anchor day</label>
                      <select id="dashboards-source-dateanchor" onchange="dashboardsSourceDateRuleChanged()"
                              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 focus:ring-togen-teal">
                        <option value="today">Today</option>
                        <option value="yesterday">Yesterday</option>
                        <option value="days_ago">N days ago</option>
                        <option value="days_ahead">N days ahead</option>
                        <option value="exact">An exact date</option>
                      </select>
                    </div>
                  </div>
                  <div class="grid grid-cols-2 gap-3">
                    <div id="dashboards-source-offset-wrap" class="hidden">
                      <label id="dashboards-source-offset-label" class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">N (days)</label>
                      <input id="dashboards-source-dateoffset" type="number" min="0" max="3650" value="0"
                             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" />
                    </div>
                    <div id="dashboards-source-fixeddate-wrap" class="hidden">
                      <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Exact date</label>
                      <input id="dashboards-source-fixeddate" type="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" />
                    </div>
                    <div id="dashboards-source-reduce-wrap">
                      <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Combine matched rows</label>
                      <select id="dashboards-source-reduce"
                              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 focus:ring-togen-teal">
                        <option value="value">Read the cell (single row)</option>
                        <option value="sum">Sum</option>
                        <option value="avg">Average</option>
                        <option value="min">Minimum</option>
                        <option value="max">Maximum</option>
                        <option value="count">Count of rows</option>
                      </select>
                    </div>
                  </div>
                  <details class="text-xs">
                    <summary class="cursor-pointer text-gray-500 dark:text-gray-400">Advanced</summary>
                    <div class="grid grid-cols-3 gap-3 mt-2">
                      <div>
                        <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Header row</label>
                        <input id="dashboards-source-headerrow" type="number" min="1" max="1000" value="1"
                               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" />
                      </div>
                      <div>
                        <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Range (optional)</label>
                        <input id="dashboards-source-datarange" maxlength="60" placeholder="e.g. A1:D5000"
                               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" />
                      </div>
                      <div>
                        <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Timezone (optional)</label>
                        <input id="dashboards-source-tz" maxlength="60" placeholder="server local"
                               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" />
                      </div>
                    </div>
                    <p class="text-gray-500 dark:text-gray-400 mt-1">Defaults read the sheet&rsquo;s used range (first 5000 data rows) with the header on row 1. &ldquo;Today&rdquo; uses the server&rsquo;s local date unless an IANA timezone (e.g. <span class="font-mono">America/Denver</span>) is set.</p>
                  </details>
                  <p class="text-xs text-gray-500 dark:text-gray-400">Press <span class="font-medium">Test</span> to resolve the value and see which date/row it picked.</p>
                </div>
              </div>

              <!-- Report-derived cell fields -->
              <div id="dashboards-source-report-fields" class="hidden space-y-3">
                <div>
                  <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Report source</label>
                  <select id="dashboards-source-report"
                          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"></select>
                  <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Any registered Reporting/Auditor report. It runs under the viewer&rsquo;s identity, keeping its access rules.</p>
                </div>
                <div class="grid grid-cols-2 gap-3">
                  <div>
                    <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Value (column)</label>
                    <input id="dashboards-source-column" maxlength="100" placeholder="column name or 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" />
                  </div>
                  <div>
                    <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Row selector</label>
                    <select id="dashboards-source-rowmode" onchange="dashboardsSourceRowModeChanged()"
                            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">
                      <option value="first">First row</option>
                      <option value="last">Last row</option>
                      <option value="sum">Sum of column</option>
                      <option value="avg">Average of column</option>
                      <option value="min">Minimum of column</option>
                      <option value="max">Maximum of column</option>
                      <option value="count">Row count</option>
                    </select>
                  </div>
                </div>
                <div class="grid grid-cols-2 gap-3">
                  <div>
                    <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Match column (optional)</label>
                    <input id="dashboards-source-matchcol" maxlength="100" placeholder="e.g. Project"
                           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" />
                  </div>
                  <div>
                    <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">equals</label>
                    <input id="dashboards-source-matchval" maxlength="200" placeholder="e.g. Bridge A"
                           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" />
                  </div>
                </div>
                <p class="text-xs text-gray-500 dark:text-gray-400">Press <span class="font-medium">Test</span> to see the report&rsquo;s column names. A column can be a name or a 1-based number. The optional match filter picks the row(s) whose match column equals the value before the selector runs.</p>
              </div>

              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Refresh (seconds)</label>
                <input id="dashboards-source-ttl" type="number" min="30" max="86400" value="300"
                       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" />
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Who may see the value</label>
                <select id="dashboards-source-visibility" onchange="dashboardsSourceVisibilityChanged()"
                        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">
                  <option value="all">Any signed-in user</option>
                  <option value="admins">Administrators only</option>
                  <option value="list">Specific people</option>
                </select>
              </div>
              <div id="dashboards-source-emails-wrap" class="hidden">
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Allowed people</label>
                <textarea id="dashboards-source-emails" rows="3" placeholder="email@icastinc.com&#10;one per line, comma, or space separated"
                          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"></textarea>
                <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">A viewer without access sees that row redacted; the rest of the tile still renders.</p>
              </div>
              <div class="flex items-center gap-2">
                <button type="button" onclick="dashboardsSourceTest()" class="togen-control 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 cursor-pointer">Test</button>
                <span id="dashboards-source-test-out" class="text-xs text-gray-500 dark:text-gray-400"></span>
                <button type="button" onclick="dashboardsSourceCancel()" class="togen-control ml-auto 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 cursor-pointer">Cancel</button>
                <button type="button" onclick="dashboardsSourceApply()" class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer">Add to list</button>
              </div>
            </div>
          </div>

          <!-- Manual tab (DVI-1370): tab-through value entry, save on exit -->
          <div id="dashboards-sources-pane-manual" class="hidden p-5 space-y-4 text-sm overflow-y-auto">
            <p class="text-xs text-gray-500 dark:text-gray-400">Update a <span class="font-medium">manual</span> value below &mdash; type a value and press <span class="font-medium">Tab</span> to move to the next; each field <span class="font-medium">saves as you leave it</span>. Bound Stat / Metric tiles pick up the new value on their next refresh (within about a minute).</p>
            <div id="dashboards-manual-list" class="space-y-2"></div>
            <div class="flex items-end gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
              <div class="flex-1">
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Add a manual value</label>
                <input id="dashboards-manual-add-name" maxlength="100" placeholder="Name (e.g. Today&rsquo;s target)" onkeydown="dashboardsManualAddKey(event)"
                       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" />
              </div>
              <button type="button" onclick="dashboardsManualAdd()" class="px-3 py-2 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer whitespace-nowrap">+ Add manual value</button>
            </div>
            <span id="dashboards-manual-status" class="block text-xs text-gray-500 dark:text-gray-400"></span>
          </div>

          <div class="flex items-center justify-end gap-2 px-5 py-4 border-t border-gray-200 dark:border-gray-700">
            <span id="dashboards-sources-status" class="text-xs text-gray-500 dark:text-gray-400 mr-auto"></span>
            <button onclick="dashboardsSourcesClose()" class="togen-control 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 cursor-pointer">Close</button>
            <button id="dashboards-sources-save-btn" onclick="dashboardsSourcesSave()" class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer">Save changes</button>
          </div>
        </div>
      </div>
    </div><!-- /tool-dashboards -->

    <!-- ===== QC CONTENT (DVI-1326 P1: Batch Tickets) ===== -->
    <div id="tool-qc" class="tool-content hidden">
      <div class="max-w-6xl mx-auto space-y-6">

        <!-- Tabs: Batch Tickets + Reports (all users) · Mixtures + Settings (admin) -->
        <div id="qc-tabbar" class="togen-tabbar flex items-center gap-1 border-b border-gray-200 dark:border-gray-700">
          <button id="qc-tab-btn-batches" onclick="qcSwitchTab('batches')"
                  class="px-4 py-2 text-sm font-medium border-b-2 border-togen-teal text-togen-teal -mb-px">Batch Tickets</button>
          <button id="qc-tab-btn-daily" onclick="qcSwitchTab('daily')"
                  class="hidden 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">Daily Production</button>
          <button id="qc-tab-btn-reports" onclick="qcSwitchTab('reports')"
                  class="hidden 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">Reports</button>
          <button id="qc-tab-btn-mixtures" onclick="qcSwitchTab('mixtures')"
                  class="hidden 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">Mixtures</button>
          <button id="qc-tab-btn-import" onclick="qcSwitchTab('import')"
                  class="hidden 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">Import</button>
          <button id="qc-tab-btn-scan" onclick="qcSwitchTab('scan')"
                  class="hidden 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">Scan Tickets</button>
          <button id="qc-tab-btn-filebrowser" onclick="qcSwitchTab('filebrowser')"
                  class="hidden 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">File Browser</button>
          <button id="qc-tab-btn-settings" onclick="qcSwitchTab('settings')"
                  class="hidden 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">Settings</button>
        </div>

        <!-- ===== Batch Tickets tab ===== -->
        <div id="qc-tab-batches">
          <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">Batch Tickets</h2>
              <button onclick="qcBatchOpen(null)"
                      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>
                New Batch Ticket
              </button>
            </div>
            <div class="px-6 py-4">
              <div class="flex items-end gap-3 flex-wrap mb-4">
                <div>
                  <label for="qc-batch-date" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Date</label>
                  <input type="date" id="qc-batch-date" onchange="qcLoadBatches()"
                         class="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="qc-segment" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Production segment</label>
                  <select id="qc-segment" onchange="qcLoadBatches()"
                          class="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"></select>
                </div>
                <p class="text-xs text-gray-400 dark:text-gray-500 pb-2">Pipe Plant batches arrive via the daily Mix Log <strong>Import</strong> tab (source: import) and stay editable here; Block Plant later.</p>
              </div>
              <div id="qc-batch-list">
                <p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
              </div>
            </div>
          </div>
        </div><!-- /qc-tab-batches -->

        <!-- ===== Daily Production tab (DVI-1410 P3; mobile rework DVI-1422 P4) ===== -->
        <!-- Mobile-first: the ingredient <table> reflows to stacked cards on narrow
             screens via CSS (single input set — save reads the same rows either way,
             so the storage path is untouched). Large touch targets + a decimal
             keypad on every number field + a full-width Submit button on phones. -->
        <style>
        @media (max-width: 767px) {
          table.qc-dp-table, table.qc-dp-table tbody,
          table.qc-dp-table tr, table.qc-dp-table td { display: block; width: 100%; }
          table.qc-dp-table thead { display: none; }
          table.qc-dp-table tr {
            border: 1px solid rgba(148,163,184,.4); border-radius: .85rem;
            padding: .85rem 1rem; margin-bottom: .85rem; }
          table.qc-dp-table td { padding: .1rem 0; }
          table.qc-dp-table td.qc-dp-name {
            font-weight: 600; font-size: 1rem; margin-bottom: .5rem; }
          table.qc-dp-table .qc-dp-actual-wrap { display: flex; align-items: center; gap: .6rem; }
          table.qc-dp-table .qc-dp-actual {
            flex: 1 1 auto; min-width: 0; font-size: 1.1rem; padding: .75rem .85rem; }
          table.qc-dp-table .qc-dp-unit { font-size: .9rem; }
          table.qc-dp-table td.qc-dp-range-cell { margin-top: .4rem; }
        }
        </style>
        <div id="qc-tab-daily" class="hidden">
          <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">Daily Production</h2>
            </div>
            <div class="p-4 sm:p-6 space-y-4">
              <p class="text-xs text-gray-500 dark:text-gray-400">End-of-day totals for a continuous-batch segment. Enter Total Yards and one value per ingredient in the assigned mixture; saving records (or corrects) the day's entry.</p>
              <div class="flex flex-wrap items-end gap-4">
                <div>
                  <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Segment</label>
                  <select id="qc-dp-segment" onchange="qcDailyLoad()"
                          class="px-3 py-2.5 sm: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-base sm:text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal"></select>
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Date</label>
                  <input id="qc-dp-date" type="date" onchange="qcDailyLoad()"
                         class="px-3 py-2.5 sm: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-base sm:text-sm focus:outline-none focus:ring-2 focus:ring-togen-teal">
                </div>
                <div id="qc-dp-mixture" class="text-xs text-gray-500 dark:text-gray-400"></div>
              </div>
              <div id="qc-dp-banner" class="hidden"></div>
              <div id="qc-dp-form"></div>
              <p id="qc-dp-status" class="text-sm hidden"></p>
            </div>
          </div>
        </div><!-- /qc-tab-daily -->

        <!-- ===== Reports tab ===== -->
        <div id="qc-tab-reports" class="hidden">
          <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">Daily Totals</h2>
              <button onclick="qcReportExportCsv()"
                      class="px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors">Export CSV</button>
            </div>
            <div class="px-6 py-4">
              <div class="flex items-end gap-3 flex-wrap mb-4">
                <div>
                  <label for="qc-report-date" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Date</label>
                  <input type="date" id="qc-report-date" onchange="qcLoadReport()"
                         class="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="qc-report-segment" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Production segment</label>
                  <select id="qc-report-segment" onchange="qcLoadReport()"
                          class="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"></select>
                </div>
              </div>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Captured totals are in each material's capture unit (what the batch ticket shows); consumption totals convert to the ordering unit with each material's rounding setting.</p>
              <div id="qc-report-body">
                <p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
              </div>
            </div>
          </div>

          <!-- Date-range consumption totals (DVI-1326 P2) -->
          <div class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mt-6">
            <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">Consumption Totals (date range)</h2>
              <button onclick="qcRangeExportCsv()"
                      class="px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors">Export CSV</button>
            </div>
            <div class="px-6 py-4">
              <div class="flex items-end gap-3 flex-wrap mb-4">
                <div>
                  <label for="qc-range-start" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">From</label>
                  <input type="date" id="qc-range-start" onchange="qcLoadRangeReport()"
                         class="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="qc-range-end" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">To</label>
                  <input type="date" id="qc-range-end" onchange="qcLoadRangeReport()"
                         class="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="qc-range-segment" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Plant</label>
                  <select id="qc-range-segment" onchange="qcLoadRangeReport()"
                          class="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"></select>
                </div>
              </div>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Consumption totals over the selected range, in each material's ordering unit (ton/lb/gal) with its rounding setting. With <em>All plants</em> the totals split per production line. Materials with no capture in the range are omitted.</p>
              <div id="qc-range-body">
                <p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
              </div>
            </div>
          </div>
        </div><!-- /qc-tab-reports -->

        <!-- ===== Mixtures tab (admin) ===== -->
        <div id="qc-tab-mixtures" class="hidden">
          <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">Mixture Recipes</h2>
              <button onclick="qcMixtureOpen(null)"
                      class="px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors">Add Mixture</button>
            </div>
            <div class="px-6 py-4">
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">Targets are per-yd&sup3; dosing amounts in each material's capture unit, with &minus;/+ acceptable tolerances (the batch form scales them by the batch's yards). Editing targets creates a new version; captured batches keep the targets they were checked against.</p>
              <div id="qc-mixture-list">
                <p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
              </div>
            </div>
          </div>
        </div><!-- /qc-tab-mixtures -->

        <!-- ===== Import tab (admin) — daily Pipe Mix Log ===== -->
        <div id="qc-tab-import" class="hidden">
          <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">Pipe Mix Log Import</h2>
            </div>
            <div class="px-6 py-4 space-y-4">
              <p class="text-xs text-gray-500 dark:text-gray-400">Upload the Pipe plant's daily PLC <strong>Mix Log</strong> export (<code>.xlsx</code>). Each row becomes one Pipe batch (source: import) that stays editable on the Batch Tickets tab. Re-uploading the same day updates those batches — it does not duplicate them. Material columns are mapped onto the Materials list below; unmapped columns are shown, never silently dropped.</p>
              <div class="flex items-end gap-3 flex-wrap">
                <div>
                  <label for="qc-import-file" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Mix Log spreadsheet</label>
                  <input type="file" id="qc-import-file" accept=".xlsx,.xlsm" onchange="qcImportFileChanged()"
                         class="text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:px-3 file:py-2 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>
                <button id="qc-import-analyze-btn" onclick="qcImportAnalyze()" disabled
                        class="togen-control px-4 py-2 text-sm font-medium rounded-xl border border-togen-teal text-togen-teal hover:bg-togen-teal/10 transition-colors disabled:opacity-40 disabled:cursor-not-allowed">Analyze</button>
                <button id="qc-import-run-btn" onclick="qcImportRun()" disabled
                        class="px-5 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed">Import</button>
              </div>
              <p id="qc-import-status" class="text-sm hidden"></p>
              <div id="qc-import-mapping"></div>
              <div id="qc-import-report"></div>
            </div>
          </div>
        </div><!-- /qc-tab-import -->

        <!-- ===== Scan Tickets tab — OCR import (DVI-1374) ===== -->
        <div id="qc-tab-scan" class="hidden">
          <!-- Upload state -->
          <div id="qc-scan-upload" 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">Scan Batch Tickets</h2>
            </div>
            <div class="px-6 py-4 space-y-4">
              <p class="text-xs text-gray-500 dark:text-gray-400">Drag &amp; drop one or more scanned batch-ticket PDFs (or click to browse). Each page becomes one Batch Ticket: the page is shown on the left, OCR fills the form on the right, you validate/edit, then <strong>Save &amp; Next</strong> through every page. Saved batches (source: scan) stay editable on the Batch Tickets tab.</p>
              <div id="qc-scan-dropzone"
                   class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl px-6 py-10 text-center cursor-pointer hover:border-togen-teal transition-colors">
                <svg class="h-8 w-8 mx-auto mb-2 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.9A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M12 12v6m0-6l-2 2m2-2l2 2"/></svg>
                <p class="text-sm text-gray-600 dark:text-gray-300">Drag &amp; drop PDF ticket(s) here, or click to browse.</p>
                <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">PDF only. Every page is treated as a separate batch ticket.</p>
              </div>
              <input type="file" id="qc-scan-file" accept="application/pdf,.pdf" multiple class="hidden">
              <p id="qc-scan-upload-status" class="text-sm hidden"></p>

              <!-- DVI-1383 P1: per-file upload progress panel (mirrors the File Browser fb-upload-panel) -->
              <div id="qc-scan-upload-panel" class="hidden 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="qc-scan-upload-panel-title" class="text-sm font-semibold text-gray-700 dark:text-gray-200"></span>
                  <button type="button" onclick="qcScanCloseUploadPanel()" id="qc-scan-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="qc-scan-upload-rows" class="divide-y divide-gray-100 dark:divide-gray-700/60 max-h-72 overflow-y-auto"></div>
              </div>
            </div>
          </div>

          <!-- DVI-1460 P1: page review grid (shown after upload/import, before the
               per-page workspace) — thumbnails of every page, all selected by
               default, click to open full-size, deselect mis-scans, then Begin. -->
          <div id="qc-scan-review" class="hidden">
            <div class="flex items-center justify-between gap-3 flex-wrap mb-3">
              <div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300 flex-wrap">
                <span id="qc-scan-review-count" class="font-semibold"></span>
                <span class="text-xs text-gray-400 dark:text-gray-500">Click a page to view it full-size. Deselect mis-scans, duplicates or blanks, then Begin.</span>
              </div>
              <div class="flex items-center gap-2 flex-wrap">
                <button onclick="qcScanSelectAll()" class="togen-control px-2.5 py-1 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">Select all</button>
                <button onclick="qcScanSelectNone()" class="togen-control px-2.5 py-1 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">Select none</button>
                <button onclick="qcScanSelectInvert()" class="togen-control px-2.5 py-1 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">Invert</button>
                <button onclick="qcScanReset()" class="togen-control 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">Start over</button>
                <!-- DVI-1460 P2: pre-process OCR on every selected page (fills each
                     ticket in advance; nothing is saved until you review + save). -->
                <button id="qc-scan-preprocess-btn" onclick="qcScanPreprocess()" class="px-4 py-2 border border-togen-teal text-togen-teal hover:bg-togen-teal/10 font-semibold rounded-xl text-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed" title="OCR every selected page now and pre-fill each ticket. Nothing is saved until you step through and save each page.">Pre-process OCR</button>
                <button id="qc-scan-begin-btn" onclick="qcScanBegin()" class="px-5 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed">Begin Scan Tickets &rarr;</button>
              </div>
            </div>
            <!-- DVI-1460 P2: pre-processing progress + cancel (hidden until running). -->
            <div id="qc-scan-preprocess-bar" class="hidden mb-3 flex items-center gap-3">
              <div class="flex-1 h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
                <div id="qc-scan-preprocess-fill" class="h-full bg-togen-teal transition-all duration-200" style="width:0%"></div>
              </div>
              <span id="qc-scan-preprocess-text" class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
              <button id="qc-scan-preprocess-cancel" onclick="qcScanPreprocessCancel()" class="px-2.5 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">Cancel</button>
            </div>
            <div id="qc-scan-thumbs" class="grid gap-3"
                 style="grid-template-columns:repeat(auto-fill,minmax(150px,1fr));"></div>
          </div>

          <!-- DVI-1460 P1: full-page viewer (lightbox) opened from a thumbnail -->
          <div id="qc-scan-viewer" class="hidden fixed inset-0 z-[75] bg-black/80 flex flex-col">
            <div class="flex items-center justify-between gap-3 px-4 py-2 bg-gray-900/80 text-gray-100">
              <div class="flex items-center gap-3 text-sm min-w-0">
                <span id="qc-scan-viewer-title" class="font-semibold truncate"></span>
                <span id="qc-scan-viewer-pos" class="text-xs text-gray-400 flex-shrink-0"></span>
              </div>
              <div class="flex items-center gap-2 flex-shrink-0">
                <button id="qc-scan-viewer-select" onclick="qcScanViewerToggleSelect()" class="px-3 py-1.5 text-xs font-semibold rounded-lg transition-colors"></button>
                <button onclick="qcScanViewerRotate()" title="Rotate 90°" class="px-2.5 py-1.5 text-xs font-medium rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-100 transition-colors">Rotate &#8635;</button>
                <button onclick="qcScanViewerClose()" title="Back to pages (Esc)" class="px-2.5 py-1.5 text-xs font-medium rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-100 transition-colors">&#10005; Close</button>
              </div>
            </div>
            <div class="flex-1 relative overflow-auto flex items-center justify-center p-4">
              <button onclick="qcScanViewerNav(-1)" title="Previous page" class="absolute left-3 top-1/2 -translate-y-1/2 h-10 w-10 rounded-full bg-gray-800/70 hover:bg-gray-700 text-gray-100 text-lg flex items-center justify-center">&lsaquo;</button>
              <img id="qc-scan-viewer-img" alt="scanned ticket page" class="max-w-full max-h-full object-contain select-none">
              <button onclick="qcScanViewerNav(1)" title="Next page" class="absolute right-3 top-1/2 -translate-y-1/2 h-10 w-10 rounded-full bg-gray-800/70 hover:bg-gray-700 text-gray-100 text-lg flex items-center justify-center">&rsaquo;</button>
            </div>
          </div>

          <!-- Workspace state (split pane) -->
          <div id="qc-scan-workspace" class="hidden">
            <div class="flex items-center justify-between gap-3 flex-wrap mb-3">
              <div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
                <button id="qc-scan-prev" onclick="qcScanPrev()" title="Previous page"
                        class="togen-control px-2 py-1 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 disabled:opacity-40 disabled:cursor-not-allowed">&lsaquo; Prev</button>
                <button id="qc-scan-next" onclick="qcScanNext()" title="Next page (without saving)"
                        class="togen-control px-2 py-1 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 disabled:opacity-40 disabled:cursor-not-allowed">Next &rsaquo;</button>
                <span id="qc-scan-progress" class="font-semibold">Page 1 of 1</span>
                <span id="qc-scan-filename" class="text-xs text-gray-400 dark:text-gray-500"></span>
              </div>
              <div class="flex items-center gap-2 flex-wrap">
                <label for="qc-scan-segment" class="text-xs font-medium text-gray-500 dark:text-gray-400">Segment</label>
                <select id="qc-scan-segment" onchange="qcScanSegmentChanged()"
                        class="px-3 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"></select>
                <span id="qc-scan-template-badge" onclick="qcScanOcrPage()" role="button" tabindex="0"
                      title="Re-run OCR with this Region Template (e.g. after picking the Mixture)"
                      class="hidden cursor-pointer text-xs font-medium px-2 py-1 rounded-full bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 transition-colors"></span>
                <button id="qc-scan-backpages-btn" onclick="qcScanBackToPages()"
                        class="togen-control hidden 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" title="Return to the thumbnail grid — your selections and entries are kept">&lsaquo; Back to pages</button>
                <button onclick="qcScanReset()"
                        class="togen-control 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">Start over</button>
              </div>
            </div>
            <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 items-start">
              <!-- Left: page preview + region select -->
              <div class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700">
                <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between gap-2 flex-wrap">
                  <span class="text-xs font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wide">Ticket page</span>
                  <div class="flex items-center gap-2 flex-wrap">
                    <button id="qc-scan-ocr-btn" onclick="qcScanOcrPage()"
                            class="togen-control px-3 py-1.5 text-xs font-medium rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal/10 transition-colors" title="Re-read the page — uses the segment's Region Template when one is assigned. Run this after picking the Mixture.">Re-run OCR (Template)</button>
                    <button id="qc-scan-ocr-full-btn" onclick="qcScanOcrPage(true)"
                            class="togen-control 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" title="Re-read the whole page, ignoring the Region Template">Full-page OCR</button>
                    <button id="qc-scan-tpl-ov-btn" onclick="qcScanToggleTemplateOverlay()"
                            class="togen-control hidden 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" title="Show/hide where the Region Template reads each field, with its skew-tolerance margin">Show template regions</button>
                    <button id="qc-scan-region-btn" onclick="qcScanArmRegion()"
                            class="togen-control 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" title="Draw a box on the page, then pick the field to read it into">OCR Region</button>
                  </div>
                </div>
                <div class="px-3 py-3">
                  <p id="qc-scan-region-hint" class="hidden text-xs text-togen-teal mb-2">Draw a box around the value on the page…</p>
                  <div id="qc-scan-preview" class="relative overflow-auto max-h-[70vh] bg-gray-100 dark:bg-gray-800 rounded-lg">
                    <div class="relative inline-block">
                      <img id="qc-scan-img" alt="scanned ticket page" class="block max-w-full h-auto select-none">
                      <div id="qc-scan-tpl-ov" class="absolute inset-0" style="pointer-events:none;"></div>
                      <div id="qc-scan-region-ov" class="absolute inset-0" style="pointer-events:none;"></div>
                    </div>
                  </div>
                  <p id="qc-scan-preview-status" class="text-xs text-gray-400 dark:text-gray-500 mt-2 hidden"></p>
                </div>
              </div>
              <!-- Right: New Batch Ticket form -->
              <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-4 py-2">
                  <h3 class="text-sm font-bold text-white uppercase tracking-wide">New Batch Ticket</h3>
                </div>
                <div class="px-4 py-3 space-y-3">
                  <div class="grid grid-cols-2 gap-3">
                    <div>
                      <label for="qc-s-date" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Date <span class="text-red-500">*</span> <button type="button" onclick="qcScanRegionField('qc-s-date')" class="text-togen-teal text-[10px] align-middle" title="OCR a region into this field">◎</button></label>
                      <input type="date" id="qc-s-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 id="qc-s-field-batch_number">
                      <label for="qc-s-number" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Batch # <button type="button" onclick="qcScanRegionField('qc-s-number')" class="text-togen-teal text-[10px] align-middle" title="OCR a region into this field">◎</button></label>
                      <input type="text" id="qc-s-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>
                      <label for="qc-s-mixture" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Mixture</label>
                      <select id="qc-s-mixture" onchange="qcScanRenderLines()"
                              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"></select>
                    </div>
                    <div>
                      <label for="qc-s-yards" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Yards (yd&sup3;) <span class="text-red-500">*</span> <button type="button" onclick="qcScanRegionField('qc-s-yards')" class="text-togen-teal text-[10px] align-middle" title="OCR a region into this field">◎</button></label>
                      <input type="number" step="0.1" min="0" id="qc-s-yards" oninput="qcScanRenderLines()"
                             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 id="qc-s-field-rock_moisture">
                      <label for="qc-s-rock-moist" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Rock moisture % <button type="button" onclick="qcScanRegionField('qc-s-rock-moist')" class="text-togen-teal text-[10px] align-middle" title="OCR a region into this field">◎</button></label>
                      <input type="number" step="0.1" id="qc-s-rock-moist" 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 id="qc-s-field-sand_moisture">
                      <label for="qc-s-sand-moist" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Sand moisture % <button type="button" onclick="qcScanRegionField('qc-s-sand-moist')" class="text-togen-teal text-[10px] align-middle" title="OCR a region into this field">◎</button></label>
                      <input type="number" step="0.1" id="qc-s-sand-moist" 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 id="qc-s-field-initials">
                      <label for="qc-s-initials" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Initials</label>
                      <input type="text" id="qc-s-initials" 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 id="qc-s-field-notes">
                      <label for="qc-s-notes" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Notes</label>
                      <input type="text" id="qc-s-notes" 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>
                  <!-- DVI-1452 P2: admin-defined custom fields for this segment -->
                  <div id="qc-s-custom" class="grid grid-cols-2 gap-3"></div>
                  <div id="qc-scan-lines" class="pt-1"></div>
                  <p class="text-xs text-amber-600 dark:text-amber-400 hidden" id="qc-scan-oor-note">Amber values are outside the mixture's acceptable range — the ticket still saves; the exception is flagged for review.</p>
                  <p id="qc-scan-unmatched" class="text-xs text-amber-600 dark:text-amber-400 hidden"></p>
                  <details id="qc-scan-ocr-diag" class="hidden mt-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 text-xs">
                    <summary class="cursor-pointer select-none px-3 py-2 font-medium text-gray-600 dark:text-gray-300">Region OCR details <span id="qc-scan-ocr-diag-count" class="text-gray-400 dark:text-gray-500"></span></summary>
                    <div id="qc-scan-ocr-diag-body" class="px-3 pb-2 overflow-x-auto"></div>
                  </details>
                  <p id="qc-scan-status" class="text-sm hidden"></p>
                </div>
                <div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-2 flex-wrap">
                  <button id="qc-scan-back-btn" onclick="qcScanPrev()" class="togen-control px-3 py-2 text-sm font-medium 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 transition-colors disabled:opacity-40 disabled:cursor-not-allowed mr-auto" title="Go back to the previous page">&lsaquo; Back</button>
                  <button onclick="qcScanSkip()" class="togen-control px-3 py-2 text-sm font-medium 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 transition-colors" title="Advance without saving this page">Skip page</button>
                  <button onclick="qcScanSave(false)" class="togen-control px-4 py-2 text-sm font-semibold rounded-xl border border-togen-teal text-togen-teal hover:bg-togen-teal/10 transition-colors">Save</button>
                  <button id="qc-scan-save-next-btn" onclick="qcScanSave(true)" class="px-5 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors">Save &amp; Next</button>
                </div>
              </div>
            </div>
          </div>

          <!-- Region field picker (choose which field a drawn box reads into) -->
          <div id="qc-scan-fieldpick" 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-xs">
              <div class="bg-togen-teal rounded-t-2xl px-4 py-2"><h3 class="text-sm font-bold text-white uppercase tracking-wide">Read region into…</h3></div>
              <div class="px-4 py-3 space-y-2" id="qc-scan-fieldpick-body"></div>
              <div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 text-right">
                <button onclick="qcScanFieldPickClose()" class="togen-control px-3 py-1.5 text-sm 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">Cancel</button>
              </div>
            </div>
          </div>
        </div><!-- /qc-tab-scan -->

        <!-- ===== File Browser tab (qc.filebrowser) — DVI-1383 P3 ===== -->
        <div id="qc-tab-filebrowser" class="hidden">
          <div class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 space-y-4">
            <div class="flex items-center justify-between gap-4">
              <div>
                <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">File Browser</h2>
                <p class="text-xs text-gray-500 dark:text-gray-400">SharePoint PDFs available for Scan Tickets, per Production Segment.</p>
              </div>
            </div>
            <!-- one subtab per configured segment -->
            <div id="qc-fb-subtabs" class="togen-tabbar flex flex-wrap items-center gap-1 border-b border-gray-200 dark:border-gray-700"></div>
            <div id="qc-fb-controls"></div>
            <div id="qc-fb-breadcrumb" class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"></div>
            <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
              <div id="qc-fb-listing" class="min-h-[12rem] rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700/60 overflow-hidden"></div>
              <div class="rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden bg-gray-50 dark:bg-gray-800/40 flex flex-col">
                <div class="px-3 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700 truncate" id="qc-fb-preview-name">Select a PDF to preview</div>
                <iframe id="qc-fb-preview-frame" class="w-full h-[32rem] bg-white" title="PDF preview"></iframe>
              </div>
            </div>
          </div>
        </div><!-- /qc-tab-filebrowser -->

        <!-- ===== Settings tab (admin) ===== -->
        <div id="qc-tab-settings" class="hidden">
          <!-- DVI-1424: sub-tab bar splitting the Settings cards (pure
               reorganization; visibility follows the same qc.settings leaf). -->
          <div id="qc-settings-subtabbar" class="togen-tabbar flex items-center gap-1 border-b border-gray-200 dark:border-gray-700 mb-5">
            <button id="qc-set-btn-materials" onclick="qcSettingsSubtab('materials')"
                    class="px-4 py-2 text-sm font-medium border-b-2 border-togen-teal text-togen-teal -mb-px">Materials</button>
            <button id="qc-set-btn-segments" onclick="qcSettingsSubtab('segments')"
                    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">Segments</button>
            <button id="qc-set-btn-reporting" onclick="qcSettingsSubtab('reporting')"
                    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">Reporting</button>
            <button id="qc-set-btn-ocr" onclick="qcSettingsSubtab('ocr')"
                    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">OCR</button>
            <button id="qc-set-btn-files" onclick="qcSettingsSubtab('files')"
                    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">Files</button>
            <button id="qc-set-btn-daily" onclick="qcSettingsSubtab('daily')"
                    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">Daily Production</button>
            <button id="qc-set-btn-batchtickets" onclick="qcSettingsSubtab('batchtickets')"
                    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">Batch Tickets</button>
          </div>

          <!-- Materials sub-tab -->
          <div id="qc-set-pane-materials">
          <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">Materials</h2>
              <button onclick="qcMaterialOpen(null)"
                      class="px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors">Add Material</button>
            </div>
            <div class="px-6 py-4">
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">Materials, chemicals, and VMAs tracked on batch tickets. A material referenced by captured batches or recipes can be deactivated but not deleted. Mixture and material management is Togen-Admin-only for now; an Azure AD group takes over in a later phase.</p>
              <div id="qc-material-list">
                <p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
              </div>
            </div>
          </div>
          </div><!-- /qc-set-pane-materials -->

          <!-- Production Segments sub-tab (DVI-1452 P1) — admin-managed segment
               registry replacing the hardcoded wet_cast/pipe/block. Add / rename
               / reorder / deactivate; each segment auto-gets a per-segment Daily
               Production access leaf (Togen Admin → Users → Access). -->
          <div id="qc-set-pane-segments" class="hidden">
          <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">Production Segments</h2>
              <button onclick="qcSegmentAdd()"
                      class="px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors">Add Segment</button>
            </div>
            <div class="px-6 py-4 space-y-4">
              <p class="text-xs text-gray-500 dark:text-gray-400">Production Segments (Wet Cast, Pipe Plant, Block Plant, …) scope batches, mixtures, Daily Production and reporting. Adding a segment here is all it takes — no code change. A segment's <strong>name</strong> is editable; its internal id is fixed once created. A segment referenced by batches or mixtures can be <strong>deactivated</strong> (hidden from new capture) but not deleted, so history never breaks. After adding a segment, grant operators its <em>Daily Production</em> leaf in <em>Togen Admin → Users → Access</em>.</p>
              <div id="qc-seg-list" class="space-y-2">
                <p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
              </div>
              <span id="qc-seg-status" class="text-sm hidden"></span>
            </div>
          </div>
          </div><!-- /qc-set-pane-segments -->

          <!-- Reporting sub-tab (DVI-1424) — report-content options for the
               range/ordering ("Consumption Totals") sheet; extension point for
               future report options. Stored in the additive report_options meta
               key (aggregate display mode is stored on the material). -->
          <div id="qc-set-pane-reporting" class="hidden">
          <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">Reporting</h2>
              <button onclick="qcReportOptionsSave()"
                      class="px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors">Save</button>
            </div>
            <div class="px-6 py-4 space-y-5">
              <p class="text-xs text-gray-500 dark:text-gray-400">Control what appears on the consumption / ordering report (<strong>Reports → Consumption Totals</strong>). These options change only how the report is shown — captured batch data is never altered.</p>
              <div>
                <label class="flex items-start gap-2">
                  <input type="checkbox" id="qc-ro-show-zero" class="mt-0.5 rounded border-gray-300 text-togen-teal focus:ring-togen-teal">
                  <span class="text-sm text-gray-700 dark:text-gray-300">Show zero-capture materials
                    <span class="block text-xs text-gray-500 dark:text-gray-400">By default the range report lists only materials captured in the window. Turn this on to also show materials with no capture (still subject to the per-material toggle below).</span>
                  </span>
                </label>
              </div>
              <div>
                <h3 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">Materials included in reports</h3>
                <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Uncheck a material to hide it from the ordering sheet (useful for materials tracked on batch tickets but never ordered from this report). An <span class="font-semibold text-togen-teal">Aggregate</span> also chooses how its children appear on the report.</p>
                <div id="qc-ro-materials">
                  <p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
                </div>
              </div>
              <div class="flex items-center gap-3">
                <button onclick="qcReportOptionsSave()"
                        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>
                <span id="qc-ro-status" class="text-sm hidden"></span>
              </div>
            </div>
          </div>
          </div><!-- /qc-set-pane-reporting -->

          <!-- OCR sub-tab -->
          <div id="qc-set-pane-ocr" class="hidden">
          <!-- OCR Backend (DVI-1374) — QC-scoped, defaults to the global OCR backend -->
          <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 Backend</h2>
            </div>
            <div class="px-6 py-4 space-y-3">
              <p class="text-xs text-gray-500 dark:text-gray-400">Which OCR engine the <strong>Scan Tickets</strong> tab uses to read ticket pages. <em>Claude Vision</em> reads the whole page into structured fields; <em>Local</em> (Tesseract) OCRs the page and best-effort parses labelled lines. Leave on the company default to follow the OCR tool's setting, or override just for Batch Production.</p>
              <div>
                <label for="qc-ocr-backend-select" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Backend</label>
                <select id="qc-ocr-backend-select" onchange="qcOcrBackendDesc()"
                        class="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"></select>
              </div>
              <p id="qc-ocr-backend-desc" class="text-xs text-gray-500 dark:text-gray-400"></p>
              <div class="flex items-center gap-3">
                <button id="qc-ocr-backend-save" onclick="qcOcrBackendSave()"
                        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>
                <span id="qc-ocr-backend-status" class="text-sm hidden"></span>
              </div>
            </div>
          </div>

          <!-- OCR Region Templates (DVI-1442) — builder + per-segment assignment -->
          <div class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mt-6">
            <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
              <h2 class="text-sm font-bold text-white uppercase tracking-wide">OCR Region Templates</h2>
            </div>
            <div class="px-6 py-4 space-y-4">
              <p class="text-xs text-gray-500 dark:text-gray-400">Draw a rectangle over each field on a sample ticket so <strong>Scan Tickets</strong> reads that field from exactly that spot instead of the whole page. Build a template, then assign it to a Production Segment below. Templated fields are read with a targeted OCR call (per the OCR Backend above); un-templated fields fall back to whole-page OCR, and a region that reads nothing simply leaves the field blank for manual entry.</p>
              <div class="flex flex-wrap items-end gap-3">
                <div>
                  <label for="qc-tpl-select" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Template</label>
                  <select id="qc-tpl-select" onchange="qcTplSelect(this.value)"
                          class="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"></select>
                </div>
                <button onclick="qcTplNew()"
                        class="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 togen-control">+ New template</button>
                <span id="qc-tpl-status" class="text-sm hidden"></span>
              </div>

              <div id="qc-tpl-builder" class="hidden space-y-4">
                <div class="flex flex-wrap items-end gap-3">
                  <div class="flex-1 min-w-[12rem]">
                    <label for="qc-tpl-name" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Template name</label>
                    <input id="qc-tpl-name" type="text" placeholder="e.g. Wet Cast 2026 ticket"
                           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>
                  <button onclick="qcTplSave()"
                          class="px-5 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors">Save template</button>
                  <button onclick="qcTplDelete()"
                          class="px-4 py-2 rounded-lg border border-red-300 dark:border-red-700 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 togen-control">Delete</button>
                </div>

                <div class="grid md:grid-cols-2 gap-4">
                  <!-- sample page + region overlay -->
                  <div>
                    <div class="flex items-center gap-3 mb-2">
                      <input type="file" id="qc-tpl-file" accept="application/pdf" class="hidden" onchange="qcTplUploadSample(this.files)">
                      <button onclick="document.getElementById('qc-tpl-file').click()"
                              class="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 togen-control">Upload sample page</button>
                      <span id="qc-tpl-sample-status" class="text-xs text-gray-500 dark:text-gray-400"></span>
                    </div>
                    <div id="qc-tpl-stage" class="relative border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 min-h-[8rem]">
                      <img id="qc-tpl-img" class="block w-full select-none" alt="" draggable="false">
                      <div id="qc-tpl-region-ov" class="absolute inset-0" style="pointer-events:none;"></div>
                    </div>
                    <p class="text-[11px] text-gray-400 dark:text-gray-500 mt-1">Upload a representative scanned ticket, pick a field on the right, then Draw region to box where that field sits.</p>
                  </div>

                  <!-- field picker + draw/test controls -->
                  <div class="space-y-3">
                    <div>
                      <label for="qc-tpl-field" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Field</label>
                      <select id="qc-tpl-field"
                              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"></select>
                    </div>
                    <div class="flex flex-wrap items-center gap-2">
                      <button id="qc-tpl-draw-btn" onclick="qcTplDrawRegion()"
                              class="px-4 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-lg text-sm transition-colors">Draw region</button>
                      <button onclick="qcTplTestRegion()"
                              class="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 togen-control">Test this region</button>
                      <button onclick="qcTplClearField()"
                              class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 togen-control">Clear field</button>
                    </div>
                    <div id="qc-tpl-test-out" class="hidden text-xs rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2 bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300"></div>
                    <div>
                      <p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Regions in this template</p>
                      <div id="qc-tpl-fieldlist" class="space-y-1"></div>
                    </div>
                  </div>
                </div>
              </div>
            </div>
          </div>

          <!-- Segment assignment -->
          <div class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mt-6">
            <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">Template Assignment</h2>
              <button onclick="qcTplAssignSave()"
                      class="px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors">Save</button>
            </div>
            <div class="px-6 py-4 space-y-4">
              <p class="text-xs text-gray-500 dark:text-gray-400">Pick which template each Production Segment's scanned tickets use. Leave a segment on <em>None</em> to keep whole-page OCR. The OCR Backend above applies — with Claude Vision, template extraction makes one small vision call per boxed region, so a template can also reduce cost if you box only the fields you need.</p>
              <div id="qc-tpl-assign-list" class="space-y-3">
                <p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
              </div>
              <div class="flex items-center gap-3">
                <button onclick="qcTplAssignSave()"
                        class="px-5 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm transition-colors">Save assignments</button>
                <span id="qc-tpl-assign-status" class="text-sm hidden"></span>
              </div>
            </div>
          </div>
          </div><!-- /qc-set-pane-ocr -->

          <!-- Files sub-tab -->
          <div id="qc-set-pane-files" class="hidden">
          <!-- SharePoint paths (DVI-1383 P2) — per-segment folder for the QC File Browser -->
          <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">SharePoint paths (File Browser)</h2>
            </div>
            <div class="px-6 py-4 space-y-4">
              <p class="text-xs text-gray-500 dark:text-gray-400">Paste a SharePoint <strong>Copy link</strong> URL for each Production Segment's folder of ticket PDFs. Users with <em>File Browser</em> access see a subtab per segment that has a folder here, and can open a PDF into Scan Tickets. Leave a link blank to remove that segment's folder. Use <strong>Test</strong> to confirm the app can list the folder.</p>
              <div id="qc-fb-paths-list" class="space-y-4">
                <p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
              </div>
            </div>
          </div>
          </div><!-- /qc-set-pane-files -->

          <!-- Daily Production sub-tab -->
          <div id="qc-set-pane-daily" class="hidden">
          <!-- Daily Production (DVI-1410 P3) — enable a segment + assign a mixture -->
          <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">Daily Production</h2>
            </div>
            <div class="px-6 py-4 space-y-4">
              <p class="text-xs text-gray-500 dark:text-gray-400">For a segment that runs one continuous batch all day (e.g. the Block Plant), turn on <strong>Daily Production</strong> and assign the <strong>Mixture</strong> whose recipe the operator's daily form should follow. Authorize operators per segment in <em>Togen Admin → Users → Access</em> (Daily Production — &lt;segment&gt;). Changing the mixture updates tomorrow's form; captured days keep their values.</p>
              <div id="qc-dp-config-list" class="space-y-4">
                <p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
              </div>
              <div class="flex items-center gap-3">
                <button onclick="qcDpConfigSave()"
                        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>
                <span id="qc-dp-config-status" class="text-sm hidden"></span>
              </div>
            </div>
          </div>
          </div><!-- /qc-set-pane-daily -->

          <!-- Batch Tickets sub-tab (DVI-1441) — per-segment tunables for which
               optional Batch Ticket fields appear on the capture forms. Default
               all-enabled; Date / Mixture / Yards / the material grid stay
               always-on. Display-only: hidden fields are still server-accepted. -->
          <div id="qc-set-pane-batchtickets" class="hidden">
          <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">Batch Ticket Fields</h2>
              <button onclick="qcBatchFieldsSave()"
                      class="px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors">Save</button>
            </div>
            <div class="px-6 py-4 space-y-4">
              <p class="text-xs text-gray-500 dark:text-gray-400">Choose which optional fields appear on the <strong>Batch Ticket</strong> and <strong>Scan Tickets</strong> capture forms, per Production Segment. Uncheck a field to hide it for that segment. <strong>Date</strong>, <strong>Mixture</strong>, <strong>Yards</strong> and the material grid are always shown. Hiding a field only changes the form — previously captured values and Pipe / PLC imports are never altered.</p>
              <div id="qc-btf-list" class="space-y-4">
                <p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
              </div>
              <div class="flex items-center gap-3">
                <button onclick="qcBatchFieldsSave()"
                        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>
                <span id="qc-btf-status" class="text-sm hidden"></span>
              </div>
            </div>
          </div>

          <!-- DVI-1452 P2: custom Batch Ticket fields manager -->
          <div class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mt-6">
            <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">Custom Fields</h2>
              <button onclick="qcCustomFieldEdit(null)"
                      class="px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors">+ Add Field</button>
            </div>
            <div class="px-6 py-4 space-y-4">
              <p class="text-xs text-gray-500 dark:text-gray-400">Add your own fields to the <strong>Batch Ticket</strong> and <strong>Scan Tickets</strong> forms. Each field can be scoped to specific Production Segments (or all), typed (text, number, date, dropdown, checkbox), and marked required. <strong>Required is a soft flag</strong> — it is surfaced but never blocks a save, matching the Batch Production "flags never block" rule. Values are stored per batch; a field with captured values can be deactivated (hidden from new tickets) but not deleted.</p>
              <div id="qc-cf-list" class="space-y-2">
                <p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
              </div>
              <span id="qc-cf-status" class="text-sm hidden"></span>
            </div>
          </div>

          <!-- DVI-1460 P3: Scan Tickets settings (thumbnail size + auto-OCR on load) -->
          <div class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 mt-6">
            <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">Scan Tickets</h2>
              <button onclick="qcScanSettingsSave()"
                      class="px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors">Save</button>
            </div>
            <div class="px-6 py-4 space-y-5">
              <p class="text-xs text-gray-500 dark:text-gray-400">Controls for the <strong>Scan Tickets</strong> thumbnail review grid — the page preview shown after you upload PDFs or import from the File Browser.</p>
              <div>
                <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Thumbnail size</label>
                <div class="flex items-center gap-3">
                  <input id="qc-scan-thumb-size" type="range" min="100" max="320" step="10" value="150"
                         oninput="document.getElementById('qc-scan-thumb-size-val').textContent = this.value + 'px';"
                         class="w-64 accent-togen-teal">
                  <span id="qc-scan-thumb-size-val" class="text-sm text-gray-600 dark:text-gray-300 tabular-nums">150px</span>
                </div>
                <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Bigger thumbnails are easier to read; smaller ones fit more pages per row.</p>
              </div>
              <div>
                <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input id="qc-scan-auto-ocr" type="checkbox"
                         class="rounded border-gray-300 text-togen-teal focus:ring-togen-teal">
                  Automatically pre-process OCR when the review grid loads
                </label>
                <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">When on, uploading/importing pages immediately OCRs every selected page and pre-fills each ticket (nothing is saved until you review + Save &amp; Next). You can still cancel a run in progress.</p>
              </div>
              <div class="flex items-center gap-3">
                <button onclick="qcScanSettingsSave()"
                        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>
                <span id="qc-scan-settings-status" class="text-sm hidden"></span>
              </div>
            </div>
          </div>
          </div><!-- /qc-set-pane-batchtickets -->

          <!-- DVI-1452 P2: custom field add/edit modal -->
          <div id="qc-cf-modal" class="hidden fixed inset-0 z-[70] flex items-start justify-center bg-black/50 px-4 py-8 overflow-y-auto">
            <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">
              <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
                <h3 id="qc-cf-modal-title" class="text-sm font-bold text-white uppercase tracking-wide">Custom Field</h3>
              </div>
              <div class="px-6 py-4 space-y-3">
                <div>
                  <label for="qc-cf-label" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Field name <span class="text-red-500">*</span></label>
                  <input type="text" id="qc-cf-label" 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 for="qc-cf-type" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Type</label>
                    <select id="qc-cf-type" onchange="qcCustomFieldTypeChanged()" 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="text">Text</option>
                      <option value="number">Number</option>
                      <option value="date">Date</option>
                      <option value="dropdown">Dropdown</option>
                      <option value="checkbox">Checkbox</option>
                    </select>
                  </div>
                  <div class="flex items-end gap-4 pb-1">
                    <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                      <input type="checkbox" id="qc-cf-required" class="rounded border-gray-300 text-togen-teal focus:ring-togen-teal">Required (soft flag)</label>
                    <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                      <input type="checkbox" id="qc-cf-active" checked class="rounded border-gray-300 text-togen-teal focus:ring-togen-teal">Active</label>
                  </div>
                </div>
                <div id="qc-cf-choices-wrap" class="hidden">
                  <label for="qc-cf-choices" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Dropdown choices (one per line)</label>
                  <textarea id="qc-cf-choices" 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"></textarea>
                </div>
                <div>
                  <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Show on segments <span class="text-gray-400">(none = all)</span></label>
                  <div id="qc-cf-segments" class="flex flex-wrap gap-x-6 gap-y-2"></div>
                </div>
                <p id="qc-cf-modal-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-2">
                <button onclick="qcCustomFieldModalClose()" class="togen-control 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 onclick="qcCustomFieldSave()" 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><!-- /qc-tab-settings -->

      </div>

      <!-- Batch ticket entry modal -->
      <div id="qc-batch-modal" class="hidden fixed inset-0 z-[60] flex items-start justify-center bg-black/50 px-4 py-8 overflow-y-auto">
        <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-3xl">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h3 id="qc-batch-modal-title" class="text-sm font-bold text-white uppercase tracking-wide">Batch Ticket</h3>
          </div>
          <div class="px-6 py-4 space-y-3">
            <div class="grid grid-cols-2 md:grid-cols-4 gap-3">
              <div>
                <label for="qc-b-date" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Date <span class="text-red-500">*</span></label>
                <input type="date" id="qc-b-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 id="qc-b-field-batch_number">
                <label for="qc-b-number" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Batch #</label>
                <input type="text" id="qc-b-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>
                <label for="qc-b-mixture" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Mixture</label>
                <select id="qc-b-mixture" onchange="qcBatchRenderLines()"
                        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"></select>
              </div>
              <div>
                <label for="qc-b-yards" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Yards (yd&sup3;) <span class="text-red-500">*</span></label>
                <input type="number" step="0.1" min="0" id="qc-b-yards" oninput="qcBatchRenderLines()"
                       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 md:grid-cols-4 gap-3">
              <div id="qc-b-field-rock_moisture">
                <label for="qc-b-rock-moist" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Rock moisture %</label>
                <input type="number" step="0.1" id="qc-b-rock-moist" 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 id="qc-b-field-sand_moisture">
                <label for="qc-b-sand-moist" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Sand moisture %</label>
                <input type="number" step="0.1" id="qc-b-sand-moist" 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 id="qc-b-field-initials">
                <label for="qc-b-initials" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Initials</label>
                <input type="text" id="qc-b-initials" 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 id="qc-b-field-notes">
                <label for="qc-b-notes" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Notes</label>
                <input type="text" id="qc-b-notes" 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>
            <!-- DVI-1452 P2: admin-defined custom fields for this segment -->
            <div id="qc-b-custom" class="grid grid-cols-2 md:grid-cols-4 gap-3"></div>
            <div id="qc-batch-lines" class="pt-1"></div>
            <p class="text-xs text-amber-600 dark:text-amber-400 hidden" id="qc-batch-oor-note">Amber values are outside the mixture's acceptable range — the ticket still saves; the exception is flagged for review.</p>
            <p id="qc-batch-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-2 flex-wrap">
            <button onclick="qcBatchClose()" class="togen-control 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="qc-batch-save-next-btn" onclick="qcBatchSave(true)" class="togen-control px-4 py-2 text-sm font-semibold rounded-xl border border-togen-teal text-togen-teal hover:bg-togen-teal/10 transition-colors">Save &amp; Next</button>
            <button onclick="qcBatchSave(false)" 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>

      <!-- Mixture edit modal (admin) -->
      <div id="qc-mixture-modal" class="hidden fixed inset-0 z-[60] flex items-start justify-center bg-black/50 px-4 py-8 overflow-y-auto">
        <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-2xl">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h3 id="qc-mixture-modal-title" class="text-sm font-bold text-white uppercase tracking-wide">Mixture</h3>
          </div>
          <div class="px-6 py-4 space-y-3">
            <div class="grid grid-cols-2 md:grid-cols-3 gap-3">
              <div>
                <label for="qc-m-name" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Name <span class="text-red-500">*</span></label>
                <input type="text" id="qc-m-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 for="qc-m-segment" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Segment</label>
                <select id="qc-m-segment" 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"></select>
              </div>
              <div>
                <label for="qc-m-mixcode" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Mix code</label>
                <input type="text" id="qc-m-mixcode" placeholder="optional" 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-[11px] text-gray-400 dark:text-gray-500 mt-1">Pipe import matches the Mix Log recipe code to this.</p>
              </div>
              <div class="flex items-end pb-2">
                <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
                  <input type="checkbox" id="qc-m-active" class="rounded border-gray-300 text-togen-teal focus:ring-togen-teal" checked>
                  Active
                </label>
              </div>
            </div>
            <div id="qc-mixture-targets"></div>
            <p id="qc-mixture-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-2">
            <button onclick="qcMixtureClose()" class="togen-control 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 onclick="qcMixtureSave()" 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>

      <!-- Material edit modal (admin) -->
      <div id="qc-material-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 id="qc-material-modal-title" class="text-sm font-bold text-white uppercase tracking-wide">Material</h3>
          </div>
          <div class="px-6 py-4 space-y-3">
            <div>
              <label for="qc-mat-name" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Name <span class="text-red-500">*</span></label>
              <input type="text" id="qc-mat-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="grid grid-cols-2 gap-3">
              <div>
                <label for="qc-mat-capture" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Capture unit</label>
                <select id="qc-mat-capture" onchange="qcMaterialCaptureChanged()" 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="lb">Pounds (lb)</option>
                  <option value="oz">Ounces (oz)</option>
                  <option value="gal">Gallons (gal)</option>
                </select>
              </div>
              <div>
                <label for="qc-mat-consumption" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Consumption unit</label>
                <select id="qc-mat-consumption" onchange="qcMaterialAggUnitChanged()" 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"></select>
              </div>
            </div>
            <div class="grid grid-cols-2 gap-3">
              <div>
                <label for="qc-mat-decimals" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Consumption rounding</label>
                <select id="qc-mat-decimals" 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="0">Whole units</option>
                  <option value="1">1 decimal</option>
                  <option value="2">2 decimals</option>
                  <option value="3">3 decimals</option>
                </select>
              </div>
              <div>
                <label for="qc-mat-sort" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Sort order</label>
                <input type="number" id="qc-mat-sort" 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="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
              <input type="checkbox" id="qc-mat-active" class="rounded border-gray-300 text-togen-teal focus:ring-togen-teal" checked>
              Active (available on new batch tickets)
            </label>
            <!-- DVI-1423 P6: report-only aggregated material -->
            <div class="pt-3 border-t border-gray-200 dark:border-gray-700">
              <label class="inline-flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
                <input type="checkbox" id="qc-mat-is-aggregate" onchange="qcMaterialAggregateToggle()" class="rounded border-gray-300 text-togen-teal focus:ring-togen-teal">
                Aggregates other materials (report-only total)
              </label>
              <div id="qc-mat-aggregate-section" class="hidden mt-3 space-y-3">
                <p class="text-xs text-gray-500 dark:text-gray-400">A report-only total that sums the selected materials. It never appears on batch tickets, mixtures, imports, or Daily Production &mdash; its value is always computed. Children must consume in the same unit as this material (<span id="qc-mat-agg-unit" class="font-medium">—</span>).</p>
                <div>
                  <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Materials to total</label>
                  <div id="qc-mat-agg-children" class="max-h-44 overflow-y-auto rounded-lg border border-gray-300 dark:border-gray-600 p-2 space-y-1 text-sm"></div>
                </div>
                <div>
                  <label for="qc-mat-agg-mode" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">On reports</label>
                  <select id="qc-mat-agg-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="both">Show the total and its materials</option>
                    <option value="aggregate_only">Show only the total (fold the materials out)</option>
                  </select>
                </div>
              </div>
            </div>
            <p id="qc-material-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-2">
            <button onclick="qcMaterialClose()" class="togen-control 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 onclick="qcMaterialSave()" 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-qc -->

    <!-- ===== QC TILE SURFACE (DVI-1519 P3 / DVI-1522) — the `quality` tool.
         A Tile Surface whose catalog tiles CROSS-NAVIGATE to the Batch Production
         (`qc`) and Kiln Metrics (`scada-view`) tools. No sub-tabs of its own; the
         tiles page is the whole tool. Full Add/Edit stack rides the shared
         framework (_editCtx / _seRenderEditGrid). ===== -->
    <div id="tool-quality" class="tool-content hidden">
      <div id="qs-tiles-grid">
        <div class="flex items-center justify-between gap-3 mb-5 flex-wrap">
          <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">QC</h2>
          <div class="flex items-center gap-2 flex-wrap">
            <div id="qs-edit-toolbar" class="hidden flex items-center gap-2 flex-wrap">
              <span id="qs-edit-toast" class="text-xs font-medium" aria-live="polite"></span>
              {% if is_togen_admin %}
              <div id="qs-scope-switch" class="inline-flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden text-xs font-medium" title="Which list you are editing">
                <button type="button" id="qs-scope-user" onclick="qsEditSetScope('user')" class="px-2.5 py-1 cursor-pointer bg-togen-teal text-white">My list</button>
                <button type="button" id="qs-scope-admin" onclick="qsEditSetScope('admin')" class="px-2.5 py-1 cursor-pointer text-gray-600 dark:text-gray-300">Default</button>
              </div>
              {% endif %}
              <div class="relative">
                <button type="button" id="qs-edit-add-btn" onclick="qsEditToggleAddMenu(event)"
                        class="togen-control inline-flex items-center gap-1 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">
                  <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>
                  <span>Add</span>
                </button>
                <div id="qs-edit-add-menu" class="hidden absolute right-0 mt-1 w-56 z-[20] togen-bar-surface bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-lg py-1">
                  <button type="button" onclick="qsEditAddWebsite()" class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Website tile</button>
                  <button type="button" onclick="qsEditAddDashTile()" class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Dashboard tile</button>
                  <button type="button" onclick="qsEditAddDashRef()" class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Embedded dashboard</button>
                  <button type="button" onclick="qsEditAddSeparator()" class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Separator</button>
                </div>
              </div>
              <button type="button" id="qs-edit-reset" onclick="qsResetMyLayout()" title="Clear your personal changes and use the default layout"
                      class="togen-control inline-flex items-center gap-1 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">Reset my layout</button>
            </div>
            <button id="qs-tiles-edit" onclick="qsEditToggle()" title="Edit the QC page in place"
                    class="togen-control inline-flex items-center gap-1.5 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 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>
              <span id="qs-tiles-edit-label">Edit</span>
            </button>
          </div>
        </div>
        <div id="qs-tiles-list" class="grid grid-cols-1 sm:grid-cols-2 gap-5"></div>
        <p id="qs-tiles-empty" class="hidden text-sm text-gray-500 dark:text-gray-400 text-center py-10">No tiles yet — click Edit to add some.</p>
      </div>
    </div><!-- /tool-quality -->

    <!-- ===== LOGISTICS TOOL CONTENT (DVI-1387 / DVI-1397 P1) ===== -->
    <div id="tool-logistics" class="tool-content hidden">
      <div class="max-w-6xl mx-auto space-y-6">

        <!-- Tabs: Load Manager · Resources (all) · Settings (right-justified) -->
        <div id="logistics-tabbar" class="togen-tabbar flex items-center gap-1 border-b border-gray-200 dark:border-gray-700">
          <button id="logistics-tab-btn-loads" onclick="logisticsSwitchTab('loads')"
                  class="px-4 py-2 text-sm font-medium border-b-2 border-togen-teal text-togen-teal -mb-px">Load Manager</button>
          <button id="logistics-tab-btn-calendar" onclick="logisticsSwitchTab('calendar')"
                  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">Calendar</button>
          <button id="logistics-tab-btn-ltl" onclick="logisticsSwitchTab('ltl')"
                  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">LTL</button>
          <button id="logistics-tab-btn-oversight" onclick="logisticsSwitchTab('oversight')"
                  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">Oversight</button>
          <button id="logistics-tab-btn-resources" onclick="logisticsSwitchTab('resources')"
                  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">Resources</button>
          <button id="logistics-tab-btn-settings" onclick="logisticsSwitchTab('settings')"
                  class="hidden 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">Settings</button>
        </div>

        <!-- ===== Load Manager tab ===== -->
        <div id="logistics-tab-loads">
          <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">Loads</h2>
              <div class="flex items-center gap-3">
                <label class="inline-flex items-center gap-1.5 text-xs text-white/90"><input type="checkbox" id="logistics-loads-show-cancelled" onchange="logisticsLoadLoads()" class="h-3.5 w-3.5 rounded border-white/40 text-togen-teal focus:ring-white/50"> Show cancelled</label>
                <button id="logistics-load-add-btn" onclick="logisticsLoadOpen(null)"
                        class="hidden px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors">+ Add Load</button>
              </div>
            </div>
            <div class="px-6 py-4">
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Plan a delivery: choose a window, assign an available driver / truck / trailer, and build the event timeline. Availability accounts for out-of-service dates and overlapping loads. Driver hours are a planning aid, not a legal Hours-of-Service engine.</p>
              <div id="logistics-loads-list"><p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p></div>
            </div>
          </div>
        </div><!-- /logistics-tab-loads -->

        <!-- ===== Calendar tab (DVI-1400 P4) ===== -->
        <div id="logistics-tab-calendar" class="hidden">
          <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 flex-wrap items-center justify-between gap-3">
              <div class="flex items-center gap-2">
                <button onclick="logisticsCalNav(-1)" title="Previous" class="togen-control w-7 h-7 flex items-center justify-center bg-white/15 hover:bg-white/25 text-white rounded-lg text-sm transition-colors">&#8249;</button>
                <button onclick="logisticsCalToday()" class="togen-control px-2.5 py-1 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors">Today</button>
                <button onclick="logisticsCalNav(1)" title="Next" class="togen-control w-7 h-7 flex items-center justify-center bg-white/15 hover:bg-white/25 text-white rounded-lg text-sm transition-colors">&#8250;</button>
                <h2 id="logistics-cal-title" class="text-sm font-bold text-white uppercase tracking-wide ml-1">&nbsp;</h2>
              </div>
              <div class="flex items-center gap-3">
                <label class="inline-flex items-center gap-1.5 text-xs text-white/90"><input type="checkbox" id="logistics-cal-show-cancelled" onchange="logisticsCalToggleCancelled()" class="h-3.5 w-3.5 rounded border-white/40 text-togen-teal focus:ring-white/50"> Show cancelled</label>
                <div class="inline-flex rounded-lg overflow-hidden border border-white/30">
                  <button id="logistics-cal-view-month" onclick="logisticsCalSetView('month')" class="px-2.5 py-1 text-xs font-semibold bg-white/25 text-white transition-colors">Month</button>
                  <button id="logistics-cal-view-week" onclick="logisticsCalSetView('week')" class="px-2.5 py-1 text-xs font-semibold text-white/90 hover:bg-white/15 transition-colors">Week</button>
                </div>
              </div>
            </div>
            <div class="px-4 py-4">
              <div id="logistics-cal-body"><p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p></div>
              <p id="logistics-cal-unscheduled" class="hidden text-xs text-gray-400 italic mt-3"></p>
            </div>
          </div>
        </div><!-- /logistics-tab-calendar -->

        <!-- ===== LTL tab ===== -->
        <div id="logistics-tab-ltl" class="hidden">
          <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">Built Loads</h2>
              <button id="logistics-ltl-add-btn" onclick="logisticsLtlOpen(null)"
                      class="hidden px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors">Load Builder</button>
            </div>
            <div class="px-6 py-4">
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Build a load by name with free-text notes. Built loads become selectable in Load Manager → Add Load. Wiring available products from NetSuite is a later phase.</p>
              <div id="logistics-ltl-list"><p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p></div>
            </div>
          </div>
        </div><!-- /logistics-tab-ltl -->

        <!-- ===== Resources tab ===== -->
        <div id="logistics-tab-resources" class="hidden">
          <div class="togen-tabbar flex items-center gap-1 border-b border-gray-200 dark:border-gray-700 mb-4">
            <button id="logistics-res-subtab-drivers" onclick="logisticsResSubtab('drivers')"
                    class="px-4 py-2 text-sm font-medium border-b-2 border-togen-teal text-togen-teal -mb-px">Drivers</button>
            <button id="logistics-res-subtab-trucks" onclick="logisticsResSubtab('trucks')"
                    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">Trucks</button>
            <button id="logistics-res-subtab-trailers" onclick="logisticsResSubtab('trailers')"
                    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">Trailers</button>
          </div>

          <!-- Drivers subtab -->
          <div id="logistics-res-pane-drivers">
            <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">Drivers</h2>
                <button id="logistics-driver-add-btn" onclick="logisticsDriverOpen(null)"
                        class="hidden px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors">+ Add Driver</button>
              </div>
              <div class="px-6 py-4">
                <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Drivers are Personnel who carry the Driver designation. Max hours are a planning aid, not a legal Hours-of-Service compliance engine.</p>
                <div id="logistics-drivers-list"><p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p></div>
              </div>
            </div>
          </div>

          <!-- Trucks subtab -->
          <div id="logistics-res-pane-trucks" class="hidden">
            <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">Trucks</h2>
                <button id="logistics-truck-add-btn" onclick="logisticsTruckOpen(null)"
                        class="hidden px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors">+ Add Truck</button>
              </div>
              <div class="px-6 py-4">
                <div id="logistics-trucks-list"><p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p></div>
              </div>
            </div>
          </div>

          <!-- Trailers subtab -->
          <div id="logistics-res-pane-trailers" class="hidden">
            <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">Trailers</h2>
                <button id="logistics-trailer-add-btn" onclick="logisticsTrailerOpen(null)"
                        class="hidden px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors">+ Add Trailer</button>
              </div>
              <div class="px-6 py-4">
                <div id="logistics-trailers-list"><p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p></div>
              </div>
            </div>
          </div>
        </div><!-- /logistics-tab-resources -->

        <!-- ===== Oversight tab (DVI-1401 P5) — Plan / Map subtabs ===== -->
        <div id="logistics-tab-oversight" class="hidden">
          <div class="togen-tabbar flex items-center gap-1 border-b border-gray-200 dark:border-gray-700 mb-4">
            <button id="logistics-ov-subtab-plan" onclick="logisticsOvSubtab('plan')"
                    class="px-4 py-2 text-sm font-medium border-b-2 border-togen-teal text-togen-teal -mb-px">Plan</button>
            <button id="logistics-ov-subtab-map" onclick="logisticsOvSubtab('map')"
                    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">Map</button>
          </div>

          <!-- Plan subtab: read-only oversight viewer of the admin-picked focused plan -->
          <div id="logistics-ov-pane-plan">
            <div id="logistics-ov-plan-picker" class="hidden mb-3 flex flex-wrap items-center gap-2 text-sm">
              <label class="text-xs font-medium text-gray-500 dark:text-gray-400">Focused plan (admin):</label>
              <select id="logistics-ov-plan-sel" class="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"></select>
              <button onclick="logisticsOvSavePlan()" class="togen-control px-2.5 py-1 text-xs font-semibold rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal/10">Save</button>
              <span id="logistics-ov-plan-status" class="text-xs text-gray-500 dark:text-gray-400"></span>
            </div>
            <div id="logov-header" class="flex items-center justify-between gap-3 mb-3 flex-wrap">
              <div class="min-w-0">
                <h3 id="logov-title" class="text-base font-semibold text-gray-800 dark:text-gray-100 truncate">Oversight</h3>
                <p class="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">Facility floor plan with location tags. Live truck tracking arrives on the Map subtab.</p>
              </div>
            </div>
            <div id="logov-empty" class="hidden togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-8 text-center text-sm text-gray-500 dark:text-gray-400">
              No focused plan configured. <span id="logov-empty-admin" class="hidden">Choose one from the selector above.</span>
            </div>
            <div id="logov-viewer-wrap" class="hidden overflow-hidden relative rounded-lg border border-gray-200 dark:border-gray-700">
              <div id="logov-noimg" class="hidden p-8 text-center text-sm text-gray-500 dark:text-gray-400">This plan has no image yet.</div>
              <div id="logov-stage" class="relative overflow-hidden select-none" style="height:70vh;touch-action:none;">
                <div id="logov-pan" style="position:absolute;left:0;top:0;transform-origin:0 0;">
                  <img id="logov-plan-img" alt="Floor plan" style="display:block;max-width:none;pointer-events:none;" />
                  <div id="logov-markers" style="position:absolute;left:0;top:0;right:0;bottom:0;"></div>
                </div>
              </div>
              <button id="logov-full-btn" onclick="logovToggleFull()" title="Full view"
                      class="absolute top-2 right-2 z-10 h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer inline-flex items-center justify-center">
                <svg id="logov-full-icon" 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 4a1 1 0 011-1h4a1 1 0 010 2H6.414l3.293 3.293a1 1 0 11-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm14 12a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-3.293-3.293a1 1 0 111.414-1.414L15 13.586V12a1 1 0 012 0v4z" clip-rule="evenodd"/></svg>
              </button>
              <div class="absolute bottom-2 right-2 z-10 flex flex-col gap-1">
                <button onclick="logovZoomBy(1.25)" class="h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer text-lg leading-none">+</button>
                <button onclick="logovZoomBy(0.8)" class="h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer text-lg leading-none">&minus;</button>
                <button onclick="logovZoomReset()" class="h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer text-[10px] leading-none">fit</button>
              </div>
            </div>
          </div>

          <!-- Map subtab: Samsara truck tracking placeholder (gated later phase) -->
          <div id="logistics-ov-pane-map" class="hidden">
            <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">Truck Map</h2>
              </div>
              <div class="px-6 py-12 text-center">
                <svg class="mx-auto h-10 w-10 text-gray-300 dark:text-gray-600 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="M9 6.75V15m6-6v8.25m.503 3.498 4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 0 0-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0Z"/></svg>
                <p class="text-sm font-medium text-gray-600 dark:text-gray-300">Samsara truck tracking — pending service account / API</p>
                <p class="mt-1 text-xs text-gray-400 dark:text-gray-500 max-w-md mx-auto">Live truck GPS locations will appear here once the Samsara integration (board-owned service account + API token) is configured. That integration is a separate gated phase.</p>
              </div>
            </div>
          </div>
        </div><!-- /logistics-tab-oversight -->

        <!-- ===== Settings tab ===== -->
        <div id="logistics-tab-settings" class="hidden">
          <div class="togen-tabbar flex items-center gap-1 border-b border-gray-200 dark:border-gray-700 mb-4">
            <button id="logistics-set-subtab-events" onclick="logisticsSetSubtab('events')"
                    class="px-4 py-2 text-sm font-medium border-b-2 border-togen-teal text-togen-teal -mb-px">Events</button>
            <button id="logistics-set-subtab-notifications" onclick="logisticsSetSubtab('notifications')"
                    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">Notifications</button>
          </div>

          <div id="logistics-set-pane-events">
            <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">Load-Timeline Events</h2>
                <button onclick="logisticsEventOpen(null)"
                        class="px-3 py-1.5 bg-white/15 hover:bg-white/25 text-white font-semibold rounded-lg text-xs transition-colors">+ Add Event</button>
              </div>
              <div class="px-6 py-4">
                <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Reusable steps in a load's timeline (hook trailer, load, fuel, drive, unload…). Each event's typical duration feeds the computed load duration in Load Manager (a later phase).</p>
                <div id="logistics-events-list"><p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p></div>
              </div>
            </div>
          </div>

          <div id="logistics-set-pane-notifications" class="hidden">
            <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">Notifications</h2>
              </div>
              <div class="px-6 py-8 text-center">
                <p class="text-sm text-gray-500 dark:text-gray-400">Notification rules for scheduled loads arrive in a later phase.</p>
              </div>
            </div>
          </div>
        </div><!-- /logistics-tab-settings -->

      </div>

      <!-- ===== Driver modal ===== -->
      <div id="logistics-driver-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-lg max-h-[90vh] overflow-y-auto">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h3 id="logistics-driver-modal-title" class="text-sm font-bold text-white uppercase tracking-wide">Add Driver</h3>
          </div>
          <div class="px-6 py-4 space-y-3">
            <div id="logistics-driver-person-block">
              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Driver</label>
              <div class="flex items-center gap-4 text-sm mb-2">
                <label class="inline-flex items-center gap-1.5"><input type="radio" name="logistics-driver-mode" value="existing" checked onchange="logisticsDriverModeChanged()" class="text-togen-teal focus:ring-togen-teal"> Pick existing person</label>
                <label class="inline-flex items-center gap-1.5"><input type="radio" name="logistics-driver-mode" value="new" onchange="logisticsDriverModeChanged()" class="text-togen-teal focus:ring-togen-teal"> New person</label>
              </div>
              <div id="logistics-driver-existing-block">
                <select id="logistics-driver-person" 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"></select>
              </div>
              <div id="logistics-driver-new-block" class="hidden grid grid-cols-2 gap-3">
                <input type="text" id="logistics-driver-first" placeholder="First name" class="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">
                <input type="text" id="logistics-driver-last" placeholder="Last name" class="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">
                <input type="email" id="logistics-driver-email" placeholder="Email (optional)" class="col-span-2 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="col-span-2 text-xs text-gray-400 dark:text-gray-500">Adds this person to the Personnel roster with the Driver designation.</p>
              </div>
            </div>
            <div class="grid grid-cols-2 gap-3">
              <div>
                <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Max hours / day</label>
                <input type="number" min="0" step="0.5" id="logistics-driver-maxday" 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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Max hours / week</label>
                <input type="number" min="0" step="0.5" id="logistics-driver-maxweek" 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>
              <div class="flex items-center justify-between mb-1">
                <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Unavailable / time-off dates</label>
                <button type="button" onclick="logisticsWinAdd('logistics-driver-windows')" class="togen-control text-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">+ Add date range</button>
              </div>
              <div id="logistics-driver-windows" class="space-y-2"></div>
            </div>
            <p id="logistics-driver-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-2">
            <button onclick="logisticsCloseModal('logistics-driver-modal')" class="togen-control 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">Cancel</button>
            <button onclick="logisticsDriverSave()" class="px-5 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm">Save</button>
          </div>
        </div>
      </div>

      <!-- ===== Vehicle modal (trucks + trailers) ===== -->
      <div id="logistics-vehicle-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-lg max-h-[90vh] overflow-y-auto">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h3 id="logistics-vehicle-modal-title" class="text-sm font-bold text-white uppercase tracking-wide">Add Truck</h3>
          </div>
          <div class="px-6 py-4 space-y-3">
            <div>
              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name <span class="text-red-500">*</span></label>
              <input type="text" id="logistics-vehicle-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="grid grid-cols-2 gap-3">
              <div>
                <label id="logistics-vehicle-number-label" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Truck #</label>
                <input type="text" id="logistics-vehicle-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>
                <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Asset #</label>
                <input type="text" id="logistics-vehicle-asset" 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 id="logistics-vehicle-weight-label" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Gross weight to haul (lb)</label>
              <input type="number" min="0" step="1" id="logistics-vehicle-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>
            <label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
              <input type="checkbox" id="logistics-vehicle-inservice" checked class="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> In service
            </label>
            <div id="logistics-vehicle-features-block">
              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Special features</label>
              <div id="logistics-vehicle-features" class="space-y-1"></div>
            </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">Out-of-service dates</label>
                <button type="button" onclick="logisticsWinAdd('logistics-vehicle-windows')" class="togen-control text-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">+ Add date range</button>
              </div>
              <div id="logistics-vehicle-windows" class="space-y-2"></div>
            </div>
            <p id="logistics-vehicle-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-2">
            <button onclick="logisticsCloseModal('logistics-vehicle-modal')" class="togen-control 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">Cancel</button>
            <button id="logistics-vehicle-save-btn" onclick="logisticsVehicleSave()" class="px-5 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm">Save</button>
          </div>
        </div>
      </div>

      <!-- ===== Event modal ===== -->
      <div id="logistics-event-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 id="logistics-event-modal-title" class="text-sm font-bold text-white uppercase tracking-wide">Add Event</h3>
          </div>
          <div class="px-6 py-4 space-y-3">
            <div>
              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name <span class="text-red-500">*</span></label>
              <input type="text" id="logistics-event-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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Typical minutes</label>
              <input type="number" min="0" step="1" id="logistics-event-minutes" 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="logistics-event-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-2">
            <button onclick="logisticsCloseModal('logistics-event-modal')" class="togen-control 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">Cancel</button>
            <button onclick="logisticsEventSave()" class="px-5 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm">Save</button>
          </div>
        </div>
      </div>

      <!-- ===== LTL Load Builder modal ===== -->
      <div id="logistics-ltl-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-lg max-h-[90vh] overflow-y-auto">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2">
            <h3 id="logistics-ltl-modal-title" class="text-sm font-bold text-white uppercase tracking-wide">Load Builder</h3>
          </div>
          <div class="px-6 py-4 space-y-3">
            <div>
              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Load name <span class="text-red-500">*</span></label>
              <input type="text" id="logistics-ltl-name" placeholder="e.g. East route — Springfield drop" 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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Notes</label>
              <textarea id="logistics-ltl-notes" rows="5" placeholder="Free-text notes on the load (products, pieces, weight, freight…)." 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>
            <p id="logistics-ltl-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-2">
            <button onclick="logisticsCloseModal('logistics-ltl-modal')" class="togen-control 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">Cancel</button>
            <button onclick="logisticsLtlSave()" class="px-5 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm">Save</button>
          </div>
        </div>
      </div>

      <!-- ===== Load modal (Add / Edit Load) ===== -->
      <div id="logistics-load-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-3xl max-h-[92vh] overflow-y-auto">
          <div class="bg-togen-teal rounded-t-2xl px-6 py-2 flex items-center justify-between sticky top-0 z-10">
            <h3 id="logistics-load-modal-title" class="text-sm font-bold text-white uppercase tracking-wide">Add Load</h3>
            <span id="logistics-load-duration" class="text-xs font-semibold text-white/90"></span>
          </div>
          <div class="px-6 py-4 space-y-4">

            <!-- Load identity + window -->
            <div>
              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Built load (LTL) <span class="text-xs font-normal text-gray-400">— optional; pick an LTL-built load or plan ad hoc</span></label>
              <select id="logistics-load-ltl-pick" onchange="logisticsLoadLtlPicked()" 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="">— ad hoc (no built load) —</option></select>
            </div>
            <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
              <div>
                <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Load name</label>
                <input type="text" id="logistics-load-name" placeholder="Ad hoc load 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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
                <select id="logistics-load-status" 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="planned">Planned</option>
                  <option value="scheduled">Scheduled</option>
                  <option value="completed">Completed</option>
                  <option value="cancelled">Cancelled</option>
                </select>
              </div>
            </div>

            <div>
              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Window <span class="text-red-500">*</span> <span class="text-xs font-normal text-gray-400">(plant-local — a single day or a date range the load can run in)</span></label>
              <div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
                <div><label class="block text-xs text-gray-500 dark:text-gray-400 mb-0.5">Start date</label><input type="date" id="logistics-load-start-date" onchange="logisticsLoadWindowChanged()" class="w-full 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><label class="block text-xs text-gray-500 dark:text-gray-400 mb-0.5">Start time</label><input type="time" id="logistics-load-start-time" onchange="logisticsLoadWindowChanged()" class="w-full 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><label class="block text-xs text-gray-500 dark:text-gray-400 mb-0.5">End date</label><input type="date" id="logistics-load-end-date" onchange="logisticsLoadWindowChanged()" class="w-full 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><label class="block text-xs text-gray-500 dark:text-gray-400 mb-0.5">End time</label><input type="time" id="logistics-load-end-time" onchange="logisticsLoadWindowChanged()" class="w-full 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>
            </div>

            <!-- Destination / budget -->
            <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
              <div>
                <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Destination</label>
                <input type="text" id="logistics-load-destination" 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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Miles</label><input type="number" min="0" step="1" id="logistics-load-miles" 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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Budget ($)</label><input type="number" min="0" step="0.01" id="logistics-load-budget" 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>
            <div>
              <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Cost notes</label>
              <input type="text" id="logistics-load-cost-notes" 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>

            <!-- Event timeline + travel -->
            <div class="border-t border-gray-200 dark:border-gray-700 pt-3">
              <div class="flex items-center justify-between mb-1">
                <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Event timeline</label>
                <div class="flex items-center gap-2">
                  <select id="logistics-load-event-pick" class="px-2 py-1 rounded 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"></select>
                  <button type="button" onclick="logisticsLoadAddEvent()" class="togen-control text-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">+ Add</button>
                </div>
              </div>
              <div id="logistics-load-events" class="space-y-1.5"></div>
              <div class="flex items-center gap-2 mt-2">
                <label class="text-sm text-gray-600 dark:text-gray-300">Manual travel time (min)</label>
                <input type="number" min="0" step="1" id="logistics-load-travel" value="0" oninput="logisticsLoadRecalcDuration()" class="w-28 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>

            <!-- Resource assignment -->
            <div class="border-t border-gray-200 dark:border-gray-700 pt-3">
              <div class="flex items-center justify-between mb-2">
                <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Assign resources</label>
                <label class="inline-flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400"><input type="checkbox" id="logistics-load-show-unavail" onchange="logisticsLoadRenderAvailability()" class="h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal"> Show unavailable</label>
              </div>
              <p id="logistics-load-avail-hint" class="text-xs text-gray-400 dark:text-gray-500 mb-2">Set a window above to see available resources.</p>
              <div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
                <div><div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">Drivers</div><div id="logistics-load-avail-drivers" class="space-y-1"></div></div>
                <div><div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">Trucks</div><div id="logistics-load-avail-trucks" class="space-y-1"></div></div>
                <div><div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">Trailers</div><div id="logistics-load-avail-trailers" class="space-y-1"></div></div>
              </div>
              <div id="logistics-load-hours-warn" class="hidden mt-2 text-xs text-amber-600 dark:text-amber-400"></div>
            </div>

            <p id="logistics-load-status-msg" 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-2 sticky bottom-0 bg-white dark:bg-gray-900">
            <button onclick="logisticsCloseModal('logistics-load-modal')" class="togen-control 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">Cancel</button>
            <button id="logistics-load-save-btn" onclick="logisticsLoadSave()" class="px-5 py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-sm">Save</button>
          </div>
        </div>
      </div>

    </div><!-- /tool-logistics -->

    <!-- ===== SCADA VIEW TOOL CONTENT ===== -->
    <div id="tool-scada-view" class="tool-content hidden">
      <!-- Tab bar -->
      <div id="scada-tabbar" class="togen-tabbar 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-oversight" onclick="scadaSwitchTab('oversight')"
                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">
          Oversight
        </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" class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
        <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-warning" class="hidden mb-4 rounded-xl border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/30 px-4 py-3 flex items-start gap-2">
          <svg class="h-5 w-5 shrink-0 text-amber-500 dark:text-amber-400 mt-0.5" 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-warning-msg" class="text-sm text-amber-800 dark:text-amber-200"></p>
        </div>

        <div id="scada-status-grid" class="hidden space-y-5">
        </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 gap-3 flex-wrap">
              <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>
              <!-- Filter bar + CSV export (DVI-1087 Phase 3, Auditor pattern) -->
              <div class="ml-auto flex items-center gap-2 flex-wrap">
              <input id="scada-history-q" type="text" placeholder="Filter sensors…"
                     oninput="scadaRenderHistoryList()"
                     title="Substring match on sensor name"
                     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-36" />
              <button onclick="scadaHistoryExportCsv()"
                      title="Download the listed sensors' full reading history as CSV"
                      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 transition-colors cursor-pointer">
                Export CSV
              </button>
              <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>

            <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 Kiln Metrics 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>

        <!-- Oversight tile (DVI-1117) — global focused plan for the Oversight tab -->
        <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">Oversight</h3>
            <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">Choose the focused floor plan shown in the Oversight tab. Applies to all users. The plan renders in the light/dark style matching each user's theme.</p>
            <label class="flex items-center justify-between gap-3">
              <span class="text-sm text-gray-700 dark:text-gray-300">Focused plan</span>
              <select id="scada-oversight-plan" onchange="scadaSaveOversightPlan()"
                      class="min-w-[16rem] px-2 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-100 focus:ring-togen-teal focus:border-togen-teal cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed">
              </select>
            </label>
            <div class="mt-3 flex items-center gap-3">
              <span id="scada-oversight-status" class="text-xs text-gray-500 dark:text-gray-400"></span>
              <span id="scada-oversight-note" class="hidden text-xs text-gray-400 dark:text-gray-500">Only a Togen admin can change the focused plan.</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 Kiln Metrics 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>

            <!-- (d) Report approvers (DVI-1102 F4) -->
            <div class="pt-4 mt-6 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">Report approvers</h4>
                <button type="button" title="Add approver" onclick="scadaApproverAdd()"
                        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">A report bound for external recipients is held until an approver signs off. Any one approver's approval sends it. <span class="text-amber-600 dark:text-amber-400">If no approver is set, external reports are held indefinitely.</span></p>
              <input type="email" id="scada-rn-approver-input" placeholder="name@example.com"
                     onkeydown="if(event.key==='Enter'){event.preventDefault();scadaApproverAdd();}"
                     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-approver-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>

            <!-- (e) Pending approvals (DVI-1102 F4) -->
            <div class="pt-4 mt-6 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">Pending approvals</h4>
                <button type="button" title="Refresh" onclick="scadaLoadApprovals()"
                        class="shrink-0 inline-flex items-center justify-center h-7 px-2 rounded-lg text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300 transition-colors cursor-pointer">Refresh</button>
              </div>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Reports awaiting your approval before external delivery.</p>
              <div id="scada-rn-approvals-empty" class="text-sm text-gray-400 dark:text-gray-500 italic py-2">No reports awaiting approval.</div>
              <div id="scada-rn-approvals-list" class="space-y-2"></div>
            </div>

            <!-- (f) Email content (DVI-1165) — what the report email includes -->
            <div class="pt-4 mt-6 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">Email content</h4>
                <span id="scada-rn-email-msg" class="text-xs text-gray-400"></span>
              </div>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Controls what the internal report email includes. External recipients always receive the XLSX attachment only (no summary or link).</p>
              <div class="space-y-2 text-sm">
                <label class="flex items-center gap-2 cursor-pointer">
                  <input type="checkbox" id="scada-email-viewer-link"
                         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-gray-700 dark:text-gray-300">Include a link to view the report online (sign-in required)</span>
                </label>
                <label class="flex items-center gap-2 cursor-pointer">
                  <input type="checkbox" id="scada-email-summary"
                         onchange="scadaEmailSummaryToggle()"
                         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-gray-700 dark:text-gray-300">Include a data summary ("Attention" block)</span>
                </label>
                <div id="scada-email-summary-sub" class="pl-6 space-y-2 border-l border-gray-100 dark:border-gray-800 ml-1.5">
                  <label class="flex items-center gap-2 cursor-pointer">
                    <input type="checkbox" id="scada-email-out-of-spec"
                           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-gray-700 dark:text-gray-300">Out-of-spec sensors (over high / under low)</span>
                  </label>
                  <label class="flex items-center gap-2 cursor-pointer">
                    <input type="checkbox" id="scada-email-rate-violations"
                           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-gray-700 dark:text-gray-300">Change-over-time violations (rate rules)</span>
                  </label>
                  <label class="flex items-center gap-2">
                    <span class="text-gray-600 dark:text-gray-400">Show</span>
                    <select id="scada-email-summary-mode"
                            class="px-2 py-1 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="exceptions">Only exceptions</option>
                      <option value="full">All monitored criteria</option>
                    </select>
                  </label>
                </div>
              </div>
              <button type="button" onclick="scadaSaveEmailSettings()"
                      class="mt-3 inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-medium bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors cursor-pointer">Save email settings</button>
            </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 class="flex gap-3">
              <label class="flex-1">
                <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Type</span>
                <select id="scada-edit-type"
                        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="">Auto (from name)</option>
                  <option value="temperature">Temperature</option>
                  <option value="humidity">Humidity</option>
                  <option value="digital">Digital input</option>
                  <option value="relay">Relay</option>
                  <option value="other">Other (numeric)</option>
                </select>
              </label>
              <label class="w-24">
                <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Unit</span>
                <input type="text" id="scada-edit-unit" placeholder="auto"
                       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="block">
              <span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Location</span>
              <input type="text" id="scada-edit-location" placeholder="e.g. Kiln room — north wall"
                     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>
              <div class="flex items-center gap-2">
                <button id="scada-report-view-btn" onclick="scadaViewCurrentReport()"
                        class="hidden 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 12a2 2 0 100-4 2 2 0 000 4z" />
                    <path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd" />
                  </svg>
                  View report
                </button>
                <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>
            </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">Report type</label>
                <select id="scada-report-type" onchange="scadaReportTypeChanged()"
                        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 min-w-[10rem]">
                </select>
              </div>
              <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>

            <!-- Report criteria (DVI-1095 P3): editable compliance thresholds.
                 Shown only for report types that define criteria (F3). -->
            <div id="scada-report-criteria-block" class="mb-4 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/40 p-4">
              <div class="flex flex-wrap items-center gap-3 mb-3">
                <h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Report Criteria</h4>
                <span class="text-xs text-gray-400 dark:text-gray-500">Pass/fail checks shown in the report and the XLSX Summary sheet</span>
                <span id="scada-criteria-msg" class="text-xs ml-auto"></span>
                <button onclick="scadaSaveCriteria()"
                        class="px-3 py-1 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">
                  Save Criteria
                </button>
              </div>
              <div class="flex flex-wrap items-end gap-4 mb-3">
                <div>
                  <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Low limit (&deg;F)</label>
                  <input type="number" step="any" id="scada-crit-low"
                         class="w-24 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">High limit (&deg;F)</label>
                  <input type="number" step="any" id="scada-crit-high"
                         class="w-24 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">Sustained window (min)</label>
                  <input type="number" min="1" id="scada-crit-sustain"
                         class="w-28 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 class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1.5">
                <label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
                  <input type="checkbox" id="scada-crit-lowest_overall" class="rounded">
                  <span>Lowest temp over the period (fails if any reading is below the low limit)</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-crit-lowest_sustained" class="rounded">
                  <span>Below low limit for longer than the sustained window</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-crit-highest_sustained" class="rounded">
                  <span>Above high limit for longer than the sustained window</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-crit-highest_overall" class="rounded">
                  <span>Highest temp over the period (fails if any reading is above the high limit)</span>
                </label>
              </div>

              <!-- Change over time (DVI-1148): configurable rate-of-change rules.
                   Each flags a Temp sensor changing by more than the max change
                   within the window (seeded 45F/60min; add e.g. 10F/10min). -->
              <div class="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
                <div class="flex flex-wrap items-center gap-3 mb-2">
                  <h5 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Change over time</h5>
                  <span class="text-xs text-gray-400 dark:text-gray-500">Flag when a temperature changes too fast (rise or fall) within a window</span>
                  <button type="button" onclick="scadaAddRateRule()"
                          class="ml-auto 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 cursor-pointer">
                    + Add rule
                  </button>
                </div>
                <div id="scada-crit-rate-rules" class="space-y-2"></div>
              </div>
            </div>

            <div id="scada-criteria-summary" class="hidden mb-4"></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="togen-inset 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>

      <!-- Report Viewer (read-only) — DVI-1163. Mirrors the existing SCADA
           modals; renders a normalized view-model (on-demand: built from the
           loaded report; persisted: fetched from /scada/report-view). Built so
           edit controls can be added later — the view-model JSON is the future
           edit surface. -->
      <div id="scada-report-viewer-overlay" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
        <div class="absolute inset-0 bg-black/40" onclick="scadaCloseReportViewer()"></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-5xl max-h-[90vh] flex flex-col">
          <div class="flex items-start justify-between gap-4 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
            <div>
              <h3 id="scada-report-viewer-title" class="text-base font-semibold text-gray-800 dark:text-gray-100">Kiln Metrics Report</h3>
              <p id="scada-report-viewer-meta" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"></p>
            </div>
            <div class="flex items-center gap-2">
              <a id="scada-report-viewer-download" href="#"
                 class="hidden 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">
                Download XLSX
              </a>
              <button onclick="scadaCloseReportViewer()" aria-label="Close"
                      class="p-1.5 rounded-lg text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 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>
          <div class="px-6 py-4 overflow-y-auto space-y-4">
            <div id="scada-report-viewer-error" class="hidden text-center py-8">
              <p id="scada-report-viewer-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
            </div>
            <div id="scada-report-viewer-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-viewer-summary"></div>
            <div id="scada-report-viewer-body" class="space-y-6"></div>
          </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="togen-inset 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>

      <!-- Per-schedule run History dialog (DVI-1164): lists this schedule's runs
           from scada_report_log.json with a View link per row. -->
      <div id="scada-sched-hist-overlay" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
        <div class="absolute inset-0 bg-black/40" onclick="scadaCloseSchedHistory()"></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-3xl p-6 max-h-[85vh] flex flex-col">
          <div class="flex items-center justify-between mb-4">
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">
              Run History — <span id="scada-sched-hist-name" class="text-togen-teal"></span>
            </h3>
            <button onclick="scadaCloseSchedHistory()"
                    class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
          </div>
          <div id="scada-sched-hist-loading" class="hidden text-center py-8">
            <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="scada-sched-hist-empty" class="hidden text-center py-8 text-sm text-gray-500 dark:text-gray-400">
            No runs recorded yet. Use <span class="font-medium">Test</span> to generate one now.
          </div>
          <div id="scada-sched-hist-error" class="hidden text-center py-8 text-sm text-red-600 dark:text-red-400"></div>
          <div id="scada-sched-hist-wrap" class="hidden overflow-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">When</th>
                  <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Range</th>
                  <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300 text-right">Rows</th>
                  <th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Source</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">Report</th>
                </tr>
              </thead>
              <tbody id="scada-sched-hist-tbody" class="divide-y divide-gray-100 dark:divide-gray-800"></tbody>
            </table>
          </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>
          <button onclick="scadaSensorExportCsv()"
                  title="Download this sensor's recorded events as CSV"
                  class="ml-auto 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 cursor-pointer">
            Export CSV
          </button>
        </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 class="flex items-center justify-between gap-3 flex-wrap mb-4">
              <div id="scada-sensor-range-picker" class="flex items-center gap-1.5"></div>
              <span id="scada-sensor-window-note" class="text-xs text-gray-400 dark:text-gray-500"></span>
            </div>
            <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>

      <!-- Oversight tab (DVI-1117): view-only floor plan with the SCADA layer.
           The Oversight tool remains the sole editor of placements. -->
      <div id="scada-panel-oversight" class="hidden">
        <div id="scadaov-header" class="flex items-center justify-between gap-3 mb-3 flex-wrap">
          <div class="min-w-0">
            <h3 id="scadaov-title" class="text-base font-semibold text-gray-800 dark:text-gray-100 truncate">Oversight</h3>
            <p class="text-xs text-gray-500 dark:text-gray-400">Read-only floor plan with live SCADA sensor locations. Edit locations in the Oversight tool.</p>
          </div>
        </div>

        <!-- Empty state -->
        <div id="scadaov-empty" class="hidden togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-8 text-center text-sm text-gray-500 dark:text-gray-400">
          No focused plan configured. <span id="scadaov-empty-admin" class="hidden">Choose one under <button onclick="scadaSwitchTab('settings')" class="text-togen-teal underline cursor-pointer">Settings &rarr; Oversight</button>.</span>
        </div>

        <!-- Plan viewer stage -->
        <div id="scadaov-viewer-wrap" class="hidden overflow-hidden relative rounded-lg border border-gray-200 dark:border-gray-700">
          <div id="scadaov-noimg" class="hidden p-8 text-center text-sm text-gray-500 dark:text-gray-400">This plan has no image yet.</div>
          <div id="scadaov-stage" class="relative overflow-hidden select-none" style="height:70vh;touch-action:none;">
            <div id="scadaov-pan" style="position:absolute;left:0;top:0;transform-origin:0 0;">
              <img id="scadaov-plan-img" alt="Floor plan" style="display:block;max-width:none;pointer-events:none;" />
              <div id="scadaov-markers" style="position:absolute;left:0;top:0;right:0;bottom:0;"></div>
            </div>
          </div>
          <button id="scadaov-full-btn" onclick="scadaovToggleFull()" title="Full view"
                  class="absolute top-3 right-3 h-8 w-8 flex items-center justify-center rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer">
            <svg id="scadaov-full-icon" 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 4a1 1 0 011-1h4a1 1 0 010 2H6.414l3.293 3.293a1 1 0 11-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm14 12a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-3.293-3.293a1 1 0 111.414-1.414L15 13.586V12a1 1 0 012 0v4z" clip-rule="evenodd"/></svg>
          </button>
          <div class="absolute bottom-3 right-3 flex flex-col gap-1">
            <button onclick="scadaovZoomBy(1.25)" class="h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer text-lg leading-none">+</button>
            <button onclick="scadaovZoomBy(0.8)" class="h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer text-lg leading-none">−</button>
            <button onclick="scadaovZoomReset()" class="h-8 w-8 rounded-lg bg-white/90 dark:bg-gray-800/90 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 shadow-sm hover:bg-white cursor-pointer text-[10px] leading-none">fit</button>
          </div>
        </div>

        <!-- Marker detail popup -->
        <div id="scadaov-detail" class="hidden fixed z-[125] w-64 rounded-xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700 p-3 text-sm"></div>
      </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="togen-tabbar 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-diagnostics" onclick="visionSwitchTab('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>
        {% 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>
        <button id="vision-tab-monitoring" onclick="visionSwitchTab('monitoring')"
                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">
          Monitoring
        </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" class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
        <!-- 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>
          <label class="inline-flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
            <span>Location</span>
            <select id="vision-filter-locality" 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="local">Local</option>
              <option value="remote">Remote</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 togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
        <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>
          <!-- Filter bar + CSV export (DVI-1087 Phase 3, Auditor pattern) -->
          <div class="ml-auto flex items-center gap-2 flex-wrap">
            <select id="vision-activity-range" onchange="visionLoadActivity()"
                    title="Limit activity to a recent window"
                    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 history</option>
              <option value="86400">Last 24h</option>
              <option value="259200">Last 3 days</option>
              <option value="604800">Last 7 days</option>
              <option value="2592000">Last 30 days</option>
            </select>
            <select id="vision-activity-status" onchange="visionActivityApplyFilter()"
                    title="Filter the timeline by span status"
                    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 statuses</option>
              <option value="active">Active</option>
              <option value="idle">Idle</option>
              <option value="paused">Paused</option>
              <option value="error">Error</option>
            </select>
            <input id="vision-activity-q" type="text" placeholder="Search app/user…"
                   oninput="visionActivityApplyFilter()"
                   title="Substring match on foreground app, logged-in user, and status"
                   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-36" />
            <button onclick="visionActivityExportCsv()"
                    title="Download the raw check-in telemetry matching the current filters as CSV"
                    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 transition-colors cursor-pointer">
              Export CSV
            </button>
            <span id="vision-activity-time" class="text-sm text-gray-500 dark:text-gray-400"></span>
          </div>
        </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 togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
        <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="togen-control 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 %}
      <!-- Diagnostics tab content (DVI-902): fleet rollup + per-agent drill-down -->
      <div id="vision-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 items-center justify-between mb-4">
              <div class="flex items-center gap-2">
                <button id="vision-diag-back" onclick="visionDiagShowFleet()"
                        class="hidden px-2 py-1 text-xs font-medium 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 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="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>
                  Fleet
                </button>
                <h3 id="vision-diag-title" class="text-base font-semibold text-gray-800 dark:text-gray-100">Fleet Diagnostics</h3>
              </div>
              <div class="flex items-center gap-3">
                <span id="vision-diag-status" class="text-xs text-gray-500 dark:text-gray-400"></span>
                <button onclick="visionDiagRefresh()"
                        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-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>
              <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading diagnostics...</p>
            </div>

            <div id="vision-diag-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-diag-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
            </div>

            <div id="vision-diag-fleet" class="hidden"></div>
            <div id="vision-diag-agent" class="hidden"></div>
            <div id="vision-diag-recovery" class="hidden"></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 togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
        <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="togen-control 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>

        <!-- Watch Criteria editor (DVI-890): define the shared criteria that Monitoring evaluates -->
        <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 max-w-2xl">
          <div class="flex items-center justify-between gap-3 flex-wrap mb-1">
            <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Watch Criteria</h3>
            <button onclick="visionWatchCriteriaAddRow()"
                    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">+ Add criterion</button>
          </div>
          <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
            Named prompts the Assistant answers about captured frames. These are the presets offered in the frame
            Assistant and the selectable criteria in the <strong>Monitoring</strong> tab. Shared with all Vision users.
          </p>
          <div id="vision-wc-loading" class="mt-3 text-xs text-gray-500 dark:text-gray-400">Loading…</div>
          <div id="vision-wc-empty" class="hidden mt-3 text-xs text-gray-500 dark:text-gray-400">No Watch Criteria yet — add one above.</div>
          <div id="vision-wc-list" class="mt-3 space-y-3"></div>
          <div class="flex items-center gap-3 mt-4">
            <button id="vision-wc-save-btn" onclick="visionWatchCriteriaSave()"
                    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 Watch Criteria</button>
            <span id="vision-wc-status" class="text-sm text-gray-700 dark:text-gray-300"></span>
          </div>
        </div>
      </div>

      <!-- Enroll tab content (DVI-437): onboarding, open-enroll, approvals, agent management -->
      <div id="vision-panel-enroll" class="hidden togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
        <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">

          <!-- Quick-enroll card -->
          <div class="togen-inset 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="togen-control 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="togen-control 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="togen-control 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="togen-inset 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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>

      <!-- Monitoring tab content (DVI-871 / DVI-878): automated Watch-Criteria monitoring -->
      <div id="vision-panel-monitoring" class="hidden togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
        <div class="rounded-xl border border-amber-300 dark:border-amber-800 bg-amber-50/50 dark:bg-amber-900/10 p-4 mb-4 max-w-2xl">
          <div class="flex items-start justify-between gap-3 flex-wrap">
            <div>
              <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Automated monitoring</h3>
              <p class="mt-1 text-xs text-amber-700 dark:text-amber-300">
                <strong>Warning:</strong> when enabled, the server runs continuous <strong>paid LLM vision calls</strong>
                against opted-in machines on the poll interval below. The daily call cap bounds spend.
              </p>
            </div>
            <label class="inline-flex items-center gap-2 cursor-pointer flex-shrink-0">
              <input type="checkbox" id="vision-monitor-enabled"
                     class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
              <span class="text-sm font-medium text-gray-700 dark:text-gray-200">Enabled</span>
            </label>
          </div>
        </div>

        <div id="vision-monitor-loading" class="text-xs text-gray-500 dark:text-gray-400">Loading monitor config…</div>

        <div id="vision-monitor-body" class="hidden space-y-4 max-w-2xl">
          <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
            <label class="block">
              <span class="text-xs font-medium text-gray-600 dark:text-gray-300">Poll interval (minutes)</span>
              <input type="number" id="vision-monitor-interval" min="5" step="1"
                     class="mt-1 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">
              <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">Minimum 5.</span>
            </label>
            <label class="block">
              <span class="text-xs font-medium text-gray-600 dark:text-gray-300">Daily call cap</span>
              <input type="number" id="vision-monitor-cap" min="0" step="1"
                     class="mt-1 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">
              <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">Hard fail-closed limit per day.</span>
            </label>
            <label class="block">
              <span class="text-xs font-medium text-gray-600 dark:text-gray-300">Model</span>
              <select id="vision-monitor-model"
                      class="mt-1 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"></select>
              <span id="vision-monitor-model-empty" class="hidden mt-1 block text-xs text-amber-600 dark:text-amber-400">No system models configured (Togen Admin → Assistant).</span>
            </label>
          </div>

          <!-- Live meter -->
          <div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 p-3 flex items-center justify-between gap-3 flex-wrap">
            <div>
              <span class="text-xs font-medium text-gray-500 dark:text-gray-400">Calls today</span>
              <span id="vision-monitor-meter" class="ml-2 text-sm font-semibold text-gray-800 dark:text-gray-100">— / —</span>
            </div>
            <span class="text-xs text-gray-400 dark:text-gray-500">Estimate; the cap bounds actual spend.</span>
          </div>

          <!-- Per-machine list -->
          <div>
            <h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-1">Machines</h4>
            <p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Enable a machine and pick which Watch Criteria to evaluate on each poll.</p>
            <div id="vision-monitor-machines-empty" class="hidden text-xs text-gray-500 dark:text-gray-400">No enrolled machines.</div>
            <div id="vision-monitor-criteria-empty" class="hidden text-xs text-amber-600 dark:text-amber-400 mb-2">No Watch Criteria defined yet — add some in the Policy area first.</div>
            <ul id="vision-monitor-machines" class="space-y-2"></ul>
          </div>

          <div class="flex items-center gap-3 pt-1">
            <button id="vision-monitor-save-btn" onclick="visionMonitorSave()"
                    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 monitoring config</button>
            <span id="vision-monitor-save-status" class="text-sm text-gray-700 dark:text-gray-300"></span>
          </div>
        </div>
      </div>
      {% endif %}

      <!-- Settings tab content (DVI-593): per-user Vision preferences -->
      <div id="vision-panel-settings" class="hidden togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
        <div class="togen-inset 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="togen-inset 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>

        {% if is_togen_admin %}
        <!-- Rsync server pane (DVI-977): LAN/WAN endpoints + subnet driving the local/remote indicator -->
        <div id="vision-rsync-pane" class="togen-inset 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">Rsync server</h3>
            <button onclick="visionRsyncConfigLoad()"
                    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>
          <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
            Where agents send captured footage. Agents on the LAN subnet reach the local address; all others use
            the public address. The LAN subnet also drives the <span class="font-medium">local / remote</span>
            indicator shown on each agent.
          </p>
          <div id="vision-rsync-loading" class="mt-3 text-xs text-gray-500 dark:text-gray-400">Loading&hellip;</div>
          <div id="vision-rsync-body" class="hidden mt-3 space-y-4">
            <div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
              <label class="block sm:col-span-2">
                <span class="text-xs font-medium text-gray-600 dark:text-gray-300">Local (LAN) IP address</span>
                <input type="text" id="vision-rsync-lan-ip" placeholder="192.168.250.4"
                       class="mt-1 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">
              </label>
              <label class="block">
                <span class="text-xs font-medium text-gray-600 dark:text-gray-300">LAN port</span>
                <input type="number" id="vision-rsync-lan-port" min="1" max="65535" placeholder="22"
                       class="mt-1 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">
              </label>
            </div>
            <div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
              <label class="block sm:col-span-2">
                <span class="text-xs font-medium text-gray-600 dark:text-gray-300">Public (WAN) IP address</span>
                <input type="text" id="vision-rsync-wan-ip" placeholder="165.188.108.55"
                       class="mt-1 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">
              </label>
              <label class="block">
                <span class="text-xs font-medium text-gray-600 dark:text-gray-300">WAN port</span>
                <input type="number" id="vision-rsync-wan-port" min="1" max="65535" placeholder="4423"
                       class="mt-1 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">
              </label>
            </div>
            <div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
              <label class="block">
                <span class="text-xs font-medium text-gray-600 dark:text-gray-300">Rsync user</span>
                <input type="text" id="vision-rsync-user" placeholder="aiv"
                       class="mt-1 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">
              </label>
              <label class="block sm:col-span-2">
                <span class="text-xs font-medium text-gray-600 dark:text-gray-300">LAN subnet (CIDR)</span>
                <input type="text" id="vision-rsync-lan-subnet" placeholder="192.168.250.0/24"
                       class="mt-1 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">
                <span class="mt-1 block text-xs text-gray-400 dark:text-gray-500">Agents with a local IP inside this range are shown as <span class="font-medium">local</span>.</span>
              </label>
            </div>
            <div class="flex items-center gap-3 pt-1">
              <button id="vision-rsync-save-btn" onclick="visionRsyncConfigSave()"
                      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 rsync config</button>
              <span id="vision-rsync-save-status" class="text-sm text-gray-700 dark:text-gray-300"></span>
            </div>
          </div>
          <div id="vision-rsync-error" class="hidden mt-3 text-xs text-red-600 dark:text-red-400"></div>
        </div>
        {% endif %}
      </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>

    <!-- Per-WO audit drill-down (DVI-1040): full ref-normalized event timeline,
         completion details, per-department EVH sign-offs, and WO/EVH PDF viewers. -->
    <div id="wor-audit-overlay" class="hidden fixed inset-0 z-[115] flex items-center justify-center p-4">
      <div onclick="worAuditClose()" class="absolute inset-0 bg-black/60"></div>
      <div class="relative w-full max-w-3xl 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="sticky top-0 z-10 flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
          <h3 id="wor-audit-title" class="text-base font-semibold text-gray-800 dark:text-gray-100 truncate pr-2">Work Order audit trail</h3>
          <button onclick="worAuditClose()" 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 id="wor-audit-body" class="px-5 py-4"></div>
      </div>
    </div>

    <!-- WO photo lightbox (DVI-1069): in-popup gallery viewer above the audit modal -->
    <div id="wor-audit-lightbox" class="hidden fixed inset-0 z-[125] flex items-center justify-center p-4">
      <div onclick="worAuditLightboxClose()" class="absolute inset-0 bg-black/80"></div>
      <div class="relative w-full max-w-4xl max-h-[92vh] flex flex-col items-center">
        <div class="flex items-center justify-between w-full mb-2 gap-2">
          <span id="wor-audit-lb-badge" class="px-2.5 py-1 rounded-full bg-white/15 text-white text-xs font-medium"></span>
          <div class="flex items-center gap-2">
            <span id="wor-audit-lb-counter" class="text-xs text-gray-300"></span>
            <a id="wor-audit-lb-download" href="#" download class="p-1.5 rounded-lg text-gray-200 hover:text-white hover:bg-white/15 transition-colors" title="Download">
              <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 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5 5-5M12 15V3"/></svg>
            </a>
            <button onclick="worAuditLightboxClose()" title="Close" class="p-1.5 rounded-lg text-gray-200 hover:text-white hover:bg-white/15 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 class="relative flex items-center justify-center w-full">
          <div id="wor-audit-lb-nav" class="hidden">
            <button onclick="worAuditLightboxNav(-1)" title="Previous" class="absolute left-0 top-1/2 -translate-y-1/2 -ml-2 sm:-ml-12 p-2 rounded-full bg-white/15 text-white hover:bg-white/30 transition-colors cursor-pointer">
              <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="M15 19l-7-7 7-7"/></svg>
            </button>
            <button onclick="worAuditLightboxNav(1)" title="Next" class="absolute right-0 top-1/2 -translate-y-1/2 -mr-2 sm:-mr-12 p-2 rounded-full bg-white/15 text-white hover:bg-white/30 transition-colors cursor-pointer">
              <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="M9 5l7 7-7 7"/></svg>
            </button>
          </div>
          <img id="wor-audit-lb-img" src="" alt="" class="max-h-[76vh] max-w-full rounded-lg object-contain bg-black/20">
        </div>
        <div id="wor-audit-lb-caption" class="mt-3 text-center"></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="togen-tabbar 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>
        {% if is_togen_admin or is_auditor_scoped %}
        <button id="auditor-toptab-diagnosis" onclick="auditorSwitchTopTab('diagnosis')"
                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">
          Diagnosis
        </button>
        <button id="auditor-toptab-search" onclick="auditorSwitchTopTab('search')"
                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">
          Search
        </button>
        <button id="auditor-toptab-dashboards" onclick="auditorSwitchTopTab('dashboards')"
                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">
          Dashboards
        </button>
        <button id="auditor-toptab-reporting" onclick="auditorSwitchTopTab('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>
        {% endif %}
        {% if is_togen_admin or is_auditor_scoped %}
        <button id="auditor-toptab-tags" onclick="auditorSwitchTopTab('tags')"
                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">
          Tags
        </button>
        {% endif %}
        {% if is_togen_admin %}
        <!-- Settings (DVI-1052): right-justified, admin-only. Houses the Access sub-tab. -->
        <button id="auditor-toptab-settings" onclick="auditorSwitchTopTab('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>
        {% endif %}
      </div>

      <!-- Assistant pane: tool-use audit log (DVI-615) -->
      <div id="auditor-pane-assistant">
      <!-- Tab bar -->
      <div id="auditor-tabbar" class="togen-tabbar 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-q" type="text" placeholder="Search args…"
                 oninput="auditorApplyFilter()"
                 title="Free-text search across user, tool, outcome, and arguments"
                 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" />
          <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>
          <select id="auditor-filter-outcome" onchange="auditorApplyFilter()"
                  title="Filter by tool-call outcome"
                  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 outcomes</option>
            <option value="success">success</option>
            <option value="denied">denied</option>
            <option value="input_error">input_error</option>
            <option value="handler_error">handler_error</option>
            <option value="cancelled_by_user">cancelled_by_user</option>
            <option value="unknown_tool">unknown_tool</option>
            <option value="no_callback">no_callback</option>
          </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>
          <button onclick="auditorExportCsv()" title="Export the current view (with its filters) as CSV for a compliance packet"
                  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 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 1v9.586l2.293-2.293a1 1 0 011.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 13.586V4a1 1 0 011-1zM4 16a1 1 0 100 2h12a1 1 0 100-2H4z" clip-rule="evenodd" />
            </svg>
            Export CSV
          </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">
        <!-- History storage telemetry (DVI-1045) -->
        <div id="auditor-hist-telemetry" class="hidden flex flex-wrap items-center gap-x-6 gap-y-1 mb-3 px-4 py-2.5 rounded-xl bg-gray-50 dark:bg-gray-800/60 border border-gray-200 dark:border-gray-700 text-xs text-gray-600 dark:text-gray-300">
          <span class="font-medium text-gray-500 dark:text-gray-400">History store:</span>
          <span><span class="text-gray-500 dark:text-gray-400">Live rows</span> <span id="auditor-hist-tel-rows" class="font-semibold text-gray-800 dark:text-gray-100">—</span></span>
          <span><span class="text-gray-500 dark:text-gray-400">DB size</span> <span id="auditor-hist-tel-dbsize" class="font-semibold text-gray-800 dark:text-gray-100">—</span></span>
          <span><span class="text-gray-500 dark:text-gray-400">Oldest</span> <span id="auditor-hist-tel-oldest" class="font-semibold text-gray-800 dark:text-gray-100">—</span></span>
          <span><span class="text-gray-500 dark:text-gray-400">Retention</span> <span id="auditor-hist-tel-retention" class="font-semibold text-gray-800 dark:text-gray-100">—</span></span>
          <span><span class="text-gray-500 dark:text-gray-400">Archives</span> <span id="auditor-hist-tel-archives" class="font-semibold text-gray-800 dark:text-gray-100">—</span></span>
        </div>
        <div id="auditor-histtabbar" class="togen-tabbar 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 Orders</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>
          <button id="auditor-histtab-scada-reporting" onclick="auditorSwitchHistTab('scada-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">Kiln Metrics Reporting</button>
          <button id="auditor-histtab-signage-agents" onclick="auditorSwitchHistTab('signage-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">Displays &amp; Presentation</button>
          <button id="auditor-histtab-dashboards" onclick="auditorSwitchHistTab('dashboards')"
                  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">Dashboards</button>
          <button id="auditor-histtab-assets" onclick="auditorSwitchHistTab('assets')"
                  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">Assets</button>
          <button id="auditor-histtab-datasources" onclick="auditorSwitchHistTab('datasources')"
                  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">Data sources</button>
        </div>
        <div class="flex justify-end mb-3">
          <button onclick="auditorHistExportCsv()" title="Export the current view (with its filters) as CSV for a compliance packet"
                  class="px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600 hover:bg-gray-200 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 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>
            Export CSV
          </button>
        </div>
        <div id="auditor-hist-prodrun">
          <div class="flex flex-wrap items-center gap-3 mb-3">
            <label class="inline-flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-300 cursor-pointer">
              <input type="checkbox" id="auditor-hist-prodrun-failures" onchange="auditorHistLoadProdRun()"
                     class="rounded border-gray-300 dark:border-gray-600 text-indigo-600 focus:ring-indigo-500">
              Failures only
            </label>
            <select id="auditor-hist-prodrun-category" onchange="auditorHistLoadProdRun()"
                    class="text-sm rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 py-1.5">
              <option value="">All categories</option>
              <option value="file_not_found">File not found</option>
              <option value="not_pdf">Not a PDF</option>
              <option value="http_error">HTTP/download error</option>
              <option value="sharepoint_auth">SharePoint auth</option>
              <option value="network_error">Network error</option>
              <option value="outside_approved_domain">Outside approved domains/paths</option>
              <option value="merge_error">Merge error</option>
            </select>
          </div>
          <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 id="auditor-hist-scada-reporting" class="hidden">
          <div class="flex items-center justify-end gap-2 mb-3">
            <input id="auditor-hist-scada-reporting-filter-user" type="text" placeholder="Filter source/name…"
                   oninput="auditorHistLoadGeneric('scada-reporting', 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-40" />
            <button onclick="auditorHistLoadGeneric('scada-reporting', 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-scada-reporting-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-scada-reporting-list" class="space-y-2"></div>
          <div class="mt-3 flex items-center justify-between">
            <span id="auditor-hist-scada-reporting-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
            <button id="auditor-hist-scada-reporting-loadmore" onclick="auditorHistLoadMoreGeneric('scada-reporting')"
                    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>
        <!-- DVI-1250: Assets change + usage trail (tool 'assets'). A filtered
             adapter over history.db surfaces asset_added/edited/imported/synced/
             sync_settings/viewed events regardless of their recorded tool key.
             The action filter separates changes from views. -->
        <div id="auditor-hist-assets" class="hidden">
          <div class="flex items-center justify-end gap-2 mb-3">
            <select id="auditor-hist-assets-filter-action" onchange="auditorHistLoadGeneric('assets', 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 focus:outline-none focus:ring-1 focus:ring-togen-teal cursor-pointer">
              <option value="">All events</option>
              <option value="asset_added">Added</option>
              <option value="asset_edited">Edited</option>
              <option value="asset_viewed">Viewed</option>
              <option value="assets_imported">Imported</option>
              <option value="assets_synced">Synced</option>
              <option value="assets_sync_settings">Sync settings</option>
              <option value="asset_model_added">Model added</option>
              <option value="asset_model_edited">Model edited</option>
              <option value="asset_model_deleted">Model deleted</option>
              <option value="sub_assembly_added">Sub-assembly added</option>
              <option value="sub_assembly_edited">Sub-assembly edited</option>
              <option value="sub_assembly_deleted">Sub-assembly deleted</option>
            </select>
            <input id="auditor-hist-assets-filter-user" type="text" placeholder="Filter user…"
                   oninput="auditorHistLoadGeneric('assets', 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-40" />
            <button onclick="auditorHistLoadGeneric('assets', 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-assets-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-assets-list" class="space-y-2"></div>
          <div class="mt-3 flex items-center justify-between">
            <span id="auditor-hist-assets-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
            <button id="auditor-hist-assets-loadmore" onclick="auditorHistLoadMoreGeneric('assets')"
                    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>
        <!-- DVI-1382 P1: Data sources change trail (tool 'datasources'). A
             filtered adapter over history.db surfaces dashboard_source_*
             events (manual value edits + SharePoint/report source add/edit/
             delete) regardless of their recorded tool key. The action filter
             separates value edits from structural changes. -->
        <div id="auditor-hist-datasources" class="hidden">
          <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Changes to Dashboards data sources — manual value edits and add / rename / edit / delete of any source (including SharePoint and report-derived sources).</p>
          <div class="flex items-center justify-end gap-2 mb-3">
            <select id="auditor-hist-datasources-filter-action" onchange="auditorHistLoadGeneric('datasources', 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 focus:outline-none focus:ring-1 focus:ring-togen-teal cursor-pointer">
              <option value="">All events</option>
              <option value="dashboard_source_value_set">Manual value set</option>
              <option value="dashboard_source_added">Added</option>
              <option value="dashboard_source_renamed">Renamed</option>
              <option value="dashboard_source_edited">Edited</option>
              <option value="dashboard_source_deleted">Deleted</option>
              <option value="dashboard_sources_saved">Bulk save</option>
            </select>
            <input id="auditor-hist-datasources-filter-user" type="text" placeholder="Filter user…"
                   oninput="auditorHistLoadGeneric('datasources', 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-40" />
            <button onclick="auditorHistLoadGeneric('datasources', 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-datasources-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-datasources-list" class="space-y-2"></div>
          <div class="mt-3 flex items-center justify-between">
            <span id="auditor-hist-datasources-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
            <button id="auditor-hist-datasources-loadmore" onclick="auditorHistLoadMoreGeneric('datasources')"
                    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>
        <!-- DVI-1228 P1: Displays & Presentation (tool 'signage-agents'). Display
             admin (enroll/register/revoke/delete/update) + presentation
             (present-start/-stop) share this tool; an action filter separates them. -->
        <div id="auditor-hist-signage-agents" class="hidden">
          <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Digital-signage display administration and live presentation usage. Enroll/register/revoke/delete/update events are display-admin actions; <span class="font-medium">present-start</span> / <span class="font-medium">present-stop</span> are presentation events.</p>
          <div class="flex items-center justify-end gap-2 mb-3">
            <select id="auditor-hist-signage-agents-filter-action" onchange="auditorHistLoadGeneric('signage-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 focus:outline-none focus:ring-1 focus:ring-togen-teal">
              <option value="">All events</option>
              <option value="present">Presentation only</option>
              <option value="registered">Registered</option>
              <option value="updated">Updated</option>
              <option value="revoked">Revoked</option>
              <option value="deleted">Deleted</option>
              <option value="enroll-token">Enroll tokens</option>
            </select>
            <input id="auditor-hist-signage-agents-filter-user" type="text" placeholder="Filter actor/display…"
                   oninput="auditorHistLoadGeneric('signage-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-40" />
            <button onclick="auditorHistLoadGeneric('signage-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-signage-agents-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-signage-agents-list" class="space-y-2"></div>
          <div class="mt-3 flex items-center justify-between">
            <span id="auditor-hist-signage-agents-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
            <button id="auditor-hist-signage-agents-loadmore" onclick="auditorHistLoadMoreGeneric('signage-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>
        <!-- DVI-1228 P2: Dashboards personal-dashboard lifecycle (tool 'dashboards'). -->
        <div id="auditor-hist-dashboards" class="hidden">
          <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Personal dashboard lifecycle: create, delete, share, set-default, copy-from-system. (Tile edits are not tracked — use the Dashboards inspector to see current configuration.)</p>
          <div class="flex items-center justify-end gap-2 mb-3">
            <input id="auditor-hist-dashboards-filter-user" type="text" placeholder="Filter actor…"
                   oninput="auditorHistLoadGeneric('dashboards', 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-40" />
            <button onclick="auditorHistLoadGeneric('dashboards', 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-dashboards-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
          <div id="auditor-hist-dashboards-list" class="space-y-2"></div>
          <div class="mt-3 flex items-center justify-between">
            <span id="auditor-hist-dashboards-count" class="text-xs text-gray-400 dark:text-gray-500"></span>
            <button id="auditor-hist-dashboards-loadmore" onclick="auditorHistLoadMoreGeneric('dashboards')"
                    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><!-- /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="togen-tabbar 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 Orders</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 -->

      {% if is_togen_admin or is_auditor_scoped %}
      <!-- Diagnosis pane (DVI-1041): read-only aggregation of the per-tool diagnostics
           platforms. Bots reuses the shared renderDiagnosticFlow + /diagnostics/<bot>/runs;
           Vision reuses /vision/diagnostics/fleet|agent with a read-only presentation.
           Naturally extends as more tools adopt bot_diagnostics. -->
      <div id="auditor-pane-diagnosis" class="hidden">
        <div id="auditor-diagtabbar" class="togen-tabbar 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-diagtab-timeline" onclick="auditorSwitchDiagTab('timeline')"
                  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">Timeline</button>
          <button id="auditor-diagtab-bots" onclick="auditorSwitchDiagTab('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>
          <button id="auditor-diagtab-vision" onclick="auditorSwitchDiagTab('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>

        <!-- Timeline sub-pane (DVI-1047): unified cross-tool diagnostics stream -->
        <div id="auditor-diag-timeline">
          <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">Unified diagnostics timeline</h3>
              <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">One time-ordered stream merging bot pipeline runs and Vision agent events/recovery across tools.</p>
            </div>
            <div class="flex items-end gap-2 flex-wrap">
              <label class="block">
                <span class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Search</span>
                <input id="auditor-diag-tl-q" type="text" placeholder="ref, machine, text…"
                       onkeydown="if(event.key==='Enter'){auditorDiagLoadTimeline();}"
                       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-[12rem]">
              </label>
              <label class="block">
                <span class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Source</span>
                <select id="auditor-diag-tl-source" onchange="auditorDiagLoadTimeline()"
                        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">
                  <option value="">All sources</option>
                  <option value="bot">Bots</option>
                  <option value="vision">Vision</option>
                </select>
              </label>
              <label class="block">
                <span class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Status</span>
                <select id="auditor-diag-tl-status" onchange="auditorDiagLoadTimeline()"
                        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">
                  <option value="">All</option>
                  <option value="ok">OK</option>
                  <option value="warn">Warning</option>
                  <option value="error">Error</option>
                  <option value="running">Running</option>
                  <option value="cancelled">Cancelled</option>
                </select>
              </label>
              <button onclick="auditorDiagLoadTimeline(true)"
                      class="togen-control px-3 py-2 text-sm 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-800 transition-colors cursor-pointer">Refresh</button>
            </div>
          </div>
          <div id="auditor-diag-tl-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="auditor-diag-tl-empty" class="hidden text-center py-8">
            <p class="text-sm text-gray-500 dark:text-gray-400">No diagnostic events match.</p>
          </div>
          <div id="auditor-diag-tl-error" class="hidden text-center py-8">
            <p id="auditor-diag-tl-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
          </div>
          <div id="auditor-diag-tl-list"></div>
        </div>

        <!-- Bots sub-pane: per-bot run picker + reused diagnostic-flow drill-down -->
        <div id="auditor-diag-bots">
          <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">Bot pipeline diagnostics</h3>
              <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Read-only view of bot runs — step-by-step flow with per-step logs and artifacts.</p>
            </div>
            <div class="flex items-end gap-3">
              <label class="block">
                <span class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Bot</span>
                <select id="auditor-diag-bot-select" onchange="auditorDiagLoadBotRuns()"
                        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">
                  <option value="winston">Winston</option>
                </select>
              </label>
              <label class="block">
                <span class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Run</span>
                <select id="auditor-diag-bot-run-select" onchange="auditorDiagSelectBotRun(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>
          <div id="auditor-diag-bot-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="auditor-diag-bot-empty" class="hidden text-center py-8">
            <p class="text-sm text-gray-500 dark:text-gray-400">No runs recorded for this bot yet.</p>
          </div>
          <div id="auditor-diag-bot-error" class="hidden text-center py-8">
            <p id="auditor-diag-bot-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
          </div>
          <div id="auditor-diag-bot-flow"></div>
        </div>

        <!-- Vision sub-pane: fleet snapshot + per-agent read-only drill-down -->
        <div id="auditor-diag-vision" class="hidden">
          <div class="flex items-center justify-between mb-4">
            <div class="flex items-center gap-2">
              <button id="auditor-diag-vision-back" onclick="auditorDiagVisionShowFleet()"
                      class="hidden px-2 py-1 text-xs font-medium 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 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="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>
                Fleet
              </button>
              <h3 id="auditor-diag-vision-title" class="text-base font-semibold text-gray-800 dark:text-gray-100">Fleet Diagnostics</h3>
            </div>
            <span id="auditor-diag-vision-status" class="text-xs text-gray-500 dark:text-gray-400"></span>
          </div>
          <div id="auditor-diag-vision-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 diagnostics...</p>
          </div>
          <div id="auditor-diag-vision-error" class="hidden text-center py-8">
            <p id="auditor-diag-vision-error-msg" class="text-sm text-red-600 dark:text-red-400"></p>
          </div>
          <div id="auditor-diag-vision-fleet" class="hidden"></div>
          <div id="auditor-diag-vision-agent" class="hidden"></div>
        </div>
      </div><!-- /auditor-pane-diagnosis -->

      <!-- Search pane (DVI-1043): unified cross-tool audit search by reference
           (WO#, machine name, quote #). Hits from history.db, bot_diagnostics
           and Vision are grouped and deep-link into their per-tool views. -->
      <div id="auditor-pane-search" class="hidden">
        <div class="mb-4">
          <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Cross-tool audit search</h3>
          <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Search by Work Order #, machine name, or quote # across Work Orders, tool history, bot runs and Vision agents.</p>
        </div>
        <div class="flex items-center gap-2 mb-4">
          <div class="relative flex-1 max-w-lg">
            <svg class="absolute left-3 top-1/2 -translate-y-1/2 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="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>
            <input id="auditor-search-input" type="text" placeholder="e.g. 47260, MACHINE-01, QT1234…"
                   onkeydown="if(event.key==='Enter')auditorSearchRun()"
                   class="w-full pl-9 pr-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 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
          </div>
          <button onclick="auditorSearchRun()"
                  class="px-4 py-2 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">
            Search
          </button>
        </div>
        <div id="auditor-search-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
        <div id="auditor-search-results" class="space-y-6"></div>
      </div><!-- /auditor-pane-search -->

      <!-- Dashboards inspector pane (DVI-1228 P3): read-only view of EVERY
           user's dashboard configuration for troubleshooting & tuning. -->
      <div id="auditor-pane-dashboards" class="hidden">
        <div class="mb-4">
          <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Dashboard configuration inspector</h3>
          <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Read-only view of every user's dashboards — owner, tiles, sharing, signage flag, who has it as default, and live display / present assignment. For troubleshooting and tuning; nothing is changed here.</p>
        </div>
        <div class="flex items-center gap-2 mb-4">
          <div class="relative flex-1 max-w-md">
            <svg class="absolute left-3 top-1/2 -translate-y-1/2 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="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>
            <input id="dashinsp-q" type="text" placeholder="Filter by owner or dashboard name…"
                   oninput="dashInspectorLoad()"
                   class="w-full pl-9 pr-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 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
          </div>
          <button onclick="dashInspectorLoad()"
                  class="px-3 py-2 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 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="dashinsp-status" class="text-sm text-gray-500 dark:text-gray-400 mb-3"></div>
        <div id="dashinsp-list" class="space-y-2"></div>

        <!-- Per-dashboard read-only detail modal -->
        <div id="dashinsp-detail-overlay" class="hidden fixed inset-0 z-[120] flex items-center justify-center p-4 bg-black/50">
          <div class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col 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="dashinsp-detail-title" class="text-base font-semibold text-gray-800 dark:text-gray-100">Dashboard configuration</h3>
              <button onclick="dashInspectorCloseDetail()" 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 id="dashinsp-detail-body" class="px-5 py-4 overflow-y-auto text-sm"></div>
          </div>
        </div>
      </div><!-- /auditor-pane-dashboards -->
      {% endif %}

      {% if is_togen_admin %}
      <!-- Settings pane (DVI-1052): admin-only. Sub-tabs bar + Access sub-tab to
           grant individual Auditor resources to non-admin users by email. -->
      <div id="auditor-pane-settings" class="hidden">
        <div id="auditor-settabbar" class="togen-tabbar 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-settab-access" onclick="auditorSwitchSetTab('access')"
                  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">Access</button>
        </div>

        <div id="auditor-set-access">
          <div class="max-w-3xl">
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-1">User Access</h3>
            <p class="text-xs text-gray-500 dark:text-gray-400 mb-4">Grant individual Auditor tabs and sub-tabs to users by email. A user with any grant sees the Auditor tile and only the tabs you enable. Togen Admins always have full access.</p>

            <!-- Add-user row -->
            <div class="flex items-center gap-2 mb-5">
              <input id="auditor-access-email" type="email" placeholder="user@icastinc.com"
                     onkeydown="if(event.key==='Enter')auditorAccessAddUser()"
                     class="flex-1 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 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-togen-teal/40" />
              <button onclick="auditorAccessAddUser()"
                      class="px-4 py-2 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">
                Add user
              </button>
            </div>

            <div id="auditor-access-status" class="hidden text-sm mb-3"></div>
            <div id="auditor-access-empty" class="hidden text-sm text-gray-500 dark:text-gray-400 py-6 text-center">No users have been granted Auditor access yet.</div>
            <div id="auditor-access-list" class="space-y-4"></div>
          </div>
        </div>
      </div><!-- /auditor-pane-settings -->
      {% endif %}

      {% if is_togen_admin or is_auditor_scoped %}
      <!-- Tags pane: LoRaWAN ThingPark uplinks (DVI-1136) -->
      <div id="auditor-pane-tags" class="hidden">
        <div class="flex items-center justify-between gap-3 mb-3 flex-wrap">
          <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">LoRaWAN Tag Telemetry</h3>
          <button onclick="auditorTagsLoad()" class="text-xs text-togen-teal hover:underline cursor-pointer">Refresh</button>
        </div>
        <div id="auditor-tags-status" class="hidden mb-3 px-3 py-2 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 text-xs text-yellow-700 dark:text-yellow-300"></div>
        <div id="auditor-tags-empty" class="hidden text-sm text-gray-500 dark:text-gray-400 text-center py-8">No uplinks received yet. Configure ThingPark to POST to <code class="font-mono">/thingpark/ingest</code>.</div>
        <div id="auditor-tags-wrap" class="hidden overflow-x-auto rounded-xl border border-gray-200 dark:border-gray-700">
          <table class="min-w-full text-xs">
            <thead>
              <tr class="bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 uppercase tracking-wide text-[10px]">
                <th class="px-3 py-2 text-left font-medium">Device</th>
                <th class="px-3 py-2 text-left font-medium">DevEUI</th>
                <th class="px-3 py-2 text-left font-medium">Last Seen</th>
                <th class="px-3 py-2 text-left font-medium">RSSI</th>
                <th class="px-3 py-2 text-left font-medium">SNR</th>
                <th class="px-3 py-2 text-left font-medium">Battery</th>
                <th class="px-3 py-2 text-left font-medium">Location</th>
                <th class="px-3 py-2 text-left font-medium">Uplinks</th>
              </tr>
            </thead>
            <tbody id="auditor-tags-tbody" class="divide-y divide-gray-100 dark:divide-gray-800 text-gray-700 dark:text-gray-300"></tbody>
          </table>
        </div>
      </div><!-- /auditor-pane-tags -->
      {% endif %}

      {% if is_togen_admin or is_auditor_scoped %}
      <!-- Reporting pane (DVI-1226 P3): report registry over Togen + external
           data sources. First reports read Idencia (live-first with cached
           fallback badge); each run exports as CSV. -->
      <div id="auditor-pane-reporting" class="hidden">
        <div class="mb-4 flex items-start justify-between gap-3 flex-wrap">
          <div>
            <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Reporting</h3>
            <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Run reports over Togen and connected external data sources. Reports run live against the source and fall back to the last synced data when it is unreachable. Any report can be exported as CSV.</p>
          </div>
          <!-- DVI-1255/DVI-1256: Run reports / Report Viewer / Report Builder mode toggle
               (Viewer shown to reporting.viewer|builder|admin; Builder only to reporting.builder|admin) -->
          <div id="reporting-mode-toggle" class="togen-inset hidden inline-flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden text-sm">
            <button id="reporting-mode-run" onclick="reportingSwitchMode('run')" class="px-3 py-1.5 font-medium bg-togen-teal text-white cursor-pointer">Run reports</button>
            <button id="reporting-mode-view" onclick="reportingSwitchMode('view')" class="hidden px-3 py-1.5 font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer">Report Viewer</button>
            <button id="reporting-mode-build" onclick="reportingSwitchMode('build')" class="hidden px-3 py-1.5 font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer">Report Builder</button>
          </div>
        </div>
        <div id="reporting-run-view">
        <div class="flex items-end gap-2 mb-4 flex-wrap">
          <div>
            <label class="block text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">Report</label>
            <select id="auditor-report-select" onchange="auditorReportingPickChanged()"
                    class="px-2 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-1 focus:ring-togen-teal max-w-xs"></select>
          </div>
          <div id="auditor-report-params" class="flex items-end gap-2 flex-wrap"></div>
          <button onclick="auditorReportingRun()"
                  class="px-4 py-2 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">
            Run
          </button>
          <button id="auditor-report-transpose" onclick="auditorReportingToggleTranspose()"
                  class="togen-control hidden px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
                  title="Flip rows and columns — useful when viewing a few dates across many sources">
            Transpose
          </button>
          <button id="auditor-report-export" onclick="auditorReportingExport()"
                  class="togen-control hidden px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
            Export CSV
          </button>
        </div>
        <div id="auditor-report-note" class="hidden mb-3 px-3 py-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 text-xs text-amber-700 dark:text-amber-300"></div>
        <div id="auditor-report-status" class="hidden mb-3 px-3 py-2 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 text-xs text-yellow-700 dark:text-yellow-300"></div>
        <div id="auditor-report-empty" class="text-sm text-gray-500 dark:text-gray-400 text-center py-8">Pick a report and press Run.</div>
        <div id="auditor-report-wrap" class="togen-inset hidden overflow-x-auto rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
          <table class="min-w-full text-xs">
            <thead id="auditor-report-thead"></thead>
            <tbody id="auditor-report-tbody" class="divide-y divide-gray-100 dark:divide-gray-800 text-gray-700 dark:text-gray-300"></tbody>
          </table>
        </div>
        </div><!-- /reporting-run-view -->

        <!-- DVI-1382 follow-up: checkbox picker to populate a report's Sources
             field by name. The picked names are written back as comma text so a
             user can copy them for repeated runs. -->
        <div id="auditor-src-picker-overlay" class="hidden fixed inset-0 z-[120] flex items-center justify-center bg-black/40 p-4">
          <div class="togen-pane w-full max-w-md rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-xl max-h-[80vh] flex flex-col">
            <div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
              <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Select data sources</h3>
              <button onclick="auditorSrcPickerClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-lg leading-none">&times;</button>
            </div>
            <div class="px-4 py-2 border-b border-gray-100 dark:border-gray-800 flex items-center gap-2">
              <input id="auditor-src-picker-filter" type="text" placeholder="Filter…" oninput="auditorSrcPickerRender()"
                     class="flex-1 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-1 focus:ring-togen-teal" />
              <button onclick="auditorSrcPickerAll(true)" class="text-xs text-togen-teal hover:underline cursor-pointer">All</button>
              <button onclick="auditorSrcPickerAll(false)" class="text-xs text-gray-500 hover:underline cursor-pointer">None</button>
            </div>
            <div id="auditor-src-picker-list" class="flex-1 overflow-y-auto px-4 py-2 space-y-1 text-sm text-gray-700 dark:text-gray-200"></div>
            <div class="flex items-center justify-end gap-2 px-4 py-3 border-t border-gray-200 dark:border-gray-700">
              <button onclick="auditorSrcPickerClose()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer">Cancel</button>
              <button onclick="auditorSrcPickerApply()" class="px-3 py-1.5 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal-dark cursor-pointer">Use selection</button>
            </div>
          </div>
        </div>

        <!-- DVI-1255: Report Builder — create/edit reusable, versioned Report Templates -->
        <div id="reporting-builder-view" class="hidden">
          <!-- Template list -->
          <div id="reporting-builder-list-view">
            <div class="flex items-center justify-between gap-2 mb-3">
              <p class="text-xs text-gray-500 dark:text-gray-400">Report Templates are reusable layouts of chart / table tiles plus a report-level date range. Templates are versioned; the Report Viewer (next phase) renders them into shareable reports.</p>
              <div class="flex items-center gap-2 flex-shrink-0">
                <!-- DVI-1388: build a schedulable report from a configured dashboard. -->
                <button onclick="reportBuilderFromDashOpen()" 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 cursor-pointer">+ From a dashboard&hellip;</button>
                <button onclick="reportBuilderNew()" class="px-3 py-1.5 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer">+ New template</button>
              </div>
            </div>
            <div id="reporting-builder-empty" class="hidden text-sm text-gray-500 dark:text-gray-400 text-center py-8">No report templates yet. Create one to get started.</div>
            <div id="reporting-builder-list" class="space-y-2"></div>
          </div>

          <!-- Template editor -->
          <div id="reporting-builder-editor" class="hidden">
            <div class="flex items-center justify-between gap-2 mb-4">
              <button onclick="reportBuilderCloseEditor()" class="text-sm text-togen-teal hover:underline cursor-pointer inline-flex items-center gap-1">&larr; Back to templates</button>
              <span id="reporting-builder-editor-meta" class="text-xs text-gray-400 dark:text-gray-500"></span>
            </div>
            <!-- DVI-1388: shown only for a report built from a dashboard. -->
            <div id="reporting-builder-resync-wrap" class="hidden flex items-center justify-between gap-2 mb-4 rounded-lg border border-togen-teal/30 bg-togen-teal/5 px-3 py-2">
              <span class="text-xs text-gray-600 dark:text-gray-300">Built from a dashboard. Re-sync to replace the tiles below with the dashboard's current tiles.</span>
              <button onclick="reportBuilderResync()" class="flex-shrink-0 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 cursor-pointer">Re-sync from dashboard</button>
            </div>
            <div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Template name</label>
                <input id="reporting-builder-name" type="text" maxlength="120" placeholder="e.g. Weekly production report" 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" />
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Report date range</label>
                <select id="reporting-builder-range" onchange="reportBuilderRangeChanged()" 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"></select>
              </div>
            </div>
            <div id="reporting-builder-custom-range" class="hidden grid grid-cols-2 gap-3 mb-4">
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">From</label>
                <input id="reporting-builder-range-start" type="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" />
              </div>
              <div>
                <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">To</label>
                <input id="reporting-builder-range-end" type="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" />
              </div>
            </div>
            <div class="mb-2 flex items-center justify-between gap-2">
              <label class="block text-xs font-medium text-gray-600 dark:text-gray-300">Tiles</label>
              <button onclick="reportBuilderAddTile()" 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 cursor-pointer">+ Add tile</button>
            </div>
            <div id="reporting-builder-tiles-empty" class="text-xs text-gray-500 dark:text-gray-400 text-center py-6 rounded-lg border border-dashed border-gray-300 dark:border-gray-700 mb-4">No tiles yet. Add a chart or table tile bound to a report source.</div>
            <div id="reporting-builder-tiles" class="space-y-2 mb-4"></div>
            <!-- DVI-1258 (DVI-1252 P5): email recipient list with a + control. -->
            <div class="mb-4">
              <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Email recipients</label>
              <div class="flex items-center gap-2">
                <input id="reporting-builder-recipient-input" type="email" placeholder="name@icastinc.com"
                       onkeydown="if(event.key==='Enter'){event.preventDefault();reportBuilderAddRecipient();}"
                       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" />
                <button type="button" onclick="reportBuilderAddRecipient()" class="flex-shrink-0 px-3 py-2 text-sm 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="reporting-builder-recipients-list" class="flex flex-wrap gap-2 mt-2"></div>
            </div>

            <!-- DVI-1258 (DVI-1252 P5): delivery schedule. When enabled + email on,
                 the togen-worker snapshots a run to history each period and mails
                 the recipient list a link to the saved run. -->
            <div class="togen-inset mb-4 rounded-lg border border-gray-200 dark:border-gray-700 p-3">
              <label class="flex items-center gap-2 cursor-pointer">
                <input type="checkbox" id="reporting-builder-schedule-enabled" onchange="reportBuilderScheduleToggle()" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
                <span class="text-sm font-medium text-gray-700 dark:text-gray-200">Run on a schedule</span>
              </label>
              <div id="reporting-builder-schedule-body" class="hidden mt-3 space-y-3">
                <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
                  <div>
                    <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Frequency</label>
                    <select id="reporting-builder-schedule-freq" onchange="reportBuilderScheduleFreqChanged()" 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">
                      <option value="daily">Daily</option>
                      <option value="weekly">Weekly</option>
                      <option value="monthly">Monthly</option>
                    </select>
                  </div>
                  <div>
                    <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Time (server time, 24h)</label>
                    <input type="time" id="reporting-builder-schedule-time" value="08:00" 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" />
                  </div>
                  <div id="reporting-builder-schedule-weekday-wrap" class="hidden">
                    <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Day of week</label>
                    <select id="reporting-builder-schedule-weekday" 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">
                      <option value="0">Monday</option><option value="1">Tuesday</option>
                      <option value="2">Wednesday</option><option value="3">Thursday</option>
                      <option value="4">Friday</option><option value="5">Saturday</option>
                      <option value="6">Sunday</option>
                    </select>
                  </div>
                  <div id="reporting-builder-schedule-dom-wrap" class="hidden">
                    <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Day of month (1&ndash;28)</label>
                    <input type="number" min="1" max="28" id="reporting-builder-schedule-dom" value="1" 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" />
                  </div>
                </div>
                <label class="flex items-start gap-2 cursor-pointer">
                  <input type="checkbox" id="reporting-builder-schedule-email" class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
                  <span class="text-xs text-gray-600 dark:text-gray-300">Email the recipients above on each run
                    <span class="block text-gray-400 dark:text-gray-500">Each run is saved to history; the email carries a deep-link to the saved run (a PDF attachment arrives in a future release).</span>
                  </span>
                </label>
                <div id="reporting-builder-schedule-health" class="hidden text-[11px] text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 pt-2"></div>
              </div>
            </div>
            <div class="flex items-center justify-end gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
              <span id="reporting-builder-status" class="text-xs text-gray-500 dark:text-gray-400 mr-auto"></span>
              <button onclick="reportBuilderCloseEditor()" class="togen-control px-3 py-1.5 text-sm 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 cursor-pointer">Cancel</button>
              <button onclick="reportBuilderSave()" class="px-4 py-1.5 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer">Save template</button>
            </div>
          </div>
          <!-- DVI-1388: pick a dashboard to snapshot into a new report. -->
          <div id="reporting-fromdash-overlay" class="hidden fixed inset-0 z-[80] flex items-center justify-center bg-black/40 p-4">
            <div class="w-full max-w-md rounded-xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700 p-5">
              <div class="flex items-center justify-between mb-3">
                <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">New report from a dashboard</h3>
                <button onclick="reportBuilderFromDashClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-lg leading-none">&times;</button>
              </div>
              <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Copies the dashboard's tiles into a new, editable report template you can schedule. Later dashboard changes won't affect the report until you re-sync it.</p>
              <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Dashboard</label>
              <select id="reporting-fromdash-select" class="w-full px-3 py-2 mb-3 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"></select>
              <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Report name (optional)</label>
              <input id="reporting-fromdash-name" type="text" maxlength="120" placeholder="Defaults to the dashboard name" class="w-full px-3 py-2 mb-3 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" />
              <div class="flex items-center justify-end gap-2">
                <span id="reporting-fromdash-status" class="text-xs text-gray-500 dark:text-gray-400 mr-auto"></span>
                <button onclick="reportBuilderFromDashClose()" class="togen-control px-3 py-1.5 text-sm 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 cursor-pointer">Cancel</button>
                <button onclick="reportBuilderFromDashCreate()" class="px-4 py-1.5 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer">Create report</button>
              </div>
            </div>
          </div>
        </div><!-- /reporting-builder-view -->

        <!-- DVI-1256 (DVI-1252 P3): Report Viewer — run a saved template with
             most-recent data + the report-level date filter (a live run), or
             open a snapshotted historical run. -->
        <div id="reporting-viewer-view" class="hidden">
          <div class="flex items-end gap-2 mb-3 flex-wrap">
            <div>
              <label class="block text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">Report template</label>
              <select id="reporting-viewer-template" onchange="reportViewerTemplatePicked()"
                      class="px-2 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-1 focus:ring-togen-teal max-w-xs"></select>
            </div>
            <div>
              <label class="block text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">Date range</label>
              <select id="reporting-viewer-range" onchange="reportViewerRangeChanged()"
                      class="px-2 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-1 focus:ring-togen-teal"></select>
            </div>
            <div id="reporting-viewer-custom" class="hidden flex items-end gap-2">
              <div>
                <label class="block text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">From</label>
                <input id="reporting-viewer-range-start" type="date" class="px-2 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" />
              </div>
              <div>
                <label class="block text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">To</label>
                <input id="reporting-viewer-range-end" type="date" class="px-2 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" />
              </div>
            </div>
            <button onclick="reportViewerRun(false)"
                    class="px-4 py-2 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">
              View latest
            </button>
            <button onclick="reportViewerRun(true)"
                    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 run to history
            </button>
            <button id="reporting-viewer-history-btn" onclick="reportViewerToggleHistory()"
                    class="togen-control px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
              History
            </button>
            <!-- DVI-1257 (DVI-1252 P4): browser Print-to-PDF export. Always
                 clickable; shows guidance if no report is rendered yet. -->
            <button id="reporting-viewer-pdf-btn" onclick="reportViewerExportPdf()"
                    title="Export the current report as a PDF via your browser's print dialog"
                    class="togen-control px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
              Export PDF
            </button>
            <!-- DVI-1259 (DVI-1252 P6): present the report (latest data or the
                 open historical run) to a presentable Display Agent, reusing the
                 dashboards Present machinery. -->
            <button id="reporting-viewer-present-btn" onclick="reportPresentOpen()"
                    title="Present this report on a display"
                    class="togen-control px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
              Present
            </button>
          </div>
          <div id="reporting-viewer-meta" class="hidden mb-3 text-xs text-gray-500 dark:text-gray-400"></div>
          <div id="reporting-viewer-status" class="hidden mb-3 px-3 py-2 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 text-xs text-yellow-700 dark:text-yellow-300"></div>

          <!-- Run history panel -->
          <div id="reporting-viewer-history" class="togen-inset hidden mb-4 rounded-xl border border-gray-200 dark:border-gray-700 p-3">
            <div class="flex items-center justify-between mb-2">
              <span class="text-xs font-semibold text-gray-600 dark:text-gray-300">Run history</span>
              <span class="text-[11px] text-gray-400 dark:text-gray-500">Snapshots reproduce exactly what a run showed.</span>
            </div>
            <div id="reporting-viewer-history-empty" class="hidden text-xs text-gray-500 dark:text-gray-400 text-center py-3">No saved runs for this template yet. Use “Save run to history”.</div>
            <div id="reporting-viewer-history-list" class="space-y-1"></div>
          </div>

          <div id="reporting-viewer-empty" class="text-sm text-gray-500 dark:text-gray-400 text-center py-8">Pick a template and press “View latest”.</div>
          <!-- DVI-1257 (DVI-1252 P4): print region = a print-only header (report
               name, run kind, date range, generated stamp) + the tile grid. The
               @media print rules reveal ONLY this region for Print-to-PDF. -->
          <div id="reporting-print-region">
            <div id="reporting-print-header" class="hidden"></div>
            <div id="reporting-viewer-grid"></div>
          </div>

          <!-- DVI-1259 (DVI-1252 P6): Present-a-report overlay. Mirrors the
               dashboards Present modal; authored inside the (visible) reporting
               viewer view so its position:fixed layer always renders. -->
          <div id="reporting-present-overlay" class="hidden fixed inset-0 z-[110] flex items-center justify-center">
            <div class="absolute inset-0 bg-black/50" onclick="reportPresentClose()"></div>
            <div class="relative 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 mx-4">
              <div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700">
                <h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Present &ldquo;<span id="reporting-present-name"></span>&rdquo;</h2>
                <button onclick="reportPresentClose()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>
              </div>
              <div class="p-5 space-y-4 text-sm">
                <p class="text-xs text-gray-500 dark:text-gray-400">Temporarily show this report on a display. It renders under the display/presentation identity (a public-in-room artifact) and reverts to the display&rsquo;s assigned dashboard when the timer ends or you press Stop.</p>
                <div id="reporting-present-empty" class="togen-inset hidden text-sm text-gray-500 dark:text-gray-400 rounded-lg border border-dashed border-gray-300 dark:border-gray-700 p-4 text-center">
                  No displays are available for you to present to. An administrator marks a display as presentable and adds you as an allowed presenter in Togen Admin &rarr; Agents &rarr; Display.
                </div>
                <div id="reporting-present-form" class="hidden space-y-4">
                  <div>
                    <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Display</label>
                    <select id="reporting-present-display" 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"></select>
                  </div>
                  <div>
                    <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Show</label>
                    <select id="reporting-present-source" 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">
                      <option value="latest">Latest data (a fresh snapshot)</option>
                    </select>
                    <p class="text-[11px] text-gray-400 dark:text-gray-500 mt-1">The presented report is snapshotted at start and stays fixed for the duration.</p>
                  </div>
                  <div>
                    <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Duration</label>
                    <select id="reporting-present-duration" 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">
                      <option value="15">15 minutes</option>
                      <option value="30" selected>30 minutes</option>
                      <option value="60">1 hour</option>
                      <option value="0">Until stopped</option>
                    </select>
                  </div>
                  <div>
                    <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Zoom</label>
                    <select id="reporting-present-zoom" 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">
                      <option value="0" selected>Keep the display&rsquo;s zoom</option>
                      <option value="50">50%</option>
                      <option value="75">75%</option>
                      <option value="90">90%</option>
                      <option value="100">100%</option>
                      <option value="110">110%</option>
                      <option value="125">125%</option>
                      <option value="150">150%</option>
                      <option value="175">175%</option>
                      <option value="200">200%</option>
                    </select>
                  </div>
                  <div class="grid grid-cols-2 gap-3">
                    <div>
                      <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Columns</label>
                      <select id="reporting-present-columns" 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">
                        <option value="keep" selected>Keep the display&rsquo;s columns</option>
                        <option value="auto">Auto (12 / portrait 6)</option>
                      </select>
                    </div>
                    <div>
                      <label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Flow</label>
                      <select id="reporting-present-flow" 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">
                        <option value="keep" selected>Keep the display&rsquo;s flow</option>
                        <option value="row">Rows (fill across)</option>
                        <option value="column">Columns (fill down)</option>
                      </select>
                    </div>
                  </div>
                  <label class="flex items-start gap-2 cursor-pointer">
                    <input type="checkbox" id="reporting-present-myiface" class="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">
                    <span class="text-xs text-gray-600 dark:text-gray-300">Use my interface settings
                      <span class="block text-gray-400 dark:text-gray-500">Your theme, background and padding replace the display&rsquo;s for the duration of the present.</span>
                    </span>
                  </label>
                </div>
                <div id="reporting-present-active" class="hidden rounded-lg border border-togen-teal/30 bg-togen-teal/5 p-3 space-y-2 text-xs"></div>
              </div>
              <div class="flex items-center justify-end gap-2 px-5 py-4 border-t border-gray-200 dark:border-gray-700">
                <span id="reporting-present-status" class="text-xs text-gray-500 dark:text-gray-400 mr-auto"></span>
                <button onclick="reportPresentClose()" class="togen-control 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 cursor-pointer">Close</button>
                <button id="reporting-present-go" onclick="reportPresentStart()" class="px-3 py-1.5 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 cursor-pointer">Present</button>
              </div>
            </div>
          </div>
        </div><!-- /reporting-viewer-view -->
      </div><!-- /auditor-pane-reporting -->
      {% endif %}

    </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) ===== -->

    <!-- 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 _isEvhApprover = {{ 'true' if is_evh_approver else 'false' }};
var _isToGenAdmin = {{ 'true' if is_togen_admin else 'false' }};
// DVI-1082: Togen Admins + Authorized Testers may create/view test WOs.
var _worCanTest = {{ 'true' if wor_can_test else 'false' }};
// DVI-1024: Authorized Approvers outside the WOA/Togen-Admin groups get the
// WO Admin tool in Approvals-only mode (single locked Approvals tab).
var _worApprovalsOnly = _isEvhApprover && !_isWorAdmin && !_isToGenAdmin;
// DVI-1052: per-user Auditor grants. Admins implicitly hold every resource;
// scoped non-admins get the Auditor tile and only the tabs/sub-tabs they hold.
var _auditorResources = {{ auditor_resources | tojson }};
var _isAuditorScoped = {{ 'true' if is_auditor_scoped else 'false' }};
var _auditorResSet = {};
_auditorResources.forEach(function(k){ _auditorResSet[k] = true; });
function _auditorCan(key) { return _isToGenAdmin || !!_auditorResSet[key]; }

// Per-user tool access (DVI-1373). A non-admin holds a subset of tool
// capabilities granted from Togen Admin -> Users -> Access; admins hold all.
var _toolAccessResources = {{ tool_access_resources | tojson }};
var _isQcScoped = {{ 'true' if is_qc_scoped else 'false' }};
var _isLogisticsScoped = {{ 'true' if is_logistics_scoped else 'false' }};
var _isQualityScoped = {{ 'true' if is_quality_scoped else 'false' }};  // DVI-1522: QC Tile Surface tile
var _toolAccessResSet = {};
_toolAccessResources.forEach(function(k){ _toolAccessResSet[k] = true; });
function _toolAccessCan(key) { return _isToGenAdmin || !!_toolAccessResSet[key]; }
function _auditorCanTop(topKey) {
  if (_isToGenAdmin) return true;
  if (topKey === 'search') return _auditorCan('search');
  for (var i = 0; i < _auditorResources.length; i++) {
    if (_auditorResources[i].indexOf(topKey + '.') === 0) return true;
  }
  return 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 }};
// Admin-default separator properties (DVI-1337): {sep_id: {label, justify,
// collapsed}}. A user's own tool_separators (in _userSettings) overrides these.
var _adminToolSeparators = {{ tool_separators | tojson }};
// Admin-default web-item (website tile) properties (DVI-1344): {item_id:
// {type, name, url, icon_mode, icon}}. A user's own tool_items (in
// _userSettings) overrides these — same D3 override/inherit model as separators.
var _adminToolItems = {{ tool_items | tojson }};
// Tile Surfaces registry (DVI-1496 P1): per-surface catalog + metadata mirror.
var _tileSurfaces = {{ tile_surfaces | tojson }};
// DVI-1496 P2: effective per-user sub-tool visibility {surface: {subtool: bool}}
// (admin_enabled AND user_not_hidden AND access_ok; admins all true) — drives
// tile + tab hiding. Server routes re-check independently (D9). The admin map is
// the raw Tools-Enabled defaults, edited in Togen Admin -> Tools Enabled.
var _subtoolVisibility = {{ subtool_visibility | tojson }};
var _subtoolVisibilityAdmin = {{ subtool_visibility_admin | tojson }};
// A sub-tool is renderable to this user when effectively visible. Admin sees all.
function _subtoolVisibleFor(surface, subtool) {
  if (_isToGenAdmin) return true;
  var s = _subtoolVisibility && _subtoolVisibility[surface];
  return !s || s[subtool] !== false;
}
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 _botVisibility = {{ bot_visibility | tojson }};
var _botOrder = {{ bot_order | tojson }};
var _botLabels = {'email-bot': 'Winston'};
var _sharedUrlPrefix = '{{ shared_url_prefix }}';
var _userSettings = {{ user_settings | tojson }};
// DVI-1364 P1: resolved regional preferences (system baseline -> org default ->
// per-user override). P2 wires these into the displayed clock/date formatting.
window._regional = {{ regional | tojson }};
var _worPrimaryEnforced = {{ wor_primary_enforced | tojson }};
window._brandingSettings = {{ branding_settings | tojson }};
// DVI-945: SSO identity promoted to globals so the bulk of the app JS below is
// Jinja-free and can be sliced out to a versioned, browser-cached static file.
window._ssoUserName = '{{ user_name }}';
window._ssoUserEmail = '{{ user_email }}';
//__TOGEN_JS_EXTRACT_START__

// ===== 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',
    description: 'Manage your equipment registry and browse repair and operator manuals.',
    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>'
  });
}

// Kiln Metrics (tool id stays `scada-view`): require Togen SCADA or Togen Admin group
if (_isScada || _isToGenAdmin) {
  TOOLS.push({
    id: 'scada-view',
    title: 'Kiln Metrics',
    description: 'View kiln temperature sensor logs and monitoring data.',
    aliases: 'SCADA SCADA View',
    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 || _worApprovalsOnly) {
  TOOLS.push({
    id: 'wor-admin',
    title: 'Work Order Admin',
    description: _worApprovalsOnly
      ? 'Review and approve Return-to-Service requests awaiting your department.'
      : '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>'
  });
}

// Auditor: Togen Admins always, plus non-admins granted >=1 resource (DVI-1052).
if (_isToGenAdmin || _isAuditorScoped) {
  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>'
  });
}

// DVI-1496 P3 (D6-B): the standalone Repair Parts Manager home tile is retired.
// RPM now lives inside Equipment as the RPM sub-tool (its catalog tile appears on
// the Equipment tiles page). A legacy openTool('repair-parts-manager') is
// redirected to Equipment -> RPM by _showTool.

// Oversight (DVI-1100): a login_required tile that is default-disabled via
// Tools-Enabled. Pushed unconditionally — _visibleSortedTools() hides it for
// non-admins until an admin turns it on, and Togen Admins always see it (same
// default-off treatment as Auditor/RPM/Bots).
TOOLS.push({
  id: 'oversight',
  title: 'Oversight',
  description: 'Facility floor plans with live sensor locations and status at a glance.',
  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="3" y="3" width="18" height="18" rx="2" />' +
        '<path d="M3 9h18" />' +
        '<path d="M9 21V9" />' +
        '<circle cx="15.5" cy="15" r="1.6" fill="currentColor" stroke="none" />' +
        '</svg>'
});

// Visitor Registry (DVI-1136 Phase 4): a login_required tile, default-disabled
// via Tools-Enabled (same treatment as Oversight). Front-desk visitor book +
// a view-only Oversight tab for onsite visitor tags.
TOOLS.push({
  id: 'visitor-registry',
  title: 'Visitor Registry',
  description: 'Register site visitors, assign tracking tags, and see who is onsite.',
  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="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2" />' +
        '<circle cx="9" cy="7" r="4" />' +
        '<path d="M19 8v6" />' +
        '<path d="M22 11h-6" />' +
        '</svg>'
});

// Dashboards (DVI-1191/DVI-1192): a login_required tile, default-disabled via
// Tools-Enabled (same treatment as Oversight). At-a-glance dashboards built from
// configurable tiles in a size-preset grid; P1 ships the Text tile.
TOOLS.push({
  id: 'dashboards',
  title: 'Dashboards',
  description: 'Build at-a-glance dashboards from configurable tiles in a size-preset grid.',
  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="3" width="7" height="9" rx="1" />' +
        '<rect x="14" y="3" width="7" height="5" rx="1" />' +
        '<rect x="14" y="12" width="7" height="9" rx="1" />' +
        '<rect x="3" y="16" width="7" height="5" rx="1" />' +
        '</svg>'
});

// QC (DVI-1326): Quality Control aggregator, default-disabled via Tools-Enabled.
// First capability is Batch Tickets — manual capture of daily concrete batch
// mixtures against versioned Mixture recipes (Wet Cast first).
TOOLS.push({
  id: 'qc',
  title: 'Batch Production',
  description: 'Batch Production: capture daily concrete batch tickets against mixture recipes, track materials consumed, and report daily totals.',
  aliases: 'QC Quality Control',
  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">' +
        '<path d="M9 12l2 2 4-5" />' +
        '<path d="M12 3l7 3v5c0 4.5-3 8.5-7 10-4-1.5-7-5.5-7-10V6l7-3z" />' +
        '</svg>'
});

// QC (DVI-1519 P3 / DVI-1522): the Quality Control aggregator surface. Opens a
// Tile Surface with quick-access tiles to Batch Production + Kiln Metrics; the
// tiles cross-navigate to those tools. Default-disabled + scoped-visible
// (_visibleSortedTools shows it to admins, qc.* grantees, and togen_scada).
TOOLS.push({
  id: 'quality',
  title: 'QC',
  description: 'Quality Control hub: quick access to Batch Production and Kiln Metrics.',
  aliases: 'Quality Control SCADA Batch',
  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="M12 3l7 3v5c0 4.5-3 8.5-7 10-4-1.5-7-5.5-7-10V6l7-3z" />' +
        '<path d="M9 11.5l2 2 4-4.5" />' +
        '</svg>'
});

// Logistics (DVI-1387/DVI-1397): truck/trailer/driver/load coordinator,
// default-disabled via Tools-Enabled. P1 ships Resources (drivers/trucks/
// trailers) + Settings → Events; Load Manager / LTL / Calendar / Oversight
// land in later phases.
TOOLS.push({
  id: 'logistics',
  title: 'Logistics',
  description: 'Plan deliveries: manage drivers, trucks and trailers, and configure load-timeline events.',
  order: 17,
  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 7h11v9H3z" />' +
        '<path d="M14 10h4l3 3v3h-7z" />' +
        '<circle cx="7" cy="18" r="1.6" />' +
        '<circle cx="17" cy="18" r="1.6" />' +
        '</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).
// DVI-1496 P5: `gridEl`/`baseCols` are optional — a tile SURFACE (e.g. the
// Equipment sub-page's #em-tiles-list) passes its own grid + base column count
// (Equipment base = 2) so per-section separator.columns overrides work there too;
// the home grid calls it with no args (grid=#tile-grid, base=_effectiveTileColumns).
function _applyTileGridColumns(gridEl, baseCols) {
  var grid = gridEl || 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 = baseCols || _effectiveTileColumns();
  Array.prototype.forEach.call(targets, function(el) {
    if (narrow) { el.style.gridTemplateColumns = ''; el.classList.remove('tile-section-single'); return; }
    // DVI-1356 F3: a section whose separator overrides columns (1..6) uses that
    // count; 'auto' (or no override / the single-grid case) follows the global
    // setting. Single-column sections get a class that clamps wide tiles to 1.
    var secCols = parseInt(el.getAttribute('data-sep-columns'), 10);
    var useCols = (secCols >= 1 && secCols <= 6) ? secCols : cols;
    el.style.gridTemplateColumns = (useCols === 'unlimited')
      ? 'repeat(auto-fill, minmax(260px, 1fr))'
      : 'repeat(' + useCols + ', minmax(0, 1fr))';
    el.classList.toggle('tile-section-single', useCols === 1);
  });
}

// 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.
// DVI-1357 F4: when editing the Default-list scope on the home grid, sort by and
// filter for the ADMIN layout (admin order + admin visibility), ignoring this
// admin's personal order/hides — so the editor is WYSIWYG for the default a plain
// user sees. Outside edit mode / in the My-list scope, behavior is unchanged.
function _visibleSortedTools() {
  var adminScope = _homeEditIsAdminScope();
  var effectiveOrder = _homeEffOrder();
  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 (adminScope) {
      // Editing the admin default: show admin-enabled tools only (a scoped Auditor
      // grantee still sees Auditor), never this admin's personal user-hides.
      if (tool.id === 'auditor' && (_isAuditorScoped || _toolVisibility[tool.id])) return true;
      if (tool.id === 'qc' && (_isQcScoped || _toolVisibility[tool.id])) return true;
      if (tool.id === 'quality' && (_isQualityScoped || _toolVisibility[tool.id])) return true;
      if (tool.id === 'logistics' && (_isLogisticsScoped || _toolVisibility[tool.id])) return true;
      if (_toolVisibility.hasOwnProperty(tool.id) && !_toolVisibility[tool.id]) return false;
      return true;
    }
    // DVI-1052: a scoped Auditor grantee sees the tile even though 'auditor' is
    // default-disabled in the global tool-visibility map.
    if (tool.id === 'auditor' && _isAuditorScoped) return true;
    // DVI-1373: a QC-scoped grantee sees the QC tile even though 'qc' is
    // default-disabled in the global tool-visibility map.
    if (tool.id === 'qc' && _isQcScoped) return true;
    // DVI-1522: a QC-surface-scoped grantee sees the 'quality' tile even though it
    // is default-disabled in the global tool-visibility map.
    if (tool.id === 'quality' && _isQualityScoped) return true;
    // DVI-1387: a Logistics-scoped grantee sees the tile even though 'logistics'
    // is default-disabled in the global tool-visibility map.
    if (tool.id === 'logistics' && _isLogisticsScoped) return true;
    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: '' };
}

// A home-grid separator token (DVI-524/DVI-1337): the legacy 'separator' marker
// or a unique 'sep_<hex>' token.
function _isSeparatorToken(tid) {
  return tid === 'separator' || (typeof tid === 'string' && tid.indexOf('sep_') === 0);
}

// Resolve a separator's properties (DVI-1337): the user's own override wins over
// the admin default; missing keys fall back to sensible defaults. Editing lives
// in P2 — P1 only consumes an optional label for the divider.
// Normalize a separator's per-section column override to auto|1..6 (DVI-1356 F3).
function _sepNormColumns(v) {
  var n = parseInt(v, 10);
  return (n >= 1 && n <= 6) ? n : 'auto';
}
function _sepProps(sepId, adminMap, userMap) {
  if (!sepId) return { label: '', justify: 'center', collapse: 'none', collapsed: false, columns: 'auto' };
  // DVI-1496 P4: adminMap/userMap default to the home stores so every existing
  // home caller + node test is unchanged; a tile surface passes its own maps.
  adminMap = adminMap || _adminToolSeparators;
  userMap = userMap || _homeSepUserMap();   // DVI-1357 F4: {} when editing the admin-default scope
  var src = userMap[sepId] || adminMap[sepId] || {};
  var justify = (src.justify === 'left' || src.justify === 'center' || src.justify === 'right') ? src.justify : 'center';
  var collapse = (src.collapse === 'left' || src.collapse === 'right' || src.collapse === 'both') ? src.collapse : 'none';
  return { label: (src.label || ''), justify: justify, collapse: collapse, collapsed: !!src.collapsed, columns: _sepNormColumns(src.columns) };
}

// Per-browser collapse state for a separator's section (DVI-1340 P2, D4). Kept in
// localStorage keyed by separator id (precedent: Oversight layer toggles) — no
// server write per toggle. The separator's collapsed-by-default seeds first paint
// when the browser has no stored preference.
function _sepCollapseKey(sepId) { return 'togenSepCollapse:' + sepId; }
function _sepIsCollapsed(sepId, props) {
  if (!sepId) return false;                       // group above the first bar never collapses
  // DVI-1516: accept pre-resolved (scope-correct) props. A tile surface (e.g.
  // Equipment) keeps its separators in its own store, not the home maps — an
  // unscoped _sepProps(sepId) there resolves to the default collapse:'none' and
  // the section would never hide (chevron click just re-renders). Callers on a
  // non-home surface pass their scoped props; home callers pass none.
  props = props || _sepProps(sepId);
  if (props.collapse === 'none') return false;    // not a collapsible bar
  var stored = null;
  try { stored = localStorage.getItem(_sepCollapseKey(sepId)); } catch (e) {}
  if (stored === '1') return true;
  if (stored === '0') return false;
  return !!props.collapsed;                        // collapsed_default seeds first paint
}
function _sepSetCollapsed(sepId, val) {
  if (!sepId) return;
  try { localStorage.setItem(_sepCollapseKey(sepId), val ? '1' : '0'); } catch (e) {}
}
function _sepToggleCollapse(sepId) {
  if (!sepId) return;
  _sepSetCollapsed(sepId, !_sepIsCollapsed(sepId));
  renderTileGrid();   // delegates to renderTileList in list view
}

// Up (^) / down (v) chevron reflecting a section's expanded / collapsed state.
function _sepChevronSvg(collapsed) {
  var path = collapsed
    ? 'M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'
    : 'M14.77 12.79a.75.75 0 01-1.06-.02L10 8.832 6.29 12.77a.75.75 0 11-1.08-1.04l4.25-4.5a.75.75 0 011.08 0l4.25 4.5a.75.75 0 01-.02 1.06z';
  return '<svg class="h-3.5 w-3.5 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="' + path + '" clip-rule="evenodd"/></svg>';
}

// ---- Separator Edit dialog (DVI-1340 P2) — shared by both tool editors ------
// Editor rows carry their separator's properties as data-sep-* attributes; the
// gear opens the shared modal, Save writes them back, and the editor's Save
// button collects them into the `separators` (admin) / `tool_separators` (user)
// payload. A row is only sent as a user override when it was actually edited
// (data-sep-edited) or is a user-minted separator (D3 inheritance).
var _sepEditTarget = null;   // the editor row/wrapper element being edited
var _sepEditContext = null;  // 'admin' | 'user'

// Admin-default props for a separator id, normalized to the resolved shape.
function _adminSepProps(sepId, adminMap) {
  adminMap = adminMap || _adminToolSeparators;
  var src = (adminMap && adminMap[sepId]) || {};
  var justify = (src.justify === 'left' || src.justify === 'right') ? src.justify : 'center';
  var collapse = (src.collapse === 'left' || src.collapse === 'right' || src.collapse === 'both') ? src.collapse : 'none';
  return { label: src.label || '', justify: justify, collapse: collapse, collapsed: !!src.collapsed, columns: _sepNormColumns(src.columns) };
}

// Seed an editor row's data-sep-* attributes from a resolved props object.
function _sepSeedElementProps(el, props) {
  el.setAttribute('data-sep-label', props.label || '');
  el.setAttribute('data-sep-justify', props.justify || 'center');
  el.setAttribute('data-sep-collapse', props.collapse || 'none');
  el.setAttribute('data-sep-collapsed', props.collapsed ? '1' : '0');
  el.setAttribute('data-sep-columns', props.columns || 'auto');
}

// Read an editor row's data-sep-* attributes into a props object for the payload.
function _sepReadElementProps(el) {
  var justify = el.getAttribute('data-sep-justify') || 'center';
  var collapse = el.getAttribute('data-sep-collapse') || 'none';
  var label = el.getAttribute('data-sep-label') || '';
  var collapsed = el.getAttribute('data-sep-collapsed') === '1';
  var columns = _sepNormColumns(el.getAttribute('data-sep-columns'));
  var props = { justify: justify };
  if (label) props.label = label;
  if (collapse !== 'none') props.collapse = collapse;
  if (collapsed && collapse !== 'none') props.collapsed = true;
  if (columns !== 'auto') props.columns = columns;   // int, matches the sanitizer enum
  return props;
}

// Refresh a row's on-screen summary (label text + collapsible badge) after edits.
function _sepUpdateRowSummary(el) {
  var summary = el.querySelector('.sep-row-summary');
  if (summary) {
    var label = el.getAttribute('data-sep-label') || '';
    summary.textContent = label || 'Separator';
    summary.classList.toggle('italic', !label);
  }
  var badge = el.querySelector('.sep-row-badge');
  if (badge) badge.classList.toggle('hidden', (el.getAttribute('data-sep-collapse') || 'none') === 'none');
}

function _sepEditOpen(el, context) {
  _sepEditTarget = el;
  _sepEditContext = context;
  document.getElementById('sep-edit-label').value = el.getAttribute('data-sep-label') || '';
  document.getElementById('sep-edit-justify').value = el.getAttribute('data-sep-justify') || 'center';
  document.getElementById('sep-edit-columns').value = String(_sepNormColumns(el.getAttribute('data-sep-columns')));
  document.getElementById('sep-edit-collapse').value = el.getAttribute('data-sep-collapse') || 'none';
  document.getElementById('sep-edit-collapsed').checked = el.getAttribute('data-sep-collapsed') === '1';
  _sepEditSyncCollapsedEnable();
  // "Reset to default" only makes sense when a user is editing a separator that
  // exists in the admin layout (an inherited admin separator, D3).
  var tid = el.getAttribute('data-tool-id');
  var isInherited = context === 'user' && _seAdminOrder().indexOf(tid) !== -1;
  document.getElementById('sep-edit-reset').classList.toggle('hidden', !isInherited);
  document.getElementById('sep-edit-modal').classList.remove('hidden');
  document.getElementById('sep-edit-label').focus();
}

// Collapsed-by-default only applies to a collapsible bar.
function _sepEditSyncCollapsedEnable() {
  var none = document.getElementById('sep-edit-collapse').value === 'none';
  var cb = document.getElementById('sep-edit-collapsed');
  cb.disabled = none;
  if (none) cb.checked = false;
}

function _sepEditSave() {
  var el = _sepEditTarget;
  if (!el) return;
  var collapse = document.getElementById('sep-edit-collapse').value;
  var collapsed = document.getElementById('sep-edit-collapsed').checked && collapse !== 'none';
  el.setAttribute('data-sep-label', (document.getElementById('sep-edit-label').value || '').trim().slice(0, 60));
  el.setAttribute('data-sep-justify', document.getElementById('sep-edit-justify').value);
  el.setAttribute('data-sep-columns', document.getElementById('sep-edit-columns').value);
  el.setAttribute('data-sep-collapse', collapse);
  el.setAttribute('data-sep-collapsed', collapsed ? '1' : '0');
  el.setAttribute('data-sep-edited', '1');   // now an explicit (user) override / admin default
  _sepUpdateRowSummary(el);
  _sepEditClose();
  _homeMaybeCommitAfterEdit(el);   // DVI-1357 F4: auto-save when the target is a home tile
}

// D3: drop a user's override and fall back to the inherited admin default. The
// row displays the admin values but is marked not-edited so the editor's Save
// omits it from tool_separators (the server then keeps no user override).
function _sepEditReset() {
  var el = _sepEditTarget;
  if (!el) return;
  _sepSeedElementProps(el, _adminSepProps(el.getAttribute('data-tool-id'), _seAdminSepMap()));
  el.setAttribute('data-sep-edited', '0');
  _sepUpdateRowSummary(el);
  _sepEditClose();
  _homeMaybeCommitAfterEdit(el);   // DVI-1357 F4
}

function _sepEditClose() {
  document.getElementById('sep-edit-modal').classList.add('hidden');
  _sepEditTarget = null;
  _sepEditContext = null;
}

// A gear button (Edit) for an editor separator row; opens the shared dialog.
function _sepEditGearHtml() {
  return '<button type="button" class="sep-row-edit shrink-0 inline-flex items-center justify-center h-7 w-7 rounded-lg ' +
    'text-gray-400 hover:text-togen-teal hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer" title="Edit separator">' +
    '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.53 1.53 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.53 1.53 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.53 1.53 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.532 1.532 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.53 1.53 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.53 1.53 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.53 1.53 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"/></svg>' +
    '</button>';
}

// ---- Web-item (website tile) helpers (DVI-1344) ----------------------------
// A website item token (a unique 'web_<hex>' token). P2/P3 dtile_/dashref_
// tokens join _isToolItemToken behind the same seams.
function _isWebItemToken(tid) { return typeof tid === 'string' && tid.indexOf('web_') === 0; }
// A dashboard-tile item token (DVI-1346 P2): 'dtile_<hex>'.
function _isDashItemToken(tid) { return typeof tid === 'string' && tid.indexOf('dtile_') === 0; }
// An embedded-dashboard item token (DVI-1347 P3): 'dashref_<hex>'.
function _isDashRefToken(tid) { return typeof tid === 'string' && tid.indexOf('dashref_') === 0; }
function _isToolItemToken(tid) { return _isWebItemToken(tid) || _isDashItemToken(tid) || _isDashRefToken(tid); }

// Resolve a web item's properties: the user's own override wins over the admin
// default (D3), like _sepProps. Returns null when the item has no name/url.
function _webItemProps(itemId, adminMap, userMap) {
  if (!itemId) return null;
  adminMap = adminMap || _adminToolItems;               // DVI-1496 P4: surface maps
  userMap = userMap || _homeItemUserMap();   // DVI-1357 F4: {} when editing the admin-default scope
  var src = userMap[itemId] || adminMap[itemId] || null;
  if (!src || !src.name || !src.url) return null;
  return {
    type: src.type || 'web',
    name: String(src.name),
    url: String(src.url),
    mode: _webModeOf(src),
    icon_mode: _webIconModeOf(src),
    icon: src.icon || '',
    glyph: src.glyph || ''
  };
}

// Admin-default props for a web item id, normalized (used by "Reset to default").
function _adminWebItemProps(itemId, adminMap) {
  adminMap = adminMap || _adminToolItems;
  var src = (adminMap && adminMap[itemId]) || {};
  return {
    type: src.type || 'web',
    name: src.name || '',
    url: src.url || '',
    mode: _webModeOf(src),
    icon_mode: _webIconModeOf(src),
    icon: src.icon || '',
    glyph: src.glyph || ''
  };
}

// Resolve a web item's open mode (DVI-1354 F1): 'embed' opens an in-app iframe
// pane, anything else (incl. missing/legacy) is the default new-tab behavior.
function _webModeOf(src) {
  return src && src.mode === 'embed' ? 'embed' : 'newtab';
}

// Mint a unique web-item token (client-side; server re-validates + caps at 24).
function _mintWebItemId() {
  var hex = '';
  for (var i = 0; i < 10; i++) hex += Math.floor(Math.random() * 16).toString(16);
  return 'web_' + hex;
}

function _webItemHost(url) { try { return new URL(url).host; } catch (e) { return url; } }
function _openWebItem(url) { try { window.open(url, '_blank', 'noopener'); } catch (e) {} }

// Activate a website tile per its open mode (DVI-1354 F1): 'embed' opens the
// in-app iframe pane, otherwise open the URL in a new tab (the default).
function _webActivateItem(item) {
  if (item && item.mode === 'embed') { _openWebEmbed(item); return; }
  _openWebItem(item.url);
}

// Generic globe fallback icon (used when a favicon fetch / custom icon 404s).
function _webItemGlobeSvg(sizeCls) {
  sizeCls = sizeCls || 'h-10 w-10';
  return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="' + sizeCls + '">' +
    '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/>' +
    '<path d="M12 3c2.5 2.5 2.5 15.5 0 18M12 3c-2.5 2.5-2.5 15.5 0 18"/></svg>';
}

// ---- Glyph/emoji icon library (DVI-1355, DVI-1344 F5) ----------------------
// A curated third icon option beside favicon + custom upload. Each entry stores
// only a short id on the web-item props (icon_mode:'glyph', glyph:'<id>'); the
// server allowlists that id (_WEB_ITEM_GLYPH_IDS) so it can never be arbitrary.
// kind 's' = an inline SVG resolved by id via _webGlyphSvg; kind 'e' = an emoji
// rendered as text. This id set MUST mirror the server allowlist exactly.
var _WEB_GLYPHS = [
  { id: 'link', kind: 's', label: 'Link' },
  { id: 'globe', kind: 's', label: 'Website' },
  { id: 'chart', kind: 's', label: 'Chart' },
  { id: 'doc', kind: 's', label: 'Document' },
  { id: 'wrench', kind: 's', label: 'Wrench' },
  { id: 'factory', kind: 's', label: 'Factory' },
  { id: 'camera', kind: 's', label: 'Camera' },
  { id: 'book', kind: 's', label: 'Book' },
  { id: 'gear', kind: 'e', v: '⚙️', label: 'Gear' },
  { id: 'toolbox', kind: 'e', v: '🧰', label: 'Toolbox' },
  { id: 'hammer', kind: 'e', v: '🔨', label: 'Hammer' },
  { id: 'screwdriver', kind: 'e', v: '🪛', label: 'Screwdriver' },
  { id: 'nut_bolt', kind: 'e', v: '🔩', label: 'Nut & bolt' },
  { id: 'thermometer', kind: 'e', v: '🌡️', label: 'Thermometer' },
  { id: 'water', kind: 'e', v: '💧', label: 'Water' },
  { id: 'fire', kind: 'e', v: '🔥', label: 'Fire' },
  { id: 'bolt', kind: 'e', v: '⚡', label: 'Power' },
  { id: 'battery', kind: 'e', v: '🔋', label: 'Battery' },
  { id: 'plug', kind: 'e', v: '🔌', label: 'Plug' },
  { id: 'lightbulb', kind: 'e', v: '💡', label: 'Idea' },
  { id: 'package', kind: 'e', v: '📦', label: 'Package' },
  { id: 'truck', kind: 'e', v: '🚚', label: 'Delivery' },
  { id: 'forklift', kind: 'e', v: '🚜', label: 'Forklift' },
  { id: 'clipboard', kind: 'e', v: '📋', label: 'Clipboard' },
  { id: 'calendar', kind: 'e', v: '📅', label: 'Calendar' },
  { id: 'clock', kind: 'e', v: '🕒', label: 'Clock' },
  { id: 'stopwatch', kind: 'e', v: '⏱️', label: 'Stopwatch' },
  { id: 'warning', kind: 'e', v: '⚠️', label: 'Warning' },
  { id: 'check', kind: 'e', v: '✅', label: 'Done' },
  { id: 'stop', kind: 'e', v: '🛑', label: 'Stop' },
  { id: 'bell', kind: 'e', v: '🔔', label: 'Alert' },
  { id: 'chart_up', kind: 'e', v: '📈', label: 'Trending up' },
  { id: 'chart_down', kind: 'e', v: '📉', label: 'Trending down' },
  { id: 'laptop', kind: 'e', v: '💻', label: 'Laptop' },
  { id: 'desktop', kind: 'e', v: '🖥️', label: 'Desktop' },
  { id: 'phone', kind: 'e', v: '📱', label: 'Phone' },
  { id: 'email', kind: 'e', v: '📧', label: 'Email' },
  { id: 'folder', kind: 'e', v: '🗂️', label: 'Folder' },
  { id: 'building', kind: 'e', v: '🏢', label: 'Building' },
  { id: 'construction', kind: 'e', v: '🚧', label: 'Construction' },
  { id: 'safety_vest', kind: 'e', v: '🦺', label: 'Safety vest' },
  { id: 'helmet', kind: 'e', v: '⛑️', label: 'Hard hat' },
  { id: 'magnify', kind: 'e', v: '🔍', label: 'Search' },
  { id: 'test_tube', kind: 'e', v: '🧪', label: 'Test tube' },
  { id: 'flask', kind: 'e', v: '⚗️', label: 'Flask' },
  { id: 'microscope', kind: 'e', v: '🔬', label: 'Microscope' },
  { id: 'magnet', kind: 'e', v: '🧲', label: 'Magnet' },
  { id: 'pin', kind: 'e', v: '📌', label: 'Pin' },
  { id: 'key', kind: 'e', v: '🔑', label: 'Key' },
  { id: 'lock', kind: 'e', v: '🔒', label: 'Lock' },
  { id: 'recycle', kind: 'e', v: '♻️', label: 'Recycle' },
  { id: 'ruler', kind: 'e', v: '📏', label: 'Measure' }
];
var _webGlyphMap = (function() { var m = {}; _WEB_GLYPHS.forEach(function(g){ m[g.id] = g; }); return m; })();

// Inline SVG markup for a glyph id (the code-side svg glyphs). Unknown id -> ''.
function _webGlyphSvg(id, sizeCls) {
  sizeCls = sizeCls || 'h-10 w-10';
  var open = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" class="' + sizeCls + '">';
  var body = {
    link: '<path d="M10 13a5 5 0 007.07 0l3-3a5 5 0 00-7.07-7.07l-1.5 1.5"/><path d="M14 11a5 5 0 00-7.07 0l-3 3a5 5 0 007.07 7.07l1.5-1.5"/>',
    globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3c2.5 2.5 2.5 15.5 0 18M12 3c-2.5 2.5-2.5 15.5 0 18"/>',
    chart: '<path d="M4 20V4"/><path d="M4 20h16"/><rect x="7" y="12" width="3" height="5"/><rect x="12" y="8" width="3" height="9"/><rect x="17" y="5" width="3" height="12"/>',
    doc: '<path d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 17h6"/>',
    wrench: '<path d="M14.7 6.3a4 4 0 00-5.4 5l-6 6a1.5 1.5 0 002.1 2.1l6-6a4 4 0 005-5.4l-2.5 2.5-2.1-.6-.6-2.1z"/>',
    factory: '<path d="M3 21V10l6 4V10l6 4V6l3-2v17z"/><path d="M3 21h18"/><path d="M7 17h1M11 17h1M15 17h1"/>',
    camera: '<path d="M4 8a2 2 0 012-2h1l1.2-1.6a1 1 0 01.8-.4h6a1 1 0 01.8.4L21 6h1"/><rect x="3" y="6" width="18" height="13" rx="2"/><circle cx="12" cy="12.5" r="3.5"/>',
    book: '<path d="M4 5a2 2 0 012-2h13v16H6a2 2 0 00-2 2z"/><path d="M4 19a2 2 0 012-2h13"/>'
  };
  return body[id] ? (open + body[id] + '</svg>') : '';
}

// A DOM node for a glyph icon: an inline SVG (kind 's') or an emoji span sized to
// the icon box (kind 'e'). Unknown id falls back to the link glyph.
function _webGlyphNode(id, sizeCls) {
  sizeCls = sizeCls || 'h-10 w-10';
  var g = _webGlyphMap[id];
  if (g && g.kind === 's') {
    var wrap = document.createElement('span');
    wrap.innerHTML = _webGlyphSvg(id, sizeCls);
    if (wrap.firstChild) return wrap.firstChild;
  }
  var el = document.createElement('span');
  el.className = sizeCls + ' inline-flex items-center justify-center leading-none select-none';
  var m = /h-(\d+)/.exec(sizeCls);
  var n = m ? parseInt(m[1], 10) : 10;
  el.style.fontSize = (n * 0.2) + 'rem';
  el.textContent = (g && g.v) ? g.v : '🔗';
  return el;
}

// Resolve a web item's effective icon_mode from its stored props, degrading a
// glyph/custom entry whose id is missing/unknown back to favicon.
function _webIconModeOf(src) {
  if (src.icon_mode === 'glyph' && src.glyph && _webGlyphMap[src.glyph]) return 'glyph';
  if (src.icon_mode === 'custom' && src.icon) return 'custom';
  return 'favicon';
}

// A same-origin <img> for a web item's icon (favicon fetch or custom upload),
// swapping to the globe SVG on load error. Built as a DOM node so the error
// handler needs no quote-escaping in an inline attribute. A 'glyph' item renders
// a code-side glyph instead of an <img> (DVI-1355).
function _webIconNode(item, sizeCls) {
  sizeCls = sizeCls || 'h-10 w-10';
  if (item.icon_mode === 'glyph' && item.glyph) return _webGlyphNode(item.glyph, sizeCls);
  var img = document.createElement('img');
  img.alt = '';
  img.className = sizeCls + ' object-contain';
  img.src = (item.icon_mode === 'custom' && item.icon)
    ? ('/tool-icon/' + encodeURIComponent(item.icon))
    : ('/tool-icon/favicon?u=' + encodeURIComponent(item.url));
  img.addEventListener('error', function() {
    var span = document.createElement('span');
    span.innerHTML = _webItemGlobeSvg(sizeCls);
    var svg = span.firstChild;
    if (svg && img.parentNode) img.parentNode.replaceChild(svg, img);
  });
  return img;
}

// Small external-link glyph shown beside a web tile's name.
function _webExternalSvg() {
  return '<svg class="h-3.5 w-3.5 shrink-0 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">' +
    '<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>';
}

// A home-grid website tile (opens the URL in a new tab, D2).
function _buildWebTile(item) {
  var tile = document.createElement('button');
  tile.type = 'button';
  var embed = item.mode === 'embed';
  tile.onclick = function() { _webActivateItem(item); };
  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';
  var host = _webItemHost(item.url);
  tile.setAttribute('aria-label', item.name + (embed ? ' — opens ' + host + ' in an embedded pane' : ' — opens ' + host + ' in a new tab'));
  var iconWrap = document.createElement('div');
  iconWrap.className = 'text-gray-400 dark:text-gray-500 group-hover:text-togen-teal transition-colors flex items-center justify-center';
  iconWrap.appendChild(_webIconNode(item, 'h-10 w-10'));
  var body = document.createElement('div');
  body.innerHTML =
    '<p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors flex items-center justify-center gap-1.5">' +
      esc(item.name) + (embed ? '' : _webExternalSvg()) + '</p>' +
    '<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 leading-relaxed truncate">' + esc(host) + '</p>';
  tile.appendChild(iconWrap);
  tile.appendChild(body);
  return tile;
}

// Compact list-view row for a website item, mirroring _buildToolListRow.
function _buildWebListRow(item) {
  var row = document.createElement('button');
  row.type = 'button';
  var embed = item.mode === 'embed';
  row.onclick = function() { _webActivateItem(item); };
  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 iconWrap = document.createElement('div');
  iconWrap.className = 'flex-shrink-0 text-gray-400 dark:text-gray-500 group-hover:text-togen-teal transition-colors flex items-center justify-center';
  iconWrap.appendChild(_webIconNode(item, 'h-8 w-8'));
  var mid = document.createElement('div');
  mid.className = 'min-w-0 flex-1';
  mid.innerHTML =
    '<p class="text-sm font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors truncate flex items-center gap-1.5">' +
      esc(item.name) + (embed ? '' : _webExternalSvg()) + '</p>' +
    '<p class="text-xs text-gray-500 dark:text-gray-400 truncate">' + esc(_webItemHost(item.url)) + '</p>';
  row.appendChild(iconWrap);
  row.appendChild(mid);
  return row;
}

// ---- Website embed pane (DVI-1354, DVI-1344 F1) ----------------------------
// An 'embed' website tile opens an in-app pane (breadcrumb/back via _showTool)
// rendering the site in a sandboxed <iframe>. Many sites forbid framing
// (X-Frame-Options / CSP frame-ancestors) and that is NOT reliably detectable
// cross-origin, so the pane ALWAYS shows an "Open in new tab" button in its
// header and, after a load-timeout heuristic, a fallback notice — a refused or
// blank frame is never a dead end. The sandbox keeps normal sites working
// (scripts/same-origin/forms/popups) while denying top-navigation and other
// escapes; allow-same-origin is required for most sites' own scripts/cookies.
var _WEBEMBED_TOOL_ID = '__webembed';
var _webEmbedTimer = null;

// Lazily build the embed pane as a .tool-content sibling so _showTool reveals it
// like any tool pane. Returns the pane element, or null if there's no host.
function _webEmbedEnsurePane() {
  var pane = document.getElementById('tool-' + _WEBEMBED_TOOL_ID);
  if (pane) return pane;
  var anchor = document.querySelector('.tool-content');
  if (!anchor || !anchor.parentNode) return null;
  pane = document.createElement('div');
  pane.id = 'tool-' + _WEBEMBED_TOOL_ID;
  pane.className = 'tool-content hidden';
  pane.innerHTML =
    '<div class="togen-pane bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col" style="height:calc(100vh - 13rem);min-height:30rem;">' +
      '<div class="flex items-center gap-3 px-4 py-2.5 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/60">' +
        '<div class="min-w-0 flex-1">' +
          '<p id="webembed-name" class="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate"></p>' +
          '<p id="webembed-host" class="text-xs text-gray-500 dark:text-gray-400 truncate"></p>' +
        '</div>' +
        '<a id="webembed-open" href="#" target="_blank" rel="noopener" ' +
          'class="shrink-0 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/10 transition-colors cursor-pointer">' +
          'Open in new tab' +
          '<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">' +
          '<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>' +
        '</a>' +
      '</div>' +
      '<div id="webembed-notice" class="hidden px-4 py-2 text-xs text-amber-800 dark:text-amber-200 bg-amber-50 dark:bg-amber-900/30 border-b border-amber-200 dark:border-amber-800">' +
        'This site is taking a while or may not allow embedding. Use <span class="font-semibold">Open in new tab</span> above.' +
      '</div>' +
      '<div class="relative flex-1 bg-white dark:bg-gray-950">' +
        '<iframe id="webembed-frame" class="absolute inset-0 w-full h-full border-0" title="Embedded website" ' +
          'referrerpolicy="no-referrer-when-downgrade" ' +
          'sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"></iframe>' +
      '</div>' +
    '</div>';
  anchor.parentNode.appendChild(pane);
  return pane;
}

function _openWebEmbed(item) {
  var pane = _webEmbedEnsurePane();
  if (!pane) { _openWebItem(item.url); return; }  // no pane host -> new tab
  var frame = document.getElementById('webembed-frame');
  var nameEl = document.getElementById('webembed-name');
  var hostEl = document.getElementById('webembed-host');
  var openEl = document.getElementById('webembed-open');
  var notice = document.getElementById('webembed-notice');
  if (nameEl) nameEl.textContent = item.name || 'Website';
  if (hostEl) hostEl.textContent = _webItemHost(item.url);
  if (openEl) openEl.href = item.url;
  if (notice) notice.classList.add('hidden');
  if (_webEmbedTimer) { clearTimeout(_webEmbedTimer); _webEmbedTimer = null; }
  if (frame) {
    frame.onload = function() {
      if (_webEmbedTimer) { clearTimeout(_webEmbedTimer); _webEmbedTimer = null; }
    };
    frame.src = item.url;
    // Cross-origin framing refusal isn't detectable; if load hasn't fired in a
    // few seconds, surface the always-available "Open in new tab" fallback.
    _webEmbedTimer = setTimeout(function() {
      if (notice) notice.classList.remove('hidden');
    }, 4500);
  }
  // Push history like openTool so the browser Back returns to Tools.
  if (_activeTool) history.replaceState({page: 'tool', tool: _WEBEMBED_TOOL_ID, title: item.name || 'Website'}, '');
  else history.pushState({page: 'tool', tool: _WEBEMBED_TOOL_ID, title: item.name || 'Website'}, '');
  _showTool(_WEBEMBED_TOOL_ID, item.name || 'Website');
}

// ---- Dashboard-tile home items (DVI-1346 P2) -------------------------------
// A dtile_ item wraps a dashboards tile (registered type + validated config)
// rendered on the home grid with live data polled from /tools/tile-data under
// the viewer identity. Resolution mirrors _webItemProps: the user's own override
// wins over the admin default (D3).
function _dtileItemProps(itemId, adminMap, userMap) {
  if (!itemId) return null;
  adminMap = adminMap || _adminToolItems;               // DVI-1496 P4: surface maps
  userMap = userMap || _homeItemUserMap();   // DVI-1357 F4: {} when editing the admin-default scope
  var src = userMap[itemId] || adminMap[itemId] || null;
  if (!src || src.type !== 'dtile' || !src.tile_type) return null;
  return {
    type: 'dtile',
    tile_type: String(src.tile_type),
    config: src.config || {},
    size: (src.size === 'wide' || src.size === 'tall') ? src.size : 'default',
    title: src.title || ''
  };
}
function _adminDashItemProps(itemId, adminMap) {
  adminMap = adminMap || _adminToolItems;
  var src = (adminMap && adminMap[itemId]) || {};
  return {
    type: 'dtile',
    tile_type: src.tile_type || '',
    config: src.config || {},
    size: (src.size === 'wide' || src.size === 'tall') ? src.size : 'default',
    title: src.title || ''
  };
}
function _mintDashItemId() {
  var hex = '';
  for (var i = 0; i < 10; i++) hex += Math.floor(Math.random() * 16).toString(16);
  return 'dtile_' + hex;
}
function _dtileTypeLabel(tt) {
  return (_dashTileTypes && _dashTileTypes[tt] && _dashTileTypes[tt].label) || tt || '';
}

// Live payloads for home dashboard tiles, keyed by item id (polled).
var _toolTileData = {};

// F2 (DVI-1346): clicking a dashboard tile navigates to its source tool/view.
// Types with no meaningful home have no target (the tile is display-only).
function _dtileSourceTool(tileType) {
  var map = {
    work_order_view: 'wor-admin', parts: 'repair-parts-manager',
    scada_view: 'scada-view', oversight: 'oversight',
    chart: 'auditor', report_table: 'auditor',
    idencia: 'auditor', powerbi_data: 'auditor', powerbi_report: 'auditor'
  };
  return map[tileType] || '';
}
function _dtileToolTitle(id) {
  for (var i = 0; i < TOOLS.length; i++) { if (TOOLS[i].id === id) return TOOLS[i].title; }
  return (_toolVisibilityLabels && _toolVisibilityLabels[id]) || id;
}

// A home-grid dashboard tile: a card whose body is rendered through the shared
// dashboards renderer (dashboardsRenderTileBody) from the polled payload.
function _buildDashTile(item, itemId) {
  var card = document.createElement('div');
  var sizeCls = (item.size === 'wide') ? ' sm:col-span-2' : '';
  card.className = 'togen-pane flex flex-col p-5 bg-white dark:bg-gray-900 rounded-2xl shadow-sm ' +
                   'border border-gray-200 dark:border-gray-700' + sizeCls;
  if (item.size === 'tall') card.style.minHeight = '22rem';
  var srcTool = _dtileSourceTool(item.tile_type);
  // DVI-1353 (F0): the home-page tile face shows a header ONLY when the tile has
  // a custom title. An untitled tile no longer falls back to the source-tool/type
  // label. Keep the F2 click-through button (right-aligned) when srcTool exists;
  // if there is neither a title nor a srcTool button, omit the header row entirely
  // so the tile body gets the full card height.
  var titleText = item.title || '';
  if (titleText || srcTool) {
    var head = document.createElement('div');
    // Right-align the lone open button when there is no title to balance it against.
    head.className = 'flex items-center gap-2 mb-3 ' + (titleText ? 'justify-between' : 'justify-end');
    if (titleText) {
      head.innerHTML = '<span class="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">' + esc(titleText) + '</span>';
    }
    if (srcTool) {
      var open = document.createElement('button');
      open.type = 'button';
      open.className = 'shrink-0 inline-flex items-center gap-1 text-xs text-gray-400 hover:text-togen-teal transition-colors cursor-pointer';
      open.title = 'Open ' + _dtileToolTitle(srcTool);
      open.innerHTML = _webExternalSvg();
      open.onclick = function() { openTool(srcTool, _dtileToolTitle(srcTool)); };
      head.appendChild(open);
    }
    card.appendChild(head);
  }
  var body = document.createElement('div');
  body.className = 'dash-tile-body flex-1 min-h-0 overflow-auto';
  body.setAttribute('data-tile-id', itemId);
  card.appendChild(body);
  dashboardsRenderTileBody(body, { id: itemId, type: item.tile_type, config: item.config }, _toolTileData[itemId]);
  return card;
}

// Dispatch a partition entry ({kind:'tool'|'web'|'dtile'}) to the right builder.
function _buildHomeEntry(entry) {
  if (entry.kind === 'web') return _buildWebTile(entry.item);
  if (entry.kind === 'dtile') return _buildDashTile(entry.item, entry.id);
  if (entry.kind === 'dashref') return _buildDashRef(entry.item, entry.id);
  return _buildToolTile(entry.tool);
}
function _buildHomeEntryListRow(entry) {
  if (entry.kind === 'web') return _buildWebListRow(entry.item);
  if (entry.kind === 'dtile') return _buildDashTile(entry.item, entry.id);
  if (entry.kind === 'dashref') return _buildDashRef(entry.item, entry.id);
  return _buildToolListRow(entry.tool);
}

// ---- Embedded-dashboard home items (DVI-1347 P3) ---------------------------
// A dashref_ item references a whole Dashboard, rendered as a full-width
// collapsible section under a bar that follows the Separator model. Its tiles +
// live data are polled from /tools/tile-data (dashrefs map) under the viewer
// identity. Resolution mirrors _dtileItemProps: user override wins over admin.
function _dashRefProps(itemId, adminMap, userMap) {
  if (!itemId) return null;
  adminMap = adminMap || _adminToolItems;               // DVI-1496 P4: surface maps
  userMap = userMap || _homeItemUserMap();   // DVI-1357 F4: {} when editing the admin-default scope
  var src = userMap[itemId] || adminMap[itemId] || null;
  if (!src || src.type !== 'dashref' || !src.dashboard_id) return null;
  return {
    type: 'dashref',
    dashboard_id: String(src.dashboard_id),
    title: src.title || '',
    justify: (src.justify === 'left' || src.justify === 'right') ? src.justify : 'center',
    collapse: (src.collapse === 'left' || src.collapse === 'right' || src.collapse === 'both') ? src.collapse : 'none',
    collapsed: !!src.collapsed,
    columns: _dashRefNormColumns(src.columns),
    rows: _dashRefNormColumns(src.rows)
  };
}
function _adminDashRefProps(itemId, adminMap) {
  adminMap = adminMap || _adminToolItems;
  var src = (adminMap && adminMap[itemId]) || {};
  return {
    type: 'dashref',
    dashboard_id: src.dashboard_id || '',
    title: src.title || '',
    justify: (src.justify === 'left' || src.justify === 'right') ? src.justify : 'center',
    collapse: (src.collapse === 'left' || src.collapse === 'right' || src.collapse === 'both') ? src.collapse : 'none',
    collapsed: !!src.collapsed,
    columns: _dashRefNormColumns(src.columns),
    rows: _dashRefNormColumns(src.rows)
  };
}
function _dashRefNormColumns(v) {
  var n = parseInt(v, 10);
  return (n >= 1 && n <= 6) ? n : 'auto';
}
function _mintDashRefId() {
  var hex = '';
  for (var i = 0; i < 10; i++) hex += Math.floor(Math.random() * 16).toString(16);
  return 'dashref_' + hex;
}

// Live payloads for embedded dashboards, keyed by item id (polled). Each is
// { access, available?, name, columns, rows, tiles:[{id,type,config,size}],
//   data:{tile_id: payload} } — see _resolve_dashref_payload.
var _toolDashRefData = {};

// Per-browser collapse state for a dashref's section (mirrors _sepIsCollapsed
// but reads the collapse config from _dashRefProps, keyed by the dashref id).
function _dashRefIsCollapsed(itemId, props) {
  if (!itemId) return false;
  props = props || _dashRefProps(itemId) || {};
  if (props.collapse === 'none' || !props.collapse) return false;
  var stored = null;
  try { stored = localStorage.getItem(_sepCollapseKey(itemId)); } catch (e) {}
  if (stored === '1') return true;
  if (stored === '0') return false;
  return !!props.collapsed;   // collapsed-by-default seeds first paint
}
function _dashRefToggleCollapse(itemId) {
  if (!itemId) return;
  _sepSetCollapsed(itemId, !_dashRefIsCollapsed(itemId));
  renderTileGrid();
}

// The embedded dashboard's title falls back to the referenced dashboard's own
// name (from the last poll) when the item carries no explicit title.
function _dashRefTitle(item, itemId) {
  if (item.title) return item.title;
  var d = _toolDashRefData[itemId];
  if (d && d.name) return d.name;
  return 'Dashboard';
}

// A full-width embedded-dashboard section: a Separator-model bar (justify /
// chevrons / per-browser collapse) heading the referenced dashboard's tiles.
function _buildDashRef(item, itemId) {
  var wrap = document.createElement('div');
  // Span the full section width regardless of the home grid column count.
  wrap.className = 'dashref-section col-span-full flex flex-col gap-4';
  var collapsible = item.collapse !== 'none' && item.collapse;
  var collapsed = collapsible && _dashRefIsCollapsed(itemId, item);
  wrap.appendChild(_dashRefBar(item, itemId, collapsible, collapsed));
  var body = document.createElement('div');
  body.className = 'dashref-body';
  body.setAttribute('data-dashref-id', itemId);
  if (collapsed) body.classList.add('hidden');
  _buildDashRefBody(body, itemId, item, _toolDashRefData[itemId]);
  wrap.appendChild(body);
  return wrap;
}

// The collapsible bar heading a dashref section — reuses the separator pill /
// chevron styling (_sepChevronSvg) so it reads as one system with dividers.
function _dashRefBar(item, itemId, collapsible, collapsed) {
  var d = document.createElement('div');
  d.className = 'dashref-bar flex items-center gap-3';
  var rule = '<span class="h-px flex-1 bg-gray-300 dark:bg-gray-600"></span>';
  var showLeft = item.collapse === 'left' || item.collapse === 'both';
  var showRight = item.collapse === 'right' || item.collapse === 'both';
  var chev = collapsible ? _sepChevronSvg(collapsed) : '';
  var labelHtml = '<span class="text-sm font-semibold text-gray-700 dark:text-gray-200 truncate max-w-[20rem]">' +
    esc(_dashRefTitle(item, itemId)) + '</span>';
  var pillInner = (showLeft ? chev : '') + labelHtml + (showRight ? chev : '');
  var pill;
  if (collapsible) {
    pill = '<button type="button" class="dashref-pill togen-control shrink-0 inline-flex items-center gap-1.5 px-3 py-1 rounded-full ' +
      'border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 ' +
      'text-gray-600 dark:text-gray-300 hover:text-togen-teal dark:hover:text-togen-teal transition-colors cursor-pointer" ' +
      'aria-expanded="' + (collapsed ? 'false' : 'true') + '" title="' + (collapsed ? 'Expand' : 'Collapse') + ' this dashboard">' + pillInner + '</button>';
  } else {
    pill = '<span class="dashref-pill togen-inset shrink-0 inline-flex items-center gap-1.5 px-3 py-1 rounded-full ' +
      'border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-300">' + pillInner + '</span>';
  }
  if (item.justify === 'left') d.innerHTML = pill + rule;
  else if (item.justify === 'right') d.innerHTML = rule + pill;
  else d.innerHTML = rule + pill + rule;
  if (collapsible) {
    var btn = d.querySelector('.dashref-pill');
    if (btn) btn.addEventListener('click', function() { _dashRefToggleCollapse(itemId); });
  }
  return d;
}

// Grid column template for an embedded dashboard: an explicit column count, else
// an auto count scaling with the number of tiles (1/2/3-up).
function _dashRefGridStyle(item, tileCount) {
  var cols = _dashRefNormColumns(item.columns);
  if (cols === 'auto') cols = tileCount <= 1 ? 1 : (tileCount === 2 ? 2 : 3);
  return 'display:grid;gap:1rem;grid-template-columns:repeat(' + cols + ',minmax(0,1fr))';
}

// Render an embedded dashboard's body from the polled payload. Redacts the whole
// section (not the page) when the viewer can't access the dashboard; each tile
// still enforces its own source gate inside dashboardsRenderTileBody.
function _buildDashRefBody(body, itemId, item, data) {
  body.innerHTML = '';
  if (!data) {
    body.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 py-6 text-center">Loading dashboard…</p>';
    return;
  }
  if (data.access === false) {
    body.innerHTML = '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 p-6 text-center text-sm text-gray-500 dark:text-gray-400">' +
      esc(data.message || "You don't have access to this dashboard.") + '</div>';
    return;
  }
  if (data.available === false) {
    body.innerHTML = '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 p-6 text-center text-sm text-gray-500 dark:text-gray-400">' +
      esc(data.message || 'This dashboard is unavailable.') + '</div>';
    return;
  }
  var tiles = data.tiles || [];
  if (!tiles.length) {
    body.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 py-6 text-center">This dashboard has no tiles.</p>';
    return;
  }
  var grid = document.createElement('div');
  grid.setAttribute('style', _dashRefGridStyle(item, tiles.length));
  tiles.forEach(function(t) {
    var card = document.createElement('div');
    var sizeCls = (t.size === 'large' || t.size === 'wide' || t.size === 'xlarge') ? ' col-span-full sm:col-span-2' : '';
    card.className = 'togen-pane flex flex-col p-4 bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700' + sizeCls;
    var cfgTitle = (t.config && t.config.title) ? String(t.config.title) : '';
    if (cfgTitle) {
      var h = document.createElement('div');
      h.className = 'text-xs font-semibold text-gray-700 dark:text-gray-200 truncate mb-2';
      h.textContent = cfgTitle;
      card.appendChild(h);
    }
    var tb = document.createElement('div');
    tb.className = 'dashref-tile-body flex-1 min-h-0 overflow-auto';
    card.appendChild(tb);
    dashboardsRenderTileBody(tb, { id: t.id, type: t.type, config: t.config || {} }, (data.data || {})[t.id]);
    grid.appendChild(card);
  });
  body.appendChild(grid);
}

// ---- Web-item Edit dialog (DVI-1344) — shared by both tool editors ----------
// Editor rows carry a web item's properties as data-web-* attributes; the gear
// opens the shared modal, Save writes them back, and the editor's Save collects
// them into the items (admin) / tool_items (user) payload. A row is sent as a
// user override only when edited (data-web-edited) or user-minted (D3).
var _webEditTarget = null;
var _webEditContext = null;
var _webEditIconId = '';     // pending uploaded custom-icon id
var _webEditGlyphId = '';    // selected library glyph id (DVI-1355)
var _webEditIconMode = 'favicon';

function _webSeedElementProps(el, props) {
  el.setAttribute('data-web-type', props.type || 'web');
  el.setAttribute('data-web-name', props.name || '');
  el.setAttribute('data-web-url', props.url || '');
  el.setAttribute('data-web-mode', props.mode === 'embed' ? 'embed' : 'newtab');
  el.setAttribute('data-web-icon-mode', props.icon_mode || 'favicon');
  el.setAttribute('data-web-icon', props.icon || '');
  el.setAttribute('data-web-glyph', props.glyph || '');
}

function _webReadElementProps(el) {
  var mode = el.getAttribute('data-web-icon-mode') || 'favicon';
  var icon = el.getAttribute('data-web-icon') || '';
  var glyph = el.getAttribute('data-web-glyph') || '';
  var props = {
    type: el.getAttribute('data-web-type') || 'web',
    name: el.getAttribute('data-web-name') || '',
    url: el.getAttribute('data-web-url') || '',
    mode: el.getAttribute('data-web-mode') === 'embed' ? 'embed' : 'newtab',
    icon_mode: 'favicon'
  };
  if (mode === 'glyph' && glyph && _webGlyphMap[glyph]) { props.icon_mode = 'glyph'; props.glyph = glyph; }
  else if (mode === 'custom' && icon) { props.icon_mode = 'custom'; props.icon = icon; }
  return props;
}

function _webUpdateRowSummary(el) {
  var name = el.getAttribute('data-web-name') || '';
  var url = el.getAttribute('data-web-url') || '';
  var nameEl = el.querySelector('.web-row-name');
  if (nameEl) { nameEl.textContent = name || 'Website'; nameEl.classList.toggle('italic', !name); }
  var hostEl = el.querySelector('.web-row-host');
  if (hostEl) {
    var embed = el.getAttribute('data-web-mode') === 'embed';
    hostEl.textContent = url ? (_webItemHost(url) + (embed ? ' · Embedded' : '')) : 'Not configured';
  }
  var iconEl = el.querySelector('.web-row-icon');
  if (iconEl) {
    iconEl.innerHTML = '';
    if (url) iconEl.appendChild(_webIconNode(_webReadElementProps(el), 'h-6 w-6'));
    else iconEl.innerHTML = _webItemGlobeSvg('h-6 w-6');
  }
}

function _webItemEditOpen(el, context) {
  _webEditTarget = el;
  _webEditContext = context;
  _webEditIconMode = el.getAttribute('data-web-icon-mode') || 'favicon';
  _webEditIconId = _webEditIconMode === 'custom' ? (el.getAttribute('data-web-icon') || '') : '';
  _webEditGlyphId = _webEditIconMode === 'glyph' ? (el.getAttribute('data-web-glyph') || '') : '';
  document.getElementById('web-edit-name').value = el.getAttribute('data-web-name') || '';
  document.getElementById('web-edit-url').value = el.getAttribute('data-web-url') || '';
  document.getElementById('web-edit-mode').value = el.getAttribute('data-web-mode') === 'embed' ? 'embed' : 'newtab';
  var favRadio = document.getElementById('web-edit-icon-favicon');
  var cusRadio = document.getElementById('web-edit-icon-custom');
  var glyRadio = document.getElementById('web-edit-icon-glyph');
  if (_webEditIconMode === 'custom') { cusRadio.checked = true; }
  else if (_webEditIconMode === 'glyph') { glyRadio.checked = true; }
  else { favRadio.checked = true; }
  _webEditSyncIconMode();
  _webEditRenderIconPreview();
  document.getElementById('web-edit-error').textContent = '';
  // "Reset to default" only for a user editing an inherited admin item (D3).
  var tid = el.getAttribute('data-tool-id');
  var isInherited = context === 'user' && _seAdminOrder().indexOf(tid) !== -1;
  document.getElementById('web-edit-reset').classList.toggle('hidden', !isInherited);
  _webEditBindDrop();
  document.getElementById('web-item-edit-modal').classList.remove('hidden');
  document.getElementById('web-edit-name').focus();
}

function _webEditSyncIconMode() {
  var custom = document.getElementById('web-edit-icon-custom').checked;
  var glyph = document.getElementById('web-edit-icon-glyph').checked;
  document.getElementById('web-edit-custom-block').classList.toggle('hidden', !custom);
  document.getElementById('web-edit-glyph-block').classList.toggle('hidden', !glyph);
  if (glyph) _webEditRenderGlyphGrid();
}

// Render the curated glyph picker grid, highlighting the current selection.
function _webEditRenderGlyphGrid() {
  var grid = document.getElementById('web-edit-glyph-grid');
  if (!grid) return;
  grid.innerHTML = '';
  _WEB_GLYPHS.forEach(function(g) {
    var b = document.createElement('button');
    b.type = 'button';
    var sel = g.id === _webEditGlyphId;
    b.className = 'flex items-center justify-center h-9 w-9 rounded-lg border transition-colors cursor-pointer ' +
      (sel ? 'border-togen-teal bg-togen-teal/10 text-togen-teal'
           : 'border-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700');
    b.title = g.label;
    b.setAttribute('aria-label', g.label);
    b.appendChild(_webGlyphNode(g.id, 'h-6 w-6'));
    b.onclick = function() { _webEditPickGlyph(g.id); };
    grid.appendChild(b);
  });
}

function _webEditPickGlyph(id) {
  _webEditGlyphId = id;
  document.getElementById('web-edit-icon-glyph').checked = true;
  _webEditSyncIconMode();
  _webEditRenderGlyphGrid();
  _webEditRenderIconPreview();
}

function _webEditRenderIconPreview() {
  var prev = document.getElementById('web-edit-icon-preview');
  if (!prev) return;
  prev.innerHTML = '';
  var url = (document.getElementById('web-edit-url').value || '').trim();
  var custom = document.getElementById('web-edit-icon-custom').checked;
  var glyph = document.getElementById('web-edit-icon-glyph').checked;
  if (glyph && _webEditGlyphId) {
    prev.appendChild(_webGlyphNode(_webEditGlyphId, 'h-10 w-10'));
  } else if (custom && _webEditIconId) {
    prev.appendChild(_webIconNode({ icon_mode: 'custom', icon: _webEditIconId, url: url }, 'h-10 w-10'));
  } else if (!custom && !glyph && url) {
    prev.appendChild(_webIconNode({ icon_mode: 'favicon', url: url }, 'h-10 w-10'));
  } else {
    prev.innerHTML = _webItemGlobeSvg('h-10 w-10');
  }
}

function _webEditUploadIcon(file) {
  if (!file) return;
  var err = document.getElementById('web-edit-error');
  var fd = new FormData();
  fd.append('icon', file);
  err.textContent = 'Uploading icon…';
  fetch('/tool-icon/upload', { method: 'POST', body: fd })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { err.textContent = d.error; return; }
      _webEditIconId = d.id;
      document.getElementById('web-edit-icon-custom').checked = true;
      _webEditSyncIconMode();
      _webEditRenderIconPreview();
      err.textContent = '';
    })
    .catch(function(e){ err.textContent = 'Upload failed: ' + e.message; });
}

function _webEditPickIcon() {
  var inp = document.getElementById('web-edit-icon-file');
  if (inp) inp.click();
}
function _webEditIconFileChanged(inp) {
  if (inp && inp.files && inp.files[0]) _webEditUploadIcon(inp.files[0]);
}

// Bind drag & drop on the custom-icon drop zone once (the modal is static HTML).
var _webEditDropBound = false;
function _webEditBindDrop() {
  if (_webEditDropBound) return;
  var zone = document.getElementById('web-edit-drop');
  if (!zone) return;
  _webEditDropBound = true;
  ['dragenter', 'dragover'].forEach(function(ev) {
    zone.addEventListener(ev, function(e) { e.preventDefault(); e.stopPropagation(); zone.classList.add('border-togen-teal'); });
  });
  ['dragleave', 'dragend'].forEach(function(ev) {
    zone.addEventListener(ev, function(e) { e.preventDefault(); e.stopPropagation(); zone.classList.remove('border-togen-teal'); });
  });
  zone.addEventListener('drop', function(e) {
    e.preventDefault(); e.stopPropagation();
    zone.classList.remove('border-togen-teal');
    if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]) _webEditUploadIcon(e.dataTransfer.files[0]);
  });
}
function _webEditClearIcon() {
  _webEditIconId = '';
  document.getElementById('web-edit-icon-favicon').checked = true;
  _webEditSyncIconMode();
  _webEditRenderIconPreview();
}

function _webEditSave() {
  var el = _webEditTarget;
  if (!el) return;
  var name = (document.getElementById('web-edit-name').value || '').trim().slice(0, 80);
  var url = (document.getElementById('web-edit-url').value || '').trim().slice(0, 2048);
  var err = document.getElementById('web-edit-error');
  if (!name) { err.textContent = 'Please enter a name.'; return; }
  if (!url) { err.textContent = 'Please enter a URL.'; return; }
  var openMode = document.getElementById('web-edit-mode').value === 'embed' ? 'embed' : 'newtab';
  var glyphSel = document.getElementById('web-edit-icon-glyph').checked && !!_webEditGlyphId;
  var custom = document.getElementById('web-edit-icon-custom').checked && !!_webEditIconId;
  var mode = glyphSel ? 'glyph' : (custom ? 'custom' : 'favicon');
  el.setAttribute('data-web-name', name);
  el.setAttribute('data-web-url', url);
  el.setAttribute('data-web-mode', openMode);
  el.setAttribute('data-web-icon-mode', mode);
  el.setAttribute('data-web-icon', mode === 'custom' ? _webEditIconId : '');
  el.setAttribute('data-web-glyph', mode === 'glyph' ? _webEditGlyphId : '');
  el.setAttribute('data-web-edited', '1');
  el.removeAttribute('data-web-new');   // now configured — keep on cancel
  _webUpdateRowSummary(el);
  _webEditClose();
  _homeMaybeCommitAfterEdit(el);   // DVI-1357 F4
}

// D3: drop a user's override, fall back to the inherited admin default.
function _webEditReset() {
  var el = _webEditTarget;
  if (!el) return;
  _webSeedElementProps(el, _adminWebItemProps(el.getAttribute('data-tool-id'), _seAdminItemMap()));
  el.setAttribute('data-web-edited', '0');
  _webUpdateRowSummary(el);
  _webEditClose();
  _homeMaybeCommitAfterEdit(el);   // DVI-1357 F4
}

function _webEditClose() {
  // A brand-new website that was never configured (cancelled add) leaves no
  // empty orphan row behind.
  var el = _webEditTarget;
  if (el && el.getAttribute('data-web-new') === '1' && !(el.getAttribute('data-web-name') || '').trim()) {
    if (el.parentNode) el.parentNode.removeChild(el);
  }
  document.getElementById('web-item-edit-modal').classList.add('hidden');
  _webEditTarget = null;
  _webEditContext = null;
  _webEditIconId = '';
  _webEditGlyphId = '';
}

// A gear button (Edit) for an editor web-item row; opens the shared dialog.
function _webEditGearHtml() {
  return '<button type="button" class="web-row-edit shrink-0 inline-flex items-center justify-center h-7 w-7 rounded-lg ' +
    'text-gray-400 hover:text-togen-teal hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer" title="Edit website">' +
    '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.53 1.53 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.53 1.53 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.53 1.53 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.532 1.532 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.53 1.53 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.53 1.53 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.53 1.53 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"/></svg>' +
    '</button>';
}

// The inner HTML shared by both editors' web-item rows (gear + remove; a
// leading favicon + name/host summary). The drag handle is prepended by the
// per-editor wrapper (admin uses _TOGENADMIN_DRAG_SVG, user a grab handle).
function _webRowSummaryHtml() {
  return '<div class="web-row-icon shrink-0 text-gray-400 dark:text-gray-500 flex items-center justify-center h-6 w-6"></div>' +
    '<div class="flex-1 min-w-0">' +
      '<span class="web-row-name block text-sm font-medium text-gray-800 dark:text-gray-100 truncate">Website</span>' +
      '<span class="web-row-host block text-xs text-gray-400 dark:text-gray-500 truncate">Not configured</span>' +
    '</div>' +
    _webEditGearHtml() +
    '<button type="button" title="Remove website" ' +
      'class="web-row-remove 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>';
}

// ---- Dashboard-tile Edit dialog (DVI-1346 P2) — shared by both tool editors --
// Editor rows carry a dashboard tile's props as data-dash-* attributes; the gear
// opens the shared modal; "Configure tile…" reuses the dashboards tile picker /
// config modal via _dashTileSaveHook (the DVI-1255 Report-Builder pattern). The
// editor's Save collects a row into the items (admin) / tool_items (user) payload;
// a row is a user override only when edited (data-dash-edited) or user-minted (D3).
var _dtileEditTarget = null;
var _dtileEditContext = null;
var _dtileEditType = '';        // pending dashboard tile type
var _dtileEditConfig = {};      // pending dashboard tile config

function _dtileSeedElementProps(el, props) {
  el.setAttribute('data-dash-tile-type', props.tile_type || '');
  el.setAttribute('data-dash-config', JSON.stringify(props.config || {}));
  el.setAttribute('data-dash-size', (props.size === 'wide' || props.size === 'tall') ? props.size : 'default');
  el.setAttribute('data-dash-title', props.title || '');
}

function _dtileReadElementProps(el) {
  var cfg = {};
  try { cfg = JSON.parse(el.getAttribute('data-dash-config') || '{}'); } catch (e) { cfg = {}; }
  var sz = el.getAttribute('data-dash-size');
  if (sz !== 'wide' && sz !== 'tall') sz = 'default';
  var out = { type: 'dtile', tile_type: el.getAttribute('data-dash-tile-type') || '', config: cfg, size: sz };
  var title = el.getAttribute('data-dash-title') || '';
  if (title) out.title = title;
  return out;
}

function _dtileUpdateRowSummary(el) {
  var tt = el.getAttribute('data-dash-tile-type') || '';
  var title = el.getAttribute('data-dash-title') || '';
  var nameEl = el.querySelector('.dash-row-name');
  if (nameEl) { nameEl.textContent = title || _dtileTypeLabel(tt) || 'Dashboard tile'; nameEl.classList.toggle('italic', !tt); }
  var subEl = el.querySelector('.dash-row-sub');
  if (subEl) subEl.textContent = tt ? (_dtileTypeLabel(tt) + ' tile') : 'Not configured';
}

function _dtileEditOpen(el, context) {
  _dtileEditTarget = el;
  _dtileEditContext = context;
  _dtileEditType = el.getAttribute('data-dash-tile-type') || '';
  try { _dtileEditConfig = JSON.parse(el.getAttribute('data-dash-config') || '{}'); } catch (e) { _dtileEditConfig = {}; }
  document.getElementById('dash-item-edit-title').value = el.getAttribute('data-dash-title') || '';
  document.getElementById('dash-item-edit-size').value = el.getAttribute('data-dash-size') || 'default';
  document.getElementById('dash-item-edit-error').textContent = '';
  _dtileEditRenderSummary();
  var tid = el.getAttribute('data-tool-id');
  var isInherited = context === 'user' && _seAdminOrder().indexOf(tid) !== -1;
  document.getElementById('dash-item-edit-reset').classList.toggle('hidden', !isInherited);
  document.getElementById('dash-tile-item-edit-modal').classList.remove('hidden');
}

function _dtileEditRenderSummary() {
  var el = document.getElementById('dash-item-edit-summary');
  if (el) el.textContent = _dtileEditType ? ('Configured: ' + _dtileTypeLabel(_dtileEditType) + ' tile') : 'No tile configured yet.';
}

// Reuse the dashboards tile picker + config modal via the save hook. An already
// configured item re-opens straight into its config editor; a fresh item shows
// the type picker first.
function _dtileEditConfigure() {
  reportBuilderEnsureTileTypes(function() {
    _dashTileSaveHook = function(out) {
      _dtileEditType = out.type;
      _dtileEditConfig = out.config || {};
      _dtileEditRenderSummary();
    };
    if (_dtileEditType && _dashTileTypes[_dtileEditType]) {
      _dashEditingTileId = null;
      _dashNewTileType = _dtileEditType;
      dashboardsOpenTileEditor(_dtileEditType, 'medium', _dtileEditConfig, null);
    } else {
      dashboardsAddTile();
    }
  });
}

function _dtileEditSave() {
  var el = _dtileEditTarget;
  if (!el) return;
  var err = document.getElementById('dash-item-edit-error');
  if (!_dtileEditType) { err.textContent = 'Please configure a tile first.'; return; }
  var title = (document.getElementById('dash-item-edit-title').value || '').trim().slice(0, 80);
  var size = document.getElementById('dash-item-edit-size').value;
  if (size !== 'wide' && size !== 'tall') size = 'default';
  el.setAttribute('data-dash-tile-type', _dtileEditType);
  el.setAttribute('data-dash-config', JSON.stringify(_dtileEditConfig || {}));
  el.setAttribute('data-dash-size', size);
  el.setAttribute('data-dash-title', title);
  el.setAttribute('data-dash-edited', '1');
  el.removeAttribute('data-dash-new');
  _dtileUpdateRowSummary(el);
  _dtileEditClose();
  _homeMaybeCommitAfterEdit(el);   // DVI-1357 F4
}

// D3: drop a user's override, fall back to the inherited admin default.
function _dtileEditReset() {
  var el = _dtileEditTarget;
  if (!el) return;
  _dtileSeedElementProps(el, _adminDashItemProps(el.getAttribute('data-tool-id'), _seAdminItemMap()));
  el.setAttribute('data-dash-edited', '0');
  _dtileUpdateRowSummary(el);
  _dtileEditClose();
  _homeMaybeCommitAfterEdit(el);   // DVI-1357 F4
}

function _dtileEditClose() {
  // A brand-new tile that was never configured (cancelled add) leaves no orphan.
  var el = _dtileEditTarget;
  if (el && el.getAttribute('data-dash-new') === '1' && !(el.getAttribute('data-dash-tile-type') || '')) {
    if (el.parentNode) el.parentNode.removeChild(el);
  }
  document.getElementById('dash-tile-item-edit-modal').classList.add('hidden');
  _dtileEditTarget = null;
  _dtileEditContext = null;
}

// A gear button (Edit) for an editor dashboard-tile row; opens the shared dialog.
function _dtileEditGearHtml() {
  return '<button type="button" class="dash-row-edit shrink-0 inline-flex items-center justify-center h-7 w-7 rounded-lg ' +
    'text-gray-400 hover:text-togen-teal hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer" title="Edit tile">' +
    '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.53 1.53 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.53 1.53 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.53 1.53 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.532 1.532 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.53 1.53 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.53 1.53 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.53 1.53 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"/></svg>' +
    '</button>';
}

// Inner HTML shared by both editors' dashboard-tile rows (a chart glyph + name/
// sub summary + gear + remove). The drag handle is prepended by the per-editor
// wrapper (admin uses _TOGENADMIN_DRAG_SVG, user a grab handle).
function _dtileRowSummaryHtml() {
  return '<div class="shrink-0 text-gray-400 dark:text-gray-500 flex items-center justify-center h-6 w-6">' +
      '<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M3 3a1 1 0 011-1h4a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1V3zm8 0a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1h-4a1 1 0 01-1-1V3zM3 13a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1H4a1 1 0 01-1-1v-3zm8-2a1 1 0 011-1h4a1 1 0 011 1v6a1 1 0 01-1 1h-4a1 1 0 01-1-1v-6z"/></svg>' +
    '</div>' +
    '<div class="flex-1 min-w-0">' +
      '<span class="dash-row-name block text-sm font-medium text-gray-800 dark:text-gray-100 truncate">Dashboard tile</span>' +
      '<span class="dash-row-sub block text-xs text-gray-400 dark:text-gray-500 truncate">Not configured</span>' +
    '</div>' +
    _dtileEditGearHtml() +
    '<button type="button" title="Remove tile" ' +
      'class="dash-row-remove 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>';
}

// After an editor list renders, lazily load the dashboards tile-type catalog (if
// not already loaded) so each dashboard-tile row shows its friendly type label
// rather than the raw type slug. A no-op when there are no dtile rows.
function _dtileRefreshEditorLabels() {
  var rows = document.querySelectorAll('[data-tool-id][data-dash-tile-type]');
  if (!rows.length) return;
  var needLoad = !(_dashTileTypes && Object.keys(_dashTileTypes).length);
  var refresh = function() {
    document.querySelectorAll('[data-tool-id][data-dash-tile-type]').forEach(function(r){ _dtileUpdateRowSummary(r); });
  };
  if (needLoad) reportBuilderEnsureTileTypes(refresh); else refresh();
}

// ---- Embedded-dashboard Edit dialog (DVI-1347 P3) — shared by both editors ---
// Editor rows carry a dashref's props as data-dashref-* attributes; the gear
// opens the shared modal, Save writes them back, and the editor's Save collects
// the row into the items (admin) / tool_items (user) payload. A row is a user
// override only when edited (data-dashref-edited) or user-minted (D3).
var _dashRefEditTarget = null;
var _dashRefEditContext = null;
var _dashRefOptions = null;        // cached [{id,name,owner,mine}]
var _dashRefOptionsMap = {};       // id -> name
var _dashRefCanEmbedAny = false;

function _dashRefSeedElementProps(el, props) {
  el.setAttribute('data-dashref-dashboard', props.dashboard_id || '');
  el.setAttribute('data-dashref-title', props.title || '');
  el.setAttribute('data-dashref-justify', props.justify || 'center');
  el.setAttribute('data-dashref-collapse', props.collapse || 'none');
  el.setAttribute('data-dashref-collapsed', props.collapsed ? '1' : '0');
  el.setAttribute('data-dashref-columns', (props.columns === 'auto' || props.columns == null) ? 'auto' : String(props.columns));
  el.setAttribute('data-dashref-rows', (props.rows === 'auto' || props.rows == null) ? 'auto' : String(props.rows));
}

function _dashRefReadElementProps(el) {
  var collapse = el.getAttribute('data-dashref-collapse') || 'none';
  var out = {
    type: 'dashref',
    dashboard_id: el.getAttribute('data-dashref-dashboard') || '',
    justify: el.getAttribute('data-dashref-justify') || 'center',
    columns: _dashRefNormColumns(el.getAttribute('data-dashref-columns')),
    rows: _dashRefNormColumns(el.getAttribute('data-dashref-rows'))
  };
  var title = el.getAttribute('data-dashref-title') || '';
  if (title) out.title = title;
  if (collapse === 'left' || collapse === 'right' || collapse === 'both') {
    out.collapse = collapse;
    if (el.getAttribute('data-dashref-collapsed') === '1') out.collapsed = true;
  }
  return out;
}

function _dashRefRowLabel(el) {
  var did = el.getAttribute('data-dashref-dashboard') || '';
  var title = el.getAttribute('data-dashref-title') || '';
  return title || _dashRefOptionsMap[did] || (did ? 'Dashboard' : 'Not configured');
}
function _dashRefUpdateRowSummary(el) {
  var nameEl = el.querySelector('.dashref-row-name');
  var did = el.getAttribute('data-dashref-dashboard') || '';
  if (nameEl) { nameEl.textContent = _dashRefRowLabel(el); nameEl.classList.toggle('italic', !did); }
  var subEl = el.querySelector('.dashref-row-sub');
  if (subEl) subEl.textContent = did ? 'Embedded dashboard' : 'Not configured';
}

// Lazily fetch the embeddable-dashboard list (admin: all; user: owned + shared).
function _dashRefEnsureOptions(cb) {
  if (_dashRefOptions) { if (cb) cb(); return; }
  fetch('/tools/dashref-options').then(function(r){ return r.json(); }).then(function(d){
    _dashRefOptions = (d && d.dashboards) || [];
    _dashRefCanEmbedAny = !!(d && d.can_embed_any);
    _dashRefOptionsMap = {};
    _dashRefOptions.forEach(function(o){ _dashRefOptionsMap[o.id] = o.name; });
    if (cb) cb();
  }).catch(function(){ _dashRefOptions = _dashRefOptions || []; if (cb) cb(); });
}

function _dashRefPopulateSelect(selectedId) {
  var sel = document.getElementById('dashref-edit-dashboard');
  if (!sel) return;
  sel.innerHTML = '';
  var opts = _dashRefOptions || [];
  if (!opts.length) {
    sel.innerHTML = '<option value="">No dashboards available to embed</option>';
    return;
  }
  var ph = document.createElement('option');
  ph.value = ''; ph.textContent = 'Select a dashboard…';
  sel.appendChild(ph);
  var haveSelected = false;
  opts.forEach(function(o){
    var op = document.createElement('option');
    op.value = o.id;
    op.textContent = o.name + (o.mine ? '' : (o.owner ? ('  ·  ' + o.owner) : ''));
    if (o.id === selectedId) { op.selected = true; haveSelected = true; }
    sel.appendChild(op);
  });
  // Keep a stored-but-unlisted id selectable (e.g. a dashboard no longer shared).
  if (selectedId && !haveSelected) {
    var op2 = document.createElement('option');
    op2.value = selectedId; op2.textContent = (_dashRefOptionsMap[selectedId] || selectedId) + ' (current)';
    op2.selected = true;
    sel.appendChild(op2);
  }
}

function _dashRefEditSyncCollapsed() {
  var collapse = document.getElementById('dashref-edit-collapse').value;
  var cb = document.getElementById('dashref-edit-collapsed');
  if (!cb) return;
  var enable = collapse !== 'none';
  cb.disabled = !enable;
  if (!enable) cb.checked = false;
}

function _dashRefEditOpen(el, context) {
  _dashRefEditTarget = el;
  _dashRefEditContext = context;
  document.getElementById('dashref-edit-error').textContent = '';
  document.getElementById('dashref-edit-title').value = el.getAttribute('data-dashref-title') || '';
  document.getElementById('dashref-edit-justify').value = el.getAttribute('data-dashref-justify') || 'center';
  document.getElementById('dashref-edit-collapse').value = el.getAttribute('data-dashref-collapse') || 'none';
  document.getElementById('dashref-edit-collapsed').checked = el.getAttribute('data-dashref-collapsed') === '1';
  document.getElementById('dashref-edit-columns').value = el.getAttribute('data-dashref-columns') || 'auto';
  document.getElementById('dashref-edit-rows').value = el.getAttribute('data-dashref-rows') || 'auto';
  _dashRefEditSyncCollapsed();
  var tid = el.getAttribute('data-tool-id');
  var isInherited = context === 'user' && _seAdminOrder().indexOf(tid) !== -1;
  document.getElementById('dashref-edit-reset').classList.toggle('hidden', !isInherited);
  var sel = document.getElementById('dashref-edit-dashboard');
  if (sel) sel.innerHTML = '<option value="">Loading dashboards…</option>';
  _dashRefEnsureOptions(function() {
    _dashRefPopulateSelect(el.getAttribute('data-dashref-dashboard') || '');
    // The share-to-all note applies to admin embeds (org-viewable, D5).
    document.getElementById('dashref-edit-share-note').classList.toggle('hidden', context !== 'admin');
  });
  document.getElementById('dashref-item-edit-modal').classList.remove('hidden');
}

function _dashRefEditSave() {
  var el = _dashRefEditTarget;
  if (!el) return;
  var err = document.getElementById('dashref-edit-error');
  var did = document.getElementById('dashref-edit-dashboard').value || '';
  if (!did) { err.textContent = 'Please choose a dashboard to embed.'; return; }
  var collapse = document.getElementById('dashref-edit-collapse').value;
  el.setAttribute('data-dashref-dashboard', did);
  el.setAttribute('data-dashref-title', (document.getElementById('dashref-edit-title').value || '').trim().slice(0, 60));
  el.setAttribute('data-dashref-justify', document.getElementById('dashref-edit-justify').value || 'center');
  el.setAttribute('data-dashref-collapse', collapse);
  el.setAttribute('data-dashref-collapsed', (collapse !== 'none' && document.getElementById('dashref-edit-collapsed').checked) ? '1' : '0');
  el.setAttribute('data-dashref-columns', document.getElementById('dashref-edit-columns').value || 'auto');
  el.setAttribute('data-dashref-rows', document.getElementById('dashref-edit-rows').value || 'auto');
  el.setAttribute('data-dashref-edited', '1');
  el.removeAttribute('data-dashref-new');
  _dashRefUpdateRowSummary(el);
  _dashRefEditClose();
  _homeMaybeCommitAfterEdit(el);   // DVI-1357 F4
}

function _dashRefEditReset() {
  var el = _dashRefEditTarget;
  if (!el) return;
  _dashRefSeedElementProps(el, _adminDashRefProps(el.getAttribute('data-tool-id'), _seAdminItemMap()));
  el.setAttribute('data-dashref-edited', '0');
  _dashRefUpdateRowSummary(el);
  _dashRefEditClose();
  _homeMaybeCommitAfterEdit(el);   // DVI-1357 F4
}

function _dashRefEditClose() {
  var el = _dashRefEditTarget;
  // A brand-new, never-configured dashref (cancelled add) leaves no orphan.
  if (el && el.getAttribute('data-dashref-new') === '1' && !(el.getAttribute('data-dashref-dashboard') || '')) {
    if (el.parentNode) el.parentNode.removeChild(el);
  }
  document.getElementById('dashref-item-edit-modal').classList.add('hidden');
  _dashRefEditTarget = null;
  _dashRefEditContext = null;
}

function _dashRefEditGearHtml() {
  return '<button type="button" class="dashref-row-edit shrink-0 inline-flex items-center justify-center h-7 w-7 rounded-lg ' +
    'text-gray-400 hover:text-togen-teal hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer" title="Edit embedded dashboard">' +
    '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.53 1.53 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.53 1.53 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.53 1.53 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.532 1.532 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.53 1.53 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.53 1.53 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.53 1.53 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"/></svg>' +
    '</button>';
}

// Inner HTML shared by both editors' dashref rows (a grid glyph + name/sub +
// gear + remove). The drag handle is prepended by the per-editor wrapper.
function _dashRefRowSummaryHtml() {
  return '<div class="shrink-0 text-gray-400 dark:text-gray-500 flex items-center justify-center h-6 w-6">' +
      '<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="M5 3a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2V5a2 2 0 00-2-2H5zm0 2h10v3H5V5zm0 5h4v5H5v-5zm6 0h4v5h-4v-5z" clip-rule="evenodd"/></svg>' +
    '</div>' +
    '<div class="flex-1 min-w-0">' +
      '<span class="dashref-row-name block text-sm font-medium text-gray-800 dark:text-gray-100 truncate">Embedded dashboard</span>' +
      '<span class="dashref-row-sub block text-xs text-gray-400 dark:text-gray-500 truncate">Not configured</span>' +
    '</div>' +
    _dashRefEditGearHtml() +
    '<button type="button" title="Remove embedded dashboard" ' +
      'class="dashref-row-remove 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>';
}

// After an editor list renders, lazily load the dashboard names so each dashref
// row shows a friendly label. A no-op when there are no dashref rows.
function _dashRefRefreshEditorLabels() {
  var rows = document.querySelectorAll('[data-tool-id][data-dashref-dashboard]');
  if (!rows.length) return;
  _dashRefEnsureOptions(function() {
    document.querySelectorAll('[data-tool-id][data-dashref-dashboard]').forEach(function(r){ _dashRefUpdateRowSummary(r); });
  });
}

// Split the visible, ordered tools + web items into ordered sections around the
// separator tokens in the effective tool order (DVI-524/DVI-1337/DVI-1344).
// Returns {sections:[{sepId, entries, tools}]} — `entries` interleaves tool + web
// items ({kind, id, tool?/item?}) in order; `tools` is the tool objects only
// (kept for callers/tests reading the DVI-1337 shape). The first section's sepId
// is null; each later section's sepId identifies the divider that precedes it. A
// divider is only emitted between two non-empty sections.
function _partitionToolsBySeparator() {
  var effectiveOrder = (_userSettings.tool_order && _userSettings.tool_order.length) ? _userSettings.tool_order : _toolOrder;
  var visible = _visibleSortedTools();
  var toolById = {};
  visible.forEach(function(t) { toolById[t.id] = t; });
  var seenTool = {};
  var buckets = [[]];   // bucket 0 = before the first separator
  var sepIds = [];      // sepIds[k] precedes bucket k+1
  // DVI-1521: raw (pre-visibility) non-separator token count per bucket, so the
  // emit step can tell a genuinely LEADING separator (nothing ordered before it)
  // from a section that merely collapsed because its tools are hidden/inaccessible.
  var rawCounts = [0];
  effectiveOrder.forEach(function(tid) {
    if (_isSeparatorToken(tid)) { sepIds.push(tid); buckets.push([]); rawCounts.push(0); return; }
    rawCounts[rawCounts.length - 1]++;
    var cur = buckets[buckets.length - 1];
    if (_isWebItemToken(tid)) {
      var it = _webItemProps(tid);
      if (it) cur.push({ kind: 'web', id: tid, item: it });
    } else if (_isDashItemToken(tid)) {
      var dt = _dtileItemProps(tid);
      if (dt) cur.push({ kind: 'dtile', id: tid, item: dt });
    } else if (_isDashRefToken(tid)) {
      var dr = _dashRefProps(tid);
      if (dr) cur.push({ kind: 'dashref', id: tid, item: dr });
    } else if (toolById[tid] && !seenTool[tid]) {
      seenTool[tid] = true;
      cur.push({ kind: 'tool', id: tid, tool: toolById[tid] });
    }
  });
  // Append any visible tools missing from the order (newly added tools).
  visible.forEach(function(t) {
    if (!seenTool[t.id]) { seenTool[t.id] = true; buckets[buckets.length - 1].push({ kind: 'tool', id: t.id, tool: t }); }
  });

  // Emit only non-empty buckets; a divider before a bucket uses the separator
  // immediately preceding it (adjacent empty buckets collapse into one gap).
  var sections = [];
  var rawBefore = 0;   // DVI-1521: raw tokens ordered into all buckets visited so far
  for (var k = 0; k < buckets.length; k++) {
    if (!buckets[k].length) { rawBefore += rawCounts[k]; continue; }
    var sepId = null;
    // Emit the preceding divider when a rendered section already precedes it, OR
    // the separator is genuinely leading (nothing was ordered before it) — a
    // leading separator renders with full parity (DVI-1521). A separator whose
    // preceding section merely collapsed (all its tools hidden) still drops.
    if (k > 0 && sepIds[k - 1] && (sections.length > 0 || rawBefore === 0)) sepId = sepIds[k - 1];
    var toolsOnly = buckets[k].filter(function(e) { return e.kind === 'tool'; }).map(function(e) { return e.tool; });
    sections.push({ sepId: sepId, entries: buckets[k], tools: toolsOnly });
    rawBefore += rawCounts[k];
  }
  if (!sections.length) sections.push({ sepId: null, entries: [], tools: [] });
  return { sections: sections };
}

// Full-width dashed divider drawn across the home grid between two sections. An
// optional label (DVI-1337) renders centered over the rule when the separator
// carries one.
function _makeTileDivider(sepId, opts) {
  // DVI-1496 P4: opts.props / opts.onToggle let a tile surface supply its own
  // scope-resolved separator props + re-render on collapse (home passes neither).
  opts = opts || {};
  var props = opts.props || _sepProps(sepId);
  var onToggle = opts.onToggle || function() { renderTileGrid(); };
  var collapsible = props.collapse !== 'none';
  var d = document.createElement('div');
  // A bare separator (no label, not collapsible) stays the plain dashed hairline.
  if (!collapsible && !props.label) {
    d.setAttribute('aria-hidden', 'true');
    d.className = 'tile-separator-line border-t-2 border-dashed border-gray-300 dark:border-gray-600 my-1';
    return d;
  }
  var collapsed = collapsible && _sepIsCollapsed(sepId, props);
  d.className = 'tile-separator-line flex items-center gap-3 my-1';

  var rule = '<span class="h-px flex-1 bg-gray-300 dark:bg-gray-600"></span>';
  var showLeft = props.collapse === 'left' || props.collapse === 'both';
  var showRight = props.collapse === 'right' || props.collapse === 'both';
  var chev = collapsible ? _sepChevronSvg(collapsed) : '';
  var labelHtml = props.label
    ? '<span class="text-xs font-semibold uppercase tracking-wide truncate max-w-[16rem]">' + esc(props.label) + '</span>'
    : '';
  var pillInner = (showLeft ? chev : '') + labelHtml + (showRight ? chev : '');

  // A small backed pill for the label/chevrons, legible over a transparent custom
  // background via the DVI-1329 glass classes (togen-control for the clickable
  // collapse control, togen-inset for a static label). bg-white/gray keeps it
  // opaque off-glass; the glass classes' !important backing wins under glass.
  var pill;
  if (collapsible) {
    pill = '<button type="button" class="tile-separator-pill togen-control shrink-0 inline-flex items-center gap-1.5 px-3 py-1 rounded-full ' +
      'border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 ' +
      'text-gray-500 dark:text-gray-400 hover:text-togen-teal dark:hover:text-togen-teal transition-colors cursor-pointer" ' +
      'aria-expanded="' + (collapsed ? 'false' : 'true') + '" ' +
      'title="' + (collapsed ? 'Expand' : 'Collapse') + ' this section">' + pillInner + '</button>';
  } else {
    pill = '<span class="tile-separator-pill togen-inset shrink-0 inline-flex items-center gap-1.5 px-3 py-1 rounded-full ' +
      'border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 ' +
      'text-gray-400 dark:text-gray-500">' + pillInner + '</span>';
  }

  if (props.justify === 'left') d.innerHTML = pill + rule;
  else if (props.justify === 'right') d.innerHTML = rule + pill;
  else d.innerHTML = rule + pill + rule;

  if (collapsible) {
    var btn = d.querySelector('.tile-separator-pill');
    if (btn) btn.addEventListener('click', function() { _sepSetCollapsed(sepId, !_sepIsCollapsed(sepId, props)); onToggle(); });
  }
  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() {
  if (_homeEditMode) { _homeRenderEditGrid(); return; }   // DVI-1357 F4
  var grid = document.getElementById('tile-grid');
  grid.className = 'flex flex-col gap-3';
  grid.style.gridTemplateColumns = '';
  grid.innerHTML = '';
  var part = _partitionToolsBySeparator();
  part.sections.forEach(function(sec, i) {
    if (sec.sepId) grid.appendChild(_makeTileDivider(sec.sepId));   // DVI-1521: a leading separator (section 0) renders too
    if (sec.sepId && _sepIsCollapsed(sec.sepId)) return;   // section below a collapsed bar is hidden
    sec.entries.forEach(function(e) { grid.appendChild(_buildHomeEntryListRow(e)); });
  });
  _applyTileSidePadding();
  _toolTileStartPolling();
}

// Build one home-grid tile button for a tool. Visibility/order filtering is
// handled upstream by _visibleSortedTools / _partitionToolsBySeparator.
// DVI-1523 P4 (D7-A): the SCADA live-tile inner render (history graph backdrop +
// out-of-range alert badge + sensor summary + "Updated HH:MM"), given an icon /
// title / description. Returns null when no readings are loaded yet (caller shows
// a plain tile). Shared by the home SCADA tile (_buildToolTile) and the QC
// surface Kiln Status tile (_qsBuildKilnStatusTile) so both render identically.
function _scadaLiveTileInner(iconHtml, titleText, descText) {
    if (!_scadaAllReadings.length) return null;
    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(descText);
    } 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>' : '';
    return backdropSvg + badgeHtml +
      '<div class="relative text-gray-400 dark:text-gray-500 group-hover:text-togen-teal transition-colors" style="z-index:1">' + iconHtml + '</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(titleText) + '</p>' +
        '<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 leading-relaxed">' + summaryHtml + timeHtml + '</p>' +
      '</div>';
}

// The class string for a SCADA live tile (relative + overflow-hidden so the
// backdrop graph is clipped). Shared by the home tile + the QC Kiln Status tile.
var _SCADA_LIVE_TILE_CLASS = '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';

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 (shared render).
      tile.className = _SCADA_LIVE_TILE_CLASS;
      tile.innerHTML = _scadaLiveTileInner(tool.icon, tool.title, tool.description);
    } 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' },
        idle:     { dot: 'bg-gray-400',  cls: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300',      word: 'Backup idle' },
        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.idle;
      // 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;
}

// ---- Home dashboard-tile polling (DVI-1346 P2) -----------------------------
// One shared timer polls /tools/tile-data every 60s while on the home view and
// re-renders each dtile body in place (mirrors the dashboards auto-refresh). No
// polling starts when there are no dashboard-tile items on the grid.
var _toolTileTimer = null;
function _homeHasDashTiles() {
  var order = (_userSettings.tool_order && _userSettings.tool_order.length) ? _userSettings.tool_order : _toolOrder;
  for (var i = 0; i < order.length; i++) {
    if (_isDashItemToken(order[i]) && _dtileItemProps(order[i])) return true;
    if (_isDashRefToken(order[i]) && _dashRefProps(order[i])) return true;   // DVI-1347
  }
  return false;
}
function _toolTileDataFetch() {
  if (_activeTool !== null) return;   // only poll on the home view
  fetch('/tools/tile-data').then(function(r){ return r.json(); }).then(function(d){
    if (!d) return;
    if (d.tiles) {
      _toolTileData = d.tiles;
      Object.keys(d.tiles).forEach(function(iid){
        var body = document.querySelector('.dash-tile-body[data-tile-id="' + iid + '"]');
        if (!body) return;
        var props = _dtileItemProps(iid);
        if (!props) return;
        dashboardsRenderTileBody(body, { id: iid, type: props.tile_type, config: props.config }, d.tiles[iid]);
      });
    }
    if (d.dashrefs) {
      // Merge (a first poll fills names used by _dashRefTitle before re-render).
      Object.keys(d.dashrefs).forEach(function(iid){ _toolDashRefData[iid] = d.dashrefs[iid]; });
      Object.keys(d.dashrefs).forEach(function(iid){
        var body = document.querySelector('.dashref-body[data-dashref-id="' + iid + '"]');
        if (!body) return;
        var props = _dashRefProps(iid);
        if (!props) return;
        // Re-title the bar (the referenced dashboard's name may have arrived).
        var bar = body.parentNode && body.parentNode.querySelector('.dashref-bar .dashref-pill span');
        if (bar && !props.title) bar.textContent = _dashRefTitle(props, iid);
        _buildDashRefBody(body, iid, props, d.dashrefs[iid]);
      });
    }
  }).catch(function(){});
}
function _toolTileStartPolling() {
  _toolTileStopPolling();
  if (_activeTool !== null || !_homeHasDashTiles()) return;
  _toolTileDataFetch();
  _toolTileTimer = setInterval(_toolTileDataFetch, 60000);
}
function _toolTileStopPolling() {
  if (_toolTileTimer) { clearInterval(_toolTileTimer); _toolTileTimer = null; }
}

// ============================================================================
// Home-grid edit mode (DVI-1357 F4) — edit the Tools/home page in place instead
// of round-tripping through Settings. An Edit toggle overlays each tile /
// separator / dtile / dashref with a drag handle + gear (+ remove for items and
// separators); reordering and gear-edits AUTO-SAVE by POSTing the SAME payloads
// the Settings editors already send (/togen-admin/tool-order for the Default-list
// scope, /user-settings for the My-list scope). No new persistence model — the
// body-scoped edit dialogs and per-type prop readers/seeders are reused verbatim.
// ============================================================================
var _homeEditMode = false;
var _homeEditScope = 'user';     // 'user' (My list) | 'admin' (Default list, admins only)
var _homeDragSrc = null;
var _homePendingAddId = null;    // a just-minted item id whose config dialog is open

// DVI-1496 P4 — the edit engine is surface-generic via an active edit CONTEXT.
// `_editCtx` is the surface currently being edited (a home context during a home
// edit, an Equipment context during an Equipment tiles-page edit; null when not
// editing). The shared engine functions below (_homeOverlay / _homeAttachDrag /
// _homeCommitOrder / _homeGearOpen / _homeMaybeCommitAfterEdit / _homeRemoveEntry
// / _homeCommit / _seRenderEditGrid) read `_editCtx` for the surface-specific bits
// (grid element, scope, order get/set, prop maps, commit routes, entry render,
// map folds) — so Equipment REUSES the home edit loop rather than cloning it. The
// four body-scoped dialogs are already surface-agnostic (they act on an element's
// data-* attrs). A home context reproduces the exact pre-P4 home behavior.
var _editCtx = null;
function _seScope()       { return _editCtx ? _editCtx.scope() : _homeEditScope; }
function _seGridEl()      { return _editCtx ? _editCtx.gridEl() : document.getElementById('tile-grid'); }
function _seAdminOrder()  { return _editCtx ? _editCtx.adminOrder() : _toolOrder; }
function _seAdminSepMap() { return _editCtx ? _editCtx.adminSepMap() : _adminToolSeparators; }
function _seAdminItemMap(){ return _editCtx ? _editCtx.adminItemMap() : _adminToolItems; }
function _seSepProps(id)  { return _editCtx ? _editCtx.sepProps(id) : _sepProps(id); }
// Generic edit-grid render: wipe the surface's grid and let the context lay out
// its entries (each decorated via the shared _homeDecorateEntry / _homeEditDivider).
function _seRenderEditGrid() {
  if (!_editCtx) return;
  var grid = _seGridEl();
  if (!grid) return;
  grid.innerHTML = '';
  _editCtx.renderInto(grid);
  if (_editCtx.afterRender) _editCtx.afterRender(grid);
}

function _homeEditIsAdminScope() { return _homeEditMode && _homeEditScope === 'admin'; }
// The separator/item override map the resolvers read: {} in the admin-default
// scope (so props resolve admin-only), else the user's overrides.
function _homeSepUserMap() { return _homeEditIsAdminScope() ? {} : ((_userSettings && _userSettings.tool_separators) || {}); }
function _homeItemUserMap() { return _homeEditIsAdminScope() ? {} : ((_userSettings && _userSettings.tool_items) || {}); }
// The base order array for the current context (a copy so callers can mutate).
function _homeEffOrder() {
  if (_homeEditIsAdminScope()) return _toolOrder;
  return (_userSettings && _userSettings.tool_order && _userSettings.tool_order.length) ? _userSettings.tool_order : _toolOrder;
}
function _homeBaseOrder() { return _homeEffOrder().slice(); }
function _homeSetBaseOrder(order) {
  if (_homeEditScope === 'admin') { _toolOrder = order; }
  else { _userSettings = _userSettings || {}; _userSettings.tool_order = order; }
}

// Re-insert order tokens NOT shown on the home grid (hidden tools; also default-
// disabled tools in the admin scope) back into a reordered visible sequence,
// anchoring each hidden token after the same visible token it followed before —
// so a home reorder never drops a hidden tool from the saved order.
function _homeMergeHiddenOrder(base, seq) {
  var shown = {};
  seq.forEach(function(t) { shown[t] = true; });
  var pending = {}, last = '__start__';
  base.forEach(function(t) {
    if (shown[t]) { last = t; }
    else { (pending[last] = pending[last] || []).push(t); }
  });
  var out = [];
  (pending['__start__'] || []).forEach(function(h) { out.push(h); });
  seq.forEach(function(t) {
    out.push(t);
    (pending[t] || []).forEach(function(h) { out.push(h); });
  });
  return out;
}

// Small SVGs for the edit overlay controls.
function _homeDragHandleSvg() {
  return '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M7 4a1 1 0 100 2 1 1 0 000-2zM7 9a1 1 0 100 2 1 1 0 000-2zM7 14a1 1 0 100 2 1 1 0 000-2zM13 4a1 1 0 100 2 1 1 0 000-2zM13 9a1 1 0 100 2 1 1 0 000-2zM13 14a1 1 0 100 2 1 1 0 000-2z"/></svg>';
}
function _homeGearSvg() {
  return '<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.53 1.53 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.53 1.53 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.53 1.53 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.532 1.532 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.53 1.53 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.53 1.53 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.53 1.53 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"/></svg>';
}
function _homeRemoveSvg() {
  return '<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>';
}

// A control button (gear / remove) for the edit overlay. draggable=false + a
// dragstart/mousedown swallow so clicking a control never starts a tile drag.
function _homeCtrlBtn(kind, title, fn) {
  var b = document.createElement('button');
  b.type = 'button';
  b.setAttribute('draggable', 'false');
  b.title = title;
  b.className = 'home-edit-ctrl inline-flex items-center justify-center h-7 w-7 rounded-lg bg-white/90 dark:bg-gray-900/90 shadow-sm transition-colors cursor-pointer ' +
    (kind === 'remove' ? 'text-gray-500 hover:text-red-500' : 'text-gray-500 hover:text-togen-teal');
  b.innerHTML = (kind === 'remove') ? _homeRemoveSvg() : _homeGearSvg();
  b.addEventListener('dragstart', function(e) { e.preventDefault(); e.stopPropagation(); });
  b.addEventListener('mousedown', function(e) { e.stopPropagation(); });
  b.addEventListener('click', function(e) {
    e.preventDefault(); e.stopPropagation();
    var host = b.closest('.home-edit-item');
    if (host) fn(host);
  });
  return b;
}

// The absolute overlay that turns a rendered tile into an edit target: a dashed
// ring, a move cursor, a drag handle, and (for items) gear + remove. The overlay
// swallows the tile's normal click so a tile isn't opened while editing.
function _homeOverlay(entry) {
  var ov = document.createElement('div');
  ov.className = 'home-edit-overlay absolute inset-0 z-[6] rounded-2xl border-2 border-dashed border-togen-teal/60 bg-togen-teal/5 dark:bg-togen-teal/10 cursor-move flex items-start justify-between p-2';
  ov.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); });
  var handle = document.createElement('span');
  handle.className = 'home-edit-handle inline-flex items-center justify-center h-7 w-7 rounded-lg bg-white/90 dark:bg-gray-900/90 text-gray-400 shadow-sm';
  handle.innerHTML = _homeDragHandleSvg();
  ov.appendChild(handle);
  var ctr = document.createElement('div');
  ctr.className = 'flex items-center gap-1';
  // A fixed nav tile (home tool / Equipment catalog sub-tool) is reorderable but
  // has no gear/remove; only added items (web/dtile/dashref) are editable.
  if (entry.editable) {
    ctr.appendChild(_homeCtrlBtn('gear', 'Edit', function(host) { _homeGearOpen(host); }));
    ctr.appendChild(_homeCtrlBtn('remove', 'Remove from home', function(host) {
      var msg = (_editCtx && _editCtx.removeMsg) || 'Remove this from your home page?';
      if (window.confirm(msg)) _homeRemoveEntry(host.getAttribute('data-tool-id'));
    }));
  }
  ov.appendChild(ctr);
  return ov;
}

// HTML5 drag reorder within the flat edit grid; auto-saves on drop.
function _homeAttachDrag(el) {
  el.addEventListener('dragstart', function(e) {
    _homeDragSrc = el;
    el.classList.add('opacity-40');
    if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', el.getAttribute('data-tool-id') || ''); } catch (_e) {} }
  });
  el.addEventListener('dragend', function() {
    el.classList.remove('opacity-40');
    _homeClearDropHints();
    _homeDragSrc = null;
  });
  el.addEventListener('dragover', function(e) {
    if (!_homeDragSrc || _homeDragSrc === el) return;
    e.preventDefault();
    if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
    el.classList.add('ring-2', 'ring-togen-teal');
  });
  el.addEventListener('dragleave', function() { el.classList.remove('ring-2', 'ring-togen-teal'); });
  el.addEventListener('drop', function(e) {
    if (!_homeDragSrc) return;
    e.preventDefault();
    el.classList.remove('ring-2', 'ring-togen-teal');
    var container = _seGridEl();
    if (_homeDragSrc !== el && container) {
      var all = Array.prototype.slice.call(container.children);
      var s = all.indexOf(_homeDragSrc), d = all.indexOf(el);
      if (s > -1 && d > -1) {
        if (s < d) container.insertBefore(_homeDragSrc, el.nextSibling);
        else container.insertBefore(_homeDragSrc, el);
        _homeCommitOrder();
      }
    }
  });
}
function _homeClearDropHints() {
  var grid = _seGridEl();
  if (!grid) return;
  Array.prototype.forEach.call(grid.querySelectorAll('.home-edit-item'), function(n) { n.classList.remove('ring-2', 'ring-togen-teal'); });
}

// Decorate a freshly-built home entry for edit mode. The built tile can be a
// <button> (tool/web) — nesting the gear/remove <button>s inside it would be
// invalid HTML — so wrap the tile in a positioned <div> and lay the control
// overlay on as a SIBLING. The wrapper is the .home-edit-item drag target and
// carries the token + seeded data-* props (read by the gear dialog + payload
// collectors); the inner tile is made inert so its own click never fires.
function _homeDecorateEntry(el, entry) {
  var wrap = document.createElement('div');
  wrap.className = 'home-edit-item relative' + (entry.kind === 'dashref' ? ' col-span-full' : '');
  wrap.setAttribute('draggable', 'true');
  wrap.setAttribute('data-tool-id', entry.id);
  if (entry.kind === 'web') _webSeedElementProps(wrap, entry.item);
  else if (entry.kind === 'dtile') _dtileSeedElementProps(wrap, entry.item);
  else if (entry.kind === 'dashref') _dashRefSeedElementProps(wrap, entry.item);
  el.classList.add('w-full');
  el.style.pointerEvents = 'none';       // clicks fall through to the overlay
  el.setAttribute('draggable', 'false'); // drag is owned by the wrapper
  wrap.appendChild(el);
  wrap.appendChild(_homeOverlay(entry));
  _homeAttachDrag(wrap);
  return wrap;
}

// A full-width, draggable separator row for the edit grid (gear + remove). Seeds
// data-sep-* so the shared #sep-edit-modal prefills; carries .sep-row-summary so
// a gear-edit updates the label in place before the commit re-renders.
function _homeEditDivider(sepId) {
  var props = _seSepProps(sepId);
  var wrap = document.createElement('div');
  wrap.className = 'home-edit-item home-edit-sep col-span-full relative flex items-center gap-2 py-2 px-2 rounded-lg border-2 border-dashed border-togen-teal/50 bg-togen-teal/5 dark:bg-togen-teal/10 cursor-move';
  wrap.setAttribute('data-tool-id', sepId);
  wrap.setAttribute('draggable', 'true');
  _sepSeedElementProps(wrap, props);
  var label = props.label || 'Separator';
  wrap.innerHTML =
    '<span class="home-edit-handle inline-flex items-center justify-center h-6 w-6 rounded text-gray-400">' + _homeDragHandleSvg() + '</span>' +
    '<span class="h-px flex-1 bg-gray-300 dark:bg-gray-600"></span>' +
    '<span class="sep-row-summary shrink-0 text-xs font-semibold uppercase tracking-wide ' + (props.label ? '' : 'italic ') + 'text-gray-500 dark:text-gray-400">' + esc(label) + '</span>' +
    '<span class="h-px flex-1 bg-gray-300 dark:bg-gray-600"></span>';
  var ctr = document.createElement('div');
  ctr.className = 'flex items-center gap-1 shrink-0';
  ctr.appendChild(_homeCtrlBtn('gear', 'Edit separator', function(host) { _homeGearOpen(host); }));
  ctr.appendChild(_homeCtrlBtn('remove', 'Remove separator', function(host) {
    if (window.confirm('Remove this separator?')) _homeRemoveEntry(host.getAttribute('data-tool-id'));
  }));
  wrap.appendChild(ctr);
  _homeAttachDrag(wrap);
  return wrap;
}

// Render the whole home grid as one flat, draggable sequence for edit mode. All
// sections are shown expanded (collapse is ignored while editing) so every tile
// is reorderable, and cross-section drags work because it is one container (a
// separator is just a full-width child token in the sequence).
function _homeRenderEditGrid() {
  _editCtx = _homeEditCtx();
  _seRenderEditGrid();
}

// The HOME surface's edit context (DVI-1496 P4). Its methods reproduce the exact
// pre-P4 home behavior (grid #tile-grid, the two Settings-editor routes, the
// _adminTool*/_userSettings.tool_* stores) — home is byte-for-byte unchanged.
function _homeEditCtx() {
  return {
    surface: 'home',
    scope: function() { return _homeEditScope; },
    toastId: 'home-edit-toast',
    gridEl: function() { return document.getElementById('tile-grid'); },
    adminOrder: function() { return _toolOrder; },
    adminSepMap: function() { return _adminToolSeparators || {}; },
    adminItemMap: function() { return _adminToolItems || {}; },
    sepProps: function(id) { return _sepProps(id); },
    baseOrder: _homeBaseOrder,
    setBaseOrder: _homeSetBaseOrder,
    removeMsg: 'Remove this from your home page?',
    renderInto: function(grid) {
      grid.className = 'grid gap-4 home-edit-grid';
      grid.style.gridTemplateColumns = '';
      var order = _homeEffOrder();
      var visible = _visibleSortedTools();
      var toolById = {};
      visible.forEach(function(t) { toolById[t.id] = t; });
      var seen = {};
      order.forEach(function(tid) {
        var el = null, entry = null;
        if (_isSeparatorToken(tid)) { grid.appendChild(_homeEditDivider(tid)); return; }
        if (_isWebItemToken(tid)) { var w = _webItemProps(tid); if (!w) return; el = _buildWebTile(w); entry = { kind: 'web', id: tid, item: w, editable: true }; }
        else if (_isDashItemToken(tid)) { var dt = _dtileItemProps(tid); if (!dt) return; el = _buildDashTile(dt, tid); entry = { kind: 'dtile', id: tid, item: dt, editable: true }; }
        else if (_isDashRefToken(tid)) { var dr = _dashRefProps(tid); if (!dr) return; el = _buildDashRef(dr, tid); entry = { kind: 'dashref', id: tid, item: dr, editable: true }; }
        else if (toolById[tid] && !seen[tid]) { seen[tid] = true; el = _buildToolTile(toolById[tid]); entry = { kind: 'tool', id: tid, tool: toolById[tid] }; }
        else return;
        grid.appendChild(_homeDecorateEntry(el, entry));
      });
      // Append any visible tools missing from the order (newly enabled tools).
      visible.forEach(function(t) {
        if (seen[t.id]) return;
        seen[t.id] = true;
        grid.appendChild(_homeDecorateEntry(_buildToolTile(t), { kind: 'tool', id: t.id, tool: t }));
      });
    },
    afterRender: function() {
      _applyTileGridColumns();   // no .tile-grid-section → sizes #tile-grid to the column setting
      _applyTileSidePadding();
    },
    commit: _homeCommitScope,
    foldEditProps: _homeFoldEditProps,
    foldRemoveProps: _homeFoldRemoveProps,
  };
}

// Dispatch the gear to the matching body-scoped edit dialog for the token type.
function _homeGearOpen(el) {
  var tid = el.getAttribute('data-tool-id');
  var scope = _seScope();
  if (_isSeparatorToken(tid)) _sepEditOpen(el, scope);
  else if (_isWebItemToken(tid)) _webItemEditOpen(el, scope);
  else if (_isDashItemToken(tid)) _dtileEditOpen(el, scope);
  else if (_isDashRefToken(tid)) _dashRefEditOpen(el, scope);
}

// Read the current DOM sequence of tokens, merge back hidden tokens, persist.
// Surface-generic: reads the active context's grid + order (DVI-1496 P4).
function _homeCommitOrder() {
  var grid = _seGridEl();
  if (!grid || !_editCtx) return;
  var seq = [];
  Array.prototype.forEach.call(grid.querySelectorAll(':scope > [data-tool-id]'), function(n) {
    seq.push(n.getAttribute('data-tool-id'));
  });
  _editCtx.setBaseOrder(_homeMergeHiddenOrder(_editCtx.baseOrder(), seq));
  _homeCommit();
}

// Called by the shared edit dialogs' Save/Reset when they act on an edit-mode
// tile (home OR a tile surface): fold the just-edited props into the active
// context's scope map (or drop the override on a My-list reset), land a pending
// add into the order, then persist + rerender. Outside edit mode (_editCtx null,
// e.g. the Settings-tab editors) it no-ops — those editors persist via their own
// Save button.
function _homeMaybeCommitAfterEdit(el) {
  if (!_editCtx || !el) return;
  var tid = el.getAttribute('data-tool-id');
  if (!tid) return;
  var isSep = _isSeparatorToken(tid);
  var reader = isSep ? _sepReadElementProps
    : (_isWebItemToken(tid) ? _webReadElementProps
      : (_isDashItemToken(tid) ? _dtileReadElementProps
        : (_isDashRefToken(tid) ? _dashRefReadElementProps : null)));
  if (!reader) return;
  var editedAttr = isSep ? 'data-sep-edited'
    : (_isWebItemToken(tid) ? 'data-web-edited'
      : (_isDashItemToken(tid) ? 'data-dash-edited' : 'data-dashref-edited'));
  var wasEdited = el.getAttribute(editedAttr) === '1';
  // A pending add only lands once configured (Save sets the edited flag).
  if (_homePendingAddId === tid) {
    if (!wasEdited) { _homePendingAddId = null; return; }   // cancelled add: nothing committed
    var ord = _editCtx.baseOrder();
    if (ord.indexOf(tid) === -1) ord.push(tid);
    _editCtx.setBaseOrder(ord);
    _homePendingAddId = null;
  }
  _editCtx.foldEditProps(tid, isSep, reader(el), wasEdited);
  _homeCommit();
}

// HOME scope map fold — the pre-P4 _homeMaybeCommitAfterEdit body, now a context
// method. Admin scope always writes the default map; My-list writes the override
// only when actually edited (a reset-to-default drops it).
function _homeFoldEditProps(tid, isSep, props, wasEdited) {
  if (_homeEditScope === 'admin') {
    if (isSep) { _adminToolSeparators = _adminToolSeparators || {}; _adminToolSeparators[tid] = props; }
    else { _adminToolItems = _adminToolItems || {}; _adminToolItems[tid] = props; }
  } else {
    _userSettings = _userSettings || {};
    if (isSep) {
      _userSettings.tool_separators = _userSettings.tool_separators || {};
      if (wasEdited) _userSettings.tool_separators[tid] = props;
      else delete _userSettings.tool_separators[tid];   // reset-to-default drops the override
    } else {
      _userSettings.tool_items = _userSettings.tool_items || {};
      if (wasEdited) _userSettings.tool_items[tid] = props;
      else delete _userSettings.tool_items[tid];
    }
  }
}
function _homeFoldRemoveProps(tid, isSep) {
  if (_homeEditScope === 'admin') {
    if (isSep) { if (_adminToolSeparators) delete _adminToolSeparators[tid]; }
    else if (_adminToolItems) delete _adminToolItems[tid];
  } else {
    if (isSep) { if (_userSettings && _userSettings.tool_separators) delete _userSettings.tool_separators[tid]; }
    else if (_userSettings && _userSettings.tool_items) delete _userSettings.tool_items[tid];
  }
}

// Remove an item/separator from the current scope: drop it from the order (and
// its props). In My-list scope removing an inherited admin item just hides it for
// this user (its token leaves the user's order); admin props are untouched.
function _homeRemoveEntry(tid) {
  if (!tid || !_editCtx) return;
  _editCtx.setBaseOrder(_editCtx.baseOrder().filter(function(t) { return t !== tid; }));
  _editCtx.foldRemoveProps(tid, _isSeparatorToken(tid));
  _homeCommit();
}

// POST the full current state for the active scope (mirrors the Settings editors'
// payloads exactly). On success, refresh the canonical globals + re-render the
// active surface's edit grid.
function _homePost(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 && d.error) { _homeEditToast(d.error, true); return; }
      onOk(d || {});
      _homeEditToast('Saved');
      if (_editCtx) _seRenderEditGrid();
    })
    .catch(function(e) { _homeEditToast('Save failed: ' + (e && e.message ? e.message : e), true); });
}
// Persist the active context's current scope.
function _homeCommit() { if (_editCtx) _editCtx.commit(); }

// HOME commit — the pre-P4 _homeCommit body, now the home context's commit method.
function _homeCommitScope() {
  if (_homeEditScope === 'admin') {
    _homePost('/togen-admin/tool-order', {
      order: (_toolOrder || []),
      separators: (_adminToolSeparators || {}),
      items: (_adminToolItems || {})
    }, function(d) {
      if (d.order) _toolOrder = d.order;
      if (d.separators) _adminToolSeparators = d.separators;
      if (d.items) _adminToolItems = d.items;
    });
  } else {
    // Materialize the order so an empty tool_order (a first gear-edit with no
    // reorder) is never sent — the /user-settings route treats empty as "clear".
    var ord = (_userSettings && _userSettings.tool_order && _userSettings.tool_order.length)
      ? _userSettings.tool_order.slice() : _toolOrder.slice();
    _userSettings = _userSettings || {};
    _userSettings.tool_order = ord;
    _homePost('/user-settings', {
      tool_order: ord,
      tool_separators: (_userSettings.tool_separators || {}),
      tool_items: (_userSettings.tool_items || {})
    }, function(d) { if (d.settings) _userSettings = d.settings; });
  }
}

// ---- Edit-mode chrome (toggle / scope switch / add menu / toast) ------------
function homeEditToggle() {
  _homeEditMode = !_homeEditMode;
  _homePendingAddId = null;
  _homeCloseAddMenu();
  var toolbar = document.getElementById('home-edit-toolbar');
  var toggle = document.getElementById('home-edit-toggle');
  var lbl = document.getElementById('home-edit-toggle-label');
  if (toolbar) toolbar.classList.toggle('hidden', !_homeEditMode);
  if (toggle) { toggle.classList.toggle('bg-togen-teal', _homeEditMode); toggle.classList.toggle('text-white', _homeEditMode); }
  if (lbl) lbl.textContent = _homeEditMode ? 'Done' : 'Edit';
  if (_homeEditMode) {
    _toolTileStopPolling();                 // no live polling while editing
    if (!_isToGenAdmin) _homeEditScope = 'user';
    _homeUpdateScopeButtons();
    _homeRenderEditGrid();
  } else {
    _editCtx = null;                        // leave edit mode: drop the active context
    renderTileGrid();                       // restore the normal (clickable) home
  }
}
function homeEditSetScope(scope) {
  if (!_isToGenAdmin && scope === 'admin') return;   // only admins edit the default list
  _homeEditScope = (scope === 'admin') ? 'admin' : 'user';
  _homePendingAddId = null;
  _homeUpdateScopeButtons();
  if (_homeEditMode) _homeRenderEditGrid();
}
function _homeUpdateScopeButtons() {
  var u = document.getElementById('home-scope-user');
  var a = document.getElementById('home-scope-admin');
  var onCls = ['bg-togen-teal', 'text-white'];
  var offCls = ['text-gray-600', 'dark:text-gray-300'];
  function set(btn, on) {
    if (!btn) return;
    onCls.forEach(function(c) { btn.classList.toggle(c, on); });
    offCls.forEach(function(c) { btn.classList.toggle(c, !on); });
  }
  set(u, _homeEditScope === 'user');
  set(a, _homeEditScope === 'admin');
  // "Reset my layout" is a My-list affordance; hide it in the Default scope
  // (DVI-1496 P6, D10-A — mirrors the Equipment sub-page edit toolbar).
  var reset = document.getElementById('home-edit-reset');
  if (reset) reset.classList.toggle('hidden', _homeEditScope !== 'user');
}
function homeEditToggleAddMenu(e) {
  if (e) e.stopPropagation();
  var m = document.getElementById('home-edit-add-menu');
  if (m) m.classList.toggle('hidden');
}
function _homeCloseAddMenu() {
  var m = document.getElementById('home-edit-add-menu');
  if (m) m.classList.add('hidden');
}
function homeEditAddSeparator() {
  _homeCloseAddMenu();
  var id = _mintSeparatorId();
  var ord = _homeBaseOrder(); ord.push(id); _homeSetBaseOrder(ord);
  _homeCommit();   // a bare separator needs no props
}
function homeEditAddWebsite() {
  _homeCloseAddMenu();
  var id = _mintWebItemId();
  var el = document.createElement('div');
  el.setAttribute('data-tool-id', id);
  el.setAttribute('data-web-new', '1');
  _webSeedElementProps(el, { type: 'web', name: '', url: '', mode: 'newtab', icon_mode: 'favicon', icon: '', glyph: '' });
  _homePendingAddId = id;
  _webItemEditOpen(el, _homeEditScope);
}
function homeEditAddDashTile() {
  _homeCloseAddMenu();
  var id = _mintDashItemId();
  var el = document.createElement('div');
  el.setAttribute('data-tool-id', id);
  el.setAttribute('data-dash-new', '1');
  _dtileSeedElementProps(el, { type: 'dtile', tile_type: '', config: {}, size: 'default', title: '' });
  _homePendingAddId = id;
  _dtileEditOpen(el, _homeEditScope);
}
function homeEditAddDashRef() {
  _homeCloseAddMenu();
  var id = _mintDashRefId();
  var el = document.createElement('div');
  el.setAttribute('data-tool-id', id);
  el.setAttribute('data-dashref-new', '1');
  _dashRefSeedElementProps(el, { type: 'dashref', dashboard_id: '', title: '', justify: 'center', collapse: 'none', collapsed: false, columns: 'auto', rows: 'auto' });
  _homePendingAddId = id;
  _dashRefEditOpen(el, _homeEditScope);
}

// DVI-1496 P6 (D10-A): "Reset my layout" on the home grid — clears THIS user's
// tool_order / tool_separators / tool_items overrides back to the admin Default.
// Ports the Equipment sub-page affordance (emResetMyLayout) to home, reusing the
// existing /user-settings empty-order clear path: an empty tool_order POST drops
// all three home stores server-side (user_settings_post) and nothing else. Only a
// My-list affordance — the Default scope has no personal layout to reset.
function homeResetMyLayout() {
  if (_homeEditScope !== 'user') return;
  if (!window.confirm('Reset the home page to the default layout? This clears your personal changes.')) return;
  _homePost('/user-settings', { tool_order: [] }, function(d) {
    _userSettings = (d && d.settings) ? d.settings : {};
    // _homePost re-renders the edit grid after this callback; _homeEffOrder /
    // _homeSepUserMap / _homeItemUserMap now fall back to the admin Default.
  });
}
var _homeEditToastTimer = null;
function _homeEditToast(msg, isError) {
  // Surface-aware: write to the active context's toast element (DVI-1496 P4).
  var tid = (_editCtx && _editCtx.toastId) ? _editCtx.toastId : 'home-edit-toast';
  var t = document.getElementById(tid);
  if (!t) return;
  t.textContent = msg;
  t.className = 'text-xs font-medium ' + (isError ? 'text-red-500' : 'text-green-600 dark:text-green-400');
  if (_homeEditToastTimer) clearTimeout(_homeEditToastTimer);
  _homeEditToastTimer = setTimeout(function() { if (t) t.textContent = ''; }, 2500);
}

function renderTileGrid() {
  if (_homeEditMode) { _homeRenderEditGrid(); return; }
  _updateTileViewToggle();
  if (_effectiveTileView() === 'list') { renderTileList(); return; }
  var grid = document.getElementById('tile-grid');
  grid.innerHTML = '';
  var part = _partitionToolsBySeparator();
  // DVI-1521: a leading separator lives on section 0, so the sectioned path also
  // runs for a single section that carries one.
  if (part.sections.length > 1 || (part.sections[0] && part.sections[0].sepId)) {
    // N grid sections split by full-width dividers (DVI-524/DVI-1337).
    grid.className = 'flex flex-col gap-5';
    grid.style.gridTemplateColumns = '';
    part.sections.forEach(function(sec, i) {
      if (sec.sepId) grid.appendChild(_makeTileDivider(sec.sepId));
      if (sec.sepId && _sepIsCollapsed(sec.sepId)) return;   // section below a collapsed bar is hidden
      var secEl = _makeGridSection();
      // DVI-1356 F3: carry this section's per-separator column override (auto|1..6)
      // so _applyTileGridColumns can size just this section's grid.
      secEl.setAttribute('data-sep-columns', String(_sepProps(sec.sepId).columns));
      sec.entries.forEach(function(e) { secEl.appendChild(_buildHomeEntry(e)); });
      grid.appendChild(secEl);
    });
  } else {
    // Restore grid-mode container classes (list mode swaps them out).
    grid.className = 'grid grid-cols-1 sm:grid-cols-2 gap-5';
    part.sections[0].entries.forEach(function(e) { grid.appendChild(_buildHomeEntry(e)); });
  }
  _applyTileGridColumns();
  _applyTileSidePadding();
  _toolTileStartPolling();
}

// 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();
});

// DVI-1496 P5: re-apply the Equipment tiles-page per-section columns on resize
// (mobile <-> lg+) while the tiles landing is visible (not in edit mode).
window.addEventListener('resize', function() {
  if (_activeTool !== 'equipment-manuals' || _emEditMode) return;
  var g = document.getElementById('em-tiles-grid');
  var l = document.getElementById('em-tiles-list');
  if (g && !g.classList.contains('hidden') && l) _applyTileGridColumns(l, 2);
});

function _showTool(toolId, toolTitle) {
  // DVI-1496 P3 (D6-B): the standalone Repair Parts Manager tool is retired —
  // it lives on as the RPM sub-tool inside Equipment. Redirect any legacy
  // openTool('repair-parts-manager') call / deep link / dashboard-tile source
  // navigation to Equipment -> RPM for one release. Moving the pane means
  // #tool-repair-parts-manager no longer exists, so _showTool can't reveal it —
  // this redirect is what keeps those entry points working.
  if (toolId === 'repair-parts-manager') {
    _showTool('equipment-manuals', 'Equipment');
    _emInitialized = true;
    emSwitchTab('rpm');   // no-access users land back on the tiles page (routes 403)
    return;
  }
  _activeTool = toolId;
  _toolTileStopPolling();   // pause home dashboard-tile polling while in a tool
  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();
    // DVI-1024: approver-only users get a locked Approvals-only view; the
    // activity feed endpoint is admin-gated, so skip it entirely.
    if (_worApprovalsOnly) {
      woradminApplyApprovalsOnly();
      woradminSetTab('approvals');
    }
    if (!_woradminInitialized) {
      _woradminInitialized = true;
      woradminLoadList();
    }
    if (!_worApprovalsOnly) 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();
    // Fetch backup state for the home tile (DVI-935)
    fetch('/togen-admin/backup/status').then(function(r){ return r.ok ? r.json() : null; }).then(function(d){
      if (d) _backupTileStatus = { state: d.state, lastRun: d.last_run };
    }).catch(function(){});
  }
  // 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 Oversight floor plans + layers on first open
  if (toolId === 'oversight' && !_oversightInitialized) {
    _oversightInitialized = true;
    oversightInit();
  }
  // Load the Visitor Registry on first open (DVI-1136 Phase 4)
  if (toolId === 'visitor-registry' && !_vrInitialized) {
    _vrInitialized = true;
    vrInit();
  }
  // Load Dashboards on first open (DVI-1192)
  if (toolId === 'dashboards' && !_dashboardsInitialized) {
    _dashboardsInitialized = true;
    dashboardsInit();
  }
  // Load QC on first open (DVI-1326)
  if (toolId === 'qc' && !_qcInitialized) {
    _qcInitialized = true;
    qcInit();
  }
  // Load Logistics on first open (DVI-1387/DVI-1397)
  if (toolId === 'logistics' && !_logisticsInitialized) {
    _logisticsInitialized = true;
    logisticsInit();
  }
  // 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();
  }
  // Equipment tool (DVI-1496 P1): land on the tiles page (not a sub-tool tab) on
  // every open. Sub-tool panes load lazily when their tab is entered.
  if (toolId === 'equipment-manuals') {
    _emInitialized = true;
    _emEditMode = false; _editCtx = null;   // always open in read mode (DVI-1496 P4)
    var _emEtb = document.getElementById('em-edit-toolbar'); if (_emEtb) _emEtb.classList.add('hidden');
    emShowTiles();
  }
  // DVI-1522: the QC Tile Surface always opens on the tiles page in read mode.
  if (toolId === 'quality') {
    _qsEditMode = false; _editCtx = null;
    var _qsEtb = document.getElementById('qs-edit-toolbar'); if (_qsEtb) _qsEtb.classList.add('hidden');
    qsShowTiles();
  }
  // Calendar: reset to current month/today on each open
  if (toolId === 'calendar') {
    calToolInit();
  }
  // Auditor: load on first open, always refresh on re-open. auditorApplyAccess
  // hides the tabs/sub-tabs a scoped non-admin can't see and lands them on their
  // first granted tab (DVI-1052); admins get everything and land on Assistant.
  if (toolId === 'auditor') {
    auditorApplyAccess();
  }
  // DVI-1496 P3: Repair Parts Manager is now the Equipment RPM sub-tool; the
  // legacy 'repair-parts-manager' tool id is redirected at the top of _showTool.
  // 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 === 'subtool') {
    // DVI-1496 P5 (rec 3): a sub-tool history entry — reveal the tool (which
    // lands on tiles) then switch to the sub-tool tab, without re-pushing state.
    _emHistNav = true;
    _showTool(st.tool, st.title);
    if (typeof emSwitchTab === 'function') emSwitchTab(st.tab);
    _emHistNav = false;
  } else if (st && st.page === 'tool') {
    // _showTool('equipment-manuals') always lands on the tiles page, so popping
    // from a sub-tool to the tool entry correctly returns to the tiles landing.
    _emHistNav = true;
    _showTool(st.tool, st.title);
    _emHistNav = false;
  } 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-935).
var _backupTileStatus = { state: 'idle', 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();
  if (typeof oversightThemeChanged === 'function') oversightThemeChanged();
  if (typeof scadaovThemeChanged === 'function') scadaovThemeChanged();
  if (typeof emovThemeChanged === 'function') emovThemeChanged();
  if (typeof visovThemeChanged === 'function') visovThemeChanged();
  if (typeof logovThemeChanged === 'function') logovThemeChanged();
}

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();
  // DVI-1364 P2: honor the resolved regional date format + 12/24h + timezone.
  el.textContent = _fmtDate(now) + ' ' + _fmtTime(now);
}
_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 || '') : '';
  }
  // Model picker belongs to Assistant mode only; assistantOpenDesktop re-shows it.
  var modelSel = document.getElementById('topbar-asst-model');
  if (modelSel) modelSel.classList.add('hidden');
  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',
  });
  var modelSel = document.getElementById('topbar-asst-model');
  if (modelSel) modelSel.classList.remove('hidden');
  _assistantLoadModels();
}

// 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);
  if (available) _assistantLoadModels();
  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.'); });
}

// Per-invocation Assistant model override (DVI-955). '' = use the user's default.
// Shared by the desktop topbar picker and the mobile popup picker.
var _assistantModel = '';

// Keep both pickers in sync and remember the choice for the next send.
function assistantModelPick(id) {
  var s = document.getElementById(id);
  _assistantModel = s ? (s.value || '') : '';
  ['topbar-asst-model', 'assistant-mobile-model'].forEach(function(oid) {
    if (oid === id) return;
    var other = document.getElementById(oid);
    if (other && other.value !== _assistantModel) other.value = _assistantModel;
  });
}

// Populate a model <select> from /assistant/connection's available_models, grouped
// by provider, with a leading "Default" option. Preserves the current selection.
function _assistantPopulateModelSelect(sel, models, defaultModel) {
  if (!sel) return;
  var defLabel = 'Default';
  (models || []).forEach(function(m){ if (m.default) defLabel = 'Default (' + m.label + ')'; });
  sel.innerHTML = '';
  var opt0 = document.createElement('option');
  opt0.value = ''; opt0.textContent = defLabel;
  sel.appendChild(opt0);
  var byProv = {};
  (models || []).forEach(function(m){
    (byProv[m.provider_id] = byProv[m.provider_id] || { label: m.provider_label, items: [] }).items.push(m);
  });
  Object.keys(byProv).forEach(function(pid){
    var grp = byProv[pid];
    var og = document.createElement('optgroup');
    og.label = grp.label;
    grp.items.forEach(function(m){
      var o = document.createElement('option');
      o.value = m.id; o.textContent = m.label;
      og.appendChild(o);
    });
    sel.appendChild(og);
  });
  // Restore prior choice if still valid, else fall back to Default.
  var valid = (models || []).some(function(m){ return m.id === _assistantModel; });
  sel.value = valid ? _assistantModel : '';
  if (!valid) _assistantModel = '';
}

// Load the available-models list and fill both pickers. Cheap; called on open.
function _assistantLoadModels() {
  fetch('/assistant/connection')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      _assistantPopulateModelSelect(document.getElementById('topbar-asst-model'), d.available_models, d.default_model);
      _assistantPopulateModelSelect(document.getElementById('assistant-mobile-model'), d.available_models, d.default_model);
    })
    .catch(function(){});
}

// 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) {
  var body = { messages: [{ role: 'user', content: text }] };
  if (_assistantModel) body.model = _assistantModel;
  fetch('/assistant/chat', {
    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) { 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', 'diagnosis', 'search', 'dashboards', 'reporting', 'tags', 'settings'].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');
  var dp = document.getElementById('auditor-pane-diagnosis');
  var sp = document.getElementById('auditor-pane-search');
  var dbp = document.getElementById('auditor-pane-dashboards');
  var rpp = document.getElementById('auditor-pane-reporting');
  var tp = document.getElementById('auditor-pane-tags');
  var stp = document.getElementById('auditor-pane-settings');
  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 (dp) dp.classList.toggle('hidden', tab !== 'diagnosis');
  if (sp) sp.classList.toggle('hidden', tab !== 'search');
  if (dbp) dbp.classList.toggle('hidden', tab !== 'dashboards');
  if (rpp) rpp.classList.toggle('hidden', tab !== 'reporting');
  if (tp) tp.classList.toggle('hidden', tab !== 'tags');
  if (stp) stp.classList.toggle('hidden', tab !== 'settings');
  if (tab === 'assistant') auditorLoad(true);
  else if (tab === 'history') { auditorSwitchHistTab(_auditorHistTab); auditorHistLoadTelemetry(); }
  else if (tab === 'diagnosis') auditorSwitchDiagTab(_auditorDiagTab);
  else if (tab === 'search') { var si = document.getElementById('auditor-search-input'); if (si) si.focus(); }
  else if (tab === 'dashboards') dashInspectorLoad();
  else if (tab === 'reporting') auditorReportingInit();
  else if (tab === 'tags') auditorTagsLoad();
  else if (tab === 'settings') auditorAccessLoad();
  else auditorLoadPolicies();
}

// DVI-1052: per-user Auditor access gating + Access sub-tab management.
// Leaf resource keys mirror _AUDITOR_RESOURCE_TREE (togen/app.py) and the tab
// bars above. The tree also drives the checkbox editor and its labels.
var _AUDITOR_RES_TREE = [
  {key: 'assistant', label: 'Assistant', children: [
    {key: 'assistant.history', label: 'History'},
    {key: 'assistant.failures', label: 'Failures'}
  ]},
  {key: 'history', label: 'History', children: [
    {key: 'history.prodrun', label: 'Production Run'},
    {key: 'history.print', label: 'Print'},
    {key: 'history.wor-admin', label: 'Work Orders'},
    {key: 'history.rpm', label: 'Repair Parts Manager'},
    {key: 'history.single-carrier-sheet', label: 'Carrier Sheet'},
    {key: 'history.mts-label', label: 'MTS Labels'},
    {key: 'history.file-browser', label: 'File Browser'},
    {key: 'history.ocr-tool', label: 'OCR Tool'},
    {key: 'history.takeoff-bridge', label: 'Take-off Bridge'},
    {key: 'history.project-builder', label: 'Project Builder'},
    {key: 'history.sync-admin', label: 'Sync Admin'},
    {key: 'history.togen-admin', label: 'Togen Admin'},
    {key: 'history.vision-agents', label: 'Vision Agents'},
    {key: 'history.bots', label: 'Bots'},
    {key: 'history.scada-reporting', label: 'Kiln Metrics Reporting'},
    {key: 'history.signage-agents', label: 'Displays & Presentation'},
    {key: 'history.dashboards', label: 'Dashboards'},
    {key: 'history.assets', label: 'Assets'},
    {key: 'history.datasources', label: 'Data sources'}
  ]},
  {key: 'policies', label: 'Policies', children: [
    {key: 'policies.wor-admin', label: 'Work Orders'},
    {key: 'policies.rpm', label: 'Repair Parts Manager'},
    {key: 'policies.vision', label: 'Vision'}
  ]},
  {key: 'diagnosis', label: 'Diagnosis', children: [
    {key: 'diagnosis.timeline', label: 'Timeline'},
    {key: 'diagnosis.bots', label: 'Bots'},
    {key: 'diagnosis.vision', label: 'Vision'}
  ]},
  {key: 'dashboards', label: 'Dashboards', children: [
    {key: 'dashboards.inspector', label: 'Config Inspector'}
  ]},
  {key: 'reporting', label: 'Reporting', children: [
    {key: 'reporting.idencia', label: 'Idencia'},
    {key: 'reporting.powerbi', label: 'Power BI'},
    {key: 'reporting.assets', label: 'Assets'},
    {key: 'reporting.qc', label: 'Batch Production'},
    {key: 'reporting.datasources', label: 'Data sources (manual values)'},
    {key: 'reporting.view', label: 'Reporting (run reports)'},
    {key: 'reporting.viewer', label: 'Report Viewer'},
    {key: 'reporting.builder', label: 'Report Builder'}
  ]},
  {key: 'search', label: 'Search', children: [
    {key: 'search', label: 'Search'}
  ]}
];

// Sub-tab button id -> leaf resource key (used to hide tabs a scoped user lacks).
var _AUDITOR_SUBTAB_KEYS = {
  'auditor-tab-history': 'assistant.history',
  'auditor-tab-failures': 'assistant.failures',
  'auditor-histtab-prodrun': 'history.prodrun',
  'auditor-histtab-print': 'history.print',
  'auditor-histtab-wor-admin': 'history.wor-admin',
  'auditor-histtab-rpm': 'history.rpm',
  'auditor-histtab-single-carrier-sheet': 'history.single-carrier-sheet',
  'auditor-histtab-mts-label': 'history.mts-label',
  'auditor-histtab-file-browser': 'history.file-browser',
  'auditor-histtab-ocr-tool': 'history.ocr-tool',
  'auditor-histtab-takeoff-bridge': 'history.takeoff-bridge',
  'auditor-histtab-project-builder': 'history.project-builder',
  'auditor-histtab-sync-admin': 'history.sync-admin',
  'auditor-histtab-togen-admin': 'history.togen-admin',
  'auditor-histtab-vision-agents': 'history.vision-agents',
  'auditor-histtab-bots': 'history.bots',
  'auditor-histtab-scada-reporting': 'history.scada-reporting',
  'auditor-histtab-signage-agents': 'history.signage-agents',
  'auditor-histtab-dashboards': 'history.dashboards',
  'auditor-histtab-assets': 'history.assets',
  'auditor-histtab-datasources': 'history.datasources',
  'auditor-poltab-woradmin': 'policies.wor-admin',
  'auditor-poltab-rpm': 'policies.rpm',
  'auditor-poltab-vision': 'policies.vision',
  'auditor-diagtab-timeline': 'diagnosis.timeline',
  'auditor-diagtab-bots': 'diagnosis.bots',
  'auditor-diagtab-vision': 'diagnosis.vision'
};
// Per-pane ordered sub-tabs and their switch function, to land a scoped user on
// their first visible sub-tab when a pane opens.
var _AUDITOR_PANE_LANDING = {
  assistant: {fn: auditorSwitchTab, tabs: [['history','assistant.history'],['failures','assistant.failures']]},
  history: {fn: auditorSwitchHistTab, tabs: [['prodrun','history.prodrun'],['print','history.print'],['wor-admin','history.wor-admin'],['rpm','history.rpm'],['single-carrier-sheet','history.single-carrier-sheet'],['mts-label','history.mts-label'],['file-browser','history.file-browser'],['ocr-tool','history.ocr-tool'],['takeoff-bridge','history.takeoff-bridge'],['project-builder','history.project-builder'],['sync-admin','history.sync-admin'],['togen-admin','history.togen-admin'],['vision-agents','history.vision-agents'],['bots','history.bots'],['scada-reporting','history.scada-reporting'],['signage-agents','history.signage-agents'],['dashboards','history.dashboards'],['assets','history.assets'],['datasources','history.datasources']]},
  policies: {fn: auditorSwitchPolTab, tabs: [['woradmin','policies.wor-admin'],['rpm','policies.rpm'],['vision','policies.vision']]},
  diagnosis: {fn: auditorSwitchDiagTab, tabs: [['timeline','diagnosis.timeline'],['bots','diagnosis.bots'],['vision','diagnosis.vision']]}
};

function auditorApplyAccess() {
  if (_isToGenAdmin) { auditorSwitchTopTab('assistant'); return; }
  // Hide top tabs the user has no grant under.
  ['assistant','history','policies','diagnosis','search','dashboards','reporting'].forEach(function(k){
    var btn = document.getElementById('auditor-toptab-' + k);
    if (btn) btn.classList.toggle('hidden', !_auditorCanTop(k));
  });
  // Hide individual sub-tabs the user lacks, and land each pane on a visible one.
  Object.keys(_AUDITOR_SUBTAB_KEYS).forEach(function(id){
    var btn = document.getElementById(id);
    if (btn) btn.classList.toggle('hidden', !_auditorCan(_AUDITOR_SUBTAB_KEYS[id]));
  });
  // Policies "All" tab: only meaningful if some policies child is granted.
  var polAll = document.getElementById('auditor-poltab-all');
  if (polAll) polAll.classList.toggle('hidden', !_auditorCanTop('policies'));
  Object.keys(_AUDITOR_PANE_LANDING).forEach(function(pane){
    var cfg = _AUDITOR_PANE_LANDING[pane];
    for (var i = 0; i < cfg.tabs.length; i++) {
      if (_auditorCan(cfg.tabs[i][1])) { try { cfg.fn(cfg.tabs[i][0]); } catch (e) {} break; }
    }
  });
  // Land the user on their first granted top tab.
  var tops = ['assistant','history','policies','diagnosis','search','dashboards','reporting'].filter(_auditorCanTop);
  auditorSwitchTopTab(tops.length ? tops[0] : 'assistant');
}

function auditorSwitchSetTab(tab) {
  var b = document.getElementById('auditor-settab-access');
  if (b) {
    var active = tab === 'access';
    b.classList.toggle('border-togen-teal', active);
    b.classList.toggle('text-togen-teal', active);
    b.classList.toggle('border-transparent', !active);
  }
  var ac = document.getElementById('auditor-set-access');
  if (ac) ac.classList.toggle('hidden', tab !== 'access');
  if (tab === 'access') auditorAccessLoad();
}

var _auditorAccessUsers = [];
function _auditorAccessSetStatus(msg, isErr) {
  var el = document.getElementById('auditor-access-status');
  if (!el) return;
  if (!msg) { el.classList.add('hidden'); el.textContent = ''; return; }
  el.classList.remove('hidden');
  el.textContent = msg;
  el.className = 'text-sm mb-3 ' + (isErr ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400');
}

// DVI-1136: LoRaWAN Tags tab — shows ThingPark uplink snapshot.
function auditorTagsLoad() {
  var wrap = document.getElementById('auditor-tags-wrap');
  var empty = document.getElementById('auditor-tags-empty');
  var status = document.getElementById('auditor-tags-status');
  var tbody = document.getElementById('auditor-tags-tbody');
  if (!tbody) return;
  if (wrap) wrap.classList.add('hidden');
  if (empty) empty.classList.add('hidden');
  if (status) status.classList.add('hidden');
  tbody.innerHTML = '<tr><td colspan="8" class="px-3 py-6 text-center text-gray-400 dark:text-gray-500 text-sm">Loading…</td></tr>';
  if (wrap) wrap.classList.remove('hidden');
  fetch('/auditor/tags').then(function(r){ return r.json(); }).then(function(d) {
    var health = d.health || {};
    if (health.last_error) {
      if (status) { status.textContent = 'Last ingest error: ' + health.last_error; status.classList.remove('hidden'); }
    }
    var devs = d.devices || [];
    if (!devs.length) {
      if (wrap) wrap.classList.add('hidden');
      if (empty) empty.classList.remove('hidden');
      return;
    }
    if (wrap) wrap.classList.remove('hidden');
    tbody.innerHTML = devs.map(function(dv) {
      var loc = (dv.lat != null && dv.lng != null) ? dv.lat.toFixed(5) + ', ' + dv.lng.toFixed(5) : '—';
      var batt = dv.battery != null ? dv.battery.toFixed(0) + '%' : '—';
      var rssi = dv.rssi != null ? dv.rssi.toFixed(0) + ' dBm' : '—';
      var snr  = dv.snr  != null ? dv.snr.toFixed(1)  + ' dB'  : '—';
      var ts = dv.ts ? dv.ts.replace('T', ' ').substring(0, 16) : '—';
      return '<tr class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/40">'
        + '<td class="px-3 py-2 font-medium">' + _tpEsc(dv.name || '') + '</td>'
        + '<td class="px-3 py-2 font-mono text-gray-500 dark:text-gray-400">' + _tpEsc(dv.dev_eui) + '</td>'
        + '<td class="px-3 py-2 text-gray-500 dark:text-gray-400 whitespace-nowrap">' + _tpEsc(ts) + '</td>'
        + '<td class="px-3 py-2">' + _tpEsc(rssi) + '</td>'
        + '<td class="px-3 py-2">' + _tpEsc(snr) + '</td>'
        + '<td class="px-3 py-2">' + _tpEsc(batt) + '</td>'
        + '<td class="px-3 py-2 text-gray-500 dark:text-gray-400">' + _tpEsc(loc) + '</td>'
        + '<td class="px-3 py-2 text-gray-500 dark:text-gray-400">' + (dv.count || 0) + '</td>'
        + '</tr>';
    }).join('');
  }).catch(function() {
    tbody.innerHTML = '<tr><td colspan="8" class="px-3 py-6 text-center text-red-500 text-sm">Failed to load tag data.</td></tr>';
  });
}

// DVI-1226 P3: Auditor → Reporting — registry-driven report runner + CSV export.
// The report list (/auditor/reports) is already access-filtered server-side;
// param inputs are generated from each report's declared params.
var _auditorReports = null;        // fetched report registry (per-session cache)
var _auditorReportLast = null;     // {id, qs} of the last successful run (CSV)
var _auditorReportData = null;     // last successful report {columns, rows} (for transpose re-render)
var _auditorReportTranspose = false; // display-only orientation flip (DVI-1382 follow-up)
var _auditorSrcPicker = { inputId: null, sources: [], checked: {} }; // Sources checkbox picker state

function auditorReportingInit() {
  reportBuilderProbe();                   // DVI-1255: builder capability + templates
  if (_auditorReports !== null) return;  // keep the current pick + result
  fetch('/auditor/reports').then(function(r){ return r.json(); }).then(function(d){
    _auditorReports = (d && d.reports) || [];
    var sel = document.getElementById('auditor-report-select');
    if (!sel) return;
    sel.innerHTML = _auditorReports.map(function(t){
      return '<option value="' + _tpEsc(t.id) + '">' + _tpEsc(t.label) + '</option>';
    }).join('');
    auditorReportingPickChanged();
  }).catch(function(){
    var st = document.getElementById('auditor-report-status');
    if (st) { st.textContent = 'Failed to load the report list.'; st.classList.remove('hidden'); }
  });
}

function _auditorReportCurrent() {
  var sel = document.getElementById('auditor-report-select');
  if (!sel || !_auditorReports) return null;
  for (var i = 0; i < _auditorReports.length; i++) {
    if (_auditorReports[i].id === sel.value) return _auditorReports[i];
  }
  return null;
}

var _AR_INPUT_CLS = 'px-2 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-1 focus:ring-togen-teal';

function auditorReportingPickChanged() {
  var t = _auditorReportCurrent();
  var box = document.getElementById('auditor-report-params');
  if (box) {
    box.innerHTML = !t ? '' : (t.params || []).map(function(p){
      var id = 'auditor-report-param-' + p.key;
      var inner;
      if (p.type === 'select') {
        inner = '<select id="' + id + '" class="' + _AR_INPUT_CLS + '">'
          + (p.options || []).map(function(o){
              var selAttr = (p['default'] && o[0] === p['default']) ? ' selected' : '';
              return '<option value="' + _tpEsc(o[0]) + '"' + selAttr + '>' + _tpEsc(o[1]) + '</option>';
            }).join('')
          + '</select>';
      } else {
        var textField = '<input id="' + id + '" type="text" placeholder="' + _tpEsc(p.placeholder || '') + '"'
          + ' onkeydown="if(event.key===\'Enter\')auditorReportingRun()"'
          + ' class="' + _AR_INPUT_CLS + ' w-64" />';
        // DVI-1382 follow-up: a param may declare a picker to help fill a text
        // field from a checkbox list (Sources). The picked names are written
        // back as copyable comma text.
        if (p.picker) {
          inner = '<div class="flex items-center gap-1">' + textField
            + '<button type="button" onclick="auditorSrcPickerOpen(\'' + id + '\', \'' + _tpEsc(p.picker) + '\')"'
            + ' title="Choose sources from a list" class="togen-control px-2 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer">&#9776;</button>'
            + '</div>';
        } else {
          inner = textField;
        }
      }
      return '<div><label class="block text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">' + _tpEsc(p.label) + '</label>' + inner + '</div>';
    }).join('');
  }
  // Reset the result surfaces for the new pick.
  _auditorReportLast = null;
  _auditorReportData = null;
  _auditorReportTranspose = false;
  ['auditor-report-wrap', 'auditor-report-note', 'auditor-report-status', 'auditor-report-export', 'auditor-report-transpose']
    .forEach(function(i){ var el = document.getElementById(i); if (el) el.classList.add('hidden'); });
  var empty = document.getElementById('auditor-report-empty');
  if (empty) empty.classList.remove('hidden');
}

function _auditorReportQs(t) {
  return (t.params || []).map(function(p){
    var el = document.getElementById('auditor-report-param-' + p.key);
    return encodeURIComponent(p.key) + '=' + encodeURIComponent(el ? el.value : '');
  }).join('&');
}

function auditorReportingRun() {
  var t = _auditorReportCurrent();
  if (!t) return;
  var qs = _auditorReportQs(t);
  var wrap = document.getElementById('auditor-report-wrap');
  var empty = document.getElementById('auditor-report-empty');
  var note = document.getElementById('auditor-report-note');
  var status = document.getElementById('auditor-report-status');
  var exp = document.getElementById('auditor-report-export');
  var thead = document.getElementById('auditor-report-thead');
  var tbody = document.getElementById('auditor-report-tbody');
  if (!thead || !tbody) return;
  if (empty) empty.classList.add('hidden');
  if (note) note.classList.add('hidden');
  if (status) status.classList.add('hidden');
  if (wrap) wrap.classList.remove('hidden');
  thead.innerHTML = '';
  tbody.innerHTML = '<tr><td class="px-3 py-6 text-center text-gray-400 dark:text-gray-500 text-sm">Running…</td></tr>';
  fetch('/auditor/reports/' + encodeURIComponent(t.id) + '?' + qs)
    .then(function(r){ return r.json(); }).then(function(d){
      if (!d || !d.ok) {
        if (wrap) wrap.classList.add('hidden');
        if (exp) exp.classList.add('hidden');
        if (status) { status.textContent = (d && d.error) || 'Report failed.'; status.classList.remove('hidden'); }
        return;
      }
      var rep = d.report || {};
      _auditorReportData = rep;
      _auditorReportTranspose = false;   // fresh run starts in normal orientation
      _auditorReportRenderGrid();
      var meta = rep.meta || {};
      if (meta.cached && note) {
        note.textContent = 'Cached — as of ' + (meta.as_of || 'unknown')
          + (meta.error ? ' (live fetch failed: ' + meta.error + ')' : '');
        note.classList.remove('hidden');
      }
      _auditorReportLast = {id: t.id, qs: qs};
      if (exp) exp.classList.remove('hidden');
      var tr = document.getElementById('auditor-report-transpose');
      if (tr) { tr.textContent = 'Transpose'; tr.classList.remove('hidden'); }
    }).catch(function(){
      if (wrap) wrap.classList.add('hidden');
      if (status) { status.textContent = 'Report failed to load.'; status.classList.remove('hidden'); }
    });
}

function auditorReportingExport() {
  // Export exactly the last successful run (its params), not the live inputs.
  // The CSV keeps the report's native orientation regardless of the on-screen
  // transpose toggle (that is display-only).
  if (!_auditorReportLast) return;
  window.location.href = '/auditor/reports/' + encodeURIComponent(_auditorReportLast.id)
    + '/export.csv?' + _auditorReportLast.qs;
}

// DVI-1382 follow-up: render the last report into the grid, honoring the
// display-only transpose flag. Normal: columns = header, rows = body. Transposed:
// each original data column becomes a row and each original row-key becomes a
// header cell (handy when viewing a few dates across many sources).
function _auditorReportRenderGrid() {
  var thead = document.getElementById('auditor-report-thead');
  var tbody = document.getElementById('auditor-report-tbody');
  if (!thead || !tbody) return;
  var rep = _auditorReportData || {};
  var cols = rep.columns || [];
  var rows = rep.rows || [];
  function cell(v){ return (v === null || v === undefined) ? '—' : String(v); }
  if (!_auditorReportTranspose) {
    thead.innerHTML = '<tr class="bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 uppercase tracking-wide text-[10px]">'
      + cols.map(function(c, ci){
          return '<th class="px-3 py-2 font-medium ' + (ci ? 'text-right' : 'text-left') + '">' + _tpEsc(c.label) + '</th>';
        }).join('') + '</tr>';
    if (!rows.length) {
      tbody.innerHTML = '<tr><td colspan="' + (cols.length || 1) + '" class="px-3 py-6 text-center text-gray-400 dark:text-gray-500 text-sm">No data for this report.</td></tr>';
      return;
    }
    tbody.innerHTML = rows.map(function(row, ri){
      var isTotal = ri === rows.length - 1 && String(row[0]).indexOf('(total)') !== -1;
      return '<tr class="' + (isTotal ? 'font-semibold bg-gray-50 dark:bg-gray-800/60' : 'hover:bg-gray-50 dark:hover:bg-gray-800/40') + '">'
        + row.map(function(c, ci){
            return '<td class="px-3 py-2 ' + (ci ? 'text-right' : 'text-left') + '">' + _tpEsc(cell(c)) + '</td>';
          }).join('') + '</tr>';
    }).join('');
    return;
  }
  // Transposed. Top-left corner shows the first column's label; the header row
  // then lists each original row's first-cell value (e.g. the dates).
  if (!rows.length || !cols.length) {
    thead.innerHTML = '';
    tbody.innerHTML = '<tr><td class="px-3 py-6 text-center text-gray-400 dark:text-gray-500 text-sm">No data for this report.</td></tr>';
    return;
  }
  thead.innerHTML = '<tr class="bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 uppercase tracking-wide text-[10px]">'
    + '<th class="px-3 py-2 font-medium text-left">' + _tpEsc((cols[0] && cols[0].label) || '') + '</th>'
    + rows.map(function(row){
        return '<th class="px-3 py-2 font-medium text-right">' + _tpEsc(cell(row[0])) + '</th>';
      }).join('') + '</tr>';
  var body = '';
  for (var ci = 1; ci < cols.length; ci++) {
    body += '<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/40">'
      + '<td class="px-3 py-2 text-left font-medium">' + _tpEsc((cols[ci] && cols[ci].label) || '') + '</td>'
      + rows.map(function(row){
          return '<td class="px-3 py-2 text-right">' + _tpEsc(cell(row[ci])) + '</td>';
        }).join('') + '</tr>';
  }
  tbody.innerHTML = body;
}

function auditorReportingToggleTranspose() {
  if (!_auditorReportData) return;
  _auditorReportTranspose = !_auditorReportTranspose;
  var tr = document.getElementById('auditor-report-transpose');
  if (tr) tr.textContent = _auditorReportTranspose ? 'Normal view' : 'Transpose';
  _auditorReportRenderGrid();
}

// ===== Sources checkbox picker (DVI-1382 follow-up) =====
// Opens a checkbox list of the manual data sources by name and writes the
// selection back into a report's Sources text field as comma-separated names,
// which the user can then copy for repeated runs.
function auditorSrcPickerOpen(inputId, kind) {
  // Lift to <body> (idempotent) so the z-[120] picker paints above the Report
  // Builder tile-config modal, which is itself lifted to body (DVI-1255 lesson).
  var ov = _dashLiftOverlay('auditor-src-picker-overlay');
  if (!ov) return;
  _auditorSrcPicker.inputId = inputId;
  _auditorSrcPicker.checked = {};
  // Seed the checked set from the current field content (names or ids).
  var input = document.getElementById(inputId);
  var current = (input && input.value ? input.value : '').split(',')
    .map(function(s){ return s.trim().toLowerCase(); }).filter(Boolean);
  var curSet = {}; current.forEach(function(s){ curSet[s] = true; });
  var filt = document.getElementById('auditor-src-picker-filter');
  if (filt) filt.value = '';
  ov.classList.remove('hidden');
  // kind is reserved for future picker sources; only manual_sources today.
  fetch('/dashboards/sources/manual').then(function(r){ return r.json(); }).then(function(d){
    _auditorSrcPicker.sources = (d && d.sources) || [];
    _auditorSrcPicker.sources.forEach(function(s){
      if (curSet[String(s.name || '').toLowerCase()] || curSet[String(s.id || '').toLowerCase()]) {
        _auditorSrcPicker.checked[s.id] = true;
      }
    });
    auditorSrcPickerRender();
  }).catch(function(){
    var list = document.getElementById('auditor-src-picker-list');
    if (list) list.innerHTML = '<div class="text-sm text-gray-400 py-4 text-center">Failed to load data sources.</div>';
  });
}

function auditorSrcPickerRender() {
  var list = document.getElementById('auditor-src-picker-list');
  if (!list) return;
  var q = (document.getElementById('auditor-src-picker-filter') || {}).value || '';
  q = q.trim().toLowerCase();
  var items = _auditorSrcPicker.sources.filter(function(s){
    return !q || String(s.name || '').toLowerCase().indexOf(q) !== -1;
  });
  if (!items.length) {
    list.innerHTML = '<div class="text-sm text-gray-400 py-4 text-center">No manual data sources' + (q ? ' match the filter.' : '.') + '</div>';
    return;
  }
  list.innerHTML = items.map(function(s){
    var checked = _auditorSrcPicker.checked[s.id] ? ' checked' : '';
    return '<label class="flex items-center gap-2 py-1 cursor-pointer">'
      + '<input type="checkbox" data-src-id="' + _tpEsc(s.id) + '" onchange="auditorSrcPickerToggle(this)"' + checked
      + ' class="h-4 w-4 rounded border-gray-300 text-togen-teal focus:ring-togen-teal" />'
      + '<span>' + _tpEsc(s.name || s.id) + '</span></label>';
  }).join('');
}

function auditorSrcPickerToggle(cb) {
  var id = cb.getAttribute('data-src-id');
  if (!id) return;
  if (cb.checked) _auditorSrcPicker.checked[id] = true;
  else delete _auditorSrcPicker.checked[id];
}

function auditorSrcPickerAll(on) {
  var q = ((document.getElementById('auditor-src-picker-filter') || {}).value || '').trim().toLowerCase();
  _auditorSrcPicker.sources.forEach(function(s){
    if (q && String(s.name || '').toLowerCase().indexOf(q) === -1) return;  // only the filtered view
    if (on) _auditorSrcPicker.checked[s.id] = true;
    else delete _auditorSrcPicker.checked[s.id];
  });
  auditorSrcPickerRender();
}

function auditorSrcPickerApply() {
  var input = _auditorSrcPicker.inputId ? document.getElementById(_auditorSrcPicker.inputId) : null;
  if (input) {
    // Write the picked NAMES (copyable + human-readable); the report resolves
    // names or ids interchangeably.
    var names = _auditorSrcPicker.sources.filter(function(s){ return _auditorSrcPicker.checked[s.id]; })
      .map(function(s){ return s.name || s.id; });
    input.value = names.join(', ');
  }
  auditorSrcPickerClose();
}

function auditorSrcPickerClose() {
  var ov = document.getElementById('auditor-src-picker-overlay');
  if (ov) ov.classList.add('hidden');
  _auditorSrcPicker.inputId = null;
}

// ===== Report Builder (DVI-1255, DVI-1252 P2) =====
// Create/edit reusable, versioned Report Templates. Tile authoring reuses the
// Dashboards tile picker + config modal via _dashTileSaveHook (the modal
// returns the collected tile to us instead of POSTing to a dashboard).
var _rbMode = 'run';
var _rbCanBuild = false;
var _rbCanView = false;       // reporting.viewer|builder|admin — Report Viewer mode
var _rbRangeModes = null;    // [[key,label], ...] from /reporting/templates
var _rbTemplates = [];
var _rbEditing = null;       // {id?, name, version?, date_range, recipients, tiles}

function reportBuilderProbe() {
  fetch('/reporting/templates').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d || !d.ok) return;
      _rbCanBuild = !!d.can_build;
      _rbCanView = !!d.can_use_viewer;
      _rbRangeModes = d.range_modes || [['last_week', 'Last week']];
      _rbTemplates = d.templates || [];
      // Show the toggle if the user can reach any mode beyond "Run reports".
      var tog = document.getElementById('reporting-mode-toggle');
      if (tog) tog.classList.toggle('hidden', !(_rbCanView || _rbCanBuild));
      var vb = document.getElementById('reporting-mode-view');
      if (vb) vb.classList.toggle('hidden', !_rbCanView);
      var bb = document.getElementById('reporting-mode-build');
      if (bb) bb.classList.toggle('hidden', !_rbCanBuild);
      if (_rbMode === 'build') reportBuilderRenderList();
      if (_rbMode === 'view') reportViewerRenderTemplateOptions();
    }).catch(function(){});
}

function reportingSwitchMode(mode) {
  _rbMode = mode;
  var runV = document.getElementById('reporting-run-view');
  var viewV = document.getElementById('reporting-viewer-view');
  var buildV = document.getElementById('reporting-builder-view');
  if (runV) runV.classList.toggle('hidden', mode !== 'run');
  if (viewV) viewV.classList.toggle('hidden', mode !== 'view');
  if (buildV) buildV.classList.toggle('hidden', mode !== 'build');
  [['reporting-mode-run', mode === 'run'], ['reporting-mode-view', mode === 'view'],
   ['reporting-mode-build', mode === 'build']]
    .forEach(function(p){
      var b = document.getElementById(p[0]); if (!b) return;
      b.classList.toggle('bg-togen-teal', p[1]);
      b.classList.toggle('text-white', p[1]);
      b.classList.toggle('text-gray-600', !p[1]);
      b.classList.toggle('dark:text-gray-300', !p[1]);
    });
  if (mode === 'build') { reportBuilderCloseEditor(); reportBuilderRefresh(); }
  if (mode === 'view') { reportViewerRefresh(); }
}

function reportBuilderRefresh() {
  fetch('/reporting/templates').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (d && d.ok) {
        _rbTemplates = d.templates || [];
        _rbRangeModes = d.range_modes || _rbRangeModes;
        _rbCanBuild = !!d.can_build;
      }
      reportBuilderRenderList();
    }).catch(reportBuilderRenderList);
}

function reportBuilderRenderList() {
  var wrap = document.getElementById('reporting-builder-list');
  var empty = document.getElementById('reporting-builder-empty');
  if (!wrap) return;
  if (!_rbTemplates.length) {
    if (empty) empty.classList.remove('hidden');
    wrap.innerHTML = ''; return;
  }
  if (empty) empty.classList.add('hidden');
  wrap.innerHTML = _rbTemplates.map(function(t){
    return '<div class="togen-inset flex items-center justify-between gap-3 rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-3">' +
      '<div class="min-w-0"><div class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + esc(t.name) + '</div>' +
      '<div class="text-[11px] text-gray-400 dark:text-gray-500">v' + esc(String(t.version)) + ' · updated ' + esc(t.updated_at || '') + '</div></div>' +
      '<div class="flex items-center gap-2 flex-shrink-0">' +
      '<button onclick="reportBuilderOpen(\'' + esc(t.id) + '\')" 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 cursor-pointer">Open</button>' +
      '<button onclick="reportBuilderDelete(\'' + esc(t.id) + '\')" class="togen-control px-3 py-1.5 text-xs font-medium rounded-lg text-red-600 dark:text-red-400 border border-red-300 dark:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 cursor-pointer">Delete</button>' +
      '</div></div>';
  }).join('');
}

function reportBuilderNew() {
  var m = (_rbRangeModes && _rbRangeModes[0]) ? _rbRangeModes[0][0] : 'last_week';
  _rbEditing = { id: null, name: '', date_range: { mode: m, start: '', end: '' },
                 recipients: [], tiles: [], schedule: {}, source_dashboard_id: '' };
  reportBuilderShowEditor();
}

function reportBuilderOpen(id) {
  fetch('/reporting/templates/' + encodeURIComponent(id))
    .then(function(r){ return r.json(); }).then(function(d){
      if (!d || !d.ok) { alert((d && d.error) || 'Failed to open template.'); return; }
      var def = d.template.definition || {};
      _rbEditing = { id: d.template.id, name: d.template.name, version: d.template.version,
        date_range: def.date_range || { mode: 'last_week' },
        recipients: def.recipients || [], tiles: def.tiles || [],
        schedule: def.schedule || {},
        source_dashboard_id: def.source_dashboard_id || '' };
      reportBuilderShowEditor();
    }).catch(function(){ alert('Failed to open template.'); });
}

// The tile picker/config modal iterates _dashTileTypes, populated by the
// Dashboards tool. Load it independently if the user never opened Dashboards.
function reportBuilderEnsureTileTypes(cb) {
  if (_dashTileTypes && Object.keys(_dashTileTypes).length) { cb(); return; }
  fetch('/dashboards').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){ if (d && d.tile_types) _dashTileTypes = d.tile_types;
      if (d && d.metric_types) _dashMetricTypes = d.metric_types; cb(); })
    .catch(cb);
}

function reportBuilderShowEditor() {
  reportBuilderEnsureTileTypes(function(){
    document.getElementById('reporting-builder-list-view').classList.add('hidden');
    document.getElementById('reporting-builder-editor').classList.remove('hidden');
    document.getElementById('reporting-builder-name').value = _rbEditing.name || '';
    _rbRecipients = (_rbEditing.recipients || []).slice();
    document.getElementById('reporting-builder-recipient-input').value = '';
    reportBuilderRenderRecipients();
    reportBuilderFillSchedule();
    document.getElementById('reporting-builder-status').textContent = '';
    var meta = document.getElementById('reporting-builder-editor-meta');
    if (meta) meta.textContent = _rbEditing.id ? ('Editing · v' + (_rbEditing.version || '')) : 'New template';
    // DVI-1388: the Re-sync banner only applies to a saved, dashboard-linked report.
    var rs = document.getElementById('reporting-builder-resync-wrap');
    if (rs) rs.classList.toggle('hidden', !(_rbEditing.id && _rbEditing.source_dashboard_id));
    reportBuilderRenderRangeOptions();
    reportBuilderRenderTiles();
  });
}

// ----- DVI-1258 (P5): recipient + control -----
var _rbRecipients = [];

function reportBuilderRenderRecipients() {
  var wrap = document.getElementById('reporting-builder-recipients-list');
  if (!wrap) return;
  if (!_rbRecipients.length) {
    wrap.innerHTML = '<span class="text-[11px] text-gray-400 dark:text-gray-500">No recipients yet. Add an address above.</span>';
    return;
  }
  wrap.innerHTML = _rbRecipients.map(function(em, i){
    return '<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-togen-teal/10 text-togen-teal text-xs border border-togen-teal/30">' +
      esc(em) +
      '<button type="button" onclick="reportBuilderRemoveRecipient(' + i + ')" title="Remove" class="ml-0.5 text-togen-teal/70 hover:text-togen-teal cursor-pointer leading-none">&times;</button></span>';
  }).join('');
}

function reportBuilderAddRecipient() {
  var input = document.getElementById('reporting-builder-recipient-input');
  var status = document.getElementById('reporting-builder-status');
  var raw = (input.value || '').trim().toLowerCase();
  if (!raw) return;
  if (raw.indexOf('@') < 0 || raw.indexOf('.') < 0) {
    if (status) status.textContent = 'Enter a valid email address.';
    return;
  }
  if (_rbRecipients.indexOf(raw) < 0) _rbRecipients.push(raw);
  input.value = '';
  if (status) status.textContent = '';
  reportBuilderRenderRecipients();
}

function reportBuilderRemoveRecipient(i) {
  _rbRecipients.splice(i, 1);
  reportBuilderRenderRecipients();
}

// ----- DVI-1258 (P5): schedule editor -----
function reportBuilderScheduleToggle() {
  var on = document.getElementById('reporting-builder-schedule-enabled').checked;
  document.getElementById('reporting-builder-schedule-body').classList.toggle('hidden', !on);
}

function reportBuilderScheduleFreqChanged() {
  var freq = document.getElementById('reporting-builder-schedule-freq').value;
  document.getElementById('reporting-builder-schedule-weekday-wrap').classList.toggle('hidden', freq !== 'weekly');
  document.getElementById('reporting-builder-schedule-dom-wrap').classList.toggle('hidden', freq !== 'monthly');
}

function reportBuilderFillSchedule() {
  var s = _rbEditing.schedule || {};
  document.getElementById('reporting-builder-schedule-enabled').checked = !!s.enabled;
  document.getElementById('reporting-builder-schedule-freq').value = s.frequency || 'daily';
  document.getElementById('reporting-builder-schedule-time').value = s.time || '08:00';
  document.getElementById('reporting-builder-schedule-weekday').value = String(s.weekday != null ? s.weekday : 0);
  document.getElementById('reporting-builder-schedule-dom').value = String(s.day_of_month || 1);
  document.getElementById('reporting-builder-schedule-email').checked = !!s.email;
  reportBuilderScheduleToggle();
  reportBuilderScheduleFreqChanged();
  reportBuilderLoadHealth();
}

function reportBuilderCollectSchedule() {
  return {
    enabled: document.getElementById('reporting-builder-schedule-enabled').checked,
    frequency: document.getElementById('reporting-builder-schedule-freq').value || 'daily',
    time: document.getElementById('reporting-builder-schedule-time').value || '08:00',
    weekday: parseInt(document.getElementById('reporting-builder-schedule-weekday').value, 10) || 0,
    day_of_month: parseInt(document.getElementById('reporting-builder-schedule-dom').value, 10) || 1,
    email: document.getElementById('reporting-builder-schedule-email').checked
  };
}

// Scheduled-run health for the open template (background-worker driven).
function reportBuilderLoadHealth() {
  var box = document.getElementById('reporting-builder-schedule-health');
  if (!box) return;
  if (!_rbEditing.id) { box.classList.add('hidden'); box.innerHTML = ''; return; }
  fetch('/reporting/templates/' + encodeURIComponent(_rbEditing.id) + '/schedule-runs')
    .then(function(r){ return r.ok ? r.json() : null; }).then(function(d){
      if (!d || !d.ok || !d.runs || !d.runs.length) {
        box.classList.remove('hidden');
        box.innerHTML = 'No scheduled runs yet.';
        return;
      }
      box.classList.remove('hidden');
      box.innerHTML = '<div class="font-medium text-gray-600 dark:text-gray-300 mb-1">Recent scheduled runs</div>' +
        d.runs.slice(0, 5).map(function(r){
          var ok = r.status === 'ok';
          var dot = ok ? '<span class="text-green-600 dark:text-green-400">&#9679;</span>' : '<span class="text-red-600 dark:text-red-400">&#9679;</span>';
          var mail = r.emailed ? ' · emailed' : (ok ? ' · saved (no email)' : '');
          return '<div>' + dot + ' ' + esc(r.fired_at || r.run_date || '') + mail +
                 (ok ? '' : ' · ' + esc(r.error || 'error')) + '</div>';
        }).join('');
    }).catch(function(){ box.classList.add('hidden'); });
}

function reportBuilderCloseEditor() {
  var ed = document.getElementById('reporting-builder-editor');
  var lv = document.getElementById('reporting-builder-list-view');
  if (ed) ed.classList.add('hidden');
  if (lv) lv.classList.remove('hidden');
}

function reportBuilderRenderRangeOptions() {
  var sel = document.getElementById('reporting-builder-range');
  if (!sel) return;
  var modes = _rbRangeModes || [['last_week', 'Last week']];
  var cur = (_rbEditing.date_range && _rbEditing.date_range.mode) || 'last_week';
  sel.innerHTML = modes.map(function(m){
    return '<option value="' + esc(m[0]) + '"' + (m[0] === cur ? ' selected' : '') + '>' + esc(m[1]) + '</option>';
  }).join('');
  document.getElementById('reporting-builder-range-start').value = (_rbEditing.date_range && _rbEditing.date_range.start) || '';
  document.getElementById('reporting-builder-range-end').value = (_rbEditing.date_range && _rbEditing.date_range.end) || '';
  reportBuilderRangeChanged();
}

function reportBuilderRangeChanged() {
  var sel = document.getElementById('reporting-builder-range');
  var custom = document.getElementById('reporting-builder-custom-range');
  if (custom) custom.classList.toggle('hidden', !sel || sel.value !== 'custom');
}

function reportBuilderRenderTiles() {
  var wrap = document.getElementById('reporting-builder-tiles');
  var empty = document.getElementById('reporting-builder-tiles-empty');
  var tiles = _rbEditing.tiles || [];
  if (!wrap) return;
  if (!tiles.length) { if (empty) empty.classList.remove('hidden'); wrap.innerHTML = ''; return; }
  if (empty) empty.classList.add('hidden');
  wrap.innerHTML = tiles.map(function(t, idx){
    var td = _dashTileTypes[t.type] || {};
    var label = (t.config && t.config.title) || td.label || t.type;
    return '<div 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 text-gray-800 dark:text-gray-100 truncate">' + esc(label) + '</div>' +
      '<div class="text-[11px] text-gray-400 dark:text-gray-500">' + esc(td.label || t.type) + ' · ' + esc(t.size || 'medium') + '</div></div>' +
      '<div class="flex items-center gap-1 flex-shrink-0">' +
      '<button onclick="reportBuilderMoveTile(' + idx + ',-1)"' + (idx === 0 ? ' disabled' : '') + ' class="px-2 py-1 text-xs rounded border border-gray-200 dark:border-gray-700 text-gray-500 disabled:opacity-30 cursor-pointer">&uarr;</button>' +
      '<button onclick="reportBuilderMoveTile(' + idx + ',1)"' + (idx === tiles.length - 1 ? ' disabled' : '') + ' class="px-2 py-1 text-xs rounded border border-gray-200 dark:border-gray-700 text-gray-500 disabled:opacity-30 cursor-pointer">&darr;</button>' +
      '<button onclick="reportBuilderEditTile(' + idx + ')" class="px-2 py-1 text-xs rounded bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 cursor-pointer">Edit</button>' +
      '<button onclick="reportBuilderRemoveTile(' + idx + ')" class="px-2 py-1 text-xs rounded text-red-600 dark:text-red-400 border border-red-300 dark:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 cursor-pointer">Remove</button>' +
      '</div></div>';
  }).join('');
}

function reportBuilderAddTile() {
  _dashTileSaveHook = function(out){ reportBuilderTileSaved(out, null); };
  dashboardsAddTile();   // shared type picker → config modal
}

function reportBuilderEditTile(idx) {
  var tile = (_rbEditing.tiles || [])[idx]; if (!tile) return;
  _dashTileSaveHook = function(out){ reportBuilderTileSaved(out, idx); };
  _dashEditingTileId = null;
  _dashNewTileType = tile.type;   // hook-mode save reads the type from here
  dashboardsOpenTileEditor(tile.type, tile.size || 'medium', tile.config || {}, tile);
  var ttl = document.getElementById('dashboards-tile-title');
  if (ttl) ttl.textContent = 'Edit ' + ((_dashTileTypes[tile.type] || {}).label || tile.type) + ' tile';
}

function reportBuilderTileSaved(out, idx) {
  if (!out || !out.type) return;
  var existing = (idx != null) ? _rbEditing.tiles[idx] : null;
  var tile = {
    id: existing ? existing.id : ('tile_' + Math.random().toString(36).slice(2, 12)),
    type: out.type, size: out.size, config: out.config, overflow: out.overflow || ''
  };
  if (out.size === 'custom' && out.span) tile.span = out.span;
  if (existing) _rbEditing.tiles[idx] = tile;
  else _rbEditing.tiles.push(tile);
  reportBuilderRenderTiles();
}

function reportBuilderMoveTile(idx, dir) {
  var t = _rbEditing.tiles, j = idx + dir;
  if (j < 0 || j >= t.length) return;
  var tmp = t[idx]; t[idx] = t[j]; t[j] = tmp;
  reportBuilderRenderTiles();
}

function reportBuilderRemoveTile(idx) {
  if (!window.confirm('Remove this tile?')) return;
  _rbEditing.tiles.splice(idx, 1);
  reportBuilderRenderTiles();
}

function reportBuilderSave() {
  var status = document.getElementById('reporting-builder-status');
  var name = (document.getElementById('reporting-builder-name').value || '').trim();
  if (!name) { if (status) status.textContent = 'Please enter a template name.'; return; }
  var mode = document.getElementById('reporting-builder-range').value || 'last_week';
  var dr = { mode: mode };
  if (mode === 'custom') {
    dr.start = document.getElementById('reporting-builder-range-start').value || '';
    dr.end = document.getElementById('reporting-builder-range-end').value || '';
  }
  var schedule = reportBuilderCollectSchedule();
  if (schedule.enabled && schedule.email && !_rbRecipients.length) {
    if (status) status.textContent = 'Add at least one recipient to email the schedule.';
    return;
  }
  var definition = { tiles: _rbEditing.tiles || [], date_range: dr,
                     recipients: _rbRecipients.slice(), schedule: schedule };
  // DVI-1388: keep the dashboard link across manual saves so Re-sync stays available.
  if (_rbEditing.source_dashboard_id) definition.source_dashboard_id = _rbEditing.source_dashboard_id;
  var payload = { name: name, definition: definition };
  var url = _rbEditing.id ? ('/reporting/templates/' + encodeURIComponent(_rbEditing.id)) : '/reporting/templates';
  if (status) status.textContent = 'Saving…';
  fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
    .then(function(r){ return r.json(); }).then(function(d){
      if (!d || !d.ok) { if (status) status.textContent = (d && d.error) || 'Save failed.'; return; }
      _rbEditing.id = d.template.id;
      _rbEditing.version = d.template.version;
      var savedDef = d.template.definition || {};
      _rbEditing.tiles = savedDef.tiles || _rbEditing.tiles;
      _rbEditing.schedule = savedDef.schedule || {};
      _rbEditing.recipients = savedDef.recipients || _rbRecipients.slice();
      _rbEditing.source_dashboard_id = savedDef.source_dashboard_id || _rbEditing.source_dashboard_id || '';
      if (status) status.textContent = 'Saved (v' + d.template.version + ').';
      var meta = document.getElementById('reporting-builder-editor-meta');
      if (meta) meta.textContent = 'Editing · v' + d.template.version;
      reportBuilderRenderTiles();
      reportBuilderLoadHealth();   // surface run health for the now-saved template
      reportBuilderRefresh();   // keep the underlying list current
    }).catch(function(){ if (status) status.textContent = 'Save failed.'; });
}

function reportBuilderDelete(id) {
  if (!window.confirm('Delete this report template?')) return;
  fetch('/reporting/templates/' + encodeURIComponent(id), { method: 'DELETE' })
    .then(function(r){ return r.json(); }).then(function(d){
      if (!d || !d.ok) { alert((d && d.error) || 'Delete failed.'); return; }
      reportBuilderRefresh();
    }).catch(function(){ alert('Delete failed.'); });
}

// ----- DVI-1388: build a report from a configured dashboard -----
function reportBuilderFromDashOpen() {
  var ov = document.getElementById('reporting-fromdash-overlay');
  var sel = document.getElementById('reporting-fromdash-select');
  var status = document.getElementById('reporting-fromdash-status');
  var nm = document.getElementById('reporting-fromdash-name');
  if (nm) nm.value = '';
  if (status) status.textContent = '';
  if (sel) sel.innerHTML = '<option value="">Loading…</option>';
  if (ov) ov.classList.remove('hidden');
  // Reuse the DVI-1347 viewable-dashboards list (owner + shared + org "*").
  fetch('/tools/dashref-options').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      var list = (d && d.dashboards) || [];
      if (!sel) return;
      if (!list.length) { sel.innerHTML = '<option value="">No dashboards available</option>'; return; }
      sel.innerHTML = list.map(function(x){
        return '<option value="' + esc(x.id) + '">' + esc(x.name) + (x.mine ? '' : ' (shared)') + '</option>';
      }).join('');
    }).catch(function(){ if (sel) sel.innerHTML = '<option value="">Failed to load dashboards</option>'; });
}

function reportBuilderFromDashClose() {
  var ov = document.getElementById('reporting-fromdash-overlay');
  if (ov) ov.classList.add('hidden');
}

function reportBuilderFromDashCreate() {
  var sel = document.getElementById('reporting-fromdash-select');
  var status = document.getElementById('reporting-fromdash-status');
  var dashId = sel ? sel.value : '';
  if (!dashId) { if (status) status.textContent = 'Choose a dashboard.'; return; }
  var name = (document.getElementById('reporting-fromdash-name').value || '').trim();
  if (status) status.textContent = 'Creating…';
  fetch('/reporting/templates/from-dashboard', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ dashboard_id: dashId, name: name })
  }).then(function(r){ return r.json(); }).then(function(d){
    if (!d || !d.ok) { if (status) status.textContent = (d && d.error) || 'Create failed.'; return; }
    reportBuilderFromDashClose();
    reportBuilderRefresh();     // keep the underlying list current
    reportBuilderOpen(d.template.id);   // open the new template for editing
  }).catch(function(){ if (status) status.textContent = 'Create failed.'; });
}

// Re-pull the linked dashboard's current tiles into the open (saved) report.
function reportBuilderResync() {
  if (!_rbEditing || !_rbEditing.id || !_rbEditing.source_dashboard_id) return;
  if (!window.confirm("Replace this report's tiles with the source dashboard's current tiles?")) return;
  var status = document.getElementById('reporting-builder-status');
  if (status) status.textContent = 'Re-syncing…';
  fetch('/reporting/templates/' + encodeURIComponent(_rbEditing.id) + '/resync', { method: 'POST' })
    .then(function(r){ return r.json(); }).then(function(d){
      if (!d || !d.ok) { if (status) status.textContent = (d && d.error) || 'Re-sync failed.'; return; }
      var def = d.template.definition || {};
      _rbEditing.version = d.template.version;
      _rbEditing.tiles = def.tiles || [];
      _rbEditing.schedule = def.schedule || {};
      _rbEditing.recipients = def.recipients || [];
      _rbEditing.date_range = def.date_range || _rbEditing.date_range;
      _rbEditing.source_dashboard_id = def.source_dashboard_id || '';
      _rbRecipients = (_rbEditing.recipients || []).slice();
      reportBuilderRenderRecipients();
      reportBuilderFillSchedule();
      reportBuilderRenderRangeOptions();
      reportBuilderRenderTiles();
      var meta = document.getElementById('reporting-builder-editor-meta');
      if (meta) meta.textContent = 'Editing · v' + d.template.version;
      if (status) status.textContent = 'Re-synced from dashboard (v' + d.template.version + ').';
      reportBuilderRefresh();
    }).catch(function(){ if (status) status.textContent = 'Re-sync failed.'; });
}

// ===== Report Viewer + run history (DVI-1256, DVI-1252 P3) =====
// Run a saved template with most-recent data + the report-level date filter (a
// live run), or reopen a snapshotted historical run. Tiles are rendered by the
// shared dashboards tile renderers, so a report reuses the exact tile visuals.
var _rvTemplateId = null;      // currently selected template id
var _rvHistoryOpen = false;
// DVI-1259 (P6): the historical run currently rendered (if any) so Present can
// offer "the open historical run" alongside "latest data". Null while a live
// (unsaved) run or nothing is shown.
var _rvCurrentRunId = null;
var _rvCurrentRunLabel = '';

function reportViewerRefresh() {
  // Pull a fresh template list (a template created/edited in the Builder should
  // appear here), then repopulate the picker and, if a template is selected,
  // its date-range default.
  fetch('/reporting/templates').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (d && d.ok) {
        _rbTemplates = d.templates || [];
        _rbRangeModes = d.range_modes || _rbRangeModes;
      }
      reportViewerRenderTemplateOptions();
    }).catch(reportViewerRenderTemplateOptions);
}

function reportViewerRenderTemplateOptions() {
  var sel = document.getElementById('reporting-viewer-template');
  if (!sel) return;
  if (!_rbTemplates.length) {
    sel.innerHTML = '<option value="">No templates yet</option>';
    _rvTemplateId = null;
    reportViewerFillRangeOptions(null);
    return;
  }
  // Keep the current selection if it still exists, else pick the first.
  var ids = _rbTemplates.map(function(t){ return t.id; });
  if (!_rvTemplateId || ids.indexOf(_rvTemplateId) === -1) _rvTemplateId = ids[0];
  sel.innerHTML = _rbTemplates.map(function(t){
    return '<option value="' + esc(t.id) + '"' + (t.id === _rvTemplateId ? ' selected' : '') +
      '>' + esc(t.name) + ' (v' + esc(String(t.version)) + ')</option>';
  }).join('');
  reportViewerFillRangeOptions(_rvTemplateCurrent());
}

function _rvTemplateCurrent() {
  return _rbTemplates.filter(function(t){ return t.id === _rvTemplateId; })[0] || null;
}

function reportViewerTemplatePicked() {
  var sel = document.getElementById('reporting-viewer-template');
  _rvTemplateId = sel ? sel.value : null;
  _rvCurrentRunId = null; _rvCurrentRunLabel = '';
  reportViewerFillRangeOptions(_rvTemplateCurrent());
  // Clear any rendered report + history when switching templates.
  var grid = document.getElementById('reporting-viewer-grid');
  if (grid) grid.innerHTML = '';
  var empty = document.getElementById('reporting-viewer-empty');
  if (empty) empty.classList.remove('hidden');
  var meta = document.getElementById('reporting-viewer-meta');
  if (meta) meta.classList.add('hidden');
  if (_rvHistoryOpen) reportViewerLoadHistory();
}

// DVI-1257 (DVI-1252 P4): export the currently-rendered report as a PDF via the
// browser's native Print-to-PDF (decision D3 — zero server dependency). We flip
// on body.reporting-print (which the @media print rules key off to reveal only
// #reporting-print-region), fire window.print(), then clear the class on
// afterprint (with a timed fallback in case afterprint never fires).
function reportViewerExportPdf() {
  var grid = document.getElementById('reporting-viewer-grid');
  if (!grid || !grid.children.length) {
    reportViewerSetStatus('Run a report first, then export to PDF.', true);
    return;
  }
  var cleaned = false;
  function cleanup() {
    if (cleaned) return;
    cleaned = true;
    document.body.classList.remove('reporting-print');
    window.removeEventListener('afterprint', cleanup);
  }
  document.body.classList.add('reporting-print');
  window.addEventListener('afterprint', cleanup);
  setTimeout(cleanup, 60000);  // safety net if afterprint doesn't fire
  try { window.print(); }
  catch (e) { cleanup(); reportViewerSetStatus('Could not open the print dialog.', true); }
}

// The list route returns template metadata only (no definition), so the
// template's saved date-range default isn't known here until we fetch it.
function reportViewerFillRangeOptions(tmpl) {
  var sel = document.getElementById('reporting-viewer-range');
  if (!sel) return;
  var modes = _rbRangeModes || [['last_week', 'Last week']];
  sel.innerHTML = modes.map(function(m){
    return '<option value="' + esc(m[0]) + '">' + esc(m[1]) + '</option>';
  }).join('');
  // Default the range to the template's saved default (fetch its definition).
  if (tmpl) {
    fetch('/reporting/templates/' + encodeURIComponent(tmpl.id))
      .then(function(r){ return r.ok ? r.json() : null; })
      .then(function(d){
        if (!d || !d.ok) return;
        var dr = (d.template.definition && d.template.definition.date_range) || {};
        if (dr.mode) { sel.value = dr.mode; }
        document.getElementById('reporting-viewer-range-start').value = dr.start || '';
        document.getElementById('reporting-viewer-range-end').value = dr.end || '';
        reportViewerRangeChanged();
      }).catch(function(){});
  }
  reportViewerRangeChanged();
}

function reportViewerRangeChanged() {
  var sel = document.getElementById('reporting-viewer-range');
  var custom = document.getElementById('reporting-viewer-custom');
  if (custom) custom.classList.toggle('hidden', !sel || sel.value !== 'custom');
}

function reportViewerCollectRange() {
  var sel = document.getElementById('reporting-viewer-range');
  var mode = sel ? sel.value : 'last_week';
  var dr = { mode: mode };
  if (mode === 'custom') {
    dr.start = (document.getElementById('reporting-viewer-range-start').value || '');
    dr.end = (document.getElementById('reporting-viewer-range-end').value || '');
  }
  return dr;
}

function reportViewerSetStatus(msg, show) {
  var el = document.getElementById('reporting-viewer-status');
  if (!el) return;
  el.textContent = msg || '';
  el.classList.toggle('hidden', !show);
}

function reportViewerRun(save) {
  if (!_rvTemplateId) { reportViewerSetStatus('Pick a report template first.', true); return; }
  reportViewerSetStatus(save ? 'Running & saving…' : 'Running…', true);
  var body = { date_range: reportViewerCollectRange(), save: !!save };
  fetch('/reporting/templates/' + encodeURIComponent(_rvTemplateId) + '/run', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  }).then(function(r){ return r.json(); }).then(function(d){
    if (!d || !d.ok) { reportViewerSetStatus((d && d.error) || 'Run failed.', true); return; }
    reportViewerSetStatus('', false);
    // A live run is not a saved historical run — clear any open-run reference so
    // Present offers "latest data" (a fresh snapshot).
    _rvCurrentRunId = null; _rvCurrentRunLabel = '';
    reportViewerRenderViewModel(d.view_model, { historical: false });
    if (save) { if (_rvHistoryOpen) reportViewerLoadHistory(); else reportViewerToggleHistory(); }
  }).catch(function(){ reportViewerSetStatus('Run failed.', true); });
}

function reportViewerToggleHistory() {
  _rvHistoryOpen = !_rvHistoryOpen;
  var panel = document.getElementById('reporting-viewer-history');
  if (panel) panel.classList.toggle('hidden', !_rvHistoryOpen);
  if (_rvHistoryOpen) reportViewerLoadHistory();
}

function reportViewerLoadHistory() {
  if (!_rvTemplateId) return;
  var list = document.getElementById('reporting-viewer-history-list');
  var empty = document.getElementById('reporting-viewer-history-empty');
  if (list) list.innerHTML = '<p class="text-xs text-gray-400 dark:text-gray-500 py-2 text-center">Loading…</p>';
  fetch('/reporting/templates/' + encodeURIComponent(_rvTemplateId) + '/runs')
    .then(function(r){ return r.json(); }).then(function(d){
      var runs = (d && d.ok && d.runs) || [];
      if (!list) return;
      if (!runs.length) { list.innerHTML = ''; if (empty) empty.classList.remove('hidden'); return; }
      if (empty) empty.classList.add('hidden');
      list.innerHTML = runs.map(function(run){
        var dr = run.date_range || {};
        var rangeLabel = dr.label || (dr.mode || '');
        return '<div class="flex items-center justify-between gap-2 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-1.5">' +
          '<div class="min-w-0"><div class="text-xs text-gray-700 dark:text-gray-200 truncate">' + esc(run.ran_at || '') +
          ' · <span class="text-gray-400 dark:text-gray-500">' + esc(rangeLabel) + '</span></div>' +
          '<div class="text-[11px] text-gray-400 dark:text-gray-500 truncate">v' + esc(String(run.template_version || '')) +
          (run.ran_by ? (' · ' + esc(run.ran_by)) : '') + '</div></div>' +
          '<button onclick="reportViewerOpenRun(\'' + esc(run.id) + '\')" class="flex-shrink-0 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 cursor-pointer">Open</button>' +
          '</div>';
      }).join('');
    }).catch(function(){ if (list) list.innerHTML = '<p class="text-xs text-red-500 py-2 text-center">Failed to load history.</p>'; });
}

function reportViewerOpenRun(runId) {
  reportViewerSetStatus('Opening run…', true);
  fetch('/reporting/runs/' + encodeURIComponent(runId))
    .then(function(r){ return r.json(); }).then(function(d){
      if (!d || !d.ok) { reportViewerSetStatus((d && d.error) || 'Failed to open run.', true); return; }
      reportViewerSetStatus('', false);
      _rvCurrentRunId = runId; _rvCurrentRunLabel = (d.run && d.run.ran_at) || '';
      reportViewerRenderViewModel(d.run.view_model, { historical: true, ran_at: d.run.ran_at });
    }).catch(function(){ reportViewerSetStatus('Failed to open run.', true); });
}

// Render a resolved view-model into the tile grid, reusing the shared dashboards
// tile renderers (dashboardsRenderTileBody) so a report matches the dashboards.
function reportViewerRenderViewModel(vm, opts) {
  opts = opts || {};
  reportBuilderEnsureTileTypes(function(){
    var grid = document.getElementById('reporting-viewer-grid');
    var empty = document.getElementById('reporting-viewer-empty');
    var meta = document.getElementById('reporting-viewer-meta');
    if (!grid) return;
    if (window.dashboardsStopImgTimers) dashboardsStopImgTimers();   // clear slideshow timers before rebuild
    grid.innerHTML = '';
    var tiles = (vm && vm.tiles) || [];
    var dr = (vm && vm.date_range) || {};
    var kindPlain = opts.historical
      ? ('Historical run — snapshot from ' + (opts.ran_at || ''))
      : 'Live run — most-recent data';
    if (meta) {
      var kind = opts.historical
        ? ('Historical run · snapshot from ' + esc(opts.ran_at || (vm && vm.date_range && '') || ''))
        : 'Live run · most-recent data';
      meta.innerHTML = '<span class="font-medium text-gray-700 dark:text-gray-200">' + esc((vm && vm.template_name) || '') +
        '</span> — ' + kind + ' · <span class="text-gray-400 dark:text-gray-500">' + esc(dr.label || dr.mode || '') + '</span>';
      meta.classList.remove('hidden');
    }
    // DVI-1257 (P4): populate the print-only header.
    var ph = document.getElementById('reporting-print-header');
    if (ph) {
      ph.innerHTML =
        '<div style="font-size:18px;font-weight:700;line-height:1.2;">' + esc((vm && vm.template_name) || 'Report') + '</div>' +
        '<div style="font-size:12px;color:#444;margin-top:2px;">' + esc(kindPlain) + ' · ' + esc(dr.label || dr.mode || 'All dates') + '</div>' +
        '<div style="font-size:11px;color:#666;margin-top:1px;">Generated ' + esc(new Date().toLocaleString()) + '</div>' +
        '<hr style="border:none;border-top:1px solid #ccc;margin:8px 0 0;">';
    }
    if (!tiles.length) {
      if (empty) { empty.textContent = 'This report has no tiles yet.'; empty.classList.remove('hidden'); }
      return;
    }
    if (empty) empty.classList.add('hidden');
    tiles.forEach(function(t){
      var sizeKey = DASH_SIZES[t.size] ? t.size : 'medium';
      var size = (sizeKey === 'custom') ? dashCustomSpan(t.span) : DASH_SIZES[sizeKey];
      var el = document.createElement('div');
      el.className = 'dash-tile dash-size-' + sizeKey + ' togen-pane bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden';
      el.style.setProperty('--dash-cols', String(size.cols));
      el.style.gridRow = 'span ' + size.rows;
      el.setAttribute('data-tile-id', t.id);
      var cfg = t.config || {};
      var titleText = cfg.title || '';
      if (titleText) {
        // DVI-1286: show a header only when the optional title is set.
        var vh = document.createElement('div');
        vh.className = 'px-3 pt-2.5 pb-1 text-sm font-semibold text-gray-800 dark:text-gray-100 truncate';
        vh.innerHTML = '<span>' + esc(titleText) + '</span>';
        el.appendChild(vh);
      }
      var body = document.createElement('div');
      var ovfCls = (t.overflow === 'clip') ? 'overflow-hidden' : 'overflow-auto';
      body.className = 'dash-tile-body flex-1 min-h-0 ' + ovfCls + ' p-3 text-sm text-gray-700 dark:text-gray-200';
      // DVI-1275 P2: per-tile zoom also applies in the Report Viewer.
      var rtzoom = Math.max(50, Math.min(200, (t.zoom | 0) || 100));
      if (rtzoom !== 100) body.style.zoom = rtzoom / 100;
      el.appendChild(body);
      grid.appendChild(el);
      // Render body AFTER attach (oversight etc. resolve their scaffold by id).
      var tileObj = { id: t.id, type: t.type, size: t.size, span: t.span,
                      overflow: t.overflow, config: cfg };
      try { dashboardsRenderTileBody(body, tileObj, t.payload || {}); }
      catch (e) { body.innerHTML = '<span class="text-red-500 dark:text-red-400 text-xs">Tile failed to render.</span>'; }
    });
  });
}

// ===== Present a report (DVI-1259 / DVI-1252 P6) =====
// Push the selected report — latest data (a fresh server-side snapshot) or the
// historical run currently open — to a presentable Display Agent, reusing the
// dashboards Present machinery (/display/present/targets + /display/stop). The
// modal doubles as the live "presenting to X — Stop" surface.
var _rvPresentTargets = [];
var _rvPresentKeydownBound = false;

function reportPresentOpen() {
  if (!_rvTemplateId) { reportViewerSetStatus('Pick a report template first.', true); return; }
  if (!_rvPresentKeydownBound) {
    _rvPresentKeydownBound = true;
    document.addEventListener('keydown', function(e) {
      if (e.key !== 'Escape') return;
      var ov = document.getElementById('reporting-present-overlay');
      if (ov && !ov.classList.contains('hidden')) reportPresentClose();
    });
  }
  var tmpl = _rvTemplateCurrent();
  document.getElementById('reporting-present-name').textContent = (tmpl && tmpl.name) || 'Report';
  document.getElementById('reporting-present-status').textContent = '';
  // Source options: "latest" always; plus the open historical run when one is
  // rendered, so the presenter can pin exactly what they're looking at.
  var src = document.getElementById('reporting-present-source');
  var opts = '<option value="latest">Latest data (a fresh snapshot)</option>';
  if (_rvCurrentRunId) {
    opts += '<option value="run" selected>The open historical run' +
      (_rvCurrentRunLabel ? (' — ' + esc(_rvCurrentRunLabel)) : '') + '</option>';
  }
  src.innerHTML = opts;
  _dashFillColNums(document.getElementById('reporting-present-columns'));   // DVI-1288 P3
  document.getElementById('reporting-present-overlay').classList.remove('hidden');
  reportPresentLoadTargets();
}
function reportPresentClose() {
  document.getElementById('reporting-present-overlay').classList.add('hidden');
}
function reportPresentLoadTargets() {
  var empty = document.getElementById('reporting-present-empty');
  var form = document.getElementById('reporting-present-form');
  var go = document.getElementById('reporting-present-go');
  fetch('/display/present/targets')
    .then(function(r){ return r.json(); })
    .then(function(d){
      _rvPresentTargets = (d && d.displays) || [];
      var hasAny = _rvPresentTargets.length > 0;
      empty.classList.toggle('hidden', hasAny);
      form.classList.toggle('hidden', !hasAny);
      go.classList.toggle('hidden', !hasAny);
      if (hasAny) {
        var sel = document.getElementById('reporting-present-display');
        sel.innerHTML = _rvPresentTargets.map(function(t){
          var st = t.online_status === 'online' ? '' :
            (t.online_status === 'stale' ? ' (offline?)' : ' (never seen)');
          return '<option value="' + esc(t.id) + '">' + esc(t.name || t.id) + st + '</option>';
        }).join('');
      }
      reportPresentRenderActive();
    })
    .catch(function(){ empty.classList.remove('hidden'); form.classList.add('hidden'); go.classList.add('hidden'); });
}
function reportPresentRenderActive() {
  // Show any of my displays currently presenting THIS report, with a Stop button.
  var box = document.getElementById('reporting-present-active');
  var mine = _rvPresentTargets.filter(function(t){
    return t.present && t.present.report && t.present.report.template_id === _rvTemplateId;
  });
  if (!mine.length) { box.classList.add('hidden'); box.innerHTML = ''; return; }
  box.innerHTML = mine.map(function(t){
    var until = 'until stopped';
    if (t.present.expires_at) {
      var mins = Math.max(0, Math.round((t.present.expires_at * 1000 - Date.now()) / 60000));
      until = 'reverts in ' + mins + ' min';
    }
    return '<div class="flex items-center justify-between gap-2">' +
      '<span class="text-gray-700 dark:text-gray-200">Presenting to <span class="font-medium">' + esc(t.name || t.id) + '</span> &middot; ' + esc(until) + '</span>' +
      '<button onclick="reportPresentStop(\'' + esc(t.id) + '\')" class="togen-control px-2 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">Stop</button>' +
      '</div>';
  }).join('');
  box.classList.remove('hidden');
}
function reportPresentStart() {
  if (!_rvTemplateId) { document.getElementById('reporting-present-status').textContent = 'Pick a report first.'; return; }
  var displayId = document.getElementById('reporting-present-display').value;
  if (!displayId) return;
  var dur = parseInt(document.getElementById('reporting-present-duration').value, 10) || 0;
  var status = document.getElementById('reporting-present-status');
  var body = { display_id: displayId, template_id: _rvTemplateId, duration_minutes: dur,
    zoom: parseInt(document.getElementById('reporting-present-zoom').value, 10) || 0,
    // DVI-1288 P3: present-time columns/flow override ('keep' = keep display's).
    columns: document.getElementById('reporting-present-columns').value,
    flow: document.getElementById('reporting-present-flow').value,
    use_my_interface: document.getElementById('reporting-present-myiface').checked,
    theme_hint: document.documentElement.classList.contains('dark') ? 'dark' : 'light' };
  // "run" → pin the open historical snapshot; "latest" → a fresh snapshot the
  // server builds under my identity. When presenting latest, also send the range.
  var srcEl = document.getElementById('reporting-present-source');
  if (srcEl && srcEl.value === 'run' && _rvCurrentRunId) { body.run_id = _rvCurrentRunId; }
  else { body.date_range = reportViewerCollectRange(); }
  status.textContent = 'Starting…';
  fetch('/display/present-report', {
    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) { status.textContent = (res.d && res.d.error) || 'Failed to present'; return; }
      status.textContent = 'Presenting.';
      reportPresentLoadTargets();
    })
    .catch(function(){ status.textContent = 'Failed to present'; });
}
function reportPresentStop(displayId) {
  var status = document.getElementById('reporting-present-status');
  status.textContent = 'Stopping…';
  fetch('/display/present/stop', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ display_id: displayId })
  }).then(function(r){ return r.json().then(function(d){ return { ok: r.ok, d: d }; }); })
    .then(function(res){
      status.textContent = res.ok ? 'Stopped.' : ((res.d && res.d.error) || 'Failed to stop');
      reportPresentLoadTargets();
    })
    .catch(function(){ status.textContent = 'Failed to stop'; });
}

function auditorAccessLoad() {
  var list = document.getElementById('auditor-access-list');
  if (list) list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 py-4 text-center">Loading…</p>';
  fetch('/auditor/access').then(function(r){ return r.json(); }).then(function(d){
    _auditorAccessUsers = (d && d.users) || [];
    auditorAccessRender();
  }).catch(function(){
    if (list) list.innerHTML = '<p class="text-sm text-red-600 dark:text-red-400 py-4 text-center">Failed to load users.</p>';
  });
}

function auditorAccessRender() {
  var list = document.getElementById('auditor-access-list');
  var empty = document.getElementById('auditor-access-empty');
  if (!list) return;
  if (!_auditorAccessUsers.length) {
    list.innerHTML = '';
    if (empty) empty.classList.remove('hidden');
    return;
  }
  if (empty) empty.classList.add('hidden');
  list.innerHTML = _auditorAccessUsers.map(_auditorAccessCard).join('');
}

function _auditorAccessCard(u) {
  var granted = {};
  (u.resources || []).forEach(function(k){ granted[k] = true; });
  var emailAttr = esc(u.email);
  var groups = _AUDITOR_RES_TREE.map(function(grp){
    var kids = grp.children.map(function(ch){
      var checked = granted[ch.key] ? ' checked' : '';
      return '<label class="inline-flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">' +
        '<input type="checkbox" data-res="' + esc(ch.key) + '"' + checked +
        ' class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">' +
        esc(ch.label) + '</label>';
    }).join('');
    return '<div class="mb-2">' +
      '<div class="flex items-center gap-2 mb-1">' +
        '<button type="button" onclick="auditorAccessToggleGroup(this, \'' + emailAttr + '\', \'' + esc(grp.key) + '\', true)" class="text-[11px] font-medium text-togen-teal hover:underline cursor-pointer">All</button>' +
        '<span class="text-gray-300 dark:text-gray-600">·</span>' +
        '<button type="button" onclick="auditorAccessToggleGroup(this, \'' + emailAttr + '\', \'' + esc(grp.key) + '\', false)" class="text-[11px] font-medium text-gray-500 dark:text-gray-400 hover:underline cursor-pointer">None</button>' +
        '<span class="text-xs font-semibold text-gray-600 dark:text-gray-300">' + esc(grp.label) + '</span>' +
      '</div>' +
      '<div class="flex flex-wrap gap-x-4 gap-y-1 pl-1">' + kids + '</div>' +
    '</div>';
  }).join('');
  return '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 p-4" data-access-email="' + emailAttr + '">' +
    '<div class="flex items-center justify-between mb-3">' +
      '<span class="text-sm font-semibold text-gray-800 dark:text-gray-100">' + emailAttr + '</span>' +
      '<div class="flex items-center gap-2">' +
        '<button onclick="auditorAccessSave(\'' + emailAttr + '\')" 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">Save</button>' +
        '<button onclick="auditorAccessRemove(\'' + emailAttr + '\')" class="togen-control px-3 py-1.5 text-xs font-medium rounded-lg text-red-600 dark:text-red-400 border border-red-300 dark:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors cursor-pointer">Remove</button>' +
      '</div>' +
    '</div>' + groups + '</div>';
}

function _auditorAccessCardEl(email) {
  var cards = document.querySelectorAll('[data-access-email]');
  for (var i = 0; i < cards.length; i++) {
    if (cards[i].getAttribute('data-access-email') === email) return cards[i];
  }
  return null;
}

function auditorAccessToggleGroup(btn, email, groupKey, on) {
  var card = _auditorAccessCardEl(email);
  if (!card) return;
  card.querySelectorAll('input[data-res]').forEach(function(cb){
    var res = cb.getAttribute('data-res');
    if (res === groupKey || res.indexOf(groupKey + '.') === 0) cb.checked = on;
  });
}

function auditorAccessAddUser() {
  var inp = document.getElementById('auditor-access-email');
  if (!inp) return;
  var email = (inp.value || '').trim().toLowerCase();
  if (!email || email.indexOf('@') === -1) { _auditorAccessSetStatus('Enter a valid email address.', true); return; }
  if (_auditorAccessUsers.some(function(u){ return u.email === email; })) {
    _auditorAccessSetStatus('That user is already listed.', true);
    return;
  }
  // Add a local placeholder card (no resources yet) so the admin can tick boxes,
  // then Save persists it. Nothing is stored until at least one box is saved.
  _auditorAccessUsers.push({email: email, resources: [], added_at: '', added_by: ''});
  inp.value = '';
  _auditorAccessSetStatus('Tick the resources for ' + email + ' and click Save.', false);
  auditorAccessRender();
}

function auditorAccessSave(email) {
  var card = _auditorAccessCardEl(email);
  if (!card) return;
  var resources = [];
  card.querySelectorAll('input[data-res]:checked').forEach(function(cb){ resources.push(cb.getAttribute('data-res')); });
  fetch('/auditor/access', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({email: email, resources: resources})
  }).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok) { _auditorAccessSetStatus((res.d && res.d.error) || 'Save failed.', true); return; }
      _auditorAccessSetStatus(res.d.removed ? (email + ' removed (no resources granted).') : ('Saved access for ' + email + '.'), false);
      auditorAccessLoad();
    }).catch(function(err){ _auditorAccessSetStatus('Save failed: ' + err.message, true); });
}

function auditorAccessRemove(email) {
  fetch('/auditor/access', {
    method: 'DELETE',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({email: email})
  }).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok) { _auditorAccessSetStatus((res.d && res.d.error) || 'Remove failed.', true); return; }
      _auditorAccessSetStatus('Removed access for ' + email + '.', false);
      auditorAccessLoad();
    }).catch(function(err){ _auditorAccessSetStatus('Remove failed: ' + err.message, true); });
}

// ----- Search tab (DVI-1043): unified cross-tool audit search by reference -----
var _auditorSearchHistTabs = ['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'];
var _auditorSearchHistLabels = {
  'prodrun': 'Production Run', 'print': 'Print', 'wor-admin': 'Work Orders',
  'rpm': 'Repair Parts Manager', 'single-carrier-sheet': 'Carrier Sheet',
  'mts-label': 'MTS Labels', 'file-browser': 'File Browser', 'ocr-tool': 'OCR Tool',
  'takeoff-bridge': 'Take-off Bridge', 'project-builder': 'Project Builder',
  'sync-admin': 'Sync Admin', 'togen-admin': 'Togen Admin',
  'vision-agents': 'Vision Agents', 'bots': 'Bots'
};

function auditorSearchRun() {
  var inp = document.getElementById('auditor-search-input');
  var status = document.getElementById('auditor-search-status');
  var wrap = document.getElementById('auditor-search-results');
  if (!inp || !wrap) return;
  var q = (inp.value || '').trim();
  wrap.innerHTML = '';
  if (q.length < 2) { if (status) status.textContent = 'Enter at least 2 characters to search.'; return; }
  if (status) status.textContent = 'Searching…';
  fetch('/auditor/search?q=' + encodeURIComponent(q))
    .then(function(r) { return r.json().then(function(d) { return {ok: r.ok, d: d}; }); })
    .then(function(res) {
      if (!res.ok) { if (status) status.textContent = 'Error: ' + ((res.d && res.d.error) || 'Search failed.'); return; }
      auditorSearchRenderResults(res.d);
    })
    .catch(function(err) { if (status) status.textContent = 'Failed: ' + err.message; });
}

function _auditorSearchSection(title, count, bodyHtml) {
  return '<div>' +
    '<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-2">' +
      esc(title) + ' <span class="text-gray-400 dark:text-gray-500">(' + count + ')</span></h4>' +
    bodyHtml + '</div>';
}

function auditorSearchRenderResults(d) {
  var status = document.getElementById('auditor-search-status');
  var wrap = document.getElementById('auditor-search-results');
  var total = d.total || 0;
  if (status) status.textContent = total + ' result' + (total === 1 ? '' : 's') + ' for "' + (d.q || '') + '"';
  if (!total) { wrap.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 italic py-4">No matching audit records found.</p>'; return; }

  var html = '';

  // Work Orders — deep-link to the WO audit-trail popup.
  var wor = d.wor || [];
  if (wor.length) {
    var body = '<div class="space-y-2">' + wor.map(function(w) {
      var when = w.created_at ? new Date(w.created_at).toLocaleString() : '';
      return '<div class="border border-gray-200 dark:border-gray-700 rounded-xl p-3 bg-white dark:bg-gray-900 flex items-center justify-between gap-3 flex-wrap">' +
        '<div class="min-w-0">' +
          '<p class="font-medium text-sm text-gray-800 dark:text-gray-200">' + esc(w.wo_label || w.filename) + '</p>' +
          '<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">' + esc(w.latest_summary || w.filename) +
            ' &middot; ' + w.events + ' matched event' + (w.events === 1 ? '' : 's') + '</p>' +
        '</div>' +
        '<div class="flex items-center gap-3 flex-shrink-0">' +
          '<button onclick="worAuditOpen(\'' + esc(w.filename).replace(/'/g, "\\'") + '\')" class="text-xs font-medium text-togen-teal hover:text-togen-teal-dark underline cursor-pointer">Audit trail</button>' +
          '<span class="text-xs text-gray-400 dark:text-gray-500">' + esc(when) + '</span>' +
        '</div>' +
      '</div>';
    }).join('') + '</div>';
    html += _auditorSearchSection('Work Orders', wor.length, body);
  }

  // Bot runs — deep-link to Diagnosis → Bots run view.
  var bots = d.bot || [];
  if (bots.length) {
    var bbody = '<div class="space-y-2">' + bots.map(function(b) {
      var when = b.started_at ? new Date(b.started_at * 1000).toLocaleString() : '';
      return '<div class="border border-gray-200 dark:border-gray-700 rounded-xl p-3 bg-white dark:bg-gray-900 flex items-center justify-between gap-3 flex-wrap">' +
        '<div class="min-w-0">' +
          '<p class="font-medium text-sm text-gray-800 dark:text-gray-200">' + esc((b.bot || '') + (b.target_date ? ' · ' + b.target_date : '')) +
            ' <span class="text-xs font-normal text-gray-400 dark:text-gray-500">' + esc(b.status || '') + '</span></p>' +
          '<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">' + esc(b.summary || b.trigger || '') + '</p>' +
        '</div>' +
        '<div class="flex items-center gap-3 flex-shrink-0">' +
          '<button onclick="auditorSearchOpenBot(\'' + esc(b.bot).replace(/'/g, "\\'") + '\',\'' + esc(b.run_id).replace(/'/g, "\\'") + '\')" class="text-xs font-medium text-togen-teal hover:text-togen-teal-dark underline cursor-pointer">Open run</button>' +
          '<span class="text-xs text-gray-400 dark:text-gray-500">' + esc(when) + '</span>' +
        '</div>' +
      '</div>';
    }).join('') + '</div>';
    html += _auditorSearchSection('Bot runs', bots.length, bbody);
  }

  // Vision agents — deep-link to Diagnosis → Vision agent view.
  var vision = d.vision || [];
  if (vision.length) {
    var vbody = '<div class="space-y-2">' + vision.map(function(v) {
      var when = v.last_seen_at ? new Date(v.last_seen_at * 1000).toLocaleString() : '';
      return '<div class="border border-gray-200 dark:border-gray-700 rounded-xl p-3 bg-white dark:bg-gray-900 flex items-center justify-between gap-3 flex-wrap">' +
        '<div class="min-w-0">' +
          '<p class="font-medium text-sm text-gray-800 dark:text-gray-200">' + esc(v.name || v.machine_id) +
            ' <span class="text-xs font-normal text-gray-400 dark:text-gray-500">' + esc(v.status || '') + '</span></p>' +
          '<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono truncate">' + esc(v.machine_id) + '</p>' +
        '</div>' +
        '<div class="flex items-center gap-3 flex-shrink-0">' +
          '<button onclick="auditorSearchOpenVision(\'' + esc(v.machine_id).replace(/'/g, "\\'") + '\')" class="text-xs font-medium text-togen-teal hover:text-togen-teal-dark underline cursor-pointer">Diagnostics</button>' +
          '<span class="text-xs text-gray-400 dark:text-gray-500">' + esc(when) + '</span>' +
        '</div>' +
      '</div>';
    }).join('') + '</div>';
    html += _auditorSearchSection('Vision agents', vision.length, vbody);
  }

  // Other tool history — deep-link to that tool's History sub-tab where available.
  var hist = d.history || [];
  if (hist.length) {
    var hbody = '<div class="space-y-2">' + hist.map(function(h) {
      var when = h.created_at ? new Date(h.created_at).toLocaleString() : '';
      var hasTab = _auditorSearchHistTabs.indexOf(h.tool) !== -1;
      var toolLbl = _auditorSearchHistLabels[h.tool] || h.tool;
      var openBtn = hasTab
        ? '<button onclick="auditorSearchOpenHistory(\'' + esc(h.tool).replace(/'/g, "\\'") + '\')" class="text-xs font-medium text-togen-teal hover:text-togen-teal-dark underline cursor-pointer">Open history</button>'
        : '';
      return '<div class="border border-gray-200 dark:border-gray-700 rounded-xl p-3 bg-white dark:bg-gray-900 flex items-center justify-between gap-3 flex-wrap">' +
        '<div class="min-w-0">' +
          '<p class="font-medium text-sm text-gray-800 dark:text-gray-200">' + esc(h.summary || h.ref || h.action) + '</p>' +
          '<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">' +
            '<span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">' + esc(toolLbl) + '</span> ' +
            esc(h.actor || '—') + (h.action ? ' &middot; ' + esc(h.action) : '') + '</p>' +
        '</div>' +
        '<div class="flex items-center gap-3 flex-shrink-0">' + openBtn +
          '<span class="text-xs text-gray-400 dark:text-gray-500">' + esc(when) + '</span>' +
        '</div>' +
      '</div>';
    }).join('') + '</div>';
    html += _auditorSearchSection('Tool history', hist.length, hbody);
  }

  wrap.innerHTML = html;
}

function auditorSearchOpenHistory(tool) {
  auditorSwitchTopTab('history');
  auditorSwitchHistTab(tool);
}

function auditorSearchOpenBot(bot, runId) {
  _auditorDiagTab = 'bots';
  auditorSwitchTopTab('diagnosis');
  auditorSwitchDiagTab('bots');
  var sel = document.getElementById('auditor-diag-bot-select');
  if (sel) sel.value = bot;
  auditorDiagLoadBotRuns(runId);
}

function auditorSearchOpenVision(mid) {
  _auditorDiagTab = 'vision';
  auditorSwitchTopTab('diagnosis');
  auditorSwitchDiagTab('vision');
  auditorDiagLoadVisionAgent(mid);
}

// ----- Diagnosis tab (DVI-1041): read-only aggregation of per-tool diagnostics platforms -----
var _auditorDiagTab = 'timeline';  // DVI-1047: unified timeline is the overview default
var _auditorDiagBotRuns = [];
var _auditorDiagVisionFleet = null;
var _auditorDiagVisionAgent = null;  // machine_id when a per-agent view is open

function auditorSwitchDiagTab(tab) {
  _auditorDiagTab = tab;
  ['timeline', 'bots', 'vision'].forEach(function(t) {
    var btn = document.getElementById('auditor-diagtab-' + 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 pane = document.getElementById('auditor-diag-' + t);
    if (pane) pane.classList.toggle('hidden', !active);
  });
  if (tab === 'bots') auditorDiagLoadBotRuns();
  else if (tab === 'vision') auditorDiagLoadVisionFleet();
  else auditorDiagLoadTimeline();
}

// --- Timeline sub-tab (DVI-1047): unified cross-tool diagnostics stream ---
var _AUDITOR_DIAG_TL_BADGE = {
  ok: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
  warn: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
  error: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
  running: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
  cancelled: 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
  info: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300'
};

function auditorDiagLoadTimeline(refresh) {
  var q = (document.getElementById('auditor-diag-tl-q') || {}).value || '';
  var source = (document.getElementById('auditor-diag-tl-source') || {}).value || '';
  var status = (document.getElementById('auditor-diag-tl-status') || {}).value || '';
  var loading = document.getElementById('auditor-diag-tl-loading');
  var empty = document.getElementById('auditor-diag-tl-empty');
  var errEl = document.getElementById('auditor-diag-tl-error');
  var list = document.getElementById('auditor-diag-tl-list');
  [empty, errEl].forEach(function(el){ if (el) el.classList.add('hidden'); });
  if (loading) loading.classList.remove('hidden');
  var url = '/auditor/diagnostics/timeline?limit=300'
    + '&q=' + encodeURIComponent(q)
    + '&source=' + encodeURIComponent(source)
    + '&status=' + encodeURIComponent(status)
    + (refresh ? '&refresh=1' : '');
  fetch(url).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); }).then(function(res){
    if (loading) loading.classList.add('hidden');
    if (!res.ok) {
      if (errEl) { errEl.classList.remove('hidden'); }
      var m = document.getElementById('auditor-diag-tl-error-msg');
      if (m) m.textContent = (res.d && res.d.error) || 'Failed to load timeline.';
      if (list) list.innerHTML = '';
      return;
    }
    auditorDiagRenderTimeline(res.d);
  }).catch(function(){
    if (loading) loading.classList.add('hidden');
    if (errEl) errEl.classList.remove('hidden');
    var m = document.getElementById('auditor-diag-tl-error-msg');
    if (m) m.textContent = 'Failed to load timeline.';
  });
}

function auditorDiagRenderTimeline(d) {
  var list = document.getElementById('auditor-diag-tl-list');
  var empty = document.getElementById('auditor-diag-tl-empty');
  var evs = (d && d.events) || [];
  if (!evs.length) { if (empty) empty.classList.remove('hidden'); if (list) list.innerHTML = ''; return; }
  var rows = evs.map(function(e){
    var when = ''; try { when = new Date(e.ts * 1000).toLocaleString(); } catch(x){}
    var badgeCls = _AUDITOR_DIAG_TL_BADGE[e.badge] || _AUDITOR_DIAG_TL_BADGE.info;
    var srcLabel = e.source === 'bot' ? (e.tool || 'bot') : 'Vision';
    var open = '';
    if (e.link_kind === 'bot' && e.link_id) {
      open = '<button onclick="auditorSearchOpenBot(\'' + esc(e.tool).replace(/'/g, "\\'") + '\',\'' + esc(e.link_id).replace(/'/g, "\\'") + '\')" class="text-xs font-medium text-togen-teal hover:text-togen-teal-dark underline cursor-pointer">Open run</button>';
    } else if (e.link_kind === 'vision' && e.link_id) {
      open = '<button onclick="auditorSearchOpenVision(\'' + esc(e.link_id).replace(/'/g, "\\'") + '\')" class="text-xs font-medium text-togen-teal hover:text-togen-teal-dark underline cursor-pointer">Diagnostics</button>';
    }
    return '<div class="flex items-start justify-between gap-3 py-2.5 px-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/60">' +
      '<div class="min-w-0 flex-1">' +
        '<div class="flex items-center gap-2 flex-wrap">' +
          '<span class="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-togen-teal/10 text-togen-teal">' + esc(srcLabel) + '</span>' +
          '<span class="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ' + badgeCls + '">' + esc(e.status || e.badge) + '</span>' +
          (e.ref ? '<span class="text-xs text-gray-500 dark:text-gray-400">' + esc(e.ref) + '</span>' : '') +
        '</div>' +
        '<div class="text-sm text-gray-800 dark:text-gray-100 mt-1 break-words">' + esc(e.title) + '</div>' +
        (e.actor ? '<div class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">' + esc(e.actor) + '</div>' : '') +
      '</div>' +
      '<div class="flex flex-col items-end gap-1 flex-shrink-0">' + open +
        '<span class="text-xs text-gray-400 dark:text-gray-500">' + esc(when) + '</span>' +
      '</div>' +
    '</div>';
  }).join('');
  list.innerHTML = '<div class="space-y-2">' + rows + '</div>';
}

// --- Bots sub-tab: reuse /diagnostics/<bot>/runs + shared renderDiagnosticFlow ---
function auditorDiagLoadBotRuns(selectId) {
  var bot = (document.getElementById('auditor-diag-bot-select') || {}).value || 'winston';
  var loading = document.getElementById('auditor-diag-bot-loading');
  var empty = document.getElementById('auditor-diag-bot-empty');
  var errEl = document.getElementById('auditor-diag-bot-error');
  var sel = document.getElementById('auditor-diag-bot-run-select');
  var flow = document.getElementById('auditor-diag-bot-flow');
  [empty, errEl].forEach(function(el){ if (el) el.classList.add('hidden'); });
  fetch('/diagnostics/' + encodeURIComponent(bot) + '/runs?limit=50').then(function(r){ return r.json(); }).then(function(d){
    _auditorDiagBotRuns = d.runs || [];
    if (!_auditorDiagBotRuns.length) {
      if (empty) empty.classList.remove('hidden');
      if (sel) sel.innerHTML = '';
      if (flow) flow.innerHTML = '';
      return;
    }
    if (sel) {
      sel.innerHTML = _auditorDiagBotRuns.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 && _auditorDiagBotRuns.some(function(r){ return r.id === selectId; }) ? selectId : _auditorDiagBotRuns[0].id;
    if (sel) sel.value = target;
    auditorDiagSelectBotRun(target);
  }).catch(function(){
    if (errEl) { errEl.classList.remove('hidden'); document.getElementById('auditor-diag-bot-error-msg').textContent = 'Failed to load runs.'; }
  });
}

function auditorDiagSelectBotRun(runId) {
  if (!runId) return;
  var bot = (document.getElementById('auditor-diag-bot-select') || {}).value || 'winston';
  var loading = document.getElementById('auditor-diag-bot-loading');
  var flow = document.getElementById('auditor-diag-bot-flow');
  var errEl = document.getElementById('auditor-diag-bot-error');
  if (errEl) errEl.classList.add('hidden');
  if (flow) flow.innerHTML = '';
  if (loading) loading.classList.remove('hidden');
  fetch('/diagnostics/' + encodeURIComponent(bot) + '/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('auditor-diag-bot-error-msg').textContent = 'Run not found.'; }
      return;
    }
    renderDiagnosticFlow(flow, d.run, bot);
  }).catch(function(){
    if (loading) loading.classList.add('hidden');
    if (errEl) { errEl.classList.remove('hidden'); document.getElementById('auditor-diag-bot-error-msg').textContent = 'Failed to load run.'; }
  });
}

// --- Vision sub-tab: reuse /vision/diagnostics/fleet|agent, read-only presentation ---
function _auditorDiagVisionShow(which) {
  ['loading', 'error', 'fleet', 'agent'].forEach(function(k){
    var el = document.getElementById('auditor-diag-vision-' + k);
    if (el) el.classList.toggle('hidden', k !== which);
  });
}

function auditorDiagVisionShowFleet() {
  _auditorDiagVisionAgent = null;
  var back = document.getElementById('auditor-diag-vision-back');
  if (back) back.classList.add('hidden');
  document.getElementById('auditor-diag-vision-title').textContent = 'Fleet Diagnostics';
  if (_auditorDiagVisionFleet) { _auditorDiagRenderVisionFleet(_auditorDiagVisionFleet); _auditorDiagVisionShow('fleet'); }
  else auditorDiagLoadVisionFleet();
}

function auditorDiagLoadVisionFleet() {
  _auditorDiagVisionAgent = null;
  var back = document.getElementById('auditor-diag-vision-back');
  if (back) back.classList.add('hidden');
  document.getElementById('auditor-diag-vision-title').textContent = 'Fleet Diagnostics';
  var status = document.getElementById('auditor-diag-vision-status');
  _auditorDiagVisionShow('loading');
  fetch('/vision/diagnostics/fleet')
    .then(function(r){ if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
    .then(function(d){
      _auditorDiagVisionFleet = d;
      if (status) status.textContent = 'Last refreshed: ' + new Date().toLocaleTimeString();
      _auditorDiagRenderVisionFleet(d);
      _auditorDiagVisionShow('fleet');
    })
    .catch(function(err){
      document.getElementById('auditor-diag-vision-error-msg').textContent = 'Failed to load diagnostics: ' + err.message;
      _auditorDiagVisionShow('error');
    });
}

function _auditorDiagVisionAgentLabel(mid) {
  if (_auditorDiagVisionFleet && _auditorDiagVisionFleet.agents) {
    for (var i = 0; i < _auditorDiagVisionFleet.agents.length; i++) {
      var a = _auditorDiagVisionFleet.agents[i];
      if (a.machine_id === mid) return a.friendly_name || a.hostname || mid;
    }
  }
  return mid;
}

function _auditorDiagRenderVisionFleet(d) {
  var el = document.getElementById('auditor-diag-vision-fleet');
  if (!el) return;
  var counts = d.counts || {};
  var errSet = {}; (d.agents_with_errors || []).forEach(function(m){ errSet[m] = true; });
  var staleSet = {}; (d.capturing_but_stale || []).forEach(function(m){ staleSet[m] = true; });

  function card(label, value, cls) {
    return '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-3">' +
      '<div class="text-2xl font-semibold ' + (cls || 'text-gray-800 dark:text-gray-100') + '">' + value + '</div>' +
      '<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">' + esc(label) + '</div>' +
    '</div>';
  }

  var html = '';
  html += '<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mb-5">' +
    card('Total agents', d.total || 0) +
    card('Active', counts.active || 0, 'text-green-600 dark:text-green-400') +
    card('Offline', counts.offline || 0, 'text-gray-500 dark:text-gray-400') +
    card('Pending', counts.pending || 0, 'text-amber-600 dark:text-amber-400') +
    card('Revoked', counts.revoked || 0, 'text-red-600 dark:text-red-400') +
  '</div>';

  var fleetSigs = d.fleet_failure_signature_counts || {};
  var sigKeys = Object.keys(fleetSigs).sort(function(a, b){ return fleetSigs[b] - fleetSigs[a]; });
  var totalFailures = sigKeys.reduce(function(s, k){ return s + (fleetSigs[k] || 0); }, 0);
  html += '<div class="rounded-xl border ' + (totalFailures ? 'togen-inset-danger border-red-200 dark:border-red-900/40 bg-red-50/40 dark:bg-red-900/10' : 'togen-inset border-gray-200 dark:border-gray-700') + ' px-4 py-3 mb-5">' +
    '<div class="text-xs font-semibold ' + (totalFailures ? 'text-red-700 dark:text-red-400' : 'text-gray-600 dark:text-gray-300') + ' mb-2">Failure events by signature (' + totalFailures + ')</div>' +
    (sigKeys.length ? '<div class="flex flex-wrap gap-2">' + sigKeys.map(function(sig){
      return '<span class="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 font-mono">' + esc(sig) + ' · ' + fleetSigs[sig] + '</span>';
    }).join('') + '</div>' : '<div class="text-xs text-gray-400 dark:text-gray-500 italic">No failure events reported.</div>') +
  '</div>';

  var errList = d.agents_with_errors || [];
  var staleList = d.capturing_but_stale || [];
  html += '<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-5">';
  html += '<div class="togen-inset-danger rounded-xl border border-red-200 dark:border-red-900/40 bg-red-50/50 dark:bg-red-900/10 px-4 py-3">' +
    '<div class="text-xs font-semibold text-red-700 dark:text-red-400 mb-1">Agents with errors (' + errList.length + ')</div>' +
    (errList.length ? '<div class="flex flex-wrap gap-1.5">' + errList.map(function(m){
      return '<button onclick="auditorDiagLoadVisionAgent(\'' + esc(m).replace(/'/g, "\\'") + '\')" class="text-xs font-mono px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 hover:underline cursor-pointer">' + esc(_auditorDiagVisionAgentLabel(m)) + '</button>';
    }).join('') + '</div>' : '<div class="text-xs text-gray-400 dark:text-gray-500 italic">None</div>') +
  '</div>';
  html += '<div class="togen-inset-warn rounded-xl border border-amber-200 dark:border-amber-900/40 bg-amber-50/50 dark:bg-amber-900/10 px-4 py-3">' +
    '<div class="text-xs font-semibold text-amber-700 dark:text-amber-400 mb-1">Capturing but stale (' + staleList.length + ')</div>' +
    (staleList.length ? '<div class="flex flex-wrap gap-1.5">' + staleList.map(function(m){
      return '<button onclick="auditorDiagLoadVisionAgent(\'' + esc(m).replace(/'/g, "\\'") + '\')" class="text-xs font-mono px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 hover:underline cursor-pointer">' + esc(_auditorDiagVisionAgentLabel(m)) + '</button>';
    }).join('') + '</div>' : '<div class="text-xs text-gray-400 dark:text-gray-500 italic">None</div>') +
  '</div>';
  html += '</div>';

  var agents = d.agents || [];
  html += '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-3 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">Agent</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">Last seen</th>' +
      '<th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Version</th>' +
      '<th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Failures</th>' +
      '<th class="pb-2 font-semibold text-gray-600 dark:text-gray-300">Flags</th>' +
    '</tr></thead><tbody class="divide-y divide-gray-100 dark:divide-gray-800">';
  if (!agents.length) {
    html += '<tr><td colspan="6" class="py-4 text-center text-gray-400 dark:text-gray-500 italic">No agents enrolled.</td></tr>';
  }
  agents.forEach(function(a){
    var name = a.friendly_name || a.hostname || a.machine_id;
    var mid = a.machine_id;
    var latest = d.latest_agent_version;
    var verCls = (latest && a.agent_version && a.agent_version !== latest) ? 'text-amber-600 dark:text-amber-400' : 'text-gray-600 dark:text-gray-400';
    var flags = '';
    if (errSet[mid]) flags += '<span class="text-xs px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">error</span> ';
    if (staleSet[mid]) flags += '<span class="text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">stale</span>';
    var fc = a.failure_event_count || 0;
    var fcCell = fc > 0
      ? '<span class="text-xs px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 font-medium">' + fc + '</span>'
      : '<span class="text-gray-300 dark:text-gray-600">0</span>';
    html += '<tr onclick="auditorDiagLoadVisionAgent(\'' + esc(mid).replace(/'/g, "\\'") + '\')" class="hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer transition-colors">' +
      '<td class="py-2.5 pr-4"><div class="font-medium text-gray-800 dark:text-gray-200 truncate max-w-xs">' + esc(name) + '</div>' +
        '<div class="text-xs text-gray-400 dark:text-gray-500 font-mono truncate max-w-xs">' + esc(mid) + '</div></td>' +
      '<td class="py-2.5 pr-4">' + _visionDiagStatusBadge(a.status) + '</td>' +
      '<td class="py-2.5 pr-4 text-gray-600 dark:text-gray-400 whitespace-nowrap">' + esc(_visionDiagAgo(a.last_seen_at)) + '</td>' +
      '<td class="py-2.5 pr-4 whitespace-nowrap ' + verCls + '">' + esc(a.agent_version || '—') + '</td>' +
      '<td class="py-2.5 pr-4">' + fcCell + '</td>' +
      '<td class="py-2.5">' + (flags || '<span class="text-gray-300 dark:text-gray-600">—</span>') + '</td>' +
    '</tr>';
  });
  html += '</tbody></table></div>';
  el.innerHTML = html;
}

function auditorDiagLoadVisionAgent(mid) {
  _auditorDiagVisionAgent = mid;
  var back = document.getElementById('auditor-diag-vision-back');
  if (back) back.classList.remove('hidden');
  var status = document.getElementById('auditor-diag-vision-status');
  _auditorDiagVisionShow('loading');
  fetch('/vision/diagnostics/agent/' + encodeURIComponent(mid))
    .then(function(r){ if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
    .then(function(d){
      if (status) status.textContent = 'Last refreshed: ' + new Date().toLocaleTimeString();
      var h = d.health || {};
      document.getElementById('auditor-diag-vision-title').textContent = h.friendly_name || h.hostname || d.machine_id;
      _auditorDiagRenderVisionAgent(d);
      _auditorDiagVisionShow('agent');
    })
    .catch(function(err){
      document.getElementById('auditor-diag-vision-error-msg').textContent = 'Failed to load agent diagnostics: ' + err.message;
      _auditorDiagVisionShow('error');
    });
}

function _auditorDiagRenderVisionAgent(d) {
  var el = document.getElementById('auditor-diag-vision-agent');
  if (!el) return;
  var h = d.health || {};
  var telemetry = (d.telemetry || []).slice().reverse();
  var commands = d.commands || [];
  var alerts = d.alerts || [];

  function field(label, value) {
    return '<div><dt class="text-xs text-gray-400 dark:text-gray-500">' + esc(label) + '</dt>' +
      '<dd class="text-sm text-gray-800 dark:text-gray-200 mt-0.5">' + value + '</dd></div>';
  }

  var verMismatch = h.latest_agent_version && h.agent_version && h.agent_version !== h.latest_agent_version;
  var verVal = esc(h.agent_version || '—') +
    (verMismatch ? ' <span class="text-xs text-amber-600 dark:text-amber-400">(latest ' + esc(h.latest_agent_version) + ')</span>' :
      (h.agent_version ? ' <span class="text-xs text-green-600 dark:text-green-400">(latest)</span>' : ''));
  var capVal = h.capture_enabled === true ? '<span class="text-green-600 dark:text-green-400">Enabled</span>' :
    (h.capture_enabled === false ? '<span class="text-gray-500 dark:text-gray-400">Disabled</span>' : '—');

  var html = '';
  html += '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-4 mb-5">' +
    '<div class="flex items-center gap-2 mb-3">' + _visionDiagStatusBadge(h.status) +
      '<span class="text-sm font-medium text-gray-700 dark:text-gray-200">' + esc(h.hostname || d.machine_id) + '</span>' +
      '<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">' + esc(d.machine_id) + '</span></div>' +
    '<dl class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-x-4 gap-y-3">' +
      field('Last check-in', esc(_visionDiagAgo(h.last_seen_at))) +
      field('Agent version', verVal) +
      field('Capture', capVal) +
      field('Last capture', esc(_visionDiagAgo(h.last_capture_ts))) +
      field('OS', esc(h.os_version || '—')) +
      field('Logged-in user', esc(h.logged_in_user || '—')) +
      field('Uptime', esc(typeof h.agent_uptime_s === 'number' ? _visionFmtUptime(h.agent_uptime_s) : '—')) +
      field('Screens', esc(h.screen_count != null ? String(h.screen_count) : '—')) +
    '</dl>' +
  '</div>';

  var failEvents = d.failure_events || [];
  var failSigs = d.failure_signature_counts || {};
  var failSigKeys = Object.keys(failSigs).sort(function(a, b){ return failSigs[b] - failSigs[a]; });
  html += '<div class="togen-inset rounded-xl border ' + (failEvents.length ? 'border-red-200 dark:border-red-900/40' : 'border-gray-200 dark:border-gray-700') + ' px-4 py-4 mb-5">' +
    '<div class="text-xs font-semibold ' + (failEvents.length ? 'text-red-700 dark:text-red-400' : 'text-gray-600 dark:text-gray-300') + ' mb-3">Failure events</div>';
  if (failSigKeys.length) {
    html += '<div class="flex flex-wrap gap-2 mb-3">' + failSigKeys.map(function(sig){
      return '<span class="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 font-mono">' + esc(sig) + ' · ' + failSigs[sig] + '</span>';
    }).join('') + '</div>';
  }
  if (!failEvents.length) {
    html += '<div class="text-xs text-gray-400 dark:text-gray-500 italic">No failure events reported by this agent.</div>';
  } else {
    html += '<div class="overflow-x-auto"><table class="w-full text-xs"><thead>' +
      '<tr class="border-b border-gray-200 dark:border-gray-700 text-left text-gray-500 dark:text-gray-400">' +
        '<th class="pb-1.5 pr-3 font-medium">When</th><th class="pb-1.5 pr-3 font-medium">Signature</th>' +
        '<th class="pb-1.5 font-medium">Detail</th>' +
      '</tr></thead><tbody class="divide-y divide-gray-100 dark:divide-gray-800">';
    failEvents.forEach(function(e){
      var when = _visionDiagAgo(e.ts || e.received_at);
      var detail = e.detail || '';
      var trace = e.trace ? ('\n' + e.trace) : '';
      html += '<tr class="text-gray-600 dark:text-gray-400 align-top">' +
        '<td class="py-1 pr-3 whitespace-nowrap">' + esc(when) + '</td>' +
        '<td class="py-1 pr-3 whitespace-nowrap"><span class="font-mono text-red-600 dark:text-red-400">' + esc(e.signature || '—') + '</span></td>' +
        '<td class="py-1 whitespace-pre-wrap break-words max-w-[28rem]">' + esc(detail + trace) + '</td>' +
      '</tr>';
    });
    html += '</tbody></table></div>';
  }
  html += '</div>';

  var cpuVals = telemetry.map(function(r){ return typeof r.cpu_pct === 'number' ? r.cpu_pct : null; });
  var idleVals = telemetry.map(function(r){ return typeof r.idle_seconds === 'number' ? r.idle_seconds : null; });
  var upVals = telemetry.map(function(r){ return typeof r.agent_uptime_s === 'number' ? r.agent_uptime_s : null; });
  html += '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-4 mb-5">' +
    '<div class="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-3">Telemetry (last ' + telemetry.length + ' check-ins)</div>';
  if (!telemetry.length) {
    html += '<div class="text-xs text-gray-400 dark:text-gray-500 italic">No telemetry check-ins recorded.</div>';
  } else {
    html += '<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">' +
      '<div><div class="text-xs text-gray-400 dark:text-gray-500 mb-1">CPU %</div>' + _visionDiagSparkline(cpuVals, '#0d9488') + '</div>' +
      '<div><div class="text-xs text-gray-400 dark:text-gray-500 mb-1">Idle seconds</div>' + _visionDiagSparkline(idleVals, '#6366f1') + '</div>' +
      '<div><div class="text-xs text-gray-400 dark:text-gray-500 mb-1">Uptime (s)</div>' + _visionDiagSparkline(upVals, '#f59e0b') + '</div>' +
    '</div>';
    var recent = telemetry.slice().reverse().slice(0, 15);
    html += '<div class="overflow-x-auto"><table class="w-full text-xs"><thead>' +
      '<tr class="border-b border-gray-200 dark:border-gray-700 text-left text-gray-500 dark:text-gray-400">' +
        '<th class="pb-1.5 pr-3 font-medium">Time</th><th class="pb-1.5 pr-3 font-medium">CPU%</th>' +
        '<th class="pb-1.5 pr-3 font-medium">Idle</th><th class="pb-1.5 pr-3 font-medium">Capture</th>' +
        '<th class="pb-1.5 pr-3 font-medium">Last capture</th><th class="pb-1.5 font-medium">Foreground</th>' +
      '</tr></thead><tbody class="divide-y divide-gray-100 dark:divide-gray-800 font-mono">';
    recent.forEach(function(r){
      var t = r.ts ? new Date(r.ts * 1000).toLocaleString() : (r.received_at ? new Date(r.received_at * 1000).toLocaleString() : '—');
      var cap = r.capture_enabled === true ? 'on' : (r.capture_enabled === false ? 'off' : '—');
      html += '<tr class="text-gray-600 dark:text-gray-400">' +
        '<td class="py-1 pr-3 whitespace-nowrap">' + esc(t) + '</td>' +
        '<td class="py-1 pr-3">' + esc(typeof r.cpu_pct === 'number' ? r.cpu_pct.toFixed(0) : '—') + '</td>' +
        '<td class="py-1 pr-3">' + esc(typeof r.idle_seconds === 'number' ? r.idle_seconds + 's' : '—') + '</td>' +
        '<td class="py-1 pr-3">' + cap + '</td>' +
        '<td class="py-1 pr-3 whitespace-nowrap">' + esc(_visionDiagAgo(r.last_capture_ts)) + '</td>' +
        '<td class="py-1 truncate max-w-[10rem]">' + esc(r.foreground_app || '—') + '</td>' +
      '</tr>';
    });
    html += '</tbody></table></div>';
  }
  html += '</div>';

  html += '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-4 mb-5">' +
    '<div class="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-3">Command history</div>';
  if (!commands.length) {
    html += '<div class="text-xs text-gray-400 dark:text-gray-500 italic">No commands issued to this agent.</div>';
  } else {
    html += '<div class="overflow-x-auto"><table class="w-full text-xs"><thead>' +
      '<tr class="border-b border-gray-200 dark:border-gray-700 text-left text-gray-500 dark:text-gray-400">' +
        '<th class="pb-1.5 pr-3 font-medium">Action</th><th class="pb-1.5 pr-3 font-medium">Created</th>' +
        '<th class="pb-1.5 pr-3 font-medium">Delivered</th><th class="pb-1.5 pr-3 font-medium">Acked</th>' +
        '<th class="pb-1.5 pr-3 font-medium">Status</th><th class="pb-1.5 font-medium">Error</th>' +
      '</tr></thead><tbody class="divide-y divide-gray-100 dark:divide-gray-800">';
    commands.forEach(function(c){
      var stCls = c.acked_status === 'error' ? 'text-red-600 dark:text-red-400' :
                  c.acked_status === 'ok' ? 'text-green-600 dark:text-green-400' :
                  'text-gray-600 dark:text-gray-400';
      var stLabel = c.acked_status || c.state || '—';
      html += '<tr class="text-gray-600 dark:text-gray-400">' +
        '<td class="py-1 pr-3 font-medium text-gray-800 dark:text-gray-200 whitespace-nowrap">' + esc(c.action || '—') + '</td>' +
        '<td class="py-1 pr-3 whitespace-nowrap">' + esc(_visionDiagAgo(c.created_at)) + '</td>' +
        '<td class="py-1 pr-3 whitespace-nowrap">' + esc(c.delivered_at ? _visionDiagAgo(c.delivered_at) : '—') + '</td>' +
        '<td class="py-1 pr-3 whitespace-nowrap">' + esc(c.acked_at ? _visionDiagAgo(c.acked_at) : '—') + '</td>' +
        '<td class="py-1 pr-3 ' + stCls + '">' + esc(stLabel) + '</td>' +
        '<td class="py-1 text-red-600 dark:text-red-400 whitespace-pre-wrap break-words max-w-[16rem]">' + esc(c.error_message || '') + '</td>' +
      '</tr>';
    });
    html += '</tbody></table></div>';
  }
  html += '</div>';

  html += '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-4">' +
    '<div class="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-3">Recent alerts</div>';
  if (!alerts.length) {
    html += '<div class="text-xs text-gray-400 dark:text-gray-500 italic">No alerts for this agent.</div>';
  } else {
    html += '<ul class="space-y-1.5">';
    alerts.forEach(function(a){
      var typeCls = a.type === 'error' ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300' :
                    a.type === 'offline' ? 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300' :
                    'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300';
      var when = '';
      if (a.created_at) { try { when = new Date(a.created_at).toLocaleString(); } catch(e){} }
      html += '<li class="flex items-start gap-2 text-xs">' +
        '<span class="shrink-0 px-1.5 py-0.5 rounded ' + typeCls + '">' + esc(a.type || '—') + '</span>' +
        '<span class="flex-1 text-gray-700 dark:text-gray-300">' + esc(a.message || '') + '</span>' +
        '<span class="shrink-0 text-gray-400 dark:text-gray-500 whitespace-nowrap">' + esc(when) + '</span>' +
      '</li>';
    });
    html += '</ul>';
  }
  html += '</div>';

  el.innerHTML = html;
}

// ----- 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;

// ----- History storage telemetry (DVI-1045): size/age surfaced above sub-tabs -----
function _auditorHistFmtBytes(n) {
  if (n == null) return '—';
  if (n < 1024) return n + ' B';
  var u = ['KB','MB','GB','TB'], i = -1, v = n;
  do { v /= 1024; i++; } while (v >= 1024 && i < u.length - 1);
  return v.toFixed(v < 10 ? 1 : 0) + ' ' + u[i];
}
function _auditorHistFmtAge(iso) {
  if (!iso) return '—';
  var then = Date.parse(iso);
  if (isNaN(then)) return '—';
  var days = Math.floor((Date.now() - then) / 86400000);
  if (days <= 0) return 'today';
  if (days === 1) return '1 day ago';
  if (days < 60) return days + ' days ago';
  return Math.floor(days / 30) + ' mo ago';
}
function auditorHistLoadTelemetry() {
  var box = document.getElementById('auditor-hist-telemetry');
  if (!box) return;
  fetch('/togen-admin/history/telemetry')
    .then(function(r) { return r.ok ? r.json() : null; })
    .then(function(t) {
      if (!t) { box.classList.add('hidden'); return; }
      box.classList.remove('hidden');
      document.getElementById('auditor-hist-tel-rows').textContent =
        (t.total_rows != null ? t.total_rows.toLocaleString() : '—');
      document.getElementById('auditor-hist-tel-dbsize').textContent = _auditorHistFmtBytes(t.db_bytes);
      document.getElementById('auditor-hist-tel-oldest').textContent = _auditorHistFmtAge(t.oldest);
      var ret = (t.retention_days != null ? t.retention_days + ' days' : 'unlimited') +
        (t.max_rows_per_tool != null ? ' / ' + t.max_rows_per_tool.toLocaleString() + ' rows' : '') +
        (t.archive ? ' · archiving on' : ' · archiving off');
      document.getElementById('auditor-hist-tel-retention').textContent = ret;
      document.getElementById('auditor-hist-tel-archives').textContent =
        (t.archive_files || 0) + ' file(s), ' + _auditorHistFmtBytes(t.archive_bytes);
    })
    .catch(function() { box.classList.add('hidden'); });
}

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', 'scada-reporting',
                      'signage-agents', 'dashboards', 'assets', 'datasources'];
  _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);
}

// Export the currently-active History sub-tab (respecting its filters) as CSV
// for a compliance packet (DVI-1044).
function auditorHistExportCsv() {
  var tab = _auditorHistTab;
  var url;
  if (tab === 'prodrun') {
    var q = [];
    var failEl = document.getElementById('auditor-hist-prodrun-failures');
    var catEl = document.getElementById('auditor-hist-prodrun-category');
    if (failEl && failEl.checked) q.push('failures_only=1');
    if (catEl && catEl.value) q.push('category=' + encodeURIComponent(catEl.value));
    url = '/prodrun-history/export.csv' + (q.length ? ('?' + q.join('&')) : '');
  } else if (tab === 'bots') {
    url = '/winston/run-history/export.csv';
  } else {
    var userInp = document.getElementById('auditor-hist-' + tab + '-filter-user');
    var user = userInp ? (userInp.value || '').trim() : '';
    var actInp = document.getElementById('auditor-hist-' + tab + '-filter-action');
    var action = actInp ? (actInp.value || '').trim() : '';
    var q = [];
    if (user) q.push('user=' + encodeURIComponent(user));
    if (action) q.push('action=' + encodeURIComponent(action));
    url = '/history/' + encodeURIComponent(tab) + '/export.csv' +
      (q.length ? ('?' + q.join('&')) : '');
  }
  _auditorDownload(url);
}

// ----- 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 === 'cancelled') {
        statusBadge = '<span class="text-xs font-medium px-1.5 py-0.5 rounded bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300">Stopped</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>';
      }
      if (r.dry_run) statusBadge += ' <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" title="Test run — no ClickUp / SharePoint / draft writes">Dry Run</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() : '';
  // Optional per-tab action filter (DVI-1228: Displays & Presentation splits
  // presentation vs display-admin events). Absent on other tabs → no-op.
  var actInp = document.getElementById('auditor-hist-' + idSfx + '-filter-action');
  var action = actInp ? (actInp.value || '').trim() : '';
  var qs = 'limit=' + _auditorHistGenericLimit + '&offset=' + offset;
  if (user) qs += '&user=' + encodeURIComponent(user);
  if (action) qs += '&action=' + encodeURIComponent(action);

  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);
}

// ----- Dashboards configuration inspector (DVI-1228 P3) -----
// Read-only view over every user's dashboards. No mutation. Preview renders
// under the inspecting admin's own identity/roles (labeled caveat).
var _dashInspTypeLabels = {};

function dashInspectorLoad() {
  var status = document.getElementById('dashinsp-status');
  var list = document.getElementById('dashinsp-list');
  if (!list) return;
  var qEl = document.getElementById('dashinsp-q');
  var q = qEl ? (qEl.value || '').trim() : '';
  if (status) status.textContent = 'Loading…';
  fetch('/auditor/dashboards' + (q ? ('?q=' + encodeURIComponent(q)) : ''))
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok) { list.innerHTML = ''; if (status) status.textContent = 'Error: ' + ((res.d && res.d.error) || 'Failed to load.'); return; }
      _dashInspTypeLabels = (res.d && res.d.tile_type_labels) || {};
      var items = (res.d && res.d.dashboards) || [];
      if (!items.length) { list.innerHTML = ''; if (status) status.textContent = q ? 'No dashboards match.' : 'No dashboards yet.'; return; }
      if (status) status.textContent = items.length + ' dashboard(s)';
      list.innerHTML = items.map(dashInspectorRowHtml).join('');
    })
    .catch(function(e){ if (status) status.textContent = 'Failed: ' + e.message; });
}

function _dashInspTypeChips(types) {
  if (!types || !types.length) return '<span class="text-xs text-gray-400 dark:text-gray-500 italic">no tiles</span>';
  return types.map(function(t){
    var label = _dashInspTypeLabels[t] || t;
    return '<span class="inline-block text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300">' + esc(label) + '</span>';
  }).join(' ');
}

function _dashInspAssignHtml(assignments) {
  if (!assignments || !assignments.length) return '';
  return assignments.map(function(a){
    var name = esc(a.display_name || a.display_id || '?');
    if (a.kind === 'presenting') {
      return '<span class="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300" title="Live presentation">&#9654; Presenting on ' + name + (a.presenter ? (' by ' + esc(a.presenter)) : '') + '</span>';
    }
    return '<span class="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300" title="Assigned signage display">&#128421; ' + name + '</span>';
  }).join(' ');
}

function dashInspectorRowHtml(d) {
  var badges = '';
  if (d.system) badges += '<span class="text-xs px-1.5 py-0.5 rounded bg-togen-teal/10 text-togen-teal border border-togen-teal/30">System</span> ';
  if (d.signage) badges += '<span class="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">Signage</span> ';
  if (d.shared_count) badges += '<span class="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">Shared &times;' + d.shared_count + '</span> ';
  if (d.default_for && d.default_for.length) badges += '<span class="text-xs px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300" title="' + esc(d.default_for.join(', ')) + '">Default &times;' + d.default_for.length + '</span> ';
  var idJs = "'" + esc(String(d.id || '')).replace(/'/g, "\\'") + "'";
  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-start justify-between gap-3 flex-wrap">' +
      '<div class="min-w-0">' +
        '<p class="font-medium text-sm text-gray-800 dark:text-gray-200">' + esc(d.name || '(untitled)') + '</p>' +
        '<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">' + esc(d.owner || '—') + ' &middot; ' + (d.tile_count || 0) + ' tile(s)</p>' +
        '<div class="mt-1.5 flex flex-wrap gap-1">' + _dashInspTypeChips(d.tile_types) + '</div>' +
        (d.assignments && d.assignments.length ? ('<div class="mt-1.5 flex flex-wrap gap-1">' + _dashInspAssignHtml(d.assignments) + '</div>') : '') +
      '</div>' +
      '<div class="flex flex-col items-end gap-2 flex-shrink-0">' +
        '<div class="flex flex-wrap justify-end gap-1">' + badges + '</div>' +
        '<div class="flex items-center gap-3">' +
          '<button onclick="dashInspectorOpenDetail(' + idJs + ')" class="text-xs font-medium text-togen-teal hover:text-togen-teal-dark underline cursor-pointer">Inspect</button>' +
          '<button onclick="dashInspectorPreview(' + idJs + ')" class="text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 underline cursor-pointer" title="Renders under YOUR identity and roles — data may differ from the owner view">Preview</button>' +
        '</div>' +
      '</div>' +
    '</div>' +
  '</div>';
}

function dashInspectorPreview(id) {
  window.open('/display?dash=' + encodeURIComponent(id), '_blank');
}

function dashInspectorCloseDetail() {
  var o = document.getElementById('dashinsp-detail-overlay');
  if (o) o.classList.add('hidden');
}

function dashInspectorOpenDetail(id) {
  var o = document.getElementById('dashinsp-detail-overlay');
  var body = document.getElementById('dashinsp-detail-body');
  var title = document.getElementById('dashinsp-detail-title');
  if (!o || !body) return;
  o.classList.remove('hidden');
  body.innerHTML = '<p class="text-gray-500 dark:text-gray-400">Loading…</p>';
  fetch('/auditor/dashboards/' + encodeURIComponent(id))
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok) { body.innerHTML = '<p class="text-red-600 dark:text-red-400">' + esc((res.d && res.d.error) || 'Failed to load.') + '</p>'; return; }
      var dash = (res.d && res.d.dashboard) || {};
      _dashInspTypeLabels = (res.d && res.d.tile_type_labels) || _dashInspTypeLabels;
      if (title) title.textContent = dash.name || 'Dashboard configuration';
      body.innerHTML = dashInspectorDetailHtml(dash, id);
    })
    .catch(function(e){ body.innerHTML = '<p class="text-red-600 dark:text-red-400">Failed: ' + esc(e.message) + '</p>'; });
}

function dashInspectorDetailHtml(dash, id) {
  function row(k, v){ return '<div class="flex justify-between gap-4 py-1 border-b border-gray-100 dark:border-gray-800"><span class="text-gray-500 dark:text-gray-400">' + esc(k) + '</span><span class="text-gray-800 dark:text-gray-200 text-right break-words">' + v + '</span></div>'; }
  var h = '<div class="mb-4">';
  h += row('Owner', esc(dash.owner || '—'));
  h += row('Updated', esc(dash.updated_at || '—'));
  h += row('Tiles', (dash.tile_count || 0));
  h += row('Signage flag', dash.signage ? 'On' : 'Off');
  h += row('Shared with', (dash.shared_users && dash.shared_users.length) ? esc(dash.shared_users.join(', ')) : '—');
  h += row('Default for', (dash.default_for && dash.default_for.length) ? esc(dash.default_for.join(', ')) : '—');
  if (dash.assignments && dash.assignments.length) h += row('Displays', _dashInspAssignHtml(dash.assignments));
  h += '</div>';
  h += '<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-2">Tiles</h4>';
  var tiles = dash.tiles || [];
  if (!tiles.length) {
    h += '<p class="text-gray-400 dark:text-gray-500 italic">No tiles.</p>';
  } else {
    h += '<div class="space-y-2">';
    tiles.forEach(function(t, i){
      var label = _dashInspTypeLabels[t.type] || t.type || '?';
      var size = t.size || 'medium';
      if (size === 'custom' && t.span) size = 'custom ' + (t.span.cols || '?') + '×' + (t.span.rows || '?');
      var cfgKeys = Object.keys(t.config || {});
      var cfgStr = cfgKeys.length ? esc(JSON.stringify(t.config)) : '<span class="italic text-gray-400 dark:text-gray-500">no config</span>';
      h += '<div class="togen-inset rounded-lg border border-gray-200 dark:border-gray-700 p-2">' +
        '<div class="flex items-center justify-between gap-2">' +
          '<span class="font-medium text-gray-800 dark:text-gray-200">' + (i + 1) + '. ' + esc(label) + '</span>' +
          '<span class="text-xs text-gray-500 dark:text-gray-400">' + esc(size) + (t.overflow ? (' · ' + esc(t.overflow)) : '') + '</span>' +
        '</div>' +
        '<div class="mt-1 text-xs text-gray-500 dark:text-gray-400 break-all font-mono">' + cfgStr + '</div>' +
      '</div>';
    });
    h += '</div>';
  }
  var idJs = "'" + esc(String(id || '')).replace(/'/g, "\\'") + "'";
  h += '<div class="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">' +
    '<button onclick="dashInspectorPreview(' + idJs + ')" 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">Render read-only preview &#8599;</button>' +
    '<p class="mt-1.5 text-xs text-gray-400 dark:text-gray-500">Preview renders tile data under <span class="font-medium">your</span> identity and roles, not the owner\'s — the layout is exact, but data values may differ from what the owner sees. People-tracking layers stay excluded.</p>' +
  '</div>';
  return h;
}

// ----- 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 = '';
  var q = [];
  var failEl = document.getElementById('auditor-hist-prodrun-failures');
  var catEl = document.getElementById('auditor-hist-prodrun-category');
  if (failEl && failEl.checked) q.push('failures_only=1');
  if (catEl && catEl.value) q.push('category=' + encodeURIComponent(catEl.value));
  var url = '/prodrun-history' + (q.length ? ('?' + q.join('&')) : '');
  fetch(url).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 matching production runs.'; 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>';
  // DVI-1052: single Auditor-gated endpoint so policies.* grantees can view
  // without the WOR/RPM/admin roles the source endpoints require.
  fetch('/auditor/policies').then(function(r){ return r.json(); }).then(function(res) {
    _auditorPolicyData = {
      wor: res.wor || {},
      rpmApproval: res.rpmApproval || {},
      rpmSettings: res.rpmSettings || {},
      vision: res.vision || {},
      visionDefaults: res.visionDefaults || {}
    };
    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', evh_waiting: 'Awaiting RtS'};
  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 Orders', '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="togen-inset 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 || '';
  var filterOutcome = (document.getElementById('auditor-filter-outcome') || {}).value || '';
  var filterQ = (document.getElementById('auditor-filter-q') || {}).value || '';
  if (filterUser.trim()) params.set('user', filterUser.trim());
  if (filterTool) params.set('tool', filterTool);
  if (filterOutcome) params.set('outcome', filterOutcome);
  if (filterQ.trim()) params.set('q', filterQ.trim());

  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 auditorExportCsv() {
  var params = new URLSearchParams();
  if (_auditorTab === 'failures') params.set('failures_only', '1');
  var filterUser = ((document.getElementById('auditor-filter-user') || {}).value || '').trim();
  var filterTool = (document.getElementById('auditor-filter-tool') || {}).value || '';
  var filterOutcome = (document.getElementById('auditor-filter-outcome') || {}).value || '';
  var filterQ = ((document.getElementById('auditor-filter-q') || {}).value || '').trim();
  if (filterUser) params.set('user', filterUser);
  if (filterTool) params.set('tool', filterTool);
  if (filterOutcome) params.set('outcome', filterOutcome);
  if (filterQ) params.set('q', filterQ);
  var qs = params.toString();
  _auditorDownload('/auditor/entries/export.csv' + (qs ? ('?' + qs) : ''));
}

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.stock_on_hand !== undefined && p.stock_on_hand !== null) meta.push('Stock ' + esc(String(p.stock_on_hand)) + (p.stock_low ? ' ⚠' : ''));
  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="togen-control 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.stock_on_hand !== undefined && p.stock_on_hand !== null) meta.push('Stock ' + esc(String(p.stock_on_hand)) + (p.stock_low ? ' ⚠' : ''));
  if (p.stock_on_hand !== undefined && p.stock_on_hand !== null) meta.push('Stock ' + esc(String(p.stock_on_hand)) + (p.stock_low ? ' ⚠' : ''));
    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) ||
           (t.aliases && t.aliases.toLowerCase().indexOf(needle) !== -1);
  });
  // DVI-1496 P5 (rec 1): sub-tool matches — "RPM"/"Parts"/"Manuals"/"Oversight"
  // route to Equipment -> that tab, respecting the P2 visibility/access rules.
  var subtoolMatches = _subtoolSearchMatches(needle);

  // Project matches (backend). Render tools immediately, then merge projects in.
  searchRenderResults(toolMatches, [], render, true, subtoolMatches);
  fetch('/search?q=' + encodeURIComponent(q)).then(function(r) {
    return r.ok ? r.json() : { projects: [] };
  }).then(function(d) {
    searchRenderResults(toolMatches, (d && d.projects) || [], render, false, subtoolMatches);
  }).catch(function() {
    searchRenderResults(toolMatches, [], render, false, subtoolMatches);
  });
}

// DVI-1496 P5 (rec 1): search aliases so short/alternate names find a sub-tool
// (e.g. "rpm" / "parts" -> Repair Parts Manager). Keyed "<surface>:<catalog id>".
var _SUBTOOL_SEARCH_ALIASES = {
  'equipment:rpm': 'rpm repair parts manager parts',
  'equipment:manuals': 'manuals docs documentation',
  'equipment:oversight': 'oversight floor plan map',
  'equipment:assets': 'assets inventory equipment registry parts'
};
function _subtoolSearchMatches(needle) {
  var out = [];
  if (!window._tileSurfaces) return out;
  Object.keys(_tileSurfaces).forEach(function(sid) {
    var s = _tileSurfaces[sid] || {};
    (s.catalog || []).forEach(function(c) {
      // _subtoolVisibleFor consults _subtoolVisibility, which the server already
      // computed with access folded in (P2), so this respects visibility + access.
      if (!_subtoolVisibleFor(sid, c.id)) return;
      var hay = [c.title, c.description, _SUBTOOL_SEARCH_ALIASES[sid + ':' + c.id] || ''].join(' ').toLowerCase();
      if (hay.indexOf(needle) === -1) return;
      out.push({ surface: sid, tool_id: s.tool_id, tab: c.tab, title: c.title, surface_label: s.label });
    });
  });
  return out;
}

function searchRenderResults(toolMatches, projects, render, loading, subtoolMatches) {
  _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 (subtoolMatches && subtoolMatches.length) {
    html += '<p class="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Sub-tools</p>';
    subtoolMatches.forEach(function(m) {
      var idx = _searchResults.push({ kind: 'subtool', tool_id: m.tool_id, tab: m.tab, title: m.title, surface_label: m.surface_label }) - 1;
      html += searchRow(idx, m.title, 'Open in ' + (m.surface_label || '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 === 'subtool') {
    // DVI-1496 P5 (rec 1): open the owning tool, then switch to the sub-tool tab
    // (emSwitchTab re-checks visibility/access + pushes the P5 history entry).
    openTool(r.tool_id, r.surface_label || 'Equipment');
    setTimeout(function() { if (typeof emSwitchTab === 'function') emSwitchTab(r.tab); }, 60);
  } 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 = window._ssoUserName;
    var ssoEmail = window._ssoUserEmail;
    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 = window._ssoUserName;
    var ssoEmail = window._ssoUserEmail;
    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 = window._ssoUserName;
    var ssoEmail = window._ssoUserEmail;
    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;');
}
// ===== SHARED REGIONAL DATE/TIME FORMATTER (DVI-1364 P2) =====
// Format dates/times from the resolved regional preferences on window._regional
// (system -> org default -> per-user override; see DVI-1364 P1). Every call
// reads window._regional LIVE, so a Save in User Settings / Togen Admin updates
// formatting without a page reload. Q3: the chosen timezone is applied to the
// rendered clock/timestamp (a remote user sees a consistent zone); an invalid
// zone silently drops the timeZone option rather than throwing.
//   time_format 12h|24h    -> hour12
//   date_format            -> month_day_year "Aug 9, 2026" (default) | month_day
//                             "Aug 9" | mdy_slash "08/09/2026" | dmy_slash
//                             "09/08/2026" | iso "2026-08-09"
// Accept a Date, an epoch in ms, or a parseable date string; return '' for an
// unparseable/empty value. (No epoch-seconds heuristic — a seconds caller must
// pass new Date(sec*1000) so meaning is never silently changed.)
function _regPrefs() {
  var r = window._regional || {};
  return {
    time_format: r.time_format || '12h',
    timezone: r.timezone || '',
    date_format: r.date_format || 'month_day_year'
  };
}
function _regToDate(v) {
  if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
  if (v === null || v === undefined || v === '') return null;
  var d = new Date(v);
  return isNaN(d.getTime()) ? null : d;
}
// Returns {locale, opts} for a date_format id (explicit locale for the numeric
// slash / iso formats so the ordering is deterministic across browsers).
function _regDateOpts(df) {
  if (df === 'month_day') return { opts: { month: 'short', day: 'numeric' } };
  if (df === 'mdy_slash') return { locale: 'en-US', opts: { year: 'numeric', month: '2-digit', day: '2-digit' } };
  if (df === 'dmy_slash') return { locale: 'en-GB', opts: { year: 'numeric', month: '2-digit', day: '2-digit' } };
  if (df === 'iso') return { locale: 'en-CA', opts: { year: 'numeric', month: '2-digit', day: '2-digit' } };
  return { opts: { year: 'numeric', month: 'short', day: 'numeric' } };  // month_day_year
}
function _fmtDate(v, opts) {
  var d = _regToDate(v); if (!d) return '';
  var p = _regPrefs(); opts = opts || {};
  var spec = _regDateOpts(p.date_format);
  var o = {}; for (var k in spec.opts) o[k] = spec.opts[k];
  if (opts.weekday) o.weekday = 'short';   // caller opt-in for a weekday prefix
  var loc = spec.locale || [];
  if (p.timezone) o.timeZone = p.timezone;
  try { return d.toLocaleDateString(loc, o); }
  catch (e) { delete o.timeZone; return d.toLocaleDateString(loc, o); }
}
function _fmtTime(v, opts) {
  var d = _regToDate(v); if (!d) return '';
  var p = _regPrefs(); opts = opts || {};
  var o = { hour: 'numeric', minute: '2-digit', hour12: (p.time_format !== '24h') };
  if (opts.seconds) o.second = '2-digit';
  if (p.timezone) o.timeZone = p.timezone;
  try { return d.toLocaleTimeString([], o); }
  catch (e) { delete o.timeZone; return d.toLocaleTimeString([], o); }
}
function _fmtDateTime(v, opts) {
  var d = _regToDate(v); if (!d) return '';
  var ds = _fmtDate(v, opts), ts = _fmtTime(v, opts);
  return (ds && ts) ? (ds + ' ' + ts) : (ds || ts);
}
function fmtLocalDate(isoStr) {
  if (_regToDate(isoStr) === null) return esc(isoStr);
  return _fmtDateTime(isoStr);
}

// ===== 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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');
    if (document.getElementById('prodrun-approved-enabled')) prodRunLoadApproved();
    if (document.getElementById('prodrun-rules-list')) prodRunLoadRules();
  } else {
    setActive(runBtn, true);
    runPane.classList.remove('hidden');
  }
}

// DVI-959 P2: Approved domains/paths admin editor (Production Run Settings tab).
function prodRunLoadApproved() {
  var en = document.getElementById('prodrun-approved-enabled');
  var dm = document.getElementById('prodrun-approved-domains');
  var pf = document.getElementById('prodrun-approved-prefixes');
  if (!en || !dm || !pf) return;
  fetch('/prodrun-approved-sources').then(function(r){ return r.json(); }).then(function(cfg){
    en.checked = !!cfg.enabled;
    dm.value = (cfg.domains || []).join('\n');
    pf.value = (cfg.path_prefixes || []).join('\n');
  }).catch(function(){});
}
function prodRunSaveApproved() {
  var en = document.getElementById('prodrun-approved-enabled');
  var dm = document.getElementById('prodrun-approved-domains');
  var pf = document.getElementById('prodrun-approved-prefixes');
  var st = document.getElementById('prodrun-approved-status');
  if (!en || !dm || !pf) return;
  function lines(v){ return v.split('\n').map(function(s){ return s.trim(); }).filter(Boolean); }
  st.textContent = 'Saving…'; st.className = 'text-xs text-gray-500 dark:text-gray-400';
  fetch('/prodrun-approved-sources', {
    method: 'POST', headers: {'Content-Type':'application/json'},
    body: JSON.stringify({enabled: en.checked, domains: lines(dm.value), path_prefixes: lines(pf.value)})
  }).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
    .then(function(res){
      if (!res.ok) { st.textContent = 'Error: ' + ((res.d && res.d.error) || 'save failed'); st.className = 'text-xs text-red-600 dark:text-red-400'; return; }
      st.textContent = '✓ Saved' + (res.d.config && res.d.config.enabled ? ' (enforcement ON)' : ' (enforcement off)');
      st.className = 'text-xs text-green-700 dark:text-green-400';
    }).catch(function(e){ st.textContent = 'Failed: ' + e.message; st.className = 'text-xs text-red-600 dark:text-red-400'; });
}

// DVI-963 O3.P1: auto-tuning rules admin CRUD (Production Run Settings tab).
// Rules are inert in P1 — creating/enabling one does not transform any merge yet.
var _prRuleTypeLabels = {domain_rewrite:'Domain rewrite', path_remap:'Path remap', source_substitution:'Source substitution'};
function prodRunLoadRules() {
  var box = document.getElementById('prodrun-rules-list');
  if (!box) return;
  fetch('/prodrun-autotune-rules').then(function(r){ return r.json(); }).then(function(d){
    var rules = (d && d.rules) || [];
    if (!rules.length) { box.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500">No rules yet. Rules can be seeded from a resolved failure, or added below.</p>'; return; }
    box.innerHTML = rules.map(prRuleRowHtml).join('');
  }).catch(function(){ box.innerHTML = '<p class="text-sm text-red-600 dark:text-red-400">Failed to load rules.</p>'; });
}
function prRuleRowHtml(rule) {
  var typeLbl = _prRuleTypeLabels[rule.type] || rule.type;
  var enBadge = rule.enabled
    ? '<span class="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300">Enabled</span>'
    : '<span class="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300">Disabled</span>';
  var cat = rule.category ? '<span class="text-[10px] ml-1 px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300">' + esc(prFailCategoryLabel(rule.category)) + '</span>' : '';
  var notes = rule.notes ? '<div class="text-[11px] text-gray-500 dark:text-gray-400 mt-0.5">' + esc(rule.notes) + '</div>' : '';
  return '<div class="togen-inset border border-gray-200 dark:border-gray-700 rounded-lg p-3" data-ruleid="' + esc(rule.id) + '">' +
    '<div class="flex items-start justify-between gap-2">' +
      '<div class="min-w-0">' +
        '<div class="text-sm font-medium text-gray-800 dark:text-gray-100">' + esc(typeLbl) + ' ' + enBadge + cat + '</div>' +
        '<div class="text-[11px] font-mono text-gray-600 dark:text-gray-300 break-all mt-0.5">' + esc(rule.match) + ' <span class="text-gray-400">&rarr;</span> ' + esc(rule.replacement) + '</div>' +
        notes +
        '<div class="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">' + esc(rule.created_by || '') + (rule.updated_at ? ' · ' + esc(rule.updated_at) : '') + '</div>' +
      '</div>' +
      '<div class="flex flex-col gap-1 shrink-0">' +
        '<button type="button" onclick="prToggleRule(\'' + esc(rule.id) + '\',' + (rule.enabled ? 'false' : 'true') + ')" class="text-[11px] px-2 py-0.5 rounded border ' + (rule.enabled ? 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300' : 'border-green-400 dark:border-green-600 text-green-700 dark:text-green-300') + '">' + (rule.enabled ? 'Disable' : 'Enable') + '</button>' +
        '<button type="button" onclick="prDeleteRule(\'' + esc(rule.id) + '\')" class="text-[11px] px-2 py-0.5 rounded border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400">Delete</button>' +
      '</div>' +
    '</div>' +
  '</div>';
}
function prodRunAddRule() {
  var st = document.getElementById('prodrun-rule-status');
  var body = {
    type: document.getElementById('prodrun-rule-type').value,
    category: document.getElementById('prodrun-rule-category').value,
    match: document.getElementById('prodrun-rule-match').value,
    replacement: document.getElementById('prodrun-rule-replacement').value,
    notes: document.getElementById('prodrun-rule-notes').value,
    enabled: false
  };
  st.textContent = 'Saving…'; st.className = 'text-xs text-gray-500 dark:text-gray-400';
  fetch('/prodrun-autotune-rule', {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) { st.textContent = 'Error: ' + ((res.d && res.d.error) || 'save failed'); st.className = 'text-xs text-red-600 dark:text-red-400'; return; }
      st.textContent = '✓ Added (disabled)'; st.className = 'text-xs text-green-700 dark:text-green-400';
      document.getElementById('prodrun-rule-match').value = '';
      document.getElementById('prodrun-rule-replacement').value = '';
      document.getElementById('prodrun-rule-notes').value = '';
      prodRunLoadRules();
    }).catch(function(e){ st.textContent = 'Failed: ' + e.message; st.className = 'text-xs text-red-600 dark:text-red-400'; });
}
function prToggleRule(id, enable) {
  // Re-send the rule's current fields with the flipped enabled flag.
  fetch('/prodrun-autotune-rules').then(function(r){ return r.json(); }).then(function(d){
    var rule = ((d && d.rules) || []).find(function(x){ return x.id === id; });
    if (!rule) return;
    fetch('/prodrun-autotune-rule/' + encodeURIComponent(id), {method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({type: rule.type, category: rule.category, match: rule.match, replacement: rule.replacement, notes: rule.notes, enabled: enable})})
      .then(function(){ prodRunLoadRules(); });
  });
}
function prDeleteRule(id) {
  fetch('/prodrun-autotune-rule/' + encodeURIComponent(id), {method:'DELETE'})
    .then(function(){ prodRunLoadRules(); });
}

// 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');
  // DVI-959: red for errored/failed runs, yellow for human-corrected runs.
  var hasError = run.status === 'error' || (run.failed_downloads > 0);
  var hasCorr = run.corrected_count > 0;
  var borderCls = hasError ? 'border-red-300 dark:border-red-700'
                : hasCorr ? 'border-yellow-300 dark:border-yellow-700'
                : 'border-gray-200 dark:border-gray-700';
  var badges = '';
  function _prBadge(txt, cls) {
    return '<span class="text-xs font-medium px-2 py-0.5 rounded-full ' + cls + '">' + esc(txt) + '</span>';
  }
  if (run.status === 'error') badges += _prBadge('Run failed', 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300');
  if (run.failed_downloads) badges += _prBadge(run.failed_downloads + ' failed', 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300');
  if (run.corrected_count) badges += _prBadge(run.corrected_count + ' corrected', 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300');
  return '<button onclick="' + handlerFn + '(\'' + esc(run.job_id) + '\')" ' +
    'class="w-full text-left border ' + borderCls + ' 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>' +
        '<div class="flex items-center gap-2 flex-wrap justify-end">' +
          (badges ? '<div class="flex items-center gap-1 flex-wrap">' + badges + '</div>' : '') +
          '<span class="text-xs text-gray-500 dark:text-gray-400">' + esc(meta.join(' · ')) + '</span>' +
        '</div>' +
      '</div>' +
    '</button>';
}

// Friendly detail chips for a history entry (DVI-1040). Renders known keys
// (status, dept, approver notes, rejection reason, WO type) as small badges
// instead of leaving the `detail` JSON hidden. Unknown detail shapes render
// nothing, so this stays safe across all /history/<tool> tabs.
var _histStatusLabels = {'new': 'Reopened', in_progress: 'Accepted', completed: 'Completed', cancelled: 'Cancelled'};
var _histWorTypeLabels = {maintenance: 'Maintenance', safety: 'Safety', mobilemachine: 'Mobile Machine', preventive: 'Preventative'};
function _histChip(text, cls) {
  return '<span class="inline-flex items-center text-xs font-medium px-1.5 py-0.5 rounded ' + cls + '">' + esc(text) + '</span>';
}
function histDetailChipsHtml(entry) {
  var d = entry && entry.detail;
  if (!d || typeof d !== 'object') return '';
  var chips = [];
  if (d.new_status) {
    chips.push(_histChip(_histStatusLabels[d.new_status] || d.new_status,
      'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'));
  }
  if (d.dept) {
    var deptLbl = (typeof _RTS_DEPT_LABELS_SHORT !== 'undefined' && _RTS_DEPT_LABELS_SHORT[d.dept]) || d.dept;
    chips.push(_histChip(deptLbl, 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'));
  }
  if (d.evh_complete) {
    chips.push(_histChip('EVH complete', 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'));
  }
  if (d.notes) {
    chips.push(_histChip('Note: ' + (d.notes.length > 60 ? d.notes.substring(0, 60) + '…' : d.notes),
      'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300'));
  }
  if (d.reason) {
    chips.push(_histChip('Reason: ' + (d.reason.length > 60 ? d.reason.substring(0, 60) + '…' : d.reason),
      'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'));
  }
  if (d.wor_type && _histWorTypeLabels[d.wor_type]) {
    chips.push(_histChip(_histWorTypeLabels[d.wor_type], 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'));
  }
  if (!chips.length) return '';
  return '<div class="flex flex-wrap gap-1 mt-1.5">' + chips.join('') + '</div>';
}

// DVI-1084: status badge for WO history rows (test / moved-to-test / deleted).
// Driven by the transition marker's detail._status_change, falling back to a
// T-prefixed test-WO ref, so test/converted/deleted WOs are clearly labeled.
function worHistStatusBadge(entry) {
  var sc = entry.detail && entry.detail._status_change;
  var label = '', cls = '';
  if (sc === 'deleted') {
    label = 'DELETED'; cls = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
  } else if (sc === 'moved_to_test') {
    label = 'MOVED TO TEST'; cls = 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300';
  } else if (sc === 'moved_to_prod') {
    label = 'MOVED TO PROD'; cls = 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300';
  } else if (sc === 'restored') {
    label = 'RESTORED'; cls = 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300';
  } else {
    var ref = entry.ref || (entry.detail && entry.detail.filename) || '';
    if (/^T(WOR|SWO|MMWO|PMWO)_/.test(ref)) {
      label = 'TEST'; cls = 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300';
    }
  }
  if (!label) return '';
  return '<span class="ml-2 align-middle inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ' + cls + '">' + label + '</span>';
}

// Shared renderer for generic /history/<tool> entries (DVI-789).
function auditorHistRowHtml(entry) {
  var when = entry.created_at ? _fmtDateTime(entry.created_at) : '';  // DVI-1364 P2
  var actor = entry.actor || '—';
  var action = entry.action || '';
  var summary = entry.summary || entry.ref || '';
  var statusBadge = (entry.tool === 'wor-admin') ? worHistStatusBadge(entry) : '';
  var chips = histDetailChipsHtml(entry);
  // Work-order rows get a per-WO drill-down. `created` events carry the
  // filename in detail; all others store it as the ref (DVI-1040).
  var auditBtn = '';
  if (entry.tool === 'wor-admin') {
    var woFile = (entry.detail && entry.detail.filename) ? entry.detail.filename : entry.ref;
    if (woFile) {
      auditBtn = '<button onclick="worAuditOpen(\'' + esc(woFile).replace(/'/g, "\\'") +
        '\')" class="flex-shrink-0 text-xs font-medium text-togen-teal hover:text-togen-teal-dark underline cursor-pointer">Audit trail</button>';
    }
  }
  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) + statusBadge + '</p>' +
        '<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">' + esc(actor) +
          (action ? ' &middot; ' + esc(action) : '') + '</p>' +
      '</div>' +
      '<div class="flex items-center gap-3 flex-shrink-0">' +
        auditBtn +
        '<span class="text-xs text-gray-400 dark:text-gray-500">' + esc(when) + '</span>' +
      '</div>' +
    '</div>' +
    chips +
  '</div>';
}

// ----- Per-WO audit drill-down popup (DVI-1040) -----
var _worAuditFile = '';
var _worAuditPdfKind = '';
var _worAuditPhotos = [];   // image photos in the current WO, for lightbox nav (DVI-1069)
var _worAuditLbIndex = -1;

function worAuditClose() {
  var o = document.getElementById('wor-audit-overlay');
  if (o) o.classList.add('hidden');
  _worAuditPdfKind = '';
}

function worAuditOpen(file) {
  var o = document.getElementById('wor-audit-overlay');
  if (!o) return;
  _worAuditFile = file;
  _worAuditPdfKind = '';
  o.classList.remove('hidden');
  var body = document.getElementById('wor-audit-body');
  var titleEl = document.getElementById('wor-audit-title');
  if (titleEl) titleEl.textContent = 'Work Order audit trail';
  body.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>';
  fetch('/history/wor/' + encodeURIComponent(file))
    .then(function(r) { return r.json().then(function(d) { return {ok: r.ok, d: d}; }); })
    .then(function(res) {
      if (!res.ok) {
        body.innerHTML = '<p class="text-sm text-red-600 dark:text-red-400">' + esc((res.d && res.d.error) || 'Failed to load.') + '</p>';
        return;
      }
      worAuditRender(res.d);
    })
    .catch(function(e) { body.innerHTML = '<p class="text-sm text-red-600 dark:text-red-400">Failed: ' + esc(e.message) + '</p>'; });
}

function worAuditTogglePdf(kind) {
  var wrap = document.getElementById('wor-audit-pdf-wrap');
  var embed = document.getElementById('wor-audit-pdf-embed');
  if (!wrap || !embed) return;
  if (_worAuditPdfKind === kind) {
    wrap.classList.add('hidden');
    embed.src = '';
    _worAuditPdfKind = '';
    return;
  }
  var url = (kind === 'evh' ? '/wor-evh-pdf?file=' : '/wor-preview?file=') + encodeURIComponent(_worAuditFile);
  embed.src = url;
  wrap.classList.remove('hidden');
  _worAuditPdfKind = kind;
}

// Trigger a browser download for an attachment URL without navigating away.
function _auditorDownload(url) {
  var a = document.createElement('a');
  a.href = url;
  a.rel = 'noopener';
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
}

function worAuditExport(kind) {
  if (!_worAuditFile) return;
  var base = '/history/wor/' + encodeURIComponent(_worAuditFile) + '/export.';
  _auditorDownload(base + (kind === 'pdf' ? 'pdf' : 'csv'));
}

// ----- Photo gallery + lightbox (DVI-1069) -----
function worAuditIsImageFile(name) {
  return /\.(jpe?g|png|heic|webp|gif|bmp)$/i.test(name || '');
}

// Open the in-popup lightbox on image index `i` of _worAuditPhotos.
function worAuditLightbox(i) {
  if (!_worAuditPhotos.length || i < 0 || i >= _worAuditPhotos.length) return;
  _worAuditLbIndex = i;
  var ov = document.getElementById('wor-audit-lightbox');
  if (!ov) return;
  var p = _worAuditPhotos[i];
  var full = '/wor-photo?file=' + encodeURIComponent(_worAuditFile) + '&name=' + encodeURIComponent(p.file);
  var img = document.getElementById('wor-audit-lb-img');
  if (img) { img.src = full; img.alt = p.label || 'Work order photo'; }
  var badge = document.getElementById('wor-audit-lb-badge');
  if (badge) badge.textContent = p.label || '';
  var when = p.uploaded_at ? (function(){ try { return new Date(p.uploaded_at).toLocaleString(); } catch(e){ return p.uploaded_at; } })() : '';
  var by = [p.uploaded_by, when].filter(Boolean).join(' · ');
  var cap = document.getElementById('wor-audit-lb-caption');
  if (cap) {
    cap.innerHTML = (p.caption ? '<span class="block text-sm text-white mb-0.5">' + esc(p.caption) + '</span>' : '') +
      (by ? '<span class="block text-xs text-gray-300">' + esc(by) + '</span>' : '');
  }
  var counter = document.getElementById('wor-audit-lb-counter');
  if (counter) counter.textContent = (i + 1) + ' / ' + _worAuditPhotos.length;
  var dl = document.getElementById('wor-audit-lb-download');
  if (dl) dl.href = full;
  var nav = document.getElementById('wor-audit-lb-nav');
  if (nav) nav.classList.toggle('hidden', _worAuditPhotos.length < 2);
  ov.classList.remove('hidden');
}

function worAuditLightboxNav(delta) {
  if (!_worAuditPhotos.length) return;
  var n = _worAuditPhotos.length;
  worAuditLightbox((_worAuditLbIndex + delta + n) % n);
}

function worAuditLightboxClose() {
  var ov = document.getElementById('wor-audit-lightbox');
  if (ov) ov.classList.add('hidden');
  var img = document.getElementById('wor-audit-lb-img');
  if (img) img.src = '';
  _worAuditLbIndex = -1;
}

function worAuditEventLabel(ev) {
  var d = ev.detail || {};
  switch (ev.action) {
    case 'created': return 'Created';
    case 'status_changed': return _histStatusLabels[d.new_status] || 'Status changed';
    case 'evh_approved': return d.evh_complete ? 'RtS approved (finalized)' : 'RtS approved';
    case 'evh_rejected': return 'RtS rejected';
    case 'edited': return 'Edited';
    case 'moved_to_test': return 'Moved to Test WO';
    case 'moved_to_prod': return 'Moved back to Production';
    case 'deleted': return 'Deleted (soft)';
    case 'restored': return 'Restored from deletion';
    default: return ev.action || 'Event';
  }
}

function worAuditRender(d) {
  var titleEl = document.getElementById('wor-audit-title');
  if (titleEl) titleEl.textContent = (d.wo_label || 'Work Order') + ' — audit trail';
  var html = '';

  // Header: title + WO type.
  html += '<div class="mb-4">' +
    '<p class="text-sm font-medium text-gray-800 dark:text-gray-100">' + esc(d.title || d.filename || '') + '</p>' +
    '<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 break-all">' + esc(d.filename || '') +
      (d.wor_type ? ' &middot; ' + esc(_histWorTypeLabels[d.wor_type] || d.wor_type) : '') + '</p>' +
  '</div>';

  // PDF viewers (reuse /wor-preview + /wor-evh-pdf inline) + audit export.
  var pdfBtns = '';
  if (d.has_wo_pdf) {
    pdfBtns += '<button onclick="worAuditTogglePdf(\'wo\')" class="px-3 py-1.5 bg-gray-600 hover:bg-gray-700 text-white text-xs font-medium rounded-lg transition-colors cursor-pointer">View WO PDF</button>';
  }
  if (d.has_evh_pdf) {
    pdfBtns += '<button onclick="worAuditTogglePdf(\'evh\')" class="px-3 py-1.5 bg-togen-teal hover:bg-togen-teal-dark text-white text-xs font-medium rounded-lg transition-colors cursor-pointer">View EVH PDF</button>';
  }
  pdfBtns += '<button onclick="worAuditExport(\'csv\')" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700 text-xs font-medium rounded-lg transition-colors cursor-pointer">Export CSV</button>';
  pdfBtns += '<button onclick="worAuditExport(\'pdf\')" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700 text-xs font-medium rounded-lg transition-colors cursor-pointer" title="Printable audit packet (summary + WO &amp; EVH PDFs)">Export PDF packet</button>';
  if (pdfBtns) {
    html += '<div class="flex flex-wrap gap-2 mb-3">' + pdfBtns + '</div>';
  }
  html += '<div id="wor-audit-pdf-wrap" class="hidden mb-4 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">' +
    '<embed id="wor-audit-pdf-embed" type="application/pdf" class="w-full" style="height:480px;"></div>';

  // Completion details (live sidecar — distinct from recorded events).
  var comp = d.completion || ((d.evh && (d.evh.work_description || d.evh.outstanding_followup)) ? d.evh : null);
  if (comp && (comp.work_description || comp.outstanding_followup)) {
    html += '<div class="mb-4 rounded-xl border border-gray-200 dark:border-gray-700 p-3 bg-gray-50 dark:bg-gray-800/50">' +
      '<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">Completion details <span class="font-normal normal-case">(current)</span></p>';
    if (comp.work_description) {
      html += '<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Description of Work Performed</p>' +
        '<p class="text-sm text-gray-800 dark:text-gray-100 whitespace-pre-wrap mb-2">' + esc(comp.work_description) + '</p>';
    }
    if (comp.outstanding_followup) {
      html += '<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Outstanding Follow-up</p>' +
        '<p class="text-sm text-gray-800 dark:text-gray-100 whitespace-pre-wrap">' + esc(comp.outstanding_followup) + '</p>';
    }
    if (comp.completed_by || comp.completed_at) {
      var cwhen = comp.completed_at ? (_fmtDateTime(comp.completed_at) || comp.completed_at) : '';  // DVI-1364 P2
      html += '<p class="text-xs text-gray-400 dark:text-gray-500 mt-2">' + esc(comp.completed_by || '') + (cwhen ? ' &middot; ' + esc(cwhen) : '') + '</p>';
    }
    html += '</div>';
  }

  // Per-department EVH / Return-to-Service sign-offs (live sidecar).
  var evh = d.evh;
  if (evh && (evh.required_depts || []).length) {
    var evhStatusLabels = {awaiting_approval: 'Awaiting approval', approved: 'Approved', rejected: 'Rejected'};
    html += '<div class="togen-inset mb-4 rounded-xl border border-gray-200 dark:border-gray-700 p-3">' +
      '<div class="flex items-center justify-between mb-2">' +
        '<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Return-to-Service sign-offs <span class="font-normal normal-case">(current)</span></p>' +
        '<span class="text-xs font-medium text-gray-600 dark:text-gray-300">' + esc(evhStatusLabels[evh.status] || evh.status || '') + '</span>' +
      '</div>';
    var signOffs = evh.sign_offs || {};
    (evh.required_depts || []).forEach(function(dept) {
      var so = signOffs[dept];
      var deptLbl = (typeof _RTS_DEPT_LABELS_SHORT !== 'undefined' && _RTS_DEPT_LABELS_SHORT[dept]) || dept;
      var badge, detailLine = '';
      if (so && so.action === 'approve') {
        badge = _histChip('Approved', 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300');
      } else if (so && so.action === 'reject') {
        badge = _histChip('Rejected', 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300');
      } else {
        badge = _histChip('Pending', 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400');
      }
      if (so) {
        var swhen = so.timestamp ? (_fmtDateTime(so.timestamp) || so.timestamp) : '';  // DVI-1364 P2
        detailLine = '<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">' + esc(so.approver_name || so.approver_email || '') +
          (swhen ? ' &middot; ' + esc(swhen) : '') + '</p>';
        if (so.reason) {
          detailLine += '<p class="text-xs text-gray-600 dark:text-gray-300 mt-0.5">' +
            (so.action === 'reject' ? 'Reason: ' : 'Note: ') + esc(so.reason) + '</p>';
        }
      }
      html += '<div class="py-1.5 border-t border-gray-100 dark:border-gray-800 first:border-t-0">' +
        '<div class="flex items-center gap-2"><span class="text-sm text-gray-800 dark:text-gray-100 w-28 flex-shrink-0">' + esc(deptLbl) + '</span>' + badge + '</div>' +
        detailLine +
      '</div>';
    });
    if (evh.released_to_production_at) {
      var rwhen = _fmtDateTime(evh.released_to_production_at) || evh.released_to_production_at;  // DVI-1364 P2
      html += '<p class="text-xs text-green-700 dark:text-green-400 mt-2">Released to production &middot; ' + esc(rwhen) + '</p>';
    }
    html += '</div>';
  }

  // Photo gallery (DVI-1069): grouped thumbnails + in-popup lightbox for any WO
  // with attached / on-Completion / on-Rejection photos. Rebuilds the flat image
  // index each render so the lightbox can navigate prev/next.
  _worAuditPhotos = [];
  var allPhotos = d.photos || [];
  if (allPhotos.length) {
    var ctxLabels = { attachment: 'Attachments', rts_approval: 'Added on Completion', rts_rejection: 'Added on Rejection' };
    var ctxOrder = ['attachment', 'rts_approval', 'rts_rejection'];
    var groups = {};
    allPhotos.forEach(function(p) {
      var c = (p && p.context) || 'attachment';
      (groups[c] = groups[c] || []).push(p);
    });
    var orderedCtx = ctxOrder.concat(Object.keys(groups).filter(function(c){ return ctxOrder.indexOf(c) < 0; }));
    var gal = '<div class="togen-inset mb-4 rounded-xl border border-gray-200 dark:border-gray-700 p-3">' +
      '<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">Photos <span class="font-normal normal-case">(' + allPhotos.length + ')</span></p>';
    orderedCtx.forEach(function(c) {
      var list = groups[c];
      if (!list || !list.length) return;
      var label = ctxLabels[c] || c;
      gal += '<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">' + esc(label) + ' (' + list.length + ')</p>' +
        '<div class="flex flex-wrap gap-2 mb-2">';
      list.forEach(function(p) {
        var full = '/wor-photo?file=' + encodeURIComponent(d.filename) + '&name=' + encodeURIComponent(p.file);
        var meta = label + (p.uploaded_by ? (' — ' + p.uploaded_by) : '');
        if (worAuditIsImageFile(p.file)) {
          var idx = _worAuditPhotos.length;
          _worAuditPhotos.push({ file: p.file, label: label, caption: p.caption || '', uploaded_by: p.uploaded_by || '', uploaded_at: p.uploaded_at || '' });
          gal += '<button type="button" onclick="worAuditLightbox(' + idx + ')" title="' + esc(meta) + '" ' +
            'class="block w-24 h-24 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 hover:ring-2 hover:ring-togen-teal transition-all cursor-pointer">' +
            '<img src="' + full + '&thumb=1" alt="' + esc(meta) + '" class="w-full h-full object-cover" loading="lazy">' +
            '</button>';
        } else {
          var flabel = (p.file || '').replace(/^\d{8}_\d{6}_\d{1,6}_/, '');
          gal += '<a href="' + full + '" target="_blank" rel="noopener" title="' + esc(meta) + '" ' +
            'class="w-24 h-24 rounded-lg border border-gray-200 dark:border-gray-700 hover:ring-2 hover:ring-togen-teal transition-all flex flex-col items-center justify-center p-2 bg-gray-50 dark:bg-gray-800 text-center">' +
            '<svg class="h-8 w-8 text-gray-400 dark:text-gray-500 mb-1" 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-[10px] text-gray-600 dark:text-gray-300 truncate w-full">' + esc(flabel) + '</span>' +
            '</a>';
        }
      });
      gal += '</div>';
    });
    gal += '</div>';
    html += gal;
  }

  // Recorded event timeline (from history.db).
  html += '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 p-3">' +
    '<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">Event timeline <span class="font-normal normal-case">(recorded)</span></p>';
  var events = d.events || [];
  if (!events.length) {
    html += '<p class="text-sm text-gray-500 dark:text-gray-400">No recorded events for this work order.</p>';
  } else {
    events.forEach(function(ev) {
      var ewhen = ev.created_at ? (_fmtDateTime(ev.created_at) || ev.created_at) : '';  // DVI-1364 P2
      html += '<div class="py-2 border-t border-gray-100 dark:border-gray-800 first:border-t-0">' +
        '<div class="flex items-center justify-between gap-3 flex-wrap">' +
          '<span class="text-sm font-medium text-gray-800 dark:text-gray-100">' + esc(worAuditEventLabel(ev)) + '</span>' +
          '<span class="text-xs text-gray-400 dark:text-gray-500">' + esc(ewhen) + '</span>' +
        '</div>' +
        '<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">' + esc(ev.actor || '—') + '</p>' +
        histDetailChipsHtml(ev) +
      '</div>';
    });
  }
  html += '</div>';

  document.getElementById('wor-audit-body').innerHTML = html;
}

// 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; });
}

// DVI-959 P3: resolve workflow. PR_IS_ADMIN gates the Auditor resolve controls;
// _prResolutions holds the current run's resolutions keyed by failure key.
var PR_IS_ADMIN = {% if is_togen_admin %}true{% else %}false{% endif %};
var _prResolutions = {};
var _prCurrentJobId = '';

function prFailKey(f) {
  return String(f.item_idx) + ':' + (f.type || '') + ':' + (f.url || '');
}

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>';
  _prCurrentJobId = jobId;
  Promise.all([
    fetch('/prodrun-status?job_id=' + encodeURIComponent(jobId)).then(function(r){ return r.json(); }),
    fetch('/prodrun-resolutions?job_id=' + encodeURIComponent(jobId)).then(function(r){ return r.json(); }).catch(function(){ return {}; })
  ]).then(function(res) {
    _prResolutions = (res[1] && res[1].resolutions) || {};
    prodRunRenderHistoryDetail(res[0]);
  }).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-2">';
    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="text-xs">' +
          '<div class="flex items-start gap-2">' +
            '<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>' + prFailReasonHtml(f) + prResolveControlsHtml(f) +
        '</div>';
    });
    html += '</div></div>';
  }
  html += prCorrectionsHtml(data.corrections);
  el.innerHTML = html;
}

// DVI-959 P3: per-failure resolve controls. Shows the recorded resolution (green)
// if resolved; otherwise, for admins (Auditor), offers "Ask Assistant" (seeds the
// Assistant with the failure) and "Mark resolved" (records who/how).
function prResolveControlsHtml(f) {
  var key = prFailKey(f);
  var rec = _prResolutions[key];
  if (rec) {
    var src = rec.source === 'assistant' ? ' · via Assistant' : '';
    // DVI-963 O3.P1: let admins turn a recorded resolution into a saved auto-tuning rule.
    var mkRule = PR_IS_ADMIN
      ? '<button type="button" onclick="prRuleFromResolution(this)" data-prrec="' + encodeURIComponent(JSON.stringify(rec)) + '" class="mt-1 text-[11px] px-2 py-0.5 rounded border border-togen-teal text-togen-teal hover:bg-togen-teal/10">Create auto-tuning rule</button>'
      : '';
    return '<div class="mt-1 ml-1 border border-green-300 dark:border-green-700 rounded-lg p-2 bg-green-50 dark:bg-green-900/20">' +
      '<div class="text-[11px] font-semibold text-green-700 dark:text-green-300">Resolved' +
        ' · ' + esc(rec.resolved_by || '') + (rec.resolved_at ? ' · ' + esc(rec.resolved_at) : '') + src + '</div>' +
      '<div class="text-[11px] text-green-800 dark:text-green-200 break-words mt-0.5">' + esc(rec.resolution || '') + '</div>' +
      mkRule +
    '</div>';
  }
  if (!PR_IS_ADMIN) return '';
  var fj = encodeURIComponent(JSON.stringify({item_idx: f.item_idx, type: f.type, doc_num: f.doc_num, category: f.category, url: f.url, reason: f.reason}));
  return '<div class="mt-1 ml-1 flex flex-wrap gap-2" data-prfail="' + fj + '">' +
    '<button type="button" onclick="prAskAssistantResolve(this)" class="text-[11px] px-2 py-0.5 rounded border border-indigo-300 dark:border-indigo-600 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-50 dark:hover:bg-indigo-900/30">Ask Assistant</button>' +
    '<button type="button" onclick="prOpenResolveForm(this)" class="text-[11px] px-2 py-0.5 rounded border border-green-400 dark:border-green-600 text-green-700 dark:text-green-300 hover:bg-green-50 dark:hover:bg-green-900/30">Mark resolved</button>' +
  '</div>';
}

function _prFailFromBtn(btn) {
  var holder = btn.closest('[data-prfail]');
  if (!holder) return null;
  try { return JSON.parse(decodeURIComponent(holder.getAttribute('data-prfail'))); }
  catch (e) { return null; }
}

// Seed the existing Assistant with the failure context (mirrors DVI-662).
function _prSeedAssistant(prompt) {
  if (window.matchMedia('(min-width: 768px)').matches) {
    if (typeof assistantOpenDesktop === 'function') assistantOpenDesktop(true);
    var input = document.getElementById('topbar-field-input');
    if (input) { input.value = prompt; if (typeof topbarFieldAutoGrow === 'function') topbarFieldAutoGrow(); }
    if (typeof assistantDesktopSend === 'function') assistantDesktopSend(prompt);
  } else {
    if (typeof assistantOpenMobile === 'function') assistantOpenMobile(true);
    var minput = document.getElementById('assistant-mobile-input');
    if (minput) minput.value = prompt;
    if (typeof assistantMobileSend === 'function') assistantMobileSend();
  }
}

function prAskAssistantResolve(btn) {
  var f = _prFailFromBtn(btn);
  if (!f) return;
  var asstBtn = document.getElementById('assistant-btn');
  if (!asstBtn || asstBtn.dataset.available !== 'true') {
    if (typeof assistantIconClick === 'function') assistantIconClick();
    return;
  }
  fetch('/prodrun-resolve-prompt', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(f)
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.prompt) _prSeedAssistant(d.prompt);
  }).catch(function(){});
}

function prOpenResolveForm(btn) {
  var holder = btn.closest('[data-prfail]');
  if (!holder || holder.querySelector('.pr-resolve-form')) return;
  var form = document.createElement('div');
  form.className = 'pr-resolve-form w-full mt-1';
  form.innerHTML =
    '<textarea rows="2" placeholder="How was this resolved? (e.g. provided correct file, fixed URL, updated allowlist)" ' +
      'class="w-full text-[11px] rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"></textarea>' +
    '<label class="inline-flex items-center gap-1 text-[11px] text-gray-500 dark:text-gray-400 mt-1">' +
      '<input type="checkbox" class="pr-resolve-asst rounded border-gray-300 dark:border-gray-600"> Resolution came from the Assistant</label>' +
    '<div class="flex gap-2 mt-1">' +
      '<button type="button" onclick="prSubmitResolve(this)" class="text-[11px] px-2 py-0.5 rounded bg-green-600 text-white hover:bg-green-700">Save</button>' +
      '<button type="button" onclick="this.closest(\'.pr-resolve-form\').remove()" class="text-[11px] px-2 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300">Cancel</button>' +
      '<span class="pr-resolve-status text-[11px] text-gray-500 dark:text-gray-400 self-center"></span>' +
    '</div>';
  holder.appendChild(form);
}

function prSubmitResolve(btn) {
  var form = btn.closest('.pr-resolve-form');
  var holder = btn.closest('[data-prfail]');
  if (!form || !holder) return;
  var f = _prFailFromBtn(btn);
  if (!f) return;
  var note = (form.querySelector('textarea').value || '').trim();
  var statusEl = form.querySelector('.pr-resolve-status');
  if (!note) { statusEl.textContent = 'Enter a resolution note.'; return; }
  var fromAsst = form.querySelector('.pr-resolve-asst').checked;
  statusEl.textContent = 'Saving…';
  fetch('/prodrun-resolve-failure', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      job_id: _prCurrentJobId, item_idx: f.item_idx, type: f.type, doc_num: f.doc_num,
      category: f.category, url: f.url, reason: f.reason,
      resolution: note, source: fromAsst ? 'assistant' : 'manual'
    })
  }).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok || !res.d.record) { statusEl.textContent = res.d.error || 'Save failed.'; return; }
      _prResolutions[res.d.record.key] = res.d.record;
      holder.outerHTML = prResolveControlsHtml(f);
    }).catch(function(e){ statusEl.textContent = 'Failed: ' + e.message; });
}

// DVI-963 O3.P1: seed a saved auto-tuning rule from a recorded resolution. Opens
// an inline form (match prefilled with the failing URL) that creates a DISABLED
// rule — inert until a later phase applies it.
function prRuleFromResolution(btn) {
  if (btn.parentElement.querySelector('.pr-rule-form')) return;
  var rec = {};
  try { rec = JSON.parse(decodeURIComponent(btn.getAttribute('data-prrec'))); } catch (e) {}
  var suggestType = (rec.category === 'outside_approved_domain') ? 'domain_rewrite' : 'source_substitution';
  var form = document.createElement('div');
  form.className = 'pr-rule-form w-full mt-1 space-y-1';
  form.innerHTML =
    '<select class="pr-rule-type w-full text-[11px] rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200">' +
      '<option value="domain_rewrite"' + (suggestType==='domain_rewrite'?' selected':'') + '>Domain rewrite</option>' +
      '<option value="path_remap">Path remap</option>' +
      '<option value="source_substitution"' + (suggestType==='source_substitution'?' selected':'') + '>Source substitution</option>' +
    '</select>' +
    '<input class="pr-rule-match w-full text-[11px] font-mono rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" placeholder="match (from)" value="' + esc(rec.url || '') + '">' +
    '<input class="pr-rule-repl w-full text-[11px] font-mono rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200" placeholder="replacement (to)">' +
    '<div class="flex gap-2">' +
      '<button type="button" onclick="prSaveRuleFromResolution(this)" class="text-[11px] px-2 py-0.5 rounded bg-togen-teal text-white hover:bg-togen-teal-dark">Save rule (disabled)</button>' +
      '<button type="button" onclick="this.closest(\'.pr-rule-form\').remove()" class="text-[11px] px-2 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300">Cancel</button>' +
      '<span class="pr-rule-status text-[11px] text-gray-500 dark:text-gray-400 self-center"></span>' +
    '</div>';
  form.dataset.jobid = _prCurrentJobId;
  form.dataset.key = rec.key || '';
  form.dataset.category = rec.category || '';
  form.dataset.notes = rec.resolution || '';
  btn.parentElement.appendChild(form);
}
function prSaveRuleFromResolution(saveBtn) {
  var form = saveBtn.closest('.pr-rule-form');
  var st = form.querySelector('.pr-rule-status');
  var body = {
    type: form.querySelector('.pr-rule-type').value,
    match: form.querySelector('.pr-rule-match').value,
    replacement: form.querySelector('.pr-rule-repl').value,
    category: form.dataset.category,
    notes: form.dataset.notes,
    source_job_id: form.dataset.jobid,
    source_key: form.dataset.key,
    enabled: false
  };
  st.textContent = 'Saving…';
  fetch('/prodrun-autotune-rule', {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) { st.textContent = (res.d && res.d.error) || 'Save failed.'; return; }
      st.textContent = '✓ Rule saved (disabled) — see Settings › Auto-tuning rules';
      st.className = 'pr-rule-status text-[11px] text-green-700 dark:text-green-400 self-center';
    }).catch(function(e){ st.textContent = 'Failed: ' + e.message; });
}

// DVI-959: friendly labels for failure categories + reason line.
function prFailCategoryLabel(cat) {
  var map = {
    file_not_found: 'File not found',
    outside_approved_domain: 'Outside approved domains/paths',
    not_pdf: 'Not a PDF',
    http_error: 'HTTP error',
    sharepoint_auth: 'SharePoint access denied',
    network_error: 'Network error',
    merge_error: 'Merge error',
    unknown: 'Unknown error'
  };
  return map[cat] || (cat || 'Unknown error');
}
function prFailReasonHtml(f) {
  if (!f || (!f.category && !f.reason)) return '';
  var cat = f.category || 'unknown';
  var badge = '<span class="inline-block text-[10px] font-semibold px-1.5 py-0.5 rounded bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300">' + esc(prFailCategoryLabel(cat)) + '</span>';
  var reason = f.reason ? '<span class="text-red-500 dark:text-red-400 ml-2">' + esc(f.reason) + '</span>' : '';
  return '<div class="mt-0.5 ml-1">' + badge + reason + '</div>';
}
// Yellow "human corrected" section shared by history + live results views.
function prCorrectionsHtml(corrections) {
  if (!corrections || !corrections.length) return '';
  var h = '<div class="border border-yellow-300 dark:border-yellow-700 rounded-xl p-4 bg-yellow-50 dark:bg-yellow-900/20 mt-2">' +
    '<p class="font-semibold text-yellow-800 dark:text-yellow-300 mb-2">' + corrections.length + ' human correction(s)</p>' +
    '<div class="space-y-1">';
  corrections.forEach(function(c) {
    var typeLabel = c.type === 'drawing' ? 'Drawing' : 'Label';
    var how = c.correction_type === 'removed' ? 'removed'
            : c.correction_type === 'file' ? ('provided file' + (c.value ? ': ' + c.value : ''))
            : c.correction_type === 'url' ? ('provided URL' + (c.value ? ': ' + c.value : ''))
            : (c.correction_type || 'corrected');
    var who = c.applied_by ? (' by ' + c.applied_by) : '';
    var when = c.applied_at ? (' · ' + c.applied_at) : '';
    h += '<div class="text-xs text-yellow-800 dark:text-yellow-300 break-all">' +
      '<span class="font-medium">' + typeLabel + ' #' + esc(String(c.item_idx)) + '</span> — ' + esc(how) + esc(who) + esc(when) +
      '</div>';
  });
  h += '</div></div>';
  return h;
}

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="togen-control 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="togen-control 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-control 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-control 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-2 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="text-xs">' +
          '<div class="flex items-start gap-2">' +
            '<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>' + prFailReasonHtml(f) +
        '</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;
  }
  html += prCorrectionsHtml(data.corrections);

  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 (DVI-856). Filtered by admin per-bot
// visibility (Togen Admins bypass) and the user's own per-bot toggle, then
// ordered by the user's personal order (falling back to the admin order).
function _availableBots() {
  var byId = {};
  BOTS.forEach(function(b){ byId[b.id] = b; });
  var userOrder = _userSettings.bot_order;
  var order = (userOrder && userOrder.length)
    ? userOrder.slice()
    : ((_botOrder && _botOrder.length) ? _botOrder.slice() : []);
  BOTS.forEach(function(b){ if (order.indexOf(b.id) === -1) order.push(b.id); });
  var userVis = _userSettings.bot_visibility || {};
  var out = [];
  order.forEach(function(id){
    var b = byId[id];
    if (!b) return;
    if (!_isToGenAdmin && _botVisibility[id] === false) return;  // admin-disabled
    if (userVis[id] === false) return;                            // user-hidden
    out.push(b);
  });
  return out;
}

// 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();
  winstonLoadQuotePaths();
}

// DVI-842: read-only mirror of the 4-field quote-path model in Winston Settings.
function winstonLoadQuotePaths() {
  fetch('/winston/quote-paths')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      var map = {
        'winston-qp-shared-takeoff':    d.shared_quote_takeoff,
        'winston-qp-shared-completed':  d.shared_completed,
        'winston-qp-personal-takeoff':  d.personal_quote_takeoff,
        'winston-qp-personal-completed': d.personal_completed
      };
      Object.keys(map).forEach(function(id){
        var el = document.getElementById(id);
        if (el) el.value = map[id] || '';
      });
    })
    .catch(function(){});
}

// DVI-842: reusable OneDrive/SharePoint folder picker.
// odPickerOpen(scope, targetInputId) — scope 'shared'|'personal'.
var _odPicker = { scope: 'personal', target: null, path: '' };

function odPickerOpen(scope, targetInputId) {
  _odPicker.scope = (scope === 'shared') ? 'shared' : 'personal';
  _odPicker.target = targetInputId;
  var input = document.getElementById(targetInputId);
  // Start at the folder currently in the field if it looks like a path, else root.
  var start = (input && input.value && input.value.indexOf('http') !== 0) ? input.value.trim() : '';
  _odPicker.path = start;
  var title = document.getElementById('od-picker-title');
  if (title) title.textContent = (_odPicker.scope === 'shared') ? 'Browse Shared (SharePoint)' : 'Browse Your OneDrive';
  var ov = document.getElementById('od-picker-overlay');
  if (ov) ov.classList.remove('hidden');
  odPickerLoad(_odPicker.path);
}

function odPickerClose() {
  var ov = document.getElementById('od-picker-overlay');
  if (ov) ov.classList.add('hidden');
  _odPicker.target = null;
}

function odPickerLoad(path) {
  _odPicker.path = path || '';
  var loading = document.getElementById('od-picker-loading');
  var errEl = document.getElementById('od-picker-error');
  var empty = document.getElementById('od-picker-empty');
  var list = document.getElementById('od-picker-list');
  var cur = document.getElementById('od-picker-current');
  if (errEl) errEl.classList.add('hidden');
  if (empty) empty.classList.add('hidden');
  if (list) list.innerHTML = '';
  if (loading) loading.classList.remove('hidden');
  if (cur) cur.textContent = _odPicker.path || 'Home';
  var qs = '?scope=' + encodeURIComponent(_odPicker.scope) + '&path=' + encodeURIComponent(_odPicker.path);
  fetch('/documents/onedrive-browse' + qs)
    .then(function(r){ return r.json().then(function(j){ return { ok: r.ok, body: j }; }); })
    .then(function(res){
      if (loading) loading.classList.add('hidden');
      if (!res.ok) {
        if (errEl) { errEl.textContent = (res.body && res.body.error) || 'Could not load folder.'; errEl.classList.remove('hidden'); }
        return;
      }
      var d = res.body || {};
      odPickerRenderBreadcrumb(d.breadcrumb || [{ name: 'Home', path: '' }]);
      var folders = d.folders || [];
      if (!folders.length) { if (empty) empty.classList.remove('hidden'); return; }
      folders.forEach(function(f){
        var li = document.createElement('li');
        var btn = document.createElement('button');
        btn.type = 'button';
        btn.className = 'w-full flex items-center gap-2 px-3 py-2 text-sm text-left rounded-lg text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer';
        btn.onclick = function(){ odPickerLoad(f.path); };
        btn.innerHTML = '<svg class="h-4 w-4 text-togen-teal 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>';
        var span = document.createElement('span');
        span.className = 'truncate';
        span.textContent = f.name;
        btn.appendChild(span);
        li.appendChild(btn);
        if (list) list.appendChild(li);
      });
    })
    .catch(function(e){
      if (loading) loading.classList.add('hidden');
      if (errEl) { errEl.textContent = 'Error: ' + e.message; errEl.classList.remove('hidden'); }
    });
}

function odPickerRenderBreadcrumb(crumbs) {
  var nav = document.getElementById('od-picker-breadcrumb');
  if (!nav) return;
  nav.innerHTML = '';
  crumbs.forEach(function(c, i){
    if (i > 0) {
      var sep = document.createElement('span');
      sep.className = 'text-gray-300 dark:text-gray-600';
      sep.textContent = '/';
      nav.appendChild(sep);
    }
    var b = document.createElement('button');
    b.type = 'button';
    b.className = 'hover:underline cursor-pointer ' + (i === crumbs.length - 1 ? 'font-semibold text-togen-teal' : 'text-gray-600 dark:text-gray-300');
    b.textContent = c.name;
    b.onclick = function(){ odPickerLoad(c.path); };
    nav.appendChild(b);
  });
}

function odPickerSelect() {
  if (_odPicker.target) {
    var input = document.getElementById(_odPicker.target);
    if (input) input.value = _odPicker.path;
  }
  odPickerClose();
}

// DVI-853: administrative sub-tabs grouped under the right-justified "Admin" tab.
var _winstonAdminTabs = ['history','schedules','datelogic','diagnostics','files','email','settings'];
var _winstonLastAdminTab = 'history';

function winstonSwitchTab(tab, skipLoad) {
  // The "Admin" grouping button has no panel of its own — open the last-used
  // (or first) administrative sub-tab instead.
  if (tab === 'admin') tab = _winstonLastAdminTab || 'history';
  _winstonCurrentTab = tab;
  var isAdmin = _winstonAdminTabs.indexOf(tab) !== -1;
  if (isAdmin) _winstonLastAdminTab = tab;

  var tabs = ['status','schedules','history','drafts','diagnostics','datelogic','files','email','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);
  });

  // Reveal the Admin sub-tab bar + highlight the Admin grouping tab when any
  // administrative sub-tab is active.
  var subbar = document.getElementById('winston-admin-subbar');
  if (subbar) subbar.classList.toggle('hidden', !isAdmin);
  var adminBtn = document.getElementById('winston-tab-admin');
  if (adminBtn) {
    adminBtn.classList.toggle('border-togen-teal', isAdmin);
    adminBtn.classList.toggle('text-togen-teal', isAdmin);
    adminBtn.classList.toggle('border-transparent', !isAdmin);
    adminBtn.classList.toggle('text-gray-500', !isAdmin);
    adminBtn.classList.toggle('dark:text-gray-400', !isAdmin);
  }

  if (skipLoad) return;
  if (tab === 'status') winstonLoadStatus();
  if (tab === 'diagnostics') winstonLoadDiagRuns();
  if (tab === 'datelogic') winstonLoadDateLogic();
  if (tab === 'drafts') winstonLoadDrafts();
  if (tab === 'email') winstonLoadEmailTemplate();
  if (tab === 'files') winstonInitFilesTabs(function(){ winstonSwitchFilesTab(_winstonFilesTab); });
}

// --- Files views (DVI-943) ---
var _winstonFilesTab = 'takeoffs';
var _winstonFileViews = ['takeoffs','completed','individual_takeoffs','individual_quotes'];

function winstonSwitchFilesTab(view) {
  if (_winstonFileViews.indexOf(view) === -1) view = 'takeoffs';
  _winstonFilesTab = view;
  _winstonFileViews.forEach(function(v){
    var btn = document.getElementById('winston-files-tab-' + v);
    var active = v === view;
    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);
    }
  });
  var fEl = document.getElementById('winston-files-filter');
  if (fEl) fEl.value = '';  // don't carry a filter across locations (DVI-978)
  winstonLoadFiles(view);
}

function winstonFmtBytes(n) {
  n = Number(n) || 0;
  if (n < 1024) return n + ' B';
  var units = ['KB','MB','GB','TB'], i = -1;
  do { n /= 1024; i++; } while (n >= 1024 && i < units.length - 1);
  return (n >= 10 ? Math.round(n) : n.toFixed(1)) + ' ' + units[i];
}

function winstonFileRowHtml(f, i) {
  var when = '';
  if (f.modified) { try { when = new Date(f.modified).toLocaleString(); } catch(e){ when = f.modified; } }
  var icon = f.is_folder
    ? '<svg class="h-5 w-5 shrink-0 text-togen-teal" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path d="M2 6a2 2 0 012-2h4l2 2h6a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/></svg>'
    : '<svg class="h-5 w-5 shrink-0 text-gray-400" 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>';
  var meta = f.is_folder ? 'Folder' : winstonFmtBytes(f.size);
  // Files are clickable to open the preview pane (mirrors File Browser); folders are not.
  var clickable = !f.is_folder && f.id;
  var rowClass = 'flex items-center gap-3 px-4 py-2.5' +
    (clickable ? ' cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/60 transition-colors' : '');
  var rowAttrs = clickable ? ' id="winston-file-row-' + i + '" onclick="winstonShowFilePreview(' + i + ')"' : '';
  // Open button → the file's SharePoint/OneDrive web location in a new tab
  // (DVI-978 items 4.2 & 5; the only viable path for non-previewable xlsm).
  var openBtn = (!f.is_folder && f.web_url)
    ? '<a href="' + esc(f.web_url) + '" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="Open in SharePoint" ' +
      'class="shrink-0 text-gray-400 hover:text-togen-teal"><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></a>'
    : '';
  return '<div class="' + rowClass + '"' + rowAttrs + '>' +
    icon +
    '<div class="min-w-0 flex-1"><span class="text-sm text-gray-800 dark:text-gray-100 truncate">' + esc(f.name) + '</span></div>' +
    '<span class="shrink-0 text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap w-20 text-right">' + esc(meta) + '</span>' +
    '<span class="shrink-0 text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap hidden sm:inline">' + esc(when) + '</span>' +
    openBtn +
    '</div>';
}

// Gate the individual (SSO) Files tabs by the user's configured Quotes paths
// (DVI-975): each sub-tab is hidden unless the corresponding path is set in
// User Settings → Documents → Quotes.
function winstonInitFilesTabs(cb) {
  fetch('/user-settings/documents').then(function(r){ return r.json(); }).then(function(d){
    d = d || {};
    var hasTakeoff = !!((d.quote_takeoff_location || '').trim());
    var hasQuotes = !!((d.quotes_location || '').trim());
    var tBtn = document.getElementById('winston-files-tab-individual_takeoffs');
    var qBtn = document.getElementById('winston-files-tab-individual_quotes');
    if (tBtn) tBtn.classList.toggle('hidden', !hasTakeoff);
    if (qBtn) qBtn.classList.toggle('hidden', !hasQuotes);
    if ((_winstonFilesTab === 'individual_takeoffs' && !hasTakeoff) ||
        (_winstonFilesTab === 'individual_quotes' && !hasQuotes)) {
      _winstonFilesTab = 'takeoffs';
    }
  }).catch(function(){}).then(function(){ if (cb) cb(); });
}

var _winstonFilesData = [];
var _winstonFilesNextLink = null;   // opaque Graph paging cursor for "Show more"
var _winstonSelectedFileIdx = -1;
var _winstonPreviewTextExts = ['txt','csv','json','xml','log','md','py','js','html','css','yml','yaml','ini','cfg','conf','sh'];
var _winstonPreviewImgExts = ['png','jpg','jpeg','gif','svg','webp','bmp','ico'];

function winstonCloseFilePreview() {
  var pane = document.getElementById('winston-files-preview-pane');
  var content = document.getElementById('winston-files-preview-content');
  if (pane) pane.classList.add('hidden');
  if (content) content.innerHTML = '';
  if (_winstonSelectedFileIdx >= 0) {
    var prev = document.getElementById('winston-file-row-' + _winstonSelectedFileIdx);
    if (prev) prev.classList.remove('bg-togen-teal/10', 'dark:bg-togen-teal/20');
  }
  _winstonSelectedFileIdx = -1;
}

function winstonShowFilePreview(i) {
  var f = _winstonFilesData[i];
  if (!f || f.is_folder || !f.id) return;
  var pane = document.getElementById('winston-files-preview-pane');
  var title = document.getElementById('winston-files-preview-title');
  var content = document.getElementById('winston-files-preview-content');
  var openLink = document.getElementById('winston-files-preview-open');
  if (!pane || !content) return;

  // Highlight the selected row.
  if (_winstonSelectedFileIdx >= 0 && _winstonSelectedFileIdx !== i) {
    var prev = document.getElementById('winston-file-row-' + _winstonSelectedFileIdx);
    if (prev) prev.classList.remove('bg-togen-teal/10', 'dark:bg-togen-teal/20');
  }
  var row = document.getElementById('winston-file-row-' + i);
  if (row) row.classList.add('bg-togen-teal/10', 'dark:bg-togen-teal/20');
  _winstonSelectedFileIdx = i;

  pane.classList.remove('hidden');
  if (title) title.textContent = f.name;
  if (openLink) {
    // Prefer the SharePoint web location (DVI-978 item 5); fall back to the
    // direct download URL if a web location isn't available.
    var openHref = f.web_url || f.download_url;
    if (openHref) { openLink.href = openHref; openLink.classList.remove('hidden'); }
    else { openLink.classList.add('hidden'); }
  }

  var ext = (f.name.split('.').pop() || '').toLowerCase();
  var previewUrl = '/winston/files/preview?view=' + encodeURIComponent(_winstonFilesTab) +
    '&id=' + encodeURIComponent(f.id) + '&name=' + encodeURIComponent(f.name);

  if (ext === 'pdf') {
    content.innerHTML = '<iframe src="' + esc(previewUrl) + '" class="w-full rounded-lg" style="min-height:500px;height:100%"></iframe>';
  } else if (_winstonPreviewImgExts.indexOf(ext) !== -1) {
    content.innerHTML = '<img src="' + esc(previewUrl) + '" class="max-w-full rounded-lg" alt="' + esc(f.name) + '">';
  } else if (_winstonPreviewTextExts.indexOf(ext) !== -1) {
    content.innerHTML = '<p class="text-sm text-gray-400">Loading…</p>';
    fetch(previewUrl).then(function(r){ return r.text(); }).then(function(text) {
      if (_winstonSelectedFileIdx !== i) return;  // superseded by another click
      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 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 {
    // No inline preview (e.g. xlsm workbooks — DVI-978 item 4.1): offer an Open
    // button to the SharePoint location, falling back to a direct download.
    var openLoc = f.web_url
      ? '<a href="' + esc(f.web_url) + '" target="_blank" rel="noopener" class="togen-control mt-3 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg border border-togen-teal/40 text-togen-teal hover:bg-togen-teal/10">Open in SharePoint</a>'
      : (f.download_url
        ? '<a href="' + esc(f.download_url) + '" target="_blank" rel="noopener" class="mt-3 text-sm text-togen-teal hover:underline">Download file</a>'
        : '');
    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(f.name) + '</p>' +
      openLoc +
      '</div>';
  }
}

// Map the sort dropdown to the backend sort/dir query params (DVI-978).
function winstonFilesSortParams() {
  var sel = document.getElementById('winston-files-sort');
  var v = (sel && sel.value) || 'modified_desc';
  var map = {
    modified_desc: ['modified','desc'], modified_asc: ['modified','asc'],
    name_asc: ['name','asc'], name_desc: ['name','desc'],
    size_desc: ['size','desc'], size_asc: ['size','asc']
  };
  return map[v] || map.modified_desc;
}

// Re-render the currently-loaded rows, applying the client-side name filter
// (DVI-978). Row indices map back into _winstonFilesData so preview still works.
function winstonRenderFiles() {
  var list = document.getElementById('winston-files-list');
  var empty = document.getElementById('winston-files-empty');
  var nomatch = document.getElementById('winston-files-nomatch');
  var trunc = document.getElementById('winston-files-truncated');
  if (!list) return;
  var fEl = document.getElementById('winston-files-filter');
  var filter = ((fEl && fEl.value) || '').trim().toLowerCase();

  var total = _winstonFilesData.length;
  if (!total) {
    list.classList.add('hidden'); list.innerHTML = '';
    if (nomatch) nomatch.classList.add('hidden');
    if (empty) empty.classList.remove('hidden');
    if (trunc) trunc.classList.add('hidden');
    return;
  }
  if (empty) empty.classList.add('hidden');

  var rows = [], shown = 0;
  _winstonFilesData.forEach(function(f, i){
    if (filter && (f.name || '').toLowerCase().indexOf(filter) === -1) return;
    rows.push(winstonFileRowHtml(f, i));
    shown++;
  });

  if (!shown) {
    list.classList.add('hidden'); list.innerHTML = '';
    if (nomatch) nomatch.classList.remove('hidden');
  } else {
    list.innerHTML = rows.join('');
    list.classList.remove('hidden');
    if (nomatch) nomatch.classList.add('hidden');
  }

  if (trunc) {
    var msg = filter
      ? ('Showing ' + shown + ' of ' + total + ' loaded files matching “' + filter + '”.')
      : ('Showing ' + total + ' file' + (total === 1 ? '' : 's') +
         (_winstonFilesNextLink ? ' — more available.' : '.'));
    trunc.textContent = msg;
    trunc.classList.remove('hidden');
  }
}

function winstonLoadFiles(view, append) {
  var loading = document.getElementById('winston-files-loading');
  var notice = document.getElementById('winston-files-notice');
  var meta = document.getElementById('winston-files-meta');
  var empty = document.getElementById('winston-files-empty');
  var nomatch = document.getElementById('winston-files-nomatch');
  var list = document.getElementById('winston-files-list');
  var trunc = document.getElementById('winston-files-truncated');
  var toolbar = document.getElementById('winston-files-toolbar');
  var moreWrap = document.getElementById('winston-files-more');
  var moreBtn = document.getElementById('winston-files-showmore-btn');

  var sp = winstonFilesSortParams();
  var url = '/winston/files?view=' + encodeURIComponent(view) +
    '&sort=' + encodeURIComponent(sp[0]) + '&dir=' + encodeURIComponent(sp[1]);

  if (append) {
    // "Show more": keep existing rows, fetch the next page via the cursor.
    if (!_winstonFilesNextLink) return;
    url += '&next=' + encodeURIComponent(_winstonFilesNextLink);
    if (moreBtn) { moreBtn.disabled = true; moreBtn.textContent = 'Loading…'; }
  } else {
    // Fresh load (tab switch or sort change): reset everything.
    [notice, meta, empty, nomatch, trunc, toolbar, moreWrap].forEach(function(el){ if (el) el.classList.add('hidden'); });
    if (list) { list.classList.add('hidden'); list.innerHTML = ''; }
    if (notice) notice.className = 'hidden rounded-xl border px-4 py-3 text-sm';
    if (loading) loading.classList.remove('hidden');
    winstonCloseFilePreview();
    _winstonFilesData = [];
    _winstonFilesNextLink = null;
  }

  fetch(url).then(function(r){
    return r.json().then(function(d){ return {ok: r.ok, d: d}; });
  }).then(function(res){
    if (loading) loading.classList.add('hidden');
    if (moreBtn) { moreBtn.disabled = false; moreBtn.textContent = 'Show more'; }
    if (view !== _winstonFilesTab) return;  // a newer tab click superseded this
    var d = res.d || {};

    if (!append) {
      // Show which account + path were queried (skip when nothing is configured).
      if (meta && d.configured !== false) {
        var scopeLabel = d.scope === 'pipeline'
          ? 'Logic pipeline account' : 'Your signed-in account';
        meta.innerHTML =
          '<div><span class="font-medium text-gray-600 dark:text-gray-300">Account:</span> ' + esc(d.account || '') + ' <span class="text-gray-400">(' + esc(scopeLabel) + ')</span></div>' +
          '<div><span class="font-medium text-gray-600 dark:text-gray-300">Path:</span> <span class="font-mono">' + esc(d.path || '') + '</span></div>';
        meta.classList.remove('hidden');
      }

      if (!res.ok || d.error && d.configured === undefined) {
        if (notice) {
          notice.className = 'rounded-xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 px-4 py-3 text-sm';
          notice.textContent = (d && d.error) || 'Failed to load files.';
          notice.classList.remove('hidden');
        }
        return;
      }

      if (d.configured === false) {
        if (notice) {
          notice.className = 'rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-300 px-4 py-3 text-sm';
          notice.textContent = d.message || 'This location is not configured.';
          notice.classList.remove('hidden');
        }
        return;
      }

      if (!d.ok) {
        if (notice) {
          notice.className = 'rounded-xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 px-4 py-3 text-sm space-y-1';
          var statusLine = d.http_status ? '<div><span class="font-medium">HTTP status:</span> ' + esc(String(d.http_status)) + '</div>' : '';
          notice.innerHTML =
            '<div class="font-medium">Could not access this location.</div>' +
            statusLine +
            '<div class="font-mono text-xs break-all opacity-80">' + esc(d.error || '') + '</div>';
          notice.classList.remove('hidden');
        }
        return;
      }
    } else if (!d.ok) {
      // A "Show more" page failed; leave existing rows intact.
      return;
    }

    var files = d.files || [];
    _winstonFilesData = append ? _winstonFilesData.concat(files) : files;
    _winstonFilesNextLink = d.next_link || null;

    // Toolbar is only useful once there's a listing to sort/filter.
    if (toolbar) toolbar.classList.toggle('hidden', _winstonFilesData.length === 0);
    if (moreWrap) moreWrap.classList.toggle('hidden', !_winstonFilesNextLink);

    winstonRenderFiles();
  }).catch(function(){
    if (loading) loading.classList.add('hidden');
    if (moreBtn) { moreBtn.disabled = false; moreBtn.textContent = 'Show more'; }
    if (!append && notice) {
      notice.className = 'rounded-xl border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 px-4 py-3 text-sm';
      notice.textContent = 'Failed to load files.';
      notice.classList.remove('hidden');
    }
  });
}

// --- Drafts (DVI-848) ---
function winstonLoadDrafts() {
  var loading = document.getElementById('winston-drafts-loading');
  var empty = document.getElementById('winston-drafts-empty');
  var errEl = document.getElementById('winston-drafts-error');
  var list = document.getElementById('winston-drafts-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/drafts').then(function(r){
    return r.json().then(function(d){ return {ok: r.ok, d: d}; });
  }).then(function(res){
    if (loading) loading.classList.add('hidden');
    if (!res.ok) {
      if (errEl) { errEl.classList.remove('hidden'); document.getElementById('winston-drafts-error-msg').textContent = (res.d && res.d.error) || 'Failed to load drafts.'; }
      return;
    }
    var mb = document.getElementById('winston-drafts-mailbox');
    if (mb && res.d.mailbox) mb.textContent = res.d.mailbox;
    var records = res.d.records || [];
    if (!records.length) { if (empty) empty.classList.remove('hidden'); return; }
    if (list) list.innerHTML = records.map(winstonDraftCardHtml).join('');
  }).catch(function(){
    if (loading) loading.classList.add('hidden');
    if (errEl) { errEl.classList.remove('hidden'); document.getElementById('winston-drafts-error-msg').textContent = 'Failed to load drafts.'; }
  });
}

function winstonDraftCardHtml(m) {
  var when = '';
  if (m.modified_at) { try { when = new Date(m.modified_at).toLocaleString(); } catch(e){ when = m.modified_at; } }
  var to = (m.to || []).filter(Boolean).join(', ');
  var atts = (m.attachments || []).map(function(a){
    var kb = a.size ? ' <span class="text-gray-400">(' + Math.max(1, Math.round(a.size/1024)) + ' KB)</span>' : '';
    var icon = '<svg class="h-4 w-4 shrink-0 text-togen-teal" 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>';
    if (a.is_pdf) {
      return '<button type="button" onclick="winstonOpenDraftPreview(\'' + esc(m.id) + '\',\'' + esc(a.id) + '\',\'' + esc((a.name||'').replace(/'/g,"\\'")) + '\')" ' +
        'class="inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded-lg border border-togen-teal/30 bg-togen-teal/10 text-togen-teal hover:bg-togen-teal/20 cursor-pointer">' +
        icon + '<span class="truncate max-w-[16rem]">' + esc(a.name) + '</span>' + kb + '</button>';
    }
    return '<a href="/winston/drafts/' + encodeURIComponent(m.id) + '/attachments/' + encodeURIComponent(a.id) + '" target="_blank" rel="noopener" ' +
      'class="togen-control inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded-lg border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800">' +
      icon + '<span class="truncate max-w-[16rem]">' + esc(a.name) + '</span>' + kb + '</a>';
  }).join('');
  var attsHtml = atts
    ? '<div class="mt-3 flex flex-wrap gap-2">' + atts + '</div>'
    : '<p class="mt-3 text-xs text-gray-400 dark:text-gray-500 italic">No attachments.</p>';
  var previewHtml = m.preview
    ? '<p class="mt-2 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">' + esc(m.preview) + '</p>'
    : '';
  return '<div class="rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-3">' +
    '<div class="flex flex-wrap items-start justify-between gap-2">' +
    '<div class="min-w-0"><p class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + esc(m.subject) + '</p>' +
    (to ? '<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">To: ' + esc(to) + '</p>' : '') +
    '</div>' +
    (when ? '<span class="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">' + esc(when) + '</span>' : '') +
    '</div>' + previewHtml + attsHtml + '</div>';
}

function winstonOpenDraftPreview(msgId, attId, name) {
  var overlay = document.getElementById('winston-draft-preview-overlay');
  var frame = document.getElementById('winston-draft-preview-frame');
  var title = document.getElementById('winston-draft-preview-title');
  if (title) title.textContent = name || 'Attachment';
  if (frame) frame.src = '/winston/drafts/' + encodeURIComponent(msgId) + '/attachments/' + encodeURIComponent(attId);
  if (overlay) overlay.classList.remove('hidden');
}

function winstonCloseDraftPreview(ev) {
  if (ev && ev.target && ev.target.id !== 'winston-draft-preview-overlay' && ev.type === 'click') return;
  var overlay = document.getElementById('winston-draft-preview-overlay');
  var frame = document.getElementById('winston-draft-preview-frame');
  if (overlay) overlay.classList.add('hidden');
  if (frame) frame.src = 'about:blank';
}

// --- 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}
// DVI-948: File card for the find_download step — shows the located workbook's
// name/source/path/size/modified plus Open-online and Download-source links.
function winstonFileCardHtml(dj, xlsmArt, rid, bot) {
  if (!dj || (!dj.file_name && !dj.full_path)) return '';
  function row(label, val) {
    if (val === null || val === undefined || val === '') return '';
    return '<div class="flex gap-2"><span class="w-20 shrink-0 text-gray-400 dark:text-gray-500">' + esc(label) +
      '</span><span class="flex-1 break-all text-gray-700 dark:text-gray-200">' + esc(String(val)) + '</span></div>';
  }
  var links = '';
  if (dj.web_url) {
    links += '<a href="' + esc(dj.web_url) + '" target="_blank" rel="noopener" ' +
      'class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/30 hover:bg-blue-500/20">Open online</a>';
  }
  if (xlsmArt) {
    links += '<a href="/diagnostics/' + esc(bot) + '/runs/' + esc(rid) + '/artifacts/' + esc(xlsmArt.id) + '" ' +
      'class="inline-flex items-center gap-1 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">Download source .xlsm</a>';
  }
  return '<div class="togen-inset rounded-lg border border-gray-200 dark:border-gray-700 p-3 text-xs space-y-1">' +
    '<div class="font-semibold text-gray-700 dark:text-gray-200 mb-1">Source file</div>' +
    row('File', dj.file_name) +
    row('Source', dj.source) +
    row('Path', dj.full_path || dj.folder) +
    row('Size', (dj.size_kb !== null && dj.size_kb !== undefined) ? (dj.size_kb + ' KB') : '') +
    row('Modified', dj.modified) +
    (links ? '<div class="flex flex-wrap gap-2 pt-2">' + links + '</div>' : '') +
  '</div>';
}

// DVI-948: sampled top-left cell grid for one sheet.
function winstonCellGridHtml(sample) {
  if (!sample || !sample.length) {
    return '<p class="text-[11px] text-gray-400 dark:text-gray-500 italic py-1">No cell data sampled.</p>';
  }
  var body = sample.map(function(row, ri){
    var cells = row.map(function(v){
      var t = esc(String(v));
      return '<td class="border border-gray-200 dark:border-gray-700 px-1.5 py-0.5 max-w-[10rem] truncate" title="' + t + '">' + t + '</td>';
    }).join('');
    return '<tr><td class="border border-gray-200 dark:border-gray-700 px-1 text-gray-400 dark:text-gray-500 text-center">' + (ri + 1) + '</td>' + cells + '</tr>';
  }).join('');
  return '<div class="overflow-x-auto"><table class="text-[11px] border-collapse font-mono mt-1">' + body + '</table></div>';
}

// DVI-984: tag → sheet → total decision-chain inspector for the openpyxl_extract
// step. Renders (1) fetched ClickUp tags + per-tag base/candidate mapping, (2)
// which candidate sheets existed vs were skipped and which was chosen, (3) per
// chosen sheet the grand-total label cell + value read below it, and (4) the
// dynamic print area / hidden-row count used to trim each PDF (from sheetDetail,
// the openpyxl_extract step's per-sheet `sheets` list). Falls back to the raw
// workbook grid below. `sheetDetail` is optional (absent on the no-match path).
function winstonSheetResolutionHtml(res, sheetDetail) {
  if (!res) return '';
  var trimBySheet = {};
  (sheetDetail || []).forEach(function(s){
    var nm = s && (s.sheet || s.sheet_name);
    if (nm) trimBySheet[nm] = (s && s.trim) || null;
  });
  function chip(txt, cls) {
    return '<span class="px-1.5 py-0.5 rounded font-mono text-[11px] ' + cls + '">' + esc(txt) + '</span>';
  }
  var matched = res.matched === true;
  var banner = matched
    ? '<div class="rounded-lg border border-green-300 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-2 text-xs text-green-700 dark:text-green-300">' +
        'Resolved <span class="font-semibold">' + (res.chosen_sheets || []).length + '</span> quote sheet(s) from ' +
        '<span class="font-semibold">' + (res.tags || []).length + '</span> tag(s).</div>'
    : '<div class="rounded-lg border border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-2 text-xs text-red-700 dark:text-red-300">' +
        'No quote sheet resolved from tags — the quote was skipped. Check the tags and tag_map below.</div>';

  // (1) Tags → base → candidates
  var tagRows = (res.tag_resolution || []).map(function(t){
    var cands = (t.candidates || []).map(function(c){ return chip(c, 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300'); }).join(' ');
    return '<div class="flex flex-wrap items-center gap-1.5 py-0.5">' +
      chip(t.tag, 'bg-togen-teal/10 text-togen-teal') +
      '<span class="text-gray-400">→ base</span>' + chip(t.base || '(none)', 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300') +
      (t.version_suffix ? '<span class="text-gray-400">ver</span>' + chip(t.version_suffix, 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300') : '') +
      '<span class="text-gray-400">→</span>' + (cands || '<span class="text-red-500 italic text-[11px]">no candidates (tag not in tag_map)</span>') +
    '</div>';
  }).join('');
  var tagsSection = '<div class="togen-inset rounded-lg border border-gray-200 dark:border-gray-700 p-2 space-y-0.5">' +
    '<div class="text-xs font-semibold text-gray-700 dark:text-gray-200 mb-1">Tags → candidate sheets (' + (res.tags || []).length + ' tags)</div>' +
    (tagRows || '<span class="text-xs text-gray-400 italic">No tags were fetched from ClickUp for this quote.</span>') + '</div>';

  // (2) Candidate sheets: existed vs skipped, chosen
  var candRows = (res.candidates || []).map(function(c){
    var badge = c.chosen
      ? chip('chosen', 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300')
      : (c.exists ? chip('exists', 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300')
                  : chip('skipped — not in workbook', 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'));
    return '<div class="flex flex-wrap items-center gap-1.5 py-0.5">' +
      chip(c.candidate, c.exists ? 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200' : 'bg-gray-50 text-gray-400 dark:bg-gray-800/50 dark:text-gray-500 line-through') +
      (c.matched_name && c.matched_name !== c.candidate ? '<span class="text-gray-400">matched</span>' + chip(c.matched_name, 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300') : '') +
      badge +
    '</div>';
  }).join('');
  var candSection = '<div class="togen-inset rounded-lg border border-gray-200 dark:border-gray-700 p-2 space-y-0.5">' +
    '<div class="text-xs font-semibold text-gray-700 dark:text-gray-200 mb-1">Candidate sheets (' + (res.candidates || []).length + ')</div>' +
    (candRows || '<span class="text-xs text-gray-400 italic">No candidate sheets (no tag mapped).</span>') + '</div>';

  // (3)+(4) Chosen sheets: grand-total cells + trim / print area
  var chosenRows = (res.chosen_sheets || []).map(function(cs){
    var trim = trimBySheet[cs.sheet] || null;
    function kv(label, val, mono) {
      if (val === null || val === undefined || val === '') return '';
      return '<div class="flex gap-2"><span class="w-40 shrink-0 text-gray-400 dark:text-gray-500">' + esc(label) + '</span>' +
        '<span class="flex-1 break-all ' + (mono ? 'font-mono ' : '') + 'text-gray-700 dark:text-gray-200">' + esc(String(val)) + '</span></div>';
    }
    var body = '<div class="px-3 pb-2 pt-1 text-xs space-y-0.5">' +
      kv('"grand total" label at', cs.grand_total_label_cell || '(not found)', true) +
      kv('value read from cell', cs.grand_total_value_cell || '(none)', true) +
      kv('grand total value', (cs.grand_total === null || cs.grand_total === undefined) ? '(empty)' : cs.grand_total) +
      (trim ? kv('hidden rows', trim.hidden_rows) + kv('print area', trim.print_area, true) + kv('last content row', trim.last_row) : '') +
    '</div>';
    return '<details class="rounded border border-green-200 dark:border-green-800" open>' +
      '<summary class="flex items-center gap-2 px-3 py-1.5 cursor-pointer text-xs list-none">' +
        chip(cs.sheet, 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300') +
        (cs.version_suffix ? chip(cs.version_suffix, 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300') : '') +
        ((cs.grand_total !== null && cs.grand_total !== undefined) ? '<span class="ml-auto font-mono text-green-700 dark:text-green-400">' + esc(String(cs.grand_total)) + '</span>' : '<span class="ml-auto text-red-500 italic text-[11px]">no total</span>') +
      '</summary>' + body + '</details>';
  }).join('');
  var chosenSection = matched
    ? '<div class="togen-inset rounded-lg border border-gray-200 dark:border-gray-700 p-2 space-y-1">' +
        '<div class="text-xs font-semibold text-gray-700 dark:text-gray-200 mb-1">Chosen sheets — grand total &amp; print area</div>' +
        chosenRows + '</div>'
    : '';

  return '<div class="space-y-2">' + banner + tagsSection + candSection + chosenSection + '</div>';
}

// DVI-948/984: workbook-structure inspector for the openpyxl_extract step. Shows
// the tag→sheet→total decision chain (DVI-984) when the resolver captured it,
// followed by the raw per-sheet cell-grid preview (name + dimensions), with any
// chosen sheet highlighted green. `sheetDetail` is the step's per-sheet list
// (carries trim / print-area info); optional.
function winstonWorkbookStructureHtml(struct, sheetDetail) {
  if (!struct || !struct.sheets) return '';
  var resolutionHtml = '';
  if (struct.resolution) {
    resolutionHtml = winstonSheetResolutionHtml(struct.resolution, sheetDetail);
  } else if (struct.matched !== undefined) {
    // Back-compat: older runs stored a single target_sheet, not the full chain.
    if (struct.matched === false) {
      resolutionHtml = '<div class="rounded-lg border border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-2 text-xs text-red-700 dark:text-red-300">' +
        'Target sheet <span class="font-mono font-semibold">' + esc(struct.target_sheet || '') + '</span> not found.</div>';
    } else if (struct.matched === true) {
      resolutionHtml = '<div class="rounded-lg border border-green-300 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-2 text-xs text-green-700 dark:text-green-300">' +
        'Target sheet <span class="font-mono font-semibold">' + esc(struct.target_sheet || '') + '</span> found' +
        (struct.grand_total_cell ? ' — grand total read from ' + esc(struct.grand_total_cell) : '') + '.</div>';
    }
  }
  var chosenNames = {};
  if (struct.resolution) {
    (struct.resolution.chosen_sheets || []).forEach(function(cs){ chosenNames[cs.sheet] = 1; });
  } else if (struct.target_sheet) {
    chosenNames[struct.target_sheet] = 1;
  }
  var rows = struct.sheets.map(function(sh){
    var isTarget = !!chosenNames[sh.name];
    return '<details class="rounded border border-gray-100 dark:border-gray-800' + (isTarget ? ' ring-1 ring-green-400' : '') + '">' +
      '<summary class="flex items-center gap-2 px-2 py-1 cursor-pointer text-xs list-none">' +
        '<span class="font-mono font-medium ' + (isTarget ? 'text-green-700 dark:text-green-400' : 'text-gray-700 dark:text-gray-200') + '">' + esc(sh.name) + '</span>' +
        '<span class="text-gray-400 dark:text-gray-500">' + (sh.max_row || 0) + ' × ' + (sh.max_col || 0) + '</span>' +
      '</summary>' +
      '<div class="px-2 pb-2">' + winstonCellGridHtml(sh.sample) + '</div>' +
    '</details>';
  }).join('');
  return '<div class="space-y-2">' + resolutionHtml +
    '<details class="rounded-lg border border-gray-200 dark:border-gray-700">' +
      '<summary class="px-2 py-1 cursor-pointer text-xs font-semibold text-gray-700 dark:text-gray-200 list-none">Workbook structure (' + struct.sheets.length + ' sheets)</summary>' +
      '<div class="px-2 pb-2 space-y-1">' + rows + '</div>' +
    '</details></div>';
}

// DVI-972: ClickUp data card for the clickup_pull step — a query header + one
// expandable row per retrieved task (quote #→ClickUp link, status, recipient,
// owner, amount) revealing all custom fields, assignees and timestamps.
function winstonClickupCardHtml(dj) {
  if (!dj || (!dj.clickup_query && !dj.tasks)) return '';
  var q = dj.clickup_query || {};
  var tasks = dj.tasks || [];
  function fmtDate(v) {
    if (!v) return '';
    try { var d = new Date(v); if (!isNaN(d.getTime())) return d.toLocaleDateString(); } catch(e){}
    return String(v);
  }
  function kv(label, val) {
    if (val === null || val === undefined || val === '') return '';
    return '<div class="flex gap-2"><span class="w-24 shrink-0 text-gray-400 dark:text-gray-500">' + esc(label) +
      '</span><span class="flex-1 break-all text-gray-700 dark:text-gray-200">' + esc(String(val)) + '</span></div>';
  }
  var header = '<div class="togen-inset rounded-lg border border-gray-200 dark:border-gray-700 p-3 text-xs space-y-1">' +
    '<div class="font-semibold text-gray-700 dark:text-gray-200 mb-1">ClickUp query</div>' +
    kv('Scope', (q.scope === 'list' ? 'List ' + (q.list_id || '') : 'Team ' + (q.team_id || ''))) +
    kv('Target date', q.target_date) +
    kv('Date field', q.date_field) +
    kv('Tasks found', (q.count !== undefined ? q.count : tasks.length)) +
  '</div>';

  if (!tasks.length) {
    return '<div class="space-y-2">' + header +
      '<p class="text-xs text-gray-400 dark:text-gray-500 italic">No quote tasks returned by ClickUp for this query.</p></div>';
  }

  var rows = tasks.map(function(t){
    var qn = t.quote_number || t.task_name || '(unnamed)';
    var qnHtml = t.url
      ? '<a href="' + esc(t.url) + '" target="_blank" rel="noopener" class="text-togen-teal hover:underline font-medium">' + esc(qn) + '</a>'
      : '<span class="font-medium text-gray-800 dark:text-gray-100">' + esc(qn) + '</span>';
    var chips = '';
    if (t.status) chips += '<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300">' + esc(t.status) + '</span>';
    if (t.amount_value !== null && t.amount_value !== undefined && t.amount_value !== '')
      chips += '<span class="px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">' + esc(String(t.amount_value)) + '</span>';

    var cf = (t.custom_fields || []).map(function(f){
      var v = Array.isArray(f.value) ? f.value.join(', ') : f.value;
      return kv(f.name, v);
    }).join('');
    var body = '<div class="px-3 pb-2 pt-1 text-xs space-y-1">' +
      kv('Recipient', t.recipient_email) +
      kv('Owner', t.owner_email) +
      kv('Amount', t.amount_value) +
      kv('Assignees', (t.assignees || []).join(', ')) +
      kv('Created', fmtDate(t.date_created)) +
      kv('Updated', fmtDate(t.date_updated)) +
      kv('Due', fmtDate(t.due_date)) +
      (cf ? '<div class="pt-1 mt-1 border-t border-gray-100 dark:border-gray-800"><div class="text-gray-400 dark:text-gray-500 mb-0.5">Custom fields</div>' + cf + '</div>' : '') +
    '</div>';
    return '<details class="rounded border border-gray-100 dark:border-gray-800">' +
      '<summary class="flex items-center gap-2 px-3 py-1.5 cursor-pointer text-xs list-none">' +
        '<span class="flex-1 min-w-0 truncate">' + qnHtml + '</span>' +
        (chips ? '<span class="flex flex-wrap gap-1 shrink-0">' + chips + '</span>' : '') +
      '</summary>' + body +
    '</details>';
  }).join('');

  return '<div class="space-y-2">' + header +
    '<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">Retrieved tasks (' + tasks.length + ')</div>' +
    '<div class="space-y-1">' + rows + '</div></div>';
}

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 = '';
    var dj = null;
    if (s.detail_json) {
      try { dj = JSON.parse(s.detail_json); } catch(e){}
    }
    if (dj && dj.error) detailErr = String(dj.error);
    // DVI-948: richer per-step detail (File card for find_download, workbook
    // structure inspector for openpyxl_extract).
    var stepName = s.name || '';
    var stepExtra = '';
    if (dj && stepName.indexOf('find_download') === 0) {
      var qnDl = stepName.split(':')[1] || '';
      var xlsmArt = artifacts.filter(function(a){
        return a.kind === 'xlsm' && (a.name || '').indexOf(qnDl) !== -1;
      })[0] || null;
      stepExtra += winstonFileCardHtml(dj, xlsmArt, rid, bot);
    }
    if (dj && dj.workbook_structure && stepName.indexOf('openpyxl_extract') === 0) {
      stepExtra += winstonWorkbookStructureHtml(dj.workbook_structure, dj.sheets);
    }
    if (dj && (dj.clickup_query || dj.tasks) && stepName.indexOf('clickup_pull') === 0) {
      stepExtra += winstonClickupCardHtml(dj);
    }
    html += '<li class="togen-inset 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>' : '') +
          stepExtra +
          // 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;
}

// --- Email template (DVI-857) ---
function winstonEmailSetStatus(msg) {
  var el = document.getElementById('winston-email-status');
  if (el) el.textContent = msg || '';
}

var _winstonEmailPid = null;

function winstonSelectEmailPipeline() {
  var sel = document.getElementById('winston-email-pipeline');
  _winstonEmailPid = (sel && sel.value) ? sel.value : null;
  winstonLoadEmailTemplate();
}

function winstonLoadEmailTemplate() {
  var loading = document.getElementById('winston-email-loading');
  var body = document.getElementById('winston-email-body');
  if (loading) loading.classList.remove('hidden');
  if (body) body.classList.add('hidden');
  winstonEmailSetStatus('');
  // Mailbox comes from the shared Winston config (managed in Settings).
  fetch('/winston/config').then(function(r){ return r.json(); }).then(function(c){
    var mb = document.getElementById('winston-email-mailbox');
    if (mb) mb.textContent = c.draft_mailbox || '(not set)';
  }).catch(function(){});
  var url = '/winston/email-template' +
    (_winstonEmailPid ? ('?pipeline_id=' + encodeURIComponent(_winstonEmailPid)) : '');
  fetch(url).then(function(r){ return r.json(); }).then(function(d){
    if (loading) loading.classList.add('hidden');
    if (body) body.classList.remove('hidden');

    var enabled = !!d.per_pipeline_enabled;
    var toggle = document.getElementById('winston-email-perpipeline-toggle');
    if (toggle) toggle.checked = enabled;
    var row = document.getElementById('winston-email-pipeline-row');
    if (row) row.classList.toggle('hidden', !enabled);
    if (!enabled) _winstonEmailPid = null;
    winstonRenderEmailPipelineOptions(d.pipelines || []);

    var editingPipeline = enabled && !!_winstonEmailPid;
    var glob = d['global'] || {};

    var subj = document.getElementById('winston-email-subject');
    if (subj) subj.value = d.subject || '';
    var bh = document.getElementById('winston-email-body-html');
    if (bh) bh.value = d.body_html || '';
    var cc = document.getElementById('winston-email-cc');
    if (cc) cc.value = (d.cc || []).join(', ');
    var bcc = document.getElementById('winston-email-bcc');
    if (bcc) bcc.value = (d.bcc || []).join(', ');
    var fb = document.getElementById('winston-email-fallback');
    if (fb) fb.value = (d.fallback_to || []).join(', ');

    // Placeholders reveal the inherited baseline: global values when editing a
    // pipeline (blank field = inherit global), otherwise the built-in defaults.
    if (subj) subj.placeholder = editingPipeline ? (glob.subject || '') : ((d.defaults || {}).subject || '');
    if (bh) bh.placeholder = editingPipeline ? (glob.body_html || '') : '';
    if (cc) cc.placeholder = editingPipeline ? (glob.cc || []).join(', ') : 'a@x.com, b@y.com';
    if (bcc) bcc.placeholder = editingPipeline ? (glob.bcc || []).join(', ') : 'archive@x.com';
    if (fb) fb.placeholder = editingPipeline ? (glob.fallback_to || []).join(', ') : '';

    var note = document.getElementById('winston-email-scope-note');
    if (note) {
      if (editingPipeline) {
        note.textContent = 'Editing this pipeline only. Blank fields fall back to the default template below.';
        note.classList.remove('hidden');
      } else if (enabled) {
        note.textContent = 'Editing the default template used by every pipeline without its own override.';
        note.classList.remove('hidden');
      } else {
        note.classList.add('hidden');
      }
    }
    var resetBtn = document.getElementById('winston-email-reset-btn');
    if (resetBtn) resetBtn.classList.toggle('hidden', !editingPipeline);

    winstonRenderEmailTokens(d.tokens || []);
  }).catch(function(){
    if (loading) loading.classList.add('hidden');
    if (body) body.classList.remove('hidden');
    winstonEmailSetStatus('Error loading template.');
  });
}

function winstonRenderEmailPipelineOptions(pipelines) {
  var sel = document.getElementById('winston-email-pipeline');
  if (!sel) return;
  var opts = ['<option value="">Default (all pipelines)</option>'];
  pipelines.forEach(function(p){
    var mark = p.has_override ? ' •' : '';
    opts.push('<option value="' + winstonEscapeHtml(p.id) + '">' +
      winstonEscapeHtml(p.name) + mark + '</option>');
  });
  sel.innerHTML = opts.join('');
  sel.value = _winstonEmailPid || '';
  if (sel.value !== (_winstonEmailPid || '')) { _winstonEmailPid = null; sel.value = ''; }
}

function winstonTogglePerPipeline() {
  var cb = document.getElementById('winston-email-perpipeline-toggle');
  var enabled = cb ? cb.checked : false;
  winstonEmailSetStatus('Saving…');
  fetch('/winston/email-template', {
    method: 'POST',
    headers: {'Content-Type':'application/json'},
    body: JSON.stringify({per_pipeline_enabled: enabled})
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d.ok) {
      if (!enabled) _winstonEmailPid = null;
      winstonEmailSetStatus('');
      winstonLoadEmailTemplate();
    } else { winstonEmailSetStatus('Error: ' + (d.error || 'unknown')); }
  }).catch(function(){ winstonEmailSetStatus('Error saving.'); });
}

function winstonResetPipelineTemplate() {
  if (!_winstonEmailPid) return;
  ['winston-email-subject','winston-email-body-html','winston-email-cc',
   'winston-email-bcc','winston-email-fallback'].forEach(function(id){
    var el = document.getElementById(id); if (el) el.value = '';
  });
  winstonSaveEmailTemplate();
}

function winstonRenderEmailTokens(tokens) {
  var wrap = document.getElementById('winston-email-tokens');
  if (!wrap) return;
  if (!tokens.length) { wrap.innerHTML = '<p class="text-xs text-gray-400">No tokens available.</p>'; return; }
  var rows = tokens.map(function(t){
    return '<tr class="border-t border-gray-100 dark:border-gray-800">' +
      '<td class="py-1.5 pr-4 align-top"><code class="font-mono text-togen-teal">{' + t.token + '}</code></td>' +
      '<td class="py-1.5 text-gray-600 dark:text-gray-300">' + winstonEscapeHtml(t.source) + '</td>' +
    '</tr>';
  }).join('');
  wrap.innerHTML = '<table class="w-full text-xs"><thead><tr class="text-left text-gray-500 dark:text-gray-400">' +
    '<th class="pb-1.5 pr-4 font-medium">Token</th><th class="pb-1.5 font-medium">Source in pipeline</th>' +
    '</tr></thead><tbody>' + rows + '</tbody></table>';
}

function winstonEscapeHtml(s) {
  return String(s == null ? '' : s)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

function winstonParseEmailList(id) {
  var el = document.getElementById(id);
  var raw = el ? el.value : '';
  return raw.split(',').map(function(s){ return s.trim(); }).filter(function(s){ return s; });
}

function winstonEmailTemplatePayload() {
  var p = {
    subject: (document.getElementById('winston-email-subject') || {}).value || '',
    body_html: (document.getElementById('winston-email-body-html') || {}).value || '',
    cc: winstonParseEmailList('winston-email-cc'),
    bcc: winstonParseEmailList('winston-email-bcc'),
    fallback_to: winstonParseEmailList('winston-email-fallback')
  };
  if (_winstonEmailPid) p.pipeline_id = _winstonEmailPid;
  return p;
}

function winstonSaveEmailTemplate() {
  winstonEmailSetStatus('Saving…');
  fetch('/winston/email-template', {
    method: 'POST',
    headers: {'Content-Type':'application/json'},
    body: JSON.stringify(winstonEmailTemplatePayload())
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d.ok) { winstonEmailSetStatus('Saved.'); winstonLoadEmailTemplate(); }
    else { winstonEmailSetStatus('Error: ' + (d.error || 'unknown')); }
  }).catch(function(){ winstonEmailSetStatus('Error saving.'); });
}

function winstonPreviewEmailTemplate() {
  winstonEmailSetStatus('Rendering preview…');
  fetch('/winston/email-template/preview', {
    method: 'POST',
    headers: {'Content-Type':'application/json'},
    body: JSON.stringify(winstonEmailTemplatePayload())
  }).then(function(r){ return r.json(); }).then(function(d){
    var pane = document.getElementById('winston-email-preview-pane');
    var subj = document.getElementById('winston-email-preview-subject');
    var pbody = document.getElementById('winston-email-preview-body');
    if (subj) subj.textContent = d.subject || '';
    if (pbody) pbody.innerHTML = d.body_html || '';
    if (pane) pane.classList.remove('hidden');
    winstonEmailSetStatus('');
  }).catch(function(){ winstonEmailSetStatus('Error rendering preview.'); });
}

// --- 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 || '';
    var dry = document.getElementById('winston-cfg-dryrun');
    if (dry) dry.checked = !!d.dry_run;
    var dryStatus = document.getElementById('winston-cfg-dryrun-status');
    if (dryStatus) dryStatus.textContent = d.dry_run ? 'Dry Run is ON — no writes will be made.' : '';
    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');
    winstonTagMapLoad(d);
  }).catch(function(e) {
    if (loading) loading.classList.add('hidden');
    if (body) body.classList.remove('hidden');
  });
}

function winstonSaveDryRun() {
  var dry = document.getElementById('winston-cfg-dryrun');
  var on = !!(dry && dry.checked);
  var status = document.getElementById('winston-cfg-dryrun-status');
  if (status) status.textContent = 'Saving…';
  fetch('/winston/config', {
    method: 'POST',
    headers: {'Content-Type':'application/json'},
    body: JSON.stringify({dry_run: on})
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d.ok) {
      if (status) status.textContent = on
        ? 'Dry Run is ON — no writes will be made.'
        : 'Dry Run is OFF — runs will write normally.';
    } else {
      if (status) status.textContent = 'Error: ' + (d.error || 'unknown');
      if (dry) dry.checked = !on;
    }
  }).catch(function(){
    if (status) status.textContent = 'Error saving.';
    if (dry) dry.checked = !on;
  });
}

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');
}

// --- Tag map editor (DVI-981) ---
var _winstonTagMapCustom = {};  // {TAG: sheetName}
var _winstonTagMapStandard = []; // [TAG, ...]

function winstonTagMapLoad(d) {
  var tm = (d && d.tag_map) || {};
  var c = tm.custom;
  var s = tm.standard;
  _winstonTagMapCustom = (c && typeof c === 'object' && !Array.isArray(c)) ? Object.assign({}, c) : {};
  _winstonTagMapStandard = Array.isArray(s) ? s.slice() : [];
  winstonTagMapRenderCustom();
  winstonTagMapRenderStd();
}

function winstonTagMapRenderCustom() {
  var el = document.getElementById('winston-tagmap-custom-list');
  if (!el) return;
  var keys = Object.keys(_winstonTagMapCustom);
  if (!keys.length) {
    el.innerHTML = '<p class="text-xs text-gray-400 dark:text-gray-500 italic py-1">No custom mappings.</p>';
    return;
  }
  el.innerHTML = keys.map(function(k) {
    return '<div class="flex items-center gap-2 py-1">' +
      '<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded text-gray-700 dark:text-gray-300">' + esc(k) + '</span>' +
      '<span class="text-gray-400 text-xs">&rarr;</span>' +
      '<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded text-gray-700 dark:text-gray-300">' + esc(_winstonTagMapCustom[k]) + '</span>' +
      '<button type="button" onclick="winstonTagMapRemoveCustom(' + JSON.stringify(k) + ')" ' +
        'class="ml-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 cursor-pointer" title="Remove">' +
        '<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.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>';
  }).join('');
}

function winstonTagMapRenderStd() {
  var el = document.getElementById('winston-tagmap-std-list');
  if (!el) return;
  if (!_winstonTagMapStandard.length) {
    el.innerHTML = '<p class="text-xs text-gray-400 dark:text-gray-500 italic py-1">No standard tags.</p>';
    return;
  }
  el.innerHTML = _winstonTagMapStandard.map(function(tag) {
    return '<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-togen-teal/10 text-togen-teal border border-togen-teal/20">' +
      esc(tag) +
      '<button type="button" onclick="winstonTagMapRemoveStd(' + JSON.stringify(tag) + ')" ' +
        'class="ml-0.5 text-togen-teal/50 hover:text-red-500 dark:hover:text-red-400 cursor-pointer" title="Remove">' +
        '<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="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></span>';
  }).join('');
}

function winstonTagMapAddCustom() {
  var wrap = document.getElementById('winston-tagmap-custom-add');
  if (wrap) { wrap.classList.remove('hidden'); document.getElementById('winston-tagmap-custom-key').focus(); }
}

function winstonTagMapConfirmCustom() {
  var keyEl = document.getElementById('winston-tagmap-custom-key');
  var valEl = document.getElementById('winston-tagmap-custom-val');
  var key = (keyEl ? keyEl.value : '').trim().toUpperCase();
  var val = (valEl ? valEl.value : '').trim();
  if (!key || !val) return;
  _winstonTagMapCustom[key] = val;
  if (keyEl) keyEl.value = '';
  if (valEl) valEl.value = '';
  document.getElementById('winston-tagmap-custom-add').classList.add('hidden');
  winstonTagMapRenderCustom();
}

function winstonTagMapCancelCustom() {
  document.getElementById('winston-tagmap-custom-add').classList.add('hidden');
}

function winstonTagMapRemoveCustom(key) {
  delete _winstonTagMapCustom[key];
  winstonTagMapRenderCustom();
}

function winstonTagMapAddStd() {
  var wrap = document.getElementById('winston-tagmap-std-add');
  if (wrap) { wrap.classList.remove('hidden'); document.getElementById('winston-tagmap-std-input').focus(); }
}

function winstonTagMapConfirmStd() {
  var inp = document.getElementById('winston-tagmap-std-input');
  var tag = (inp ? inp.value : '').trim().toUpperCase();
  if (!tag) return;
  if (!_winstonTagMapStandard.includes(tag)) {
    _winstonTagMapStandard.push(tag);
    winstonTagMapRenderStd();
  }
  if (inp) inp.value = '';
  document.getElementById('winston-tagmap-std-add').classList.add('hidden');
}

function winstonTagMapCancelStd() {
  document.getElementById('winston-tagmap-std-add').classList.add('hidden');
}

function winstonTagMapRemoveStd(tag) {
  _winstonTagMapStandard = _winstonTagMapStandard.filter(function(t){ return t !== tag; });
  winstonTagMapRenderStd();
}

function winstonSaveTagMap() {
  var status = document.getElementById('winston-tagmap-status');
  if (status) status.textContent = 'Saving…';
  fetch('/winston/config', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({tag_map: {custom: _winstonTagMapCustom, standard: _winstonTagMapStandard}})
  }).then(function(r){ return r.json(); }).then(function(d){
    if (status) {
      status.textContent = d.ok ? 'Saved.' : ('Error: ' + (d.error || 'unknown'));
      if (d.ok) setTimeout(function(){ if (status) status.textContent = ''; }, 3000);
    }
  }).catch(function(e){ if (status) status.textContent = 'Error: ' + e; });
}

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>';
    if (s.dry_run) enabledBadge += ' <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" title="Runs in test mode — no writes">Dry Run</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;
      document.getElementById('winston-sched-dryrun').checked = !!s.dry_run;
      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.getElementById('winston-sched-dryrun').checked = false;
    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 dryRun = !!(document.getElementById('winston-sched-dryrun') || {}).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, dry_run: dryRun, 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' +
          '<button type="button" title="Stop this run" onclick="event.stopPropagation(); winstonStopRun(\'' + esc(r.id || '') + '\')" class="ml-1 -mr-0.5 inline-flex items-center justify-center rounded-full h-4 w-4 hover:bg-blue-200 dark:hover:bg-blue-800/50">' +
          '<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.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path></svg>' +
          '</button></span>';
      } else if (r.status === 'cancelled') {
        statusBadge = '<span class="text-xs font-medium px-1.5 py-0.5 rounded bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300">Stopped</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>';
      }
      if (r.dry_run) statusBadge += ' <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" title="Test run — no ClickUp / SharePoint / draft writes">Dry Run</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.'; }
  });
}

// --- Stop a running pipeline (DVI-893) ---
function winstonStopRun(runId) {
  if (!runId) return;
  if (!window.confirm('Stop this running pipeline? It will finish the current quote, then stop.')) return;
  fetch('/winston/stop-run', {
    method: 'POST',
    headers: {'Content-Type':'application/json'},
    body: JSON.stringify({run_id: runId})
  }).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok) { window.alert((res.d && res.d.error) || 'Could not stop the run.'); }
      // Cancellation is cooperative (checked between quotes), so reload now and
      // a couple more times to reflect the status flip to "Stopped".
      winstonLoadHistory();
      setTimeout(winstonLoadHistory, 3000);
      setTimeout(winstonLoadHistory, 8000);
    }).catch(function(){ window.alert('Network error stopping the run.'); });
}

// --- 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'); }
  });
}

// DVI-1503 board follow-up: the WO-type nav tiles are now first-class surface
// catalog tiles rendered by _worRenderTilesNow, so this is just the render entry
// point (kept for its many callers). WO-type visibility (admin wor_type_visibility
// + per-user hide + the preventive role, DVI-462) is resolved by
// _worCatalogRenderable — the same rule the old hardcoded #wor-type-selector used,
// so "existing WO-type visibility + per-user hide behavior is unchanged".
function worRenderTypeSelector() {
  worRenderTiles();
}

function _worCatalog() {
  return (_tileSurfaces && _tileSurfaces.work_order_request && _tileSurfaces.work_order_request.catalog) || [];
}

// Whether a WO-type catalog tile is renderable for this user. Mirrors the former
// worRenderTypeSelector logic EXACTLY: preventive is WO-Admin/Togen-Admin only;
// every other type follows the admin visibility toggle; a user may additionally
// hide an accessible type for themselves (DVI-856).
function _worCatalogRenderable(cat) {
  if (!cat) return false;
  var type = cat.id;
  var visible;
  if (type === 'preventive') visible = _isWorAdmin || _isToGenAdmin;
  else visible = _isToGenAdmin || _worTypeVisibility[type] !== false;
  var userVis = (_userSettings && _userSettings.wor_type_visibility) || {};
  if (visible && userVis[type] === false) visible = false;
  return visible;
}

function _worActivateCatalog(cat) {
  if (!cat) return;
  if (cat.id === 'ithelpdesk') { itOpenHelpDesk(); return; }
  worSelectType(cat.id);
}

// Build one WO-type nav tile (mirrors _emBuildCatalogTile / the old hardcoded
// buttons). Clicking it opens the request flow for that type.
function _worBuildCatalogTile(cat) {
  var tile = document.createElement('button');
  tile.type = 'button';
  tile.setAttribute('data-wor-type', cat.id);
  tile.onclick = function() { _worActivateCatalog(cat); };
  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">' + cat.icon + '</div>' +
    '<div>' +
      '<p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">' + esc(cat.title) + '</p>' +
      '<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">' + esc(cat.description) + '</p>' +
    '</div>';
  return tile;
}

// ===== WO Request photo/file drag & drop (DVI-1065) =====
// Each WO type form has a Photos pane with a drag & drop zone that stages files
// into its hidden ``wor_photos`` input. The files ride along with the form's
// FormData on submit and are saved server-side by _save_wor_request_photos.
var _worReqPhotoPrefixes = ['worph-mnt', 'worph-sfty', 'worph-mm', 'worph-pm'];

function worReqPhotoInitAll() {
  _worReqPhotoPrefixes.forEach(worReqPhotoZone);
}

function worReqPhotoZone(prefix) {
  var zone = document.getElementById(prefix + '-zone');
  var input = document.getElementById(prefix + '-input');
  if (!zone || !input || zone.dataset.wired) return;
  zone.dataset.wired = '1';
  zone.addEventListener('click', function(){ input.click(); });
  input.addEventListener('change', function(){
    if (input.files && input.files.length) worReqPhotoAdd(prefix, input.files);
  });
  ['dragenter', 'dragover'].forEach(function(ev){
    zone.addEventListener(ev, function(e){
      e.preventDefault(); e.stopPropagation();
      zone.classList.add('border-togen-teal', 'bg-togen-teal/5');
    });
  });
  ['dragleave', 'drop'].forEach(function(ev){
    zone.addEventListener(ev, function(e){
      e.preventDefault(); e.stopPropagation();
      zone.classList.remove('border-togen-teal', 'bg-togen-teal/5');
    });
  });
  zone.addEventListener('drop', function(e){
    if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
      worReqPhotoAdd(prefix, e.dataTransfer.files);
    }
  });
}

// A file input's FileList is read-only, so we accumulate into a DataTransfer
// kept on the element and re-assign .files after every add/remove.
function worReqPhotoAdd(prefix, files) {
  var input = document.getElementById(prefix + '-input');
  if (!input) return;
  if (!input._dt) input._dt = new DataTransfer();
  for (var i = 0; i < files.length; i++) input._dt.items.add(files[i]);
  input.files = input._dt.files;
  worReqPhotoRender(prefix);
}

function worReqPhotoRemove(prefix, idx) {
  var input = document.getElementById(prefix + '-input');
  if (!input || !input._dt) return;
  var dt = new DataTransfer();
  for (var i = 0; i < input._dt.files.length; i++) {
    if (i !== idx) dt.items.add(input._dt.files[i]);
  }
  input._dt = dt;
  input.files = dt.files;
  worReqPhotoRender(prefix);
}

function worReqPhotoReset(prefix) {
  var input = document.getElementById(prefix + '-input');
  if (input) { input._dt = new DataTransfer(); input.value = ''; }
  worReqPhotoRender(prefix);
}

function worReqPhotoRender(prefix) {
  var input = document.getElementById(prefix + '-input');
  var list = document.getElementById(prefix + '-list');
  if (!input || !list) return;
  list.innerHTML = '';
  var files = input.files;
  for (var i = 0; i < files.length; i++) {
    (function(f, idx){
      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';
      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>';
      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(){ worReqPhotoRemove(prefix, idx); };
      row.appendChild(rm);
      list.appendChild(row);
    })(files[i], i);
  }
}

function worSelectType(type) {
  worReqPhotoInitAll();
  _worSelectedType = type;
  if (typeof _worTileStopPolling === 'function') _worTileStopPolling();   // DVI-1503
  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 = window._ssoUserName;
        var ssoEmail = window._ssoUserEmail;
        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 = window._ssoUserName;
        var ssoEmail = window._ssoUserEmail;
        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 = window._ssoUserName;
        var ssoEmail = window._ssoUserEmail;
        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);
      }
      mmEasFetchOptions();
    }
  }
}

// Mobile Machine WO — Machine type-ahead, mobile-classified equipment only (DVI-1136 Phase 6)
var _mmEasOptions = [];

function mmEasFetchOptions(cb) {
  fetch('/mm-eas-options')
    .then(function(r){ return r.json(); })
    .then(function(d){ _mmEasOptions = (d && d.options) || []; if (cb) cb(); })
    .catch(function(){ if (cb) cb(); });
}

function mmEasOnFocus() {
  if (_mmEasOptions.length === 0) { mmEasFetchOptions(mmEasRenderDropdown); }
  else { mmEasRenderDropdown(); }
}

function mmEasOnInput() { mmEasRenderDropdown(); }

function mmEasRenderDropdown() {
  var inp = document.getElementById('mm-eas-input');
  var dd = document.getElementById('mm-eas-dropdown');
  if (!inp || !dd) return;
  var q = inp.value.trim().toLowerCase();
  dd.innerHTML = '';
  var matched = _mmEasOptions.filter(function(o){ return !q || (o.name || '').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 mobile equipment — type freely to enter manually' : 'No mobile equipment registered yet — type a name to enter manually';
    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 flex items-center justify-between gap-2';
      var nm = document.createElement('span'); nm.textContent = o.name || '';
      item.appendChild(nm);
      if (o.asset_no) {
        var an = document.createElement('span');
        an.className = 'text-xs text-gray-400 dark:text-gray-500';
        an.textContent = o.asset_no;
        item.appendChild(an);
      }
      item.addEventListener('mousedown', function(e){ e.preventDefault(); mmEasSelect(o); });
      dd.appendChild(item);
    });
  }
  dd.classList.remove('hidden');
  inp.setAttribute('aria-expanded', 'true');
}

function mmEasSelect(o) {
  var inp = document.getElementById('mm-eas-input');
  var asset = document.getElementById('mm-asset-input');
  if (inp) inp.value = o.name || '';
  if (asset && o.asset_no) asset.value = o.asset_no;
  mmEasHideDropdown();
}

function mmEasHideDropdown() {
  var inp = document.getElementById('mm-eas-input');
  var dd = document.getElementById('mm-eas-dropdown');
  if (dd) dd.classList.add('hidden');
  if (inp) inp.setAttribute('aria-expanded', 'false');
}

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 = '';
  worRenderTiles();   // DVI-1503: re-show the surface's added-items stack + resume polling
}

// ===========================================================================
// DVI-1503 (DVI-1496 P7) — Work Order Request Tile Surface.
// The type tiles (#wor-type-selector) stay driven by the WO-type stores
// (worRenderTypeSelector — single source of truth for which types show + their
// order/visibility, UNCHANGED). This surface layers ONLY the added-items stack
// (Dashboard tiles / embedded dashboards / website tiles / separators) over
// tool_subpages['work_order_request'] (D-WOR2-B), reusing the shared DVI-1357
// edit engine (_editCtx / _seRenderEditGrid / _home*) exactly like Equipment.
// Catalog tokens never enter this surface's layout order, so there is no second
// order/hide system on the WOR types (verify: WO-type behavior unchanged).
// ===========================================================================
var _worSubpage = null;       // merged effective {order, separators, items}
var _worSubpageAdmin = null;  // admin default (edit scope)
var _worSubpageUser = null;   // raw per-user override (edit scope)
var _worEditMode = false;
var _worEditScope = 'user';   // 'user' (My list) | 'admin' (Default, admins only)
var _worTileData = null;
var _worTileTimer = null;

function _worUserSepMap()  { return _worEditScope === 'admin' ? {} : ((_worSubpageUser && _worSubpageUser.separators) || {}); }
function _worUserItemMap() { return _worEditScope === 'admin' ? {} : ((_worSubpageUser && _worSubpageUser.items) || {}); }

// Partition the WOR surface into separator-split sections. Board follow-up: the
// WO-type catalog tiles now participate in the layout (reorderable + mixable with
// added items), exactly like the Equipment catalog. A catalog token renders only
// when _worCatalogRenderable (WO-type visibility); missing renderable catalog
// tiles are appended so a stored order that predates a type still shows it.
function _worPartition() {
  var layout = _worSubpage || { order: [], separators: {}, items: {} };
  var items = layout.items || {};
  var catById = {};
  _worCatalog().forEach(function(c) { catById[c.id] = c; });
  var seen = {};
  return _surfacePartitionEntries({
    order: layout.order || [],
    isSep: _isSeparatorToken,
    entryFor: function(tid) {
      if (_isWebItemToken(tid)) { var w = _webItemProps(tid, items, {}); return w ? { kind: 'web', id: tid, item: w } : null; }
      if (_isDashItemToken(tid)) { var dt = _dtileItemProps(tid, items, {}); return dt ? { kind: 'dtile', id: tid, item: dt } : null; }
      if (_isDashRefToken(tid)) { var dr = _dashRefProps(tid, items, {}); return dr ? { kind: 'dashref', id: tid, item: dr } : null; }
      if (catById[tid] && !seen[tid] && _worCatalogRenderable(catById[tid])) {
        seen[tid] = true;
        return { kind: 'catalog', id: tid, cat: catById[tid] };
      }
      return null;
    },
    appendMissing: function(bucket) {
      _worCatalog().forEach(function(c) {
        if (!seen[c.id] && _worCatalogRenderable(c)) { seen[c.id] = true; bucket.push({ kind: 'catalog', id: c.id, cat: c }); }
      });
    }
  });
}
function _worBuildEntry(e) {
  if (e.kind === 'web') return _buildWebTile(e.item);
  if (e.kind === 'dtile') return _buildDashTile(e.item, e.id);
  if (e.kind === 'dashref') return _buildDashRef(e.item, e.id);
  if (e.kind === 'catalog') return _worBuildCatalogTile(e.cat);
  return document.createElement('div');
}

// Render the WOR added-items region (called whenever the type selector is shown).
function worRenderTiles() {
  if (_worSubpage) { _worRenderTilesNow(); return; }
  _worLoadSubpage(_worRenderTilesNow);
}
function _worLoadSubpage(cb) {
  var empty = { order: [], separators: {}, items: {} };
  fetch('/tools/subpage/work_order_request')
    .then(function(r) { return r.json(); })
    .then(function(d) {
      d = d || {};
      _worSubpage = (d.order || d.separators || d.items) ? { order: d.order || [], separators: d.separators || {}, items: d.items || {} } : empty;
      _worSubpageAdmin = d.admin || { order: [], separators: {}, items: {} };
      _worSubpageUser = d.user || { order: [], separators: {}, items: {} };
      if (cb) cb();
    })
    .catch(function() { _worSubpage = empty; _worSubpageAdmin = { order: [], separators: {}, items: {} }; _worSubpageUser = { order: [], separators: {}, items: {} }; if (cb) cb(); });
}
function _worRenderTilesNow() {
  var list = document.getElementById('wor-tiles-list');
  if (!list) return;
  if (_worEditMode) { _editCtx = _worEditCtx(); _seRenderEditGrid(); return; }
  list.innerHTML = '';
  var part = _worPartition();
  var seps = (_worSubpage && _worSubpage.separators) || {};
  var count = 0;
  // DVI-1521: a leading separator lives on section 0, so the sectioned path also
  // runs for a single section that carries one.
  if (part.sections.length > 1 || (part.sections[0] && part.sections[0].sepId)) {
    list.className = 'flex flex-col gap-5';
    list.style.gridTemplateColumns = '';
    part.sections.forEach(function(sec, i) {
      if (sec.sepId) list.appendChild(_makeTileDivider(sec.sepId, { props: _sepProps(sec.sepId, seps, {}), onToggle: worRenderTiles }));
      if (sec.sepId && _sepIsCollapsed(sec.sepId, _sepProps(sec.sepId, seps, {}))) return;
      var secEl = document.createElement('div');
      secEl.className = 'tile-grid-section grid grid-cols-1 sm:grid-cols-2 gap-5';
      secEl.setAttribute('data-sep-columns', String(_sepProps(sec.sepId, seps, {}).columns));
      sec.entries.forEach(function(e) { secEl.appendChild(_worBuildEntry(e)); count++; });
      list.appendChild(secEl);
    });
  } else {
    list.className = 'grid grid-cols-1 sm:grid-cols-2 gap-5';
    list.style.gridTemplateColumns = '';
    part.sections[0].entries.forEach(function(e) { list.appendChild(_worBuildEntry(e)); count++; });
  }
  _applyTileGridColumns(list, 2);
  // Empty + not editing → hide the region so the landing looks exactly as before.
  list.classList.toggle('hidden', count === 0);
  _worTileStartPolling();
}

// ---- WOR surface live-data polling (mirrors the Equipment poller) ------------
function _worTilesVisible() {
  if (_activeTool !== 'work-order' || _worEditMode) return false;
  var sel = document.getElementById('wor-type-selector');
  if (!sel || sel.classList.contains('hidden')) return false;
  var list = document.getElementById('wor-tiles-list');
  return !!(list && !list.classList.contains('hidden'));
}
function _worTileDataFetch() {
  if (!_worTilesVisible()) { _worTileStopPolling(); return; }
  fetch('/tools/tile-data?surface=work_order_request').then(function(r) { return r.json(); }).then(function(d) {
    if (!d) return;
    _worTileData = d;
    var items = (_worSubpage && _worSubpage.items) || {};
    if (d.tiles) {
      Object.keys(d.tiles).forEach(function(iid) {
        var body = document.querySelector('#wor-tiles-list .dash-tile-body[data-tile-id="' + iid + '"]');
        var props = items[iid];
        if (!body || !props || props.type !== 'dtile') return;
        dashboardsRenderTileBody(body, { id: iid, type: props.tile_type, config: props.config }, d.tiles[iid]);
      });
    }
    if (d.dashrefs) {
      Object.keys(d.dashrefs).forEach(function(iid) {
        var body = document.querySelector('#wor-tiles-list .dashref-body[data-dashref-id="' + iid + '"]');
        var props = items[iid];
        if (!body || !props || props.type !== 'dashref') return;
        _buildDashRefBody(body, iid, props, d.dashrefs[iid]);
      });
    }
  }).catch(function() {});
}
function _worTileStartPolling() {
  _worTileStopPolling();
  if (!_worTilesVisible()) return;
  _worTileDataFetch();
  _worTileTimer = setInterval(_worTileDataFetch, 60000);
}
function _worTileStopPolling() { if (_worTileTimer) { clearInterval(_worTileTimer); _worTileTimer = null; } }

// ---- WOR surface EDIT mode (reuses the shared DVI-1357 _editCtx engine) ------
function _worEditCtx() {
  var aItems = function() { return (_worSubpageAdmin && _worSubpageAdmin.items) || {}; };
  var aSeps  = function() { return (_worSubpageAdmin && _worSubpageAdmin.separators) || {}; };
  return {
    surface: 'work_order_request',
    scope: function() { return _worEditScope; },
    toastId: 'wor-edit-toast',
    gridEl: function() { return document.getElementById('wor-tiles-list'); },
    adminOrder: function() { return (_worSubpageAdmin && _worSubpageAdmin.order) || []; },
    adminSepMap: aSeps,
    adminItemMap: aItems,
    sepProps: function(id) { return _sepProps(id, aSeps(), _worUserSepMap()); },
    baseOrder: function() {
      var o = (_worEditScope === 'admin')
        ? ((_worSubpageAdmin && _worSubpageAdmin.order) || [])
        : ((_worSubpageUser && _worSubpageUser.order && _worSubpageUser.order.length) ? _worSubpageUser.order : ((_worSubpageAdmin && _worSubpageAdmin.order) || []));
      return o.slice();
    },
    setBaseOrder: function(order) {
      if (_worEditScope === 'admin') { _worSubpageAdmin = _worSubpageAdmin || {}; _worSubpageAdmin.order = order; }
      else { _worSubpageUser = _worSubpageUser || {}; _worSubpageUser.order = order; }
    },
    removeMsg: 'Remove this from the Work Order Request page?',
    renderInto: function(grid) {
      grid.className = 'grid grid-cols-1 sm:grid-cols-2 gap-4 home-edit-grid';
      grid.style.gridTemplateColumns = '';
      var order = this.baseOrder();
      var catById = {};
      _worCatalog().forEach(function(c) { catById[c.id] = c; });
      var seen = {};
      var aI = aItems(), uI = _worUserItemMap();
      order.forEach(function(tid) {
        var el = null, entry = null;
        if (_isSeparatorToken(tid)) { grid.appendChild(_homeEditDivider(tid)); return; }
        if (_isWebItemToken(tid)) { var w = _webItemProps(tid, aI, uI); if (!w) return; el = _buildWebTile(w); entry = { kind: 'web', id: tid, item: w, editable: true }; }
        else if (_isDashItemToken(tid)) { var dt = _dtileItemProps(tid, aI, uI); if (!dt) return; el = _buildDashTile(dt, tid); entry = { kind: 'dtile', id: tid, item: dt, editable: true }; }
        else if (_isDashRefToken(tid)) { var dr = _dashRefProps(tid, aI, uI); if (!dr) return; el = _buildDashRef(dr, tid); entry = { kind: 'dashref', id: tid, item: dr, editable: true }; }
        else if (catById[tid] && !seen[tid] && _worCatalogRenderable(catById[tid])) { seen[tid] = true; el = _worBuildCatalogTile(catById[tid]); entry = { kind: 'catalog', id: tid, cat: catById[tid] }; }
        else return;   // a non-renderable catalog token is preserved by _homeMergeHiddenOrder
        grid.appendChild(_homeDecorateEntry(el, entry));
      });
      // Append any renderable WO-type catalog tiles missing from the order (a WO
      // type is reorderable but NOT removable — like a home tool / Equipment tile).
      _worCatalog().forEach(function(c) {
        if (seen[c.id] || !_worCatalogRenderable(c)) return;
        seen[c.id] = true;
        grid.appendChild(_homeDecorateEntry(_worBuildCatalogTile(c), { kind: 'catalog', id: c.id, cat: c }));
      });
    },
    commit: _worCommitScope,
    foldEditProps: _worFoldEditProps,
    foldRemoveProps: _worFoldRemoveProps,
  };
}
function _worCommitScope() {
  if (_worEditScope === 'admin') {
    _homePost('/togen-admin/tools/subpage/work_order_request', {
      order: (_worSubpageAdmin.order || []),
      separators: (_worSubpageAdmin.separators || {}),
      items: (_worSubpageAdmin.items || {})
    }, function(d) {
      if (d.admin) _worSubpageAdmin = d.admin;
      if (d.order || d.separators || d.items) _worSubpage = { order: d.order || [], separators: d.separators || {}, items: d.items || {} };
    });
  } else {
    // Materialize the order so an empty user order (a first gear-edit with no
    // reorder) is never sent — the route treats fully-empty as "reset/clear".
    var ord = (_worSubpageUser.order && _worSubpageUser.order.length) ? _worSubpageUser.order.slice() : ((_worSubpageAdmin.order || []).slice());
    _worSubpageUser = _worSubpageUser || {}; _worSubpageUser.order = ord;
    _homePost('/tools/subpage/work_order_request', {
      order: ord,
      separators: (_worSubpageUser.separators || {}),
      items: (_worSubpageUser.items || {})
    }, function(d) {
      if (d.user) _worSubpageUser = d.user;
      if (d.order || d.separators || d.items) _worSubpage = { order: d.order || [], separators: d.separators || {}, items: d.items || {} };
    });
  }
}
function _worFoldEditProps(tid, isSep, props, wasEdited) {
  if (_worEditScope === 'admin') {
    _worSubpageAdmin = _worSubpageAdmin || {};
    if (isSep) { _worSubpageAdmin.separators = _worSubpageAdmin.separators || {}; _worSubpageAdmin.separators[tid] = props; }
    else { _worSubpageAdmin.items = _worSubpageAdmin.items || {}; _worSubpageAdmin.items[tid] = props; }
  } else {
    _worSubpageUser = _worSubpageUser || {};
    if (isSep) { _worSubpageUser.separators = _worSubpageUser.separators || {}; if (wasEdited) _worSubpageUser.separators[tid] = props; else delete _worSubpageUser.separators[tid]; }
    else { _worSubpageUser.items = _worSubpageUser.items || {}; if (wasEdited) _worSubpageUser.items[tid] = props; else delete _worSubpageUser.items[tid]; }
  }
}
function _worFoldRemoveProps(tid, isSep) {
  if (_worEditScope === 'admin') {
    if (isSep) { if (_worSubpageAdmin && _worSubpageAdmin.separators) delete _worSubpageAdmin.separators[tid]; }
    else if (_worSubpageAdmin && _worSubpageAdmin.items) delete _worSubpageAdmin.items[tid];
  } else {
    if (isSep) { if (_worSubpageUser && _worSubpageUser.separators) delete _worSubpageUser.separators[tid]; }
    else if (_worSubpageUser && _worSubpageUser.items) delete _worSubpageUser.items[tid];
  }
}

function worEditToggle() {
  _worEditMode = !_worEditMode;
  _homePendingAddId = null;
  _worCloseAddMenu();
  var toolbar = document.getElementById('wor-edit-toolbar');
  var editBtn = document.getElementById('wor-tiles-edit');
  var lbl = document.getElementById('wor-tiles-edit-label');
  var list = document.getElementById('wor-tiles-list');
  if (toolbar) toolbar.classList.toggle('hidden', !_worEditMode);
  if (editBtn) { editBtn.classList.toggle('bg-togen-teal', _worEditMode); editBtn.classList.toggle('text-white', _worEditMode); }
  if (lbl) lbl.textContent = _worEditMode ? 'Done' : 'Edit';
  if (list && _worEditMode) list.classList.remove('hidden');   // edit grid must show even when empty
  if (_worEditMode) {
    _worTileStopPolling();   // no live polling while editing the layout
    if (!_isToGenAdmin) _worEditScope = 'user';
    _worUpdateScopeButtons();
    if (_worSubpageAdmin && _worSubpageUser) { _editCtx = _worEditCtx(); _seRenderEditGrid(); }
    else _worLoadSubpage(function() { _editCtx = _worEditCtx(); _seRenderEditGrid(); });
  } else {
    _editCtx = null;
    worRenderTiles();   // restore the normal (clickable) added-items view
  }
}
function worEditSetScope(scope) {
  if (!_isToGenAdmin && scope === 'admin') return;   // only admins edit the Default list
  _worEditScope = (scope === 'admin') ? 'admin' : 'user';
  _homePendingAddId = null;
  _worUpdateScopeButtons();
  if (_worEditMode) { _editCtx = _worEditCtx(); _seRenderEditGrid(); }
}
function _worUpdateScopeButtons() {
  var u = document.getElementById('wor-scope-user');
  var a = document.getElementById('wor-scope-admin');
  var onCls = ['bg-togen-teal', 'text-white'];
  var offCls = ['text-gray-600', 'dark:text-gray-300'];
  function set(btn, on) { if (!btn) return; onCls.forEach(function(c) { btn.classList.toggle(c, on); }); offCls.forEach(function(c) { btn.classList.toggle(c, !on); }); }
  set(u, _worEditScope === 'user');
  set(a, _worEditScope === 'admin');
  var reset = document.getElementById('wor-edit-reset');
  if (reset) reset.classList.toggle('hidden', _worEditScope !== 'user');
}
function worEditToggleAddMenu(e) { if (e) e.stopPropagation(); var m = document.getElementById('wor-edit-add-menu'); if (m) m.classList.toggle('hidden'); }
function _worCloseAddMenu() { var m = document.getElementById('wor-edit-add-menu'); if (m) m.classList.add('hidden'); }
function worEditAddSeparator() {
  _worCloseAddMenu();
  var id = _mintSeparatorId();
  var ord = _editCtx.baseOrder(); ord.push(id); _editCtx.setBaseOrder(ord);
  _homeCommit();   // a bare separator needs no props
}
function worEditAddWebsite() {
  _worCloseAddMenu();
  var id = _mintWebItemId();
  var el = document.createElement('div');
  el.setAttribute('data-tool-id', id); el.setAttribute('data-web-new', '1');
  _webSeedElementProps(el, { type: 'web', name: '', url: '', mode: 'newtab', icon_mode: 'favicon', icon: '', glyph: '' });
  _homePendingAddId = id;
  _webItemEditOpen(el, _seScope());
}
function worEditAddDashTile() {
  _worCloseAddMenu();
  var id = _mintDashItemId();
  var el = document.createElement('div');
  el.setAttribute('data-tool-id', id); el.setAttribute('data-dash-new', '1');
  _dtileSeedElementProps(el, { type: 'dtile', tile_type: '', config: {}, size: 'default', title: '' });
  _homePendingAddId = id;
  _dtileEditOpen(el, _seScope());
}
function worEditAddDashRef() {
  _worCloseAddMenu();
  var id = _mintDashRefId();
  var el = document.createElement('div');
  el.setAttribute('data-tool-id', id); el.setAttribute('data-dashref-new', '1');
  _dashRefSeedElementProps(el, { type: 'dashref', dashboard_id: '', title: '', justify: 'center', collapse: 'none', collapsed: false, columns: 'auto', rows: 'auto' });
  _homePendingAddId = id;
  _dashRefEditOpen(el, _seScope());
}
function worResetMyLayout() {
  if (!window.confirm('Reset the Work Order Request page to the default layout? This clears your personal changes.')) return;
  _seResetUserLayout('/tools/subpage/work_order_request', { order: [], separators: {}, items: {} }, function(d) {
    _worSubpageUser = d.user || { order: [], separators: {}, items: {} };
    if (d.order || d.separators || d.items) _worSubpage = { order: d.order || [], separators: d.separators || {}, items: d.items || {} };
    else _worSubpage = { order: [], separators: {}, items: {} };
    if (_worEditMode) { _editCtx = _worEditCtx(); _seRenderEditGrid(); }
    else worRenderTiles();
  });
}

// ===== WORK ORDER REQUEST (MAINTENANCE) =====

var _worCurrentFilename = '';
var _worEditFilename = '';

function worSubmit(e, isTest) {
  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...' : (isTest ? 'Creating test WO...' : 'Submitting...');

  var fd = new FormData(form);
  var url = '/wor-submit';
  if (_worEditFilename && _isWorAdmin) {
    url = '/wor-edit';
    fd.append('edit_filename', _worEditFilename);
  } else if (isTest) {
    fd.append('test', '1');  // DVI-1082: segregated test WO
  }
  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!' : (d.is_test ? 'Test WO created (segregated — not in audit trail).' : 'Work order submitted!');
      worReqPhotoReset('worph-mnt');
      _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;
    });
}

// WOR maintenance form — Equipment/Asset/System type-ahead + RtS lookup (DVI-1014)
var _worEasOptions = [];
var _worRtsDebounce = null;

function worEasOnFocus() {
  worEasFetchOptions(worEasRenderDropdown);
}

function worEasOnInput() {
  worEasRenderDropdown();
  clearTimeout(_worRtsDebounce);
  _worRtsDebounce = setTimeout(worRtsLookup, 400);
}

function worEasFetchOptions(cb) {
  fetch('/wor-eas-options?mobility=stationary')
    .then(function(r){ return r.json(); })
    .then(function(d){ _worEasOptions = (d && d.options) || []; if (cb) cb(); })
    .catch(function(){ if (cb) cb(); });
}

function worEasRenderDropdown() {
  var inp = document.getElementById('wor-eas-input');
  var dd = document.getElementById('wor-eas-dropdown');
  if (!inp || !dd) return;
  var q = inp.value.trim().toLowerCase();
  dd.innerHTML = '';
  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(); worEasHideDropdown(); inp.focus(); });
  dd.appendChild(neu);
  var matched = _worEasOptions.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;
      item.addEventListener('mousedown', function(e){ e.preventDefault(); worEasSelect(o); });
      dd.appendChild(item);
    });
  }
  dd.classList.remove('hidden');
  inp.setAttribute('aria-expanded', 'true');
}

function worEasSelect(value) {
  var inp = document.getElementById('wor-eas-input');
  if (!inp) return;
  inp.value = value;
  worEasHideDropdown();
  worRtsLookup();
}

function worEasHideDropdown() {
  var inp = document.getElementById('wor-eas-input');
  var dd = document.getElementById('wor-eas-dropdown');
  if (dd) dd.classList.add('hidden');
  if (inp) inp.setAttribute('aria-expanded', 'false');
}

function worRtsLookup() {
  var inp = document.getElementById('wor-eas-input');
  var sec = document.getElementById('wor-rts-section');
  if (!inp || !sec) return;
  var name = inp.value.trim();
  if (!name) { sec.classList.add('hidden'); return; }
  sec.classList.remove('hidden');
  fetch('/wor-equipment-rts?equipment=' + encodeURIComponent(name))
    .then(function(r){ return r.json(); })
    .then(function(d){ worRtsApply(d); })
    .catch(function(){ worRtsApply(null); });
}

function worRtsApply(data) {
  var cb = document.getElementById('wor-rts-required');
  var note = document.getElementById('wor-rts-forced-note');
  if (!cb) return;
  var found = !!(data && data.found);
  var forced = found && data.rts_required;
  if (forced) {
    cb.checked = true;
    cb.disabled = true;
    if (note) note.classList.remove('hidden');
  } else {
    cb.disabled = false;
    if (note) note.classList.add('hidden');
  }
  var deptNames = ['maintenance', 'operations', 'safety', 'quality'];
  var deptMap = (found && data.rts_departments) ? data.rts_departments : null;
  deptNames.forEach(function(d){
    var el = document.getElementById('wor-rts-dept-' + d);
    if (!el) return;
    el.checked = deptMap ? !!deptMap[d] : true;
    el.disabled = forced;
  });
  worRtsToggleDepts();
}

function worRtsToggleDepts() {
  var cb = document.getElementById('wor-rts-required');
  var depts = document.getElementById('wor-rts-depts');
  if (!cb || !depts) return;
  if (cb.checked) depts.classList.remove('hidden');
  else depts.classList.add('hidden');
}

function worRtsReset() {
  var sec = document.getElementById('wor-rts-section');
  var cb = document.getElementById('wor-rts-required');
  var note = document.getElementById('wor-rts-forced-note');
  var depts = document.getElementById('wor-rts-depts');
  if (sec) sec.classList.add('hidden');
  if (cb) { cb.checked = false; cb.disabled = false; }
  if (note) note.classList.add('hidden');
  if (depts) depts.classList.add('hidden');
  ['maintenance', 'operations', 'safety', 'quality'].forEach(function(d){
    var el = document.getElementById('wor-rts-dept-' + d);
    if (el) { el.checked = true; el.disabled = false; }
  });
}

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 = window._ssoUserName;
  var ssoEmail = window._ssoUserEmail;
  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';
  worRtsReset();
  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="togen-control 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="togen-control 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, isTest) {
  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...' : (isTest ? 'Creating test WO...' : 'Submitting...');

  var fd = new FormData(form);
  var url = '/safety-wor-submit';
  if (_safetyEditFilename && _isWorAdmin) {
    url = '/safety-wor-edit';
    fd.append('edit_filename', _safetyEditFilename);
  } else if (isTest) {
    fd.append('test', '1'); // DVI-1082: segregated test WO
  }
  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!' : (d.is_test ? 'Test WO created (segregated — not in audit trail).' : 'Work order submitted!');
      worReqPhotoReset('worph-sfty');
      _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 = window._ssoUserName;
  var ssoEmail = window._ssoUserEmail;
  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="togen-control 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="togen-control 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, isTest) {
  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...' : (isTest ? 'Creating test WO...' : 'Submitting...');

  var fd = new FormData(form);
  var url = '/mm-wor-submit';
  if (_mmEditFilename && _isWorAdmin) {
    url = '/mm-wor-edit';
    fd.append('edit_filename', _mmEditFilename);
  } else if (isTest) {
    fd.append('test', '1'); // DVI-1082: segregated test WO
  }
  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!' : (d.is_test ? 'Test WO created (segregated — not in audit trail).' : 'Work order submitted!');
      worReqPhotoReset('worph-mm');
      _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="togen-control 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="togen-control 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, isTest) {
  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...' : (isTest ? 'Creating test WO...' : 'Submitting...');

  var fd = new FormData(form);
  var url = '/preventive-wor-submit';
  if (_pmEditFilename && _isWorAdmin) {
    url = '/preventive-wor-edit';
    fd.append('edit_filename', _pmEditFilename);
  } else if (isTest) {
    fd.append('test', '1'); // DVI-1082: segregated test WO
  }
  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!' : (d.is_test ? 'Test WO created (segregated — not in audit trail).' : 'Work order submitted!');
      worReqPhotoReset('worph-pm');
      _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 = window._ssoUserName;
  var ssoEmail = window._ssoUserEmail;
  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?mobility=stationary')
    .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="togen-control 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="togen-control 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 RPM is enabled. DVI-1496 P3:
// RPM is now the Equipment RPM sub-tool, so "enabled" is its Tools-Enabled
// admin-default value (tile_surface_subtools.json, seeded on deploy from the
// retired repair-parts-manager tile) rather than the old top-level tool flag.
var _rpmAccess = _isToGenAdmin || (_isWorAdmin &&
  ((_subtoolVisibilityAdmin.equipment || {}).rpm !== false));
var _woradminData = [];
var _woradminCurrentFilename = '';
var _woradminEmailTarget = '';
var _woradminCurrentType = '';
var _woradminManual = null;
var _woradminTab = 'all';
var _woradminActivity = null;
var _woradminActivityCollapsed = false;
var _woradminActivityDefaultApplied = false;
var _woradminTestMode = false; // DVI-1082: show segregated test WOs

function woradminToggleTestMode() {
  var cb = document.getElementById('woradmin-testmode');
  _woradminTestMode = !!(cb && cb.checked);
  var sec = document.getElementById('woradmin-list-section');
  if (sec) sec.classList.toggle('ring-2', _woradminTestMode);
  if (sec) sec.classList.toggle('ring-amber-400', _woradminTestMode);
  woradminLoadList();
}

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' + (_woradminTestMode ? '?test=1' : ''))
    .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();
      // Keep the Approvals view current when it is the active tab (it renders
      // from _woradminData, which this fetch just replaced).
      if (_woradminTab === 'approvals') woradminRenderApprovals();
    })
    .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');
  });
}

// DVI-1024: Approvals-only mode for approver-only users — hide every tab
// except Approvals (and the admin-only Activity panel stays hidden because
// woradminLoadActivity is never called). Runs after woradminRenderTabs,
// which may have unhidden the WO-type tabs.
function woradminApplyApprovalsOnly() {
  ['all','maintenance','mobilemachine','safety','preventive','recurring','calendar','settings'].forEach(function(t){
    var el = document.getElementById('woradmin-tab-' + t);
    if (el) el.classList.add('hidden');
  });
}

function woradminSetTab(tab) {
  if (_worApprovalsOnly) tab = 'approvals';
  _woradminTab = tab;
  ['all','maintenance','mobilemachine','safety','preventive','recurring','approvals','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');
  var approvalsView = document.getElementById('woradmin-approvalsview');
  if (tab === 'settings') {
    if (listView) listView.classList.add('hidden');
    if (calendarView) calendarView.classList.add('hidden');
    if (approvalsView) approvalsView.classList.add('hidden');
    if (settingsView) settingsView.classList.remove('hidden');
    notifyBuildGridRows('woradmin-notify-grid');
    woradminLoadPolicy();
    woradminLoadPrimary();
    woradminLoadApprovers();
    return;
  }
  if (tab === 'calendar') {
    if (listView) listView.classList.add('hidden');
    if (settingsView) settingsView.classList.add('hidden');
    if (approvalsView) approvalsView.classList.add('hidden');
    if (calendarView) calendarView.classList.remove('hidden');
    woracalInit();
    return;
  }
  if (tab === 'approvals') {
    if (listView) listView.classList.add('hidden');
    if (settingsView) settingsView.classList.add('hidden');
    if (calendarView) calendarView.classList.add('hidden');
    if (approvalsView) approvalsView.classList.remove('hidden');
    woradminRenderApprovals();
    return;
  }
  if (listView) listView.classList.remove('hidden');
  if (settingsView) settingsView.classList.add('hidden');
  if (calendarView) calendarView.classList.add('hidden');
  if (approvalsView) approvalsView.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,"\\'");
}

// Shared renderer for the DVI-1033 Completion Details captured at mark-complete.
// Used on the preview pane (full) and each RtS Approval row (compact). Returns
// '' when there is nothing to show so callers can hide their container.
function woradminCompletionHtml(completion, opts) {
  if (!completion) return '';
  opts = opts || {};
  var wd = (completion.work_description || '').trim();
  var fu = (completion.outstanding_followup || '').trim();
  var by = (completion.completed_by || '').trim();
  var at = (completion.completed_at || '').trim();
  if (!wd && !fu) return '';
  var atFmt = at;
  if (at) {
    // DVI-1364 P2: keep the UTC interpretation (append Z if absent) then apply
    // the resolved regional date/time format + timezone.
    var iso = at + (at.endsWith('Z') ? '' : 'Z');
    atFmt = _fmtDateTime(iso) || at;
  }
  var meta = '';
  if (by || atFmt) {
    meta = '<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Completed'
      + (by ? ' by ' + woradminEscapeHtml(by) : '')
      + (atFmt ? ' on ' + woradminEscapeHtml(atFmt) : '') + '</p>';
  }
  var fields = '';
  if (wd) {
    fields += '<div><p class="text-xs font-medium text-gray-600 dark:text-gray-300">Description of Work Performed</p>'
      + '<p class="text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap">' + woradminEscapeHtml(wd) + '</p></div>';
  }
  if (fu) {
    fields += '<div class="mt-2"><p class="text-xs font-medium text-gray-600 dark:text-gray-300">Outstanding Follow-up Items</p>'
      + '<p class="text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap">' + woradminEscapeHtml(fu) + '</p></div>';
  }
  var pad = opts.compact ? 'p-2 mt-2' : 'p-3';
  return '<div class="rounded-lg border border-togen-teal/40 dark:border-teal-700/50 bg-teal-50/50 dark:bg-teal-900/10 ' + pad + '">'
    + '<p class="text-xs font-semibold uppercase tracking-wide text-togen-teal dark:text-teal-300 mb-1">Completion Details</p>'
    + fields + meta + '</div>';
}

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'; }
    });
}

// ===== AUTHORIZED APPROVERS (DVI-1013) =====
var _woradminApprovers = {};
var _RTS_DEPT_LABELS = {maintenance: 'Maintenance', operations: 'Operations', safety: 'Safety', quality: 'Quality'};
var _RTS_DEPT_NAMES = ['maintenance', 'operations', 'safety', 'quality'];

function woradminLoadApprovers() {
  fetch('/wor-approvers')
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d && d.approvers) { _woradminApprovers = d.approvers; woradminRenderApprovers(); }
    })
    .catch(function(){});
}

function woradminRenderApprovers() {
  var container = document.getElementById('woradmin-approvers-lists');
  if (!container) return;
  container.innerHTML = '';
  _RTS_DEPT_NAMES.forEach(function(dept) {
    var emails = _woradminApprovers[dept] || [];
    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="woradminApproverRemove(\'' + dept + '\',\'' + woradminEscapeAttr(em) + '\')" title="Remove" class="text-gray-400 hover:text-red-500 cursor-pointer">&times;</button>' +
            '</span>';
        }).join('')
      : '<span class="text-xs text-amber-600 dark:text-amber-400 italic">No approvers — a warning will show when this department\'s sign-off is required.</span>';
    block.innerHTML =
      '<span class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">' + (_RTS_DEPT_LABELS[dept] || dept) + '</span>' +
      '<div class="flex items-center gap-2">' +
        '<input type="email" id="woradmin-approver-input-' + dept + '" placeholder="email@example.com" ' +
          'onkeydown="if(event.key===\'Enter\'){event.preventDefault();woradminApproverAdd(\'' + dept + '\');}" ' +
          '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="woradminApproverAdd(\'' + dept + '\')" ' +
          '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 woradminApproverAdd(dept) {
  var input = document.getElementById('woradmin-approver-input-' + dept);
  if (!input) return;
  var email = (input.value || '').trim().toLowerCase();
  if (!email || email.indexOf('@') === -1) { alert('Enter a valid email address.'); return; }
  woradminApproverMutate(dept, 'add', email, input);
}

function woradminApproverRemove(dept, email) {
  woradminApproverMutate(dept, 'remove', email, null);
}

function woradminApproverMutate(dept, action, email, inputEl) {
  var status = document.getElementById('woradmin-approvers-status');
  if (status) { status.textContent = 'Saving…'; status.className = 'block mt-3 text-sm text-gray-500 dark:text-gray-400'; }
  fetch('/wor-approvers', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({dept: dept, 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;
      }
      _woradminApprovers = d.approvers;
      if (inputEl) inputEl.value = '';
      woradminRenderApprovers();
      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'; }
    });
}

// ===== Approvals tab (DVI-1016) =====
var _RTS_DEPT_LABELS_SHORT = {maintenance:'Maintenance', operations:'Operations', safety:'Safety', quality:'Quality'};

function woradminRenderApprovals() {
  var container = document.getElementById('woradmin-approvals-list');
  var warnEl = document.getElementById('woradmin-approvals-zero-warn');
  var warnText = document.getElementById('woradmin-approvals-zero-warn-text');
  if (!container) return;

  var pending = _woradminData.filter(function(f){ return f.evh_status === 'awaiting_approval'; });
  if (pending.length === 0) {
    container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No work orders are currently awaiting Return-to-Service approval.</p>';
    if (warnEl) warnEl.classList.add('hidden');
    return;
  }

  // Collect zero-approver warnings across all pending WOs
  var zeroWarnDepts = {};
  pending.forEach(function(f){
    (f.evh_zero_approver_depts || []).forEach(function(d){ zeroWarnDepts[d] = true; });
  });
  var zeroKeys = Object.keys(zeroWarnDepts);
  if (warnEl && warnText) {
    // Approver-only users can't reach Settings → Authorized Approvers, so the
    // remediation hint is admin-only (DVI-1024).
    if (zeroKeys.length && !_worApprovalsOnly) {
      var deptLabels = zeroKeys.map(function(d){ return _RTS_DEPT_LABELS_SHORT[d] || d; }).join(', ');
      warnText.textContent = 'No approvers are configured for: ' + deptLabels + '. Go to Settings → Authorized Approvers to add them.';
      warnEl.classList.remove('hidden');
    } else {
      warnEl.classList.add('hidden');
    }
  }

  var html = '';
  pending.forEach(function(f){
    var daysAwaiting = 0;
    if (f.evh_initialized_at) {
      var initMs = new Date(f.evh_initialized_at + (f.evh_initialized_at.endsWith('Z') ? '' : 'Z')).getTime();
      if (!isNaN(initMs)) daysAwaiting = Math.floor((Date.now() - initMs) / 86400000);
    }
    var daysClass = daysAwaiting >= 3 ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-amber-600 dark:text-amber-400';

    var deptRows = (f.evh_required_depts || []).map(function(dept){
      var signOff = (f.evh_sign_offs || {})[dept];
      var isActionable = (f.evh_actionable_depts || []).indexOf(dept) !== -1;
      var statusBadge;
      if (signOff && signOff.action === 'approve') {
        statusBadge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300">Approved</span>';
      } else if (signOff && signOff.action === 'reject') {
        statusBadge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">Rejected</span>';
      } else if (isActionable) {
        var zeroWarn = (f.evh_zero_approver_depts || []).indexOf(dept) !== -1
          ? ' <span class="text-amber-600 dark:text-amber-400" title="No approvers configured">&#9888;</span>' : '';
        statusBadge = '<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">Pending' + zeroWarn + '</span>';
      } else {
        statusBadge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Waiting</span>';
      }
      var actionBtns = '';
      if ((f.evh_user_actionable || []).indexOf(dept) !== -1) {
        var fnEsc = esc(f.name).replace(/'/g, "\\'");
        var typeEsc = esc(f.wor_type || 'maintenance').replace(/'/g, "\\'");
        actionBtns = '<span class="flex-1"></span>' +
          '<button type="button" onclick="event.stopPropagation();evhOpenApprove(\'' + fnEsc + '\',\'' + typeEsc + '\',\'' + dept + '\')" ' +
            'class="px-2.5 py-0.5 text-xs font-medium rounded-md bg-green-600 hover:bg-green-700 text-white transition-colors">Approve</button>' +
          '<button type="button" onclick="event.stopPropagation();evhOpenReject(\'' + fnEsc + '\',\'' + typeEsc + '\',\'' + dept + '\')" ' +
            'class="px-2.5 py-0.5 text-xs font-medium rounded-md bg-red-600 hover:bg-red-700 text-white transition-colors">Reject</button>';
      }
      return '<div class="flex items-center gap-2 text-xs">' +
        '<span class="w-24 text-gray-600 dark:text-gray-400 flex-shrink-0">' + (_RTS_DEPT_LABELS_SHORT[dept] || dept) + '</span>' +
        statusBadge +
        (signOff && signOff.approver_email ? '<span class="text-gray-400 dark:text-gray-500 truncate">' + woradminEscapeHtml(signOff.approver_email) + '</span>' : '') +
        actionBtns +
        '</div>';
    }).join('');

    var equipment = f.evh_equipment ? '<span class="text-xs text-gray-500 dark:text-gray-400">' + woradminEscapeHtml(f.evh_equipment) + '</span>' : '';

    // Row click jumps to the admin list preview — admin-only surface, so the
    // row is inert for approver-only users (their actions are the dept
    // Approve/Reject buttons below, DVI-1024).
    var rowNav = _worApprovalsOnly ? '' :
      ' onclick="woradminSetTab(\'all\');setTimeout(function(){woradminPreview(\'' + esc(f.name).replace(/'/g, "\\'") + '\',\'' + esc(f.wor_type || 'maintenance') + '\');},50)"';
    var rowCls = _worApprovalsOnly ? '' : ' hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer';
    html += '<div class="py-3 px-4 rounded-xl border border-amber-200 dark:border-amber-800/50 transition-colors' + rowCls + '"' + rowNav + '>' +
      '<div class="flex items-center gap-3 flex-wrap">' +
        '<p class="text-sm font-medium text-gray-800 dark:text-gray-200 flex-1 min-w-0 truncate" title="' + esc(f.name) + '">' + esc(f.title || f.name) + '</p>' +
        '<span class="' + daysClass + ' text-xs flex-shrink-0">' + daysAwaiting + ' day' + (daysAwaiting !== 1 ? 's' : '') + ' awaiting</span>' +
      '</div>' +
      (equipment ? '<div class="mt-0.5">' + equipment + '</div>' : '') +
      '<div class="mt-2 space-y-1">' + deptRows + '</div>' +
      woradminCompletionHtml(f.completion, {compact: true}) +
    '</div>';
  });

  container.innerHTML = html;
}

// ===== EVH Approve / Reject action dialogs (DVI-1011) =====
var _evhAction = { file: '', wor_type: '', dept: '' };

function _evhGatingDept(required) {
  if (required.indexOf('maintenance') !== -1) return 'maintenance';
  var order = ['operations', 'safety', 'quality'];
  for (var i = 0; i < order.length; i++) {
    if (required.indexOf(order[i]) !== -1) return order[i];
  }
  return null;
}

function evhOpenApprove(file, wor_type, dept) {
  _evhAction = { file: file, wor_type: wor_type, dept: dept };
  var f = _woradminData.filter(function(x){ return x.name === file; })[0] || {};
  var required = f.evh_required_depts || [];
  var signOffs = f.evh_sign_offs || {};
  // Departments still needing sign-off after this one approves.
  var remaining = required.filter(function(d){
    if (d === dept) return false;
    var s = signOffs[d];
    return !(s && s.action === 'approve');
  });
  var gating = _evhGatingDept(required);
  // The parallel-vs-sequential choice only matters for the gating approver when
  // 2+ departments remain, and only if parallel isn't already enabled.
  var showParallel = (dept === gating) && remaining.length >= 2 && !f.evh_allow_parallel;
  document.getElementById('evh-approve-dept').textContent = _RTS_DEPT_LABELS_SHORT[dept] || dept;
  document.getElementById('evh-approve-title').textContent = f.title || f.name;
  document.getElementById('evh-approve-notes').value = '';
  document.getElementById('evh-approve-status').textContent = '';
  var parWrap = document.getElementById('evh-approve-parallel-wrap');
  if (showParallel) {
    parWrap.classList.remove('hidden');
    var names = remaining.map(function(d){ return _RTS_DEPT_LABELS_SHORT[d] || d; }).join(', ');
    document.getElementById('evh-approve-parallel-depts').textContent = names;
    document.getElementById('evh-approve-parallel-seq').checked = true;
  } else {
    parWrap.classList.add('hidden');
  }
  document.getElementById('evh-approve-modal').classList.remove('hidden');
}

function evhCloseApprove() {
  document.getElementById('evh-approve-modal').classList.add('hidden');
}

function evhSubmitApprove() {
  var btn = document.getElementById('evh-approve-confirm-btn');
  var statusEl = document.getElementById('evh-approve-status');
  btn.disabled = true;
  statusEl.className = 'text-sm text-gray-500 dark:text-gray-400';
  statusEl.textContent = 'Submitting…';
  var notes = document.getElementById('evh-approve-notes').value.trim();
  fetch('/wor-evh-action', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ filename: _evhAction.file, dept: _evhAction.dept, action: 'approve', reason: notes })
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d.error) {
      statusEl.className = 'text-sm text-red-500';
      statusEl.textContent = d.error;
      btn.disabled = false;
      return;
    }
    var parWrap = document.getElementById('evh-approve-parallel-wrap');
    var wantParallel = !parWrap.classList.contains('hidden') &&
      document.getElementById('evh-approve-parallel-par').checked;
    if (wantParallel) {
      // Only meaningful while still awaiting (not finalized by this approval).
      return fetch('/wor-evh-parallel', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({ filename: _evhAction.file, allow_parallel: true })
      }).then(function(){ evhCloseApprove(); woradminLoadList(); });
    }
    evhCloseApprove();
    woradminLoadList();
  }).catch(function(err){
    statusEl.className = 'text-sm text-red-500';
    statusEl.textContent = err.message;
    btn.disabled = false;
  });
}

function evhOpenReject(file, wor_type, dept) {
  _evhAction = { file: file, wor_type: wor_type, dept: dept };
  var f = _woradminData.filter(function(x){ return x.name === file; })[0] || {};
  document.getElementById('evh-reject-dept').textContent = _RTS_DEPT_LABELS_SHORT[dept] || dept;
  document.getElementById('evh-reject-title').textContent = f.title || f.name;
  document.getElementById('evh-reject-reason').value = '';
  var _rp = document.getElementById('evh-reject-photos'); if (_rp) _rp.value = '';
  document.getElementById('evh-reject-status').textContent = '';
  document.getElementById('evh-reject-modal').classList.remove('hidden');
}

function evhCloseReject() {
  document.getElementById('evh-reject-modal').classList.add('hidden');
}

function evhSubmitReject() {
  var reason = document.getElementById('evh-reject-reason').value.trim();
  var statusEl = document.getElementById('evh-reject-status');
  if (!reason) {
    statusEl.className = 'text-sm text-red-500';
    statusEl.textContent = 'A reason is required to reject.';
    return;
  }
  var btn = document.getElementById('evh-reject-confirm-btn');
  btn.disabled = true;
  statusEl.className = 'text-sm text-gray-500 dark:text-gray-400';
  statusEl.textContent = 'Submitting…';
  var photosEl = document.getElementById('evh-reject-photos');
  var photoFiles = photosEl && photosEl.files ? photosEl.files : [];
  var rejectFile = _evhAction.file;
  fetch('/wor-evh-action', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ filename: rejectFile, dept: _evhAction.dept, action: 'reject', reason: reason })
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d.error) {
      statusEl.className = 'text-sm text-red-500';
      statusEl.textContent = d.error;
      btn.disabled = false;
      return null;
    }
    // Photos attached at rejection are tagged rts_rejection (DVI-1063). The WO
    // has been reopened to in_progress; the endpoint resolves it by filename.
    if (photoFiles.length) {
      statusEl.textContent = 'Uploading photos…';
      return woradminUploadPhotos(rejectFile, photoFiles, 'rts_rejection', '');
    }
    return {};
  }).then(function(pr){
    if (pr === null) return;
    evhCloseReject();
    woradminLoadList();
  }).catch(function(err){
    statusEl.className = 'text-sm text-red-500';
    statusEl.textContent = err.message;
    btn.disabled = false;
  });
}

// ===== 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 showAwaitingRts = (document.getElementById('woradmin-filter-awaiting-rts') || {}).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;
    // "Awaiting Approval" is additive (DVI-1174 P3): a completed-but-awaiting WO
    // shows whenever this box is checked, regardless of the status boxes.
    if (showAwaitingRts && f.evh_status === 'awaiting_approval') return true;
    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="togen-control 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="togen-control 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 evhBadge = '';
    if (f.evh_status === 'awaiting_approval') {
      var daysAwaiting = '';
      if (f.evh_initialized_at) {
        var initMs = new Date(f.evh_initialized_at + (f.evh_initialized_at.endsWith('Z') ? '' : 'Z')).getTime();
        if (!isNaN(initMs)) {
          var days = Math.floor((Date.now() - initMs) / 86400000);
          daysAwaiting = ' &middot; ' + days + 'd';
        }
      }
      var nextDept = (f.evh_actionable_depts && f.evh_actionable_depts.length)
        ? f.evh_actionable_depts.map(function(d){ return d.charAt(0).toUpperCase()+d.slice(1); }).join(', ')
        : '';
      evhBadge = '<button type="button" onclick="event.stopPropagation();woradminSetTab(\'approvals\')" ' +
        'title="Awaiting RtS approval' + (nextDept ? ' — ' + nextDept + ' to sign' : '') + '" ' +
        'class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 hover:bg-amber-200 dark:hover:bg-amber-800/40 transition-colors">' +
        'Awaiting RtS' + daysAwaiting +
        '</button>';
    } else if (f.evh_status === 'approved') {
      evhBadge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300">RtS Approved</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>';
    }

    // Forward icon (DVI-855): quickly forward the WO PDF by email without opening
    // the preview pane. Opens the shared email dialog pre-targeted to this WO.
    var forwardIcon = '<button type="button" title="Forward this work order PDF by email" ' +
      'onclick="event.stopPropagation();woradminForwardEmail(\'' + nm + '\')" ' +
      'class="inline-flex items-center text-gray-400 dark:text-gray-500 hover:text-togen-teal 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="M21 10H11a8 8 0 00-8 8v2m18-10l-6-6m6 6l-6 6"/>' +
      '</svg></button>';

    // Photo attachment indicator (DVI-1063): only when the WO has photos.
    // Clicking opens the preview and reveals the photo gallery.
    var photoIcon = '';
    if ((f.photo_count || 0) > 0) {
      photoIcon = '<button type="button" title="' + f.photo_count + ' photo(s) attached — click to view" ' +
        'onclick="event.stopPropagation();woradminPreview(\'' + nm + '\',\'' + esc(f.wor_type || 'maintenance') + '\');setTimeout(function(){var g=document.getElementById(\'woradmin-photos-gallery\');if(g&&!g.dataset.open)woradminViewPhotos();},300)" ' +
        'class="inline-flex items-center gap-0.5 text-togen-teal hover:text-togen-teal-dark 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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>' +
        '</svg>' +
        '<span class="text-[11px] font-semibold leading-none">' + f.photo_count + '</span>' +
        '</button>';
    }

    var rowBorder = f.evh_status === 'awaiting_approval'
      ? 'border border-amber-200 dark:border-amber-800/50'
      : 'border border-gray-100 dark:border-gray-700';
    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 ' + rowBorder + ' 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 +
          evhBadge +
          notifyIcon +
          partsIcon +
          photoIcon +
          forwardIcon +
        '</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 = '';
  woradminHidePhotoDropzone();
  woradminPlacePreview(sec);
}

function woradminHidePhotoDropzone() {
  var wrap = document.getElementById('woradmin-photo-dropzone-wrap');
  if (wrap) wrap.classList.add('hidden');
  var status = document.getElementById('woradmin-photo-dropzone-status');
  if (status) status.textContent = '';
}

// DVI-1174 P4: focus a specific WO from an email deep link (?open_wo=<number>).
// Resolves client-side against the loaded list — approver-only users land on the
// Approvals tab; full WO Admin users get the row selected + preview opened. The
// list may still be loading when this fires, so it polls briefly.
function woradminFocusWo(woNum) {
  var target = parseInt(String(woNum).replace(/[^0-9]/g, ''), 10);
  if (isNaN(target)) return;
  if (!(_woradminData && _woradminData.length) && typeof woradminLoadList === 'function') {
    woradminLoadList();
  }
  var attempts = 0;
  (function tryFocus() {
    attempts++;
    var entry = (_woradminData || []).filter(function(f){
      var n = f.wor_number;
      if (n === undefined || n === null || n === '') {
        var m = /_(\d{4,6})_/.exec(f.name || '');
        n = m ? parseInt(m[1], 10) : null;
      }
      return parseInt(n, 10) === target;
    })[0];
    if (!entry) {
      if (attempts < 20) setTimeout(tryFocus, 150);
      return;
    }
    if (_worApprovalsOnly) { woradminSetTab('approvals'); return; }
    woradminSetTab('all');
    setTimeout(function(){ woradminPreview(entry.name, entry.wor_type || 'maintenance'); }, 50);
  })();
}

// ===== 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.stock_on_hand !== undefined && p.stock_on_hand !== null) meta.push('Stock ' + esc(String(p.stock_on_hand)) + (p.stock_low ? ' ⚠' : ''));
  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);
  }
}

var _woradminCompleteFile = null;

function woradminSetStatus(filename, newStatus) {
  // Completing a WO opens the Completion Details popup (DVI-1033); all other
  // transitions post directly.
  if (newStatus === 'completed') { woradminOpenComplete(filename); return; }
  woradminPostStatus(filename, newStatus, {});
}

function woradminPostStatus(filename, newStatus, extra) {
  var body = {filename: filename, status: newStatus};
  if (extra) { for (var k in extra) { if (extra.hasOwnProperty(k)) body[k] = extra[k]; } }
  return fetch('/wor-admin-status', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body)
  }).then(function(r){ return r.json(); });
}

function woradminOpenComplete(filename) {
  _woradminCompleteFile = filename;
  var entry = _woradminData.filter(function(x){ return x.name === filename; })[0] || {};
  var rts = !!entry.rts_required;
  document.getElementById('woradmin-complete-title').textContent = entry.title || filename;
  document.getElementById('woradmin-complete-wd').value = '';
  document.getElementById('woradmin-complete-fu').value = '';
  var _cp = document.getElementById('woradmin-complete-photos'); if (_cp) _cp.value = '';
  document.getElementById('woradmin-complete-status').textContent = '';
  document.getElementById('woradmin-complete-wd-req').classList.toggle('hidden', !rts);
  document.getElementById('woradmin-complete-wd-opt').classList.toggle('hidden', rts);
  document.getElementById('woradmin-complete-rts-note').classList.toggle('hidden', !rts);
  document.getElementById('woradmin-complete-confirm-btn').disabled = false;
  document.getElementById('woradmin-complete-modal').classList.remove('hidden');
}

function woradminCloseComplete() {
  document.getElementById('woradmin-complete-modal').classList.add('hidden');
  _woradminCompleteFile = null;
}

function woradminSubmitComplete() {
  var filename = _woradminCompleteFile;
  if (!filename) return;
  var entry = _woradminData.filter(function(x){ return x.name === filename; })[0] || {};
  var rts = !!entry.rts_required;
  var wd = document.getElementById('woradmin-complete-wd').value.trim();
  var fu = document.getElementById('woradmin-complete-fu').value.trim();
  var statusEl = document.getElementById('woradmin-complete-status');
  if (rts && !wd) {
    statusEl.className = 'text-sm text-red-500';
    statusEl.textContent = 'Description of Work Performed is required for this work order.';
    return;
  }
  var btn = document.getElementById('woradmin-complete-confirm-btn');
  btn.disabled = true;
  statusEl.className = 'text-sm text-gray-500 dark:text-gray-400';
  statusEl.textContent = 'Submitting…';
  var photosEl = document.getElementById('woradmin-complete-photos');
  var photoFiles = photosEl && photosEl.files ? photosEl.files : [];
  woradminPostStatus(filename, 'completed', {work_description: wd, outstanding_followup: fu})
    .then(function(d){
      if (d.error) {
        statusEl.className = 'text-sm text-red-500';
        statusEl.textContent = d.error;
        btn.disabled = false;
        return null;
      }
      // Photos attached at completion are tagged rts_approval (DVI-1063). The
      // WO has already moved to its completed dir; the endpoint resolves it by
      // filename across dirs.
      if (photoFiles.length) {
        statusEl.textContent = 'Uploading photos…';
        return woradminUploadPhotos(filename, photoFiles, 'rts_approval', '');
      }
      return {};
    })
    .then(function(pr){
      if (pr === null) return;
      if (pr && pr.error) { statusEl.className = 'text-sm text-red-500'; statusEl.textContent = 'WO completed, but photo upload failed: ' + pr.error; }
      woradminCloseComplete();
      woradminLoadList();
    })
    .catch(function(err){
      statusEl.className = 'text-sm text-red-500';
      statusEl.textContent = err.message;
      btn.disabled = false;
    });
}

// ===== DVI-1174: preview-pane approval surfacing =====
function _woradminCurrentEntry() {
  return _woradminData.filter(function(x){ return x.name === _woradminCurrentFilename; })[0];
}

// The department this user should act on for the current WO. Approver depts are
// resolved server-side into evh_user_actionable (their approver dept ∩ the WO's
// currently-actionable depts), so the first entry is the one to sign.
function _woradminUserActionableDept(entry) {
  var ua = (entry && entry.evh_user_actionable) || [];
  return ua.length ? ua[0] : null;
}

function woradminApproveCurrent() {
  var e = _woradminCurrentEntry(); if (!e) return;
  var dept = _woradminUserActionableDept(e); if (!dept) return;
  evhOpenApprove(e.name, e.wor_type || 'maintenance', dept);
}

function woradminRejectCurrent() {
  var e = _woradminCurrentEntry(); if (!e) return;
  var dept = _woradminUserActionableDept(e); if (!dept) return;
  evhOpenReject(e.name, e.wor_type || 'maintenance', dept);
}

// Read-only per-department sign-off strip (P1). Shown for awaiting-approval WOs;
// hidden otherwise. Visible to every WO Admin user regardless of approver role.
function woradminRenderApprovalStrip(entry) {
  var strip = document.getElementById('woradmin-approval-strip');
  if (!strip) return;
  if (!entry || entry.evh_status !== 'awaiting_approval') {
    strip.classList.add('hidden');
    strip.innerHTML = '';
    return;
  }
  var required = entry.evh_required_depts || [];
  var signOffs = entry.evh_sign_offs || {};
  var actionable = entry.evh_actionable_depts || [];
  var rows = required.map(function(dept){
    var so = signOffs[dept];
    var label = _RTS_DEPT_LABELS_SHORT[dept] || dept;
    var badge;
    if (so && so.action === 'approve') {
      badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300">&#10003; Approved</span>';
    } else if (so && so.action === 'reject') {
      badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">&#10007; Rejected</span>';
    } else if (actionable.indexOf(dept) !== -1) {
      badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">&#9203; Pending</span>';
    } else {
      badge = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">Waiting</span>';
    }
    var meta = '';
    if (so && (so.approver_name || so.approver_email)) {
      var who = woradminEscapeHtml(so.approver_name || so.approver_email);
      var tip = woradminEscapeHtml((so.approver_email || '') + (so.timestamp ? ' - ' + so.timestamp : ''));
      meta = '<span class="text-[11px] text-gray-400 dark:text-gray-500 truncate" title="' + tip + '">' + who + '</span>';
    }
    return '<div class="flex items-center gap-2 text-xs">' +
      '<span class="w-24 flex-shrink-0 text-gray-600 dark:text-gray-400">' + label + '</span>' +
      badge + meta + '</div>';
  }).join('');
  strip.innerHTML =
    '<div class="rounded-xl border border-amber-200 dark:border-amber-800/50 bg-amber-50/60 dark:bg-amber-900/10 px-4 py-3">' +
      '<p class="text-xs font-semibold text-amber-700 dark:text-amber-300 mb-2">Awaiting Return-to-Service approval</p>' +
      '<div class="space-y-1">' + rows + '</div>' +
    '</div>';
  strip.classList.remove('hidden');
}

function woradminPreview(filename, worType) {
  _woradminCurrentFilename = filename;
  _woradminCurrentType = worType || 'maintenance';
  woradminHidePhotoDropzone();
  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');
    }
  }
  // Completion Details captured at mark-complete (DVI-1033/1034); shown whenever
  // populated — RtS or not, required or not (covers cases 1 & 3).
  var compEl = document.getElementById('woradmin-completion-details');
  if (compEl) {
    var _ce = _woradminData.filter(function(x){ return x.name === filename; })[0];
    var compHtml = woradminCompletionHtml(_ce && _ce.completion, {});
    if (compHtml) {
      compEl.innerHTML = compHtml;
      compEl.classList.remove('hidden');
    } else {
      compEl.innerHTML = '';
      compEl.classList.add('hidden');
    }
  }
  // EVH form button: only for RtS-approved WOs that have a generated EVH PDF (DVI-1025).
  var evhLink = document.getElementById('woradmin-evh-form-link');
  if (evhLink) {
    var _we = _woradminData.filter(function(x){ return x.name === filename; })[0];
    if (_we && _we.evh_status === 'approved' && _we.evh_pdf) {
      evhLink.href = '/wor-evh-pdf?file=' + encodeURIComponent(filename);
      evhLink.classList.remove('hidden');
    } else {
      evhLink.href = '#';
      evhLink.classList.add('hidden');
    }
  }
  // DVI-1174 P1/P2: approval-state strip + per-user Approve/Reject buttons.
  var _wePrev = _woradminData.filter(function(x){ return x.name === filename; })[0];
  woradminRenderApprovalStrip(_wePrev);
  var apBtn = document.getElementById('woradmin-approve-btn');
  var rjBtn = document.getElementById('woradmin-reject-btn');
  var canAct = _isEvhApprover && _wePrev && _wePrev.evh_status === 'awaiting_approval' &&
    (_wePrev.evh_user_actionable || []).length > 0;
  if (apBtn) apBtn.classList.toggle('hidden', !canAct);
  if (rjBtn) rjBtn.classList.toggle('hidden', !canAct);
  // 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(); });
  // Testing regime Phase 2 (DVI-1084): Togen-Admin-only convert/delete actions.
  var _wa = _woradminData.filter(function(x){ return x.name === filename; })[0];
  var _waIsTest = !!(_wa && _wa.is_test);
  var ctBtn = document.getElementById('woradmin-convert-test-btn');
  var cpBtn = document.getElementById('woradmin-convert-prod-btn');
  var delBtn = document.getElementById('woradmin-delete-btn');
  if (ctBtn) ctBtn.classList.toggle('hidden', !(_isToGenAdmin && !_waIsTest));
  if (cpBtn) cpBtn.classList.toggle('hidden', !(_isToGenAdmin && _waIsTest));
  if (delBtn) delBtn.classList.toggle('hidden', !_isToGenAdmin);
  woradminLoadPhotos(filename);
  sec.scrollIntoView({behavior: 'smooth', block: 'start'});
}

// ===== Testing regime Phase 2 admin actions (DVI-1084) =====
function woradminConvertTest() {
  var fn = _woradminCurrentFilename;
  if (!fn) return;
  if (!confirm('Convert "' + fn + '" into a TEST work order?\n\nIt will move to the segregated test tree and be removed from the audit trail (WO Admin, Auditor, Search). This is reversible.')) return;
  fetch('/wor-convert-test', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({filename: fn})})
    .then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
    .then(function(res){
      if (!res.ok) { alert(res.d.error || 'Convert failed.'); return; }
      woradminClosePreview();
      woradminLoadList();
    })
    .catch(function(){ alert('Convert failed.'); });
}

function woradminConvertProd() {
  var fn = _woradminCurrentFilename;
  if (!fn) return;
  if (!confirm('Convert test WO "' + fn + '" back to a PRODUCTION work order?\n\nIt will move to the production tree and its audit trail will be restored.')) return;
  fetch('/wor-convert-prod', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({filename: fn})})
    .then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
    .then(function(res){
      if (!res.ok) { alert(res.d.error || 'Convert failed.'); return; }
      woradminClosePreview();
      woradminLoadList();
    })
    .catch(function(){ alert('Convert failed.'); });
}

function woradminDelete() {
  var fn = _woradminCurrentFilename;
  if (!fn) return;
  var reason = prompt('Soft-delete "' + fn + '".\n\nThe work order will be quarantined (restorable) and removed from every audit surface, but the deletion is permanently recorded in the tamper-evident deletion log.\n\nEnter a reason for this deletion (required):');
  if (reason === null) return;
  reason = reason.trim();
  if (!reason) { alert('A deletion reason is required.'); return; }
  fetch('/wor-delete', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({filename: fn, reason: reason})})
    .then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
    .then(function(res){
      if (!res.ok) { alert(res.d.error || 'Delete failed.'); return; }
      woradminClosePreview();
      woradminLoadList();
    })
    .catch(function(){ alert('Delete failed.'); });
}

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';
  }
}

// ===== WO photo attachments (DVI-1063) =====
var _woradminPhotos = [];
// DVI-1076: flat index of image photos (in gallery order) driving the in-app lightbox.
var _woradminLbImages = [];
var _woradminLbIndex = -1;

function woradminLoadPhotos(filename) {
  _woradminPhotos = [];
  woradminRenderPhotos(filename);
  fetch('/wor-photos?file=' + encodeURIComponent(filename))
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (filename !== _woradminCurrentFilename) return;
      _woradminPhotos = (d && d.photos) || [];
      woradminRenderPhotos(filename);
    })
    .catch(function(){ _woradminPhotos = []; woradminRenderPhotos(filename); });
}

function woradminIsImageFile(name) {
  return /\.(jpe?g|png|heic|webp|gif|bmp)$/i.test(name || '');
}

function woradminRenderPhotos(filename) {
  var count = _woradminPhotos.length;
  var btn = document.getElementById('woradmin-photos-btn');
  var cnt = document.getElementById('woradmin-photos-count');
  if (btn) btn.classList.toggle('hidden', count === 0);
  if (cnt) cnt.textContent = count > 0 ? (' (' + count + ')') : '';
  var gallery = document.getElementById('woradmin-photos-gallery');
  if (!gallery) return;
  if (count === 0) {
    gallery.innerHTML = '';
    gallery.classList.add('hidden');
    return;
  }
  var ctxLabel = { attachment: 'Attachment', rts_approval: 'RtS Approval', rts_rejection: 'RtS Rejection' };
  // DVI-1076: rebuild the flat image index each render so the lightbox can navigate prev/next.
  _woradminLbImages = [];
  var html = '<p class="text-xs font-medium text-gray-600 dark:text-gray-300 mb-2">Attachments (' + count + ')</p>'
    + '<div class="flex flex-wrap gap-2">';
  _woradminPhotos.forEach(function(p){
    var full = '/wor-photo?file=' + encodeURIComponent(filename) + '&name=' + encodeURIComponent(p.file);
    var label = ctxLabel[p.context] || 'Attachment';
    var meta = label + (p.uploaded_by ? (' — ' + esc(p.uploaded_by)) : '');
    if (woradminIsImageFile(p.file)) {
      // DVI-1076: images open the in-app lightbox gallery (was: new tab).
      var thumb = full + '&thumb=1';
      var idx = _woradminLbImages.length;
      _woradminLbImages.push({ file: p.file, label: label, caption: p.caption || '', uploaded_by: p.uploaded_by || '', uploaded_at: p.uploaded_at || '' });
      html += '<button type="button" onclick="woradminPhotoLightbox(' + idx + ')" title="' + esc(meta) + '" '
        + 'class="block w-24 h-24 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 hover:ring-2 hover:ring-togen-teal transition-all cursor-pointer">'
        + '<img src="' + thumb + '" alt="' + esc(meta) + '" class="w-full h-full object-cover" loading="lazy">'
        + '</button>';
    } else {
      // DVI-1065: non-image files render as a downloadable file chip.
      var label = (p.file || '').replace(/^\d{8}_\d{6}_\d{1,6}_/, '');
      html += '<a href="' + full + '" target="_blank" rel="noopener" title="' + esc(meta) + '" '
        + 'class="w-24 h-24 rounded-lg border border-gray-200 dark:border-gray-700 hover:ring-2 hover:ring-togen-teal transition-all flex flex-col items-center justify-center p-2 bg-gray-50 dark:bg-gray-800 text-center">'
        + '<svg class="h-8 w-8 text-gray-400 dark:text-gray-500 mb-1" 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-[10px] text-gray-600 dark:text-gray-300 truncate w-full">' + esc(label) + '</span>'
        + '</a>';
    }
  });
  html += '</div>';
  gallery.innerHTML = html;
  gallery.classList.toggle('hidden', !gallery.dataset.open);
}

function woradminViewPhotos() {
  var gallery = document.getElementById('woradmin-photos-gallery');
  if (!gallery) return;
  var open = !gallery.dataset.open;
  gallery.dataset.open = open ? '1' : '';
  gallery.classList.toggle('hidden', !open);
}

// ----- WO photo lightbox (DVI-1076): in-app gallery viewer mirroring the Auditor lightbox -----
function woradminPhotoLightbox(i) {
  if (!_woradminLbImages.length || i < 0 || i >= _woradminLbImages.length) return;
  _woradminLbIndex = i;
  var ov = document.getElementById('woradmin-photos-lightbox');
  if (!ov) return;
  var p = _woradminLbImages[i];
  var full = '/wor-photo?file=' + encodeURIComponent(_woradminCurrentFilename) + '&name=' + encodeURIComponent(p.file);
  var img = document.getElementById('woradmin-lb-img');
  if (img) { img.src = full; img.alt = p.label || 'Work order photo'; }
  var badge = document.getElementById('woradmin-lb-badge');
  if (badge) badge.textContent = p.label || '';
  var when = p.uploaded_at ? (function(){ try { return new Date(p.uploaded_at).toLocaleString(); } catch(e){ return p.uploaded_at; } })() : '';
  var by = [p.uploaded_by, when].filter(Boolean).join(' · ');
  var cap = document.getElementById('woradmin-lb-caption');
  if (cap) {
    cap.innerHTML = (p.caption ? '<span class="block text-sm text-white mb-0.5">' + esc(p.caption) + '</span>' : '') +
      (by ? '<span class="block text-xs text-gray-300">' + esc(by) + '</span>' : '');
  }
  var counter = document.getElementById('woradmin-lb-counter');
  if (counter) counter.textContent = (i + 1) + ' / ' + _woradminLbImages.length;
  var dl = document.getElementById('woradmin-lb-download');
  if (dl) dl.href = full;
  var nav = document.getElementById('woradmin-lb-nav');
  if (nav) nav.classList.toggle('hidden', _woradminLbImages.length < 2);
  ov.classList.remove('hidden');
}

function woradminPhotoLightboxNav(delta) {
  if (!_woradminLbImages.length) return;
  var n = _woradminLbImages.length;
  woradminPhotoLightbox((_woradminLbIndex + delta + n) % n);
}

function woradminPhotoLightboxClose() {
  var ov = document.getElementById('woradmin-photos-lightbox');
  if (ov) ov.classList.add('hidden');
  var img = document.getElementById('woradmin-lb-img');
  if (img) img.src = '';
  _woradminLbIndex = -1;
}

// DVI-1065: the Attach Photo button now expands an inline drag & drop zone in
// the preview (same look/behavior as the Project Builder upload dropzone),
// accepting photos or files one or multiple at a time.
var _woradminPhotoDzWired = false;

function woradminTogglePhotoDropzone() {
  var wrap = document.getElementById('woradmin-photo-dropzone-wrap');
  if (!wrap || !_woradminCurrentFilename) return;
  woradminWirePhotoDropzone();
  wrap.classList.toggle('hidden');
  if (!wrap.classList.contains('hidden')) wrap.scrollIntoView({behavior: 'smooth', block: 'nearest'});
}

function woradminWirePhotoDropzone() {
  if (_woradminPhotoDzWired) return;
  _woradminPhotoDzWired = true;
  var dz = document.getElementById('woradmin-photo-dropzone');
  var input = document.getElementById('woradmin-photo-input');
  if (input) {
    input.addEventListener('change', function(){
      if (input.files && input.files.length) woradminDropzoneUpload(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) {
        woradminDropzoneUpload(e.dataTransfer.files);
      }
    });
  }
}

function woradminDropzoneUpload(files) {
  if (!_woradminCurrentFilename || !files || !files.length) return;
  var status = document.getElementById('woradmin-photo-dropzone-status');
  if (status) { status.textContent = 'Uploading ' + files.length + ' file(s)…'; status.className = 'mt-2 text-xs text-gray-500 dark:text-gray-400'; }
  woradminUploadPhotos(_woradminCurrentFilename, files, 'attachment', '')
    .then(function(d){
      if (d && d.error) {
        if (status) { status.textContent = 'Error: ' + d.error; status.className = 'mt-2 text-xs text-red-600 dark:text-red-400'; }
        return;
      }
      if (status) { status.textContent = 'Added ' + (d.added || 0) + ' file(s).'; status.className = 'mt-2 text-xs text-togen-teal'; }
      woradminLoadPhotos(_woradminCurrentFilename);
      woradminLoadList();
    })
    .catch(function(err){
      if (status) { status.textContent = 'Error: ' + err.message; status.className = 'mt-2 text-xs text-red-600 dark:text-red-400'; }
    });
}

function woradminUploadPhotos(filename, files, context, caption) {
  var fd = new FormData();
  fd.append('file', filename);
  fd.append('context', context || 'attachment');
  if (caption) fd.append('caption', caption);
  for (var i = 0; i < files.length; i++) { fd.append('photos', files[i]); }
  return fetch('/wor-photo-upload', {method: 'POST', body: fd})
    .then(function(r){ return r.json(); });
}

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;
          }
          // Restore RtS state from sidecar (DVI-1014)
          if (data.equipment) {
            var rtsSec = document.getElementById('wor-rts-section');
            if (rtsSec) rtsSec.classList.remove('hidden');
            var rtsCb = document.getElementById('wor-rts-required');
            if (rtsCb) rtsCb.checked = !!data.rts_required;
            var rtsMap = data.rts_departments || {};
            ['maintenance', 'operations', 'safety', 'quality'].forEach(function(d){
              var el = document.getElementById('wor-rts-dept-' + d);
              if (el) el.checked = rtsMap.hasOwnProperty(d) ? !!rtsMap[d] : true;
            });
            worRtsToggleDepts();
          }
        }

        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; }
  _woradminOpenEmailModal(_woradminCurrentFilename);
}

// DVI-855: forward a specific WO PDF straight from its list row, without first
// opening the preview pane. Resets the recipient to the signed-in SSO email so a
// quick forward defaults to the user themselves, still editable before sending.
function woradminForwardEmail(name) {
  if (!name) return;
  var input = document.getElementById('woradmin-email-input');
  if (input) input.value = window._ssoUserEmail;
  _woradminOpenEmailModal(name);
}

function _woradminOpenEmailModal(target) {
  _woradminEmailTarget = target;
  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 (!_woradminEmailTarget) { status.textContent = 'No work order selected.'; 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: _woradminEmailTarget, 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!';
      worReqPhotoReset('worph-mnt');
      _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!';
      worReqPhotoReset('worph-mm');
      _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!';
      worReqPhotoReset('worph-pm');
      _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!';
      worReqPhotoReset('worph-sfty');
      _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 = window._ssoUserName;
        var ssoEmail = window._ssoUserEmail;
        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-935) =====
var _backupHistoryOffset = 0;
var _backupHistoryHasMore = false;
var _backupPollTimer = null;

function togenadminBackupLoad() {
  fetch('/togen-admin/backup/status')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      _backupTileStatus = { state: d.state, lastRun: d.last_run };
      _backupUpdateStateChip(d.state);
      _backupUpdateRunBtn(d.state);
    })
    .catch(function(){});
  _backupHistoryOffset = 0;
  _backupHistoryHasMore = false;
  _backupLoadHistory(true);
  togenadminBackupSchedLoad();
}

var _backupSchedDayLabels = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
var _backupSchedDaysBuilt = false;

function _backupSchedBrowserTz() {
  try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'America/Chicago'; }
  catch (e) { return 'America/Chicago'; }
}

function _backupSchedBuildDays() {
  if (_backupSchedDaysBuilt) return;
  var wrap = document.getElementById('togenadmin-backup-sched-days');
  if (!wrap) return;
  wrap.innerHTML = '';
  for (var i = 0; i < 7; i++) {
    var lbl = document.createElement('label');
    lbl.className = 'inline-flex items-center gap-1 px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 text-xs text-gray-700 dark:text-gray-200 cursor-pointer';
    lbl.innerHTML = '<input type="checkbox" data-day="' + i + '" class="togenadmin-backup-sched-day h-3.5 w-3.5 rounded border-gray-300 text-togen-teal focus:ring-togen-teal">' + _backupSchedDayLabels[i];
    wrap.appendChild(lbl);
  }
  _backupSchedDaysBuilt = true;
}

function togenadminBackupSchedLoad() {
  _backupSchedBuildDays();
  fetch('/togen-admin/backup/schedule')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(resp){
      if (!resp || !resp.schedule) return;
      var s = resp.schedule;
      var enabledEl = document.getElementById('togenadmin-backup-sched-enabled');
      var freqEl = document.getElementById('togenadmin-backup-sched-freq');
      var timeEl = document.getElementById('togenadmin-backup-sched-time');
      var tzEl = document.getElementById('togenadmin-backup-sched-tz');
      if (enabledEl) enabledEl.checked = !!s.enabled;
      if (freqEl) freqEl.value = s.frequency || 'daily';
      if (timeEl) timeEl.value = s.time || '02:00';
      // Show the stored tz when a schedule exists, else the current browser tz.
      if (tzEl) tzEl.textContent = s.timezone || _backupSchedBrowserTz();
      var days = s.days || [0,1,2,3,4,5,6];
      var boxes = document.querySelectorAll('.togenadmin-backup-sched-day');
      for (var i = 0; i < boxes.length; i++) {
        boxes[i].checked = days.indexOf(parseInt(boxes[i].getAttribute('data-day'), 10)) !== -1;
      }
      togenadminBackupSchedFreqChange();
      togenadminBackupSchedToggle();
      _backupSchedShowNext(s);
    })
    .catch(function(){});
}

function _backupSchedShowNext(s) {
  var el = document.getElementById('togenadmin-backup-sched-next');
  if (!el) return;
  if (s && s.enabled && s.next_run_local) {
    el.textContent = 'Next run: ' + s.next_run_local;
  } else {
    el.textContent = '';
  }
}

function togenadminBackupSchedToggle() {
  var enabledEl = document.getElementById('togenadmin-backup-sched-enabled');
  var fields = document.getElementById('togenadmin-backup-sched-fields');
  if (!fields) return;
  var on = enabledEl && enabledEl.checked;
  fields.classList.toggle('opacity-50', !on);
  fields.classList.toggle('pointer-events-none', !on);
}

function togenadminBackupSchedFreqChange() {
  var freqEl = document.getElementById('togenadmin-backup-sched-freq');
  var daysWrap = document.getElementById('togenadmin-backup-sched-days-wrap');
  if (daysWrap) daysWrap.classList.toggle('hidden', !(freqEl && freqEl.value === 'weekly'));
}

function togenadminBackupSchedSave() {
  var status = document.getElementById('togenadmin-backup-sched-status');
  var enabled = document.getElementById('togenadmin-backup-sched-enabled').checked;
  var frequency = document.getElementById('togenadmin-backup-sched-freq').value;
  var time = document.getElementById('togenadmin-backup-sched-time').value || '02:00';
  var days = [];
  var boxes = document.querySelectorAll('.togenadmin-backup-sched-day');
  for (var i = 0; i < boxes.length; i++) {
    if (boxes[i].checked) days.push(parseInt(boxes[i].getAttribute('data-day'), 10));
  }
  if (enabled && frequency === 'weekly' && days.length === 0) {
    if (status) { status.textContent = 'Select at least one day of the week.'; status.className = 'text-sm mt-2 text-red-500'; }
    return;
  }
  var payload = {
    enabled: enabled,
    frequency: frequency,
    time: time,
    days: days,
    timezone: _backupSchedBrowserTz()
  };
  if (status) { status.textContent = 'Saving…'; status.className = 'text-sm mt-2 text-gray-500 dark:text-gray-400'; }
  fetch('/togen-admin/backup/schedule', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload)
  })
    .then(function(r){ return r.json().then(function(d){ return { ok: r.ok, d: d }; }); })
    .then(function(res){
      if (res.ok && res.d.ok) {
        if (status) { status.textContent = 'Schedule saved.'; status.className = 'text-sm mt-2 text-green-600 dark:text-green-400'; }
        var s = res.d.schedule;
        var tzEl = document.getElementById('togenadmin-backup-sched-tz');
        if (tzEl && s) tzEl.textContent = s.timezone || _backupSchedBrowserTz();
        _backupSchedShowNext(s);
      } else {
        if (status) { status.textContent = 'Error: ' + (res.d.error || 'unknown'); status.className = 'text-sm mt-2 text-red-500'; }
      }
    })
    .catch(function(e){
      if (status) { status.textContent = 'Error: ' + e.message; status.className = 'text-sm mt-2 text-red-500'; }
    });
}

function _backupUpdateStateChip(state) {
  var el = document.getElementById('togenadmin-backup-state');
  if (!el) return;
  var map = {
    ok:      { dot: 'bg-green-500', cls: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300', word: 'Last run OK' },
    running: { dot: 'bg-blue-500',  cls: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',   word: 'Running' },
    failed:  { dot: 'bg-red-500',   cls: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300',       word: 'Last run failed' },
    idle:    { dot: 'bg-gray-400',  cls: 'bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400',      word: 'Idle' }
  };
  var m = map[state] || map.idle;
  el.className = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ' + m.cls;
  el.innerHTML = '<span class="inline-block h-1.5 w-1.5 rounded-full ' + m.dot + '"></span>' + m.word;
}

function _backupUpdateRunBtn(state) {
  var btn = document.getElementById('togenadmin-backup-run-btn');
  if (!btn) return;
  var running = (state === 'running');
  btn.disabled = running;
  if (running) {
    btn.className = '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';
  } else {
    btn.className = 'px-4 py-2 text-sm font-medium rounded-xl bg-togen-teal hover:bg-togen-teal-dark text-white inline-flex items-center gap-2 transition-colors cursor-pointer';
  }
}

function togenadminBackupRun() {
  var status = document.getElementById('togenadmin-backup-run-status');
  _backupUpdateRunBtn('running');
  _backupUpdateStateChip('running');
  if (status) { status.textContent = 'Starting backup…'; status.className = 'text-sm mt-2 text-gray-500 dark:text-gray-400'; }
  fetch('/togen-admin/backup/run', { method: 'POST' })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.ok) {
        if (status) { status.textContent = 'Backup running…'; }
        _backupStartPolling();
      } else {
        if (status) { status.textContent = 'Error: ' + (d.error || 'unknown'); status.className = 'text-sm mt-2 text-red-500'; }
        togenadminBackupLoad();
      }
    })
    .catch(function(e){
      if (status) { status.textContent = 'Error: ' + e.message; status.className = 'text-sm mt-2 text-red-500'; }
      togenadminBackupLoad();
    });
}

function _backupStartPolling() {
  if (_backupPollTimer) return;
  _backupPollTimer = setInterval(function(){
    fetch('/togen-admin/backup/status')
      .then(function(r){ return r.ok ? r.json() : null; })
      .then(function(d){
        if (!d) return;
        _backupTileStatus = { state: d.state, lastRun: d.last_run };
        _backupUpdateStateChip(d.state);
        _backupUpdateRunBtn(d.state);
        if (d.state !== 'running') {
          clearInterval(_backupPollTimer); _backupPollTimer = null;
          var status = document.getElementById('togenadmin-backup-run-status');
          if (status) {
            status.textContent = d.state === 'ok' ? 'Backup completed successfully.' : 'Backup failed.';
            status.className = 'text-sm mt-2 ' + (d.state === 'ok' ? 'text-green-600 dark:text-green-400' : 'text-red-500');
          }
          _backupHistoryOffset = 0; _backupLoadHistory(true);
        }
      })
      .catch(function(){});
  }, 5000);
}

function _backupLoadHistory(reset) {
  var container = document.getElementById('togenadmin-backup-history');
  var moreBtn = document.getElementById('togenadmin-backup-more-btn');
  if (reset) { _backupHistoryOffset = 0; if (container) container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 py-2">Loading…</p>'; }
  fetch('/togen-admin/backup/history?limit=10&offset=' + _backupHistoryOffset)
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d || !container) return;
      if (reset) container.innerHTML = '';
      if (d.runs.length === 0 && _backupHistoryOffset === 0) {
        container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 py-2">No backup history yet.</p>';
      } else {
        d.runs.forEach(function(run){
          var row = document.createElement('div');
          row.className = 'py-3 flex items-center justify-between gap-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 -mx-2 px-2 rounded-lg';
          var dt = new Date(run.started_at * 1000).toLocaleString();
          var dur = run.duration_s != null ? (run.duration_s < 60 ? Math.round(run.duration_s) + 's' : Math.round(run.duration_s / 60) + 'm') : '—';
          var stateColor = run.status === 'ok' ? 'text-green-600 dark:text-green-400' : run.status === 'running' ? 'text-blue-600 dark:text-blue-400' : 'text-red-500';
          row.innerHTML =
            '<div><p class="text-sm font-medium text-gray-800 dark:text-gray-100">' + esc(dt) + '</p>' +
            '<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Duration: ' + esc(dur) + '</p></div>' +
            '<span class="text-xs font-medium ' + stateColor + '">' + esc(run.status) + '</span>';
          row.onclick = function(){ _backupShowLog(run.id, dt); };
          container.appendChild(row);
        });
      }
      _backupHistoryOffset += d.runs.length;
      _backupHistoryHasMore = d.has_more;
      if (moreBtn) moreBtn.classList.toggle('hidden', !d.has_more);
    })
    .catch(function(){ if (container && reset) container.innerHTML = '<p class="text-sm text-red-500 py-2">Failed to load history.</p>'; });
}

function togenadminBackupLoadMore() {
  if (_backupHistoryHasMore) _backupLoadHistory(false);
}

function _backupShowLog(runId, label) {
  fetch('/togen-admin/backup/log/' + runId)
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      var win = window.open('', '_blank', 'width=900,height=600,scrollbars=yes');
      if (!win) return;
      win.document.write('<html><head><title>Backup log ' + label + '</title>' +
        '<style>body{background:#111;color:#d4d4d4;font-family:monospace;font-size:13px;padding:16px;white-space:pre-wrap;word-break:break-all}</style>' +
        '</head><body>' + (d.log || '(no output)').replace(/&/g,'&amp;').replace(/</g,'&lt;') + '</body></html>');
      win.document.close();
    })
    .catch(function(){});
}

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');
  togenadminStopFqdnPoll();
  if (tab === 'branding') togenadminLoadBranding();
  if (tab === 'notifications' && !_subsListExpanded) togenadminToggleSubsList();
  if (tab === 'documents') { syncadminLoadServiceStatus(); ocrLoadBackends(); togenadminLoadQuotesLocation(); }
  if (tab === 'general') { if (_aboutContent == null) aboutLoadContent(); togenadminLoadFqdns(); togenadminRegionalLoad(); }
  if (tab === 'assistant') togenadminLoadAssistant();
  if (tab === 'agents') { togenadminLoadAgents(); togenadminLoadPrintAgents(); togenadminLoadDisplayAgents(); }
  if (tab === 'printing') togenadminLoadPrinting();
  if (tab === 'history') togenadminLoadHistory();
  if (tab === 'backup') togenadminBackupLoad();
  if (tab === 'testing') { togenadminLoadTesters(); togenadminLoadDeletionAudit(); }
  if (tab === 'services') togenadminServicesInit();
  if (tab === 'users') tgaUsersInit();
}

// ===== DVI-1136 Phase 5: Togen Admin → Users (personnel roster) =====
var _tgaPersonnel = [];
var _tgaUsersEditId = null;

function tgaUsersInit() {
  tgaUsersLoad();
  tgaUsersSubtab(_tgaUsersSubtab || 'personnel');
}

// ----- Users sub-tabs (DVI-1373): Personnel roster + tool Access -----
var _tgaUsersSubtab = 'personnel';
var _tgaAccessLoaded = false;
function tgaUsersSubtab(name) {
  _tgaUsersSubtab = name;
  var tabs = ['personnel', 'access'];
  tabs.forEach(function(t){
    var btn = document.getElementById('tga-users-subtab-' + t);
    var panel = document.getElementById('tga-users-panel-' + t);
    var on = (t === name);
    if (panel) panel.classList.toggle('hidden', !on);
    if (btn) {
      btn.classList.toggle('border-togen-teal', on);
      btn.classList.toggle('text-togen-teal', on);
      btn.classList.toggle('border-transparent', !on);
      btn.classList.toggle('text-gray-500', !on);
      btn.classList.toggle('dark:text-gray-400', !on);
    }
  });
  if (name === 'access' && !_tgaAccessLoaded) { _tgaAccessLoaded = true; tgaAccessLoad(); tgaGroupsLoad(); }
}

// Grantable tool-capability tree — MUST mirror the server _TOOL_ACCESS_TREE.
// Each group renders a "Full access" toggle + its capability leaves (DVI-1373 P2).
var _TOOL_ACCESS_RES_TREE = [
  // DVI-1522 (Amendment 2): the QC group. These leaves are the Batch Production
  // sub-tools of the QC Tile Surface; Kiln Metrics tiles on that surface are
  // gated by the Togen SCADA role (togen_scada), not a grant here (see note). The
  // `quality` tool tile is scoped-visible (any qc.* grant OR togen_scada), so it
  // needs no dedicated grant.
  {key: 'qc', label: 'QC — Batch Production', note: 'Kiln Metrics tiles (Status / Reporting / Oversight) on the QC surface are gated by the Togen SCADA role, not granted here.', children: [
    {key: 'qc',          label: 'Batch Tickets (see + capture)'},
    {key: 'qc.reports',  label: 'Reports'},
    {key: 'qc.mixtures', label: 'Mixtures'},
    {key: 'qc.import',   label: 'Import'},
    {key: 'qc.settings', label: 'Settings (materials + delete)'},
    {key: 'qc.filebrowser', label: 'File Browser (SharePoint PDFs)'},
    {key: 'qc.production.wet_cast', label: 'Daily Production — Wet Cast'},
    {key: 'qc.production.pipe',     label: 'Daily Production — Pipe Plant'},
    {key: 'qc.production.block',    label: 'Daily Production — Block Plant'}
  ]},
  {key: 'logistics', label: 'Logistics', children: [
    {key: 'logistics',           label: 'View (Load Manager / LTL / Calendar / Oversight)'},
    {key: 'logistics.loads',     label: 'Load Manager (create / edit / cancel loads)'},
    {key: 'logistics.resources', label: 'Resources (drivers / trucks / trailers)'},
    {key: 'logistics.settings',  label: 'Settings (events + notifications)'}
  ]},
  // DVI-1496 P2 (D6-B, N1-A): one Equipment group covering the sub-tools. The
  // new equipment.* leaves gate the sub-tools that had none; the existing five
  // assets.* leaves are DISPLAY-nested here (labels prefixed "Assets Inventory")
  // with their KEYS UNCHANGED — they still live in the server `assets` group, so
  // every live grant validates with no store migration.
  {key: 'equipment', label: 'Equipment', children: [
    {key: 'equipment',           label: 'View — Equipment tiles page (open to all)'},
    {key: 'equipment.manuals',   label: 'Manuals (open to all)'},
    {key: 'equipment.rpm',       label: 'RPM — Repair Parts Manager'},
    {key: 'equipment.oversight', label: 'Oversight'},
    {key: 'equipment.settings',  label: 'Settings (focused plan — admin write)'},
    {key: 'assets',           label: 'Assets Inventory: View (open to all)'},
    {key: 'assets.edit',      label: 'Assets Inventory: Edit (assets + photos)'},
    {key: 'assets.catalog',   label: 'Assets Inventory: Catalog (models + sub-assemblies)'},
    {key: 'assets.inventory', label: 'Assets Inventory: Inventory (parts + stock)'},
    {key: 'assets.admin',     label: 'Assets Inventory: Admin (import + settings)'}
  ]}
];

// DVI-1452 P1: rebuild the qc group's Daily-Production leaves from the live
// segment registry so the Access UI always shows exactly the current segments'
// qc.production.<id> checkboxes (base leaves are left untouched).
function _tgaPatchQcSegments(segs) {
  var qc = null;
  for (var i = 0; i < _TOOL_ACCESS_RES_TREE.length; i++) {
    if (_TOOL_ACCESS_RES_TREE[i].key === 'qc') { qc = _TOOL_ACCESS_RES_TREE[i]; break; }
  }
  if (!qc) return;
  qc.children = qc.children.filter(function (c) {
    return c.key.indexOf('qc.production.') !== 0;
  });
  (segs || []).forEach(function (s) {
    qc.children.push({key: 'qc.production.' + s.id,
                      label: 'Daily Production — ' + s.label});
  });
}

var _tgaAccessUsers = [];
// DVI-1432 D1-A: login-snapshot names + Personnel fallback, keyed by lowercase email.
var _tgaDirectory = {};
var _tgaPersonnelByEmail = {};

// Right-pointing chevron; add `rotate-90` when its section is expanded.
function _tgaChevron(cls) {
  return '<svg class="' + cls + ' w-3 h-3 text-gray-400 dark:text-gray-500 transition-transform 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>';
}

// DVI-1432 name fallback chain: directory (given+family, then name) ->
// Personnel-roster match by email -> '' (caller shows email only).
function _tgaDisplayName(email) {
  email = (email || '').toLowerCase();
  var d = _tgaDirectory[email];
  if (d) {
    var fn = [(d.given_name || '').trim(), (d.family_name || '').trim()].filter(Boolean).join(' ');
    if (fn) return fn;
    if ((d.name || '').trim()) return d.name.trim();
  }
  var p = _tgaPersonnelByEmail[email];
  if (p) {
    var pn = [(p.first_name || '').trim(), (p.last_name || '').trim()].filter(Boolean).join(' ');
    if (pn) return pn;
  }
  return '';
}

// R1 granted-count summary chips ("5 tool · 3 auditor" style).
function _tgaCountChips(nTool, nAud) {
  return '<span class="tga-chip-tool text-xs px-2 py-0.5 rounded-full bg-togen-teal/10 text-togen-teal">' + nTool + ' tool</span>' +
    '<span class="tga-chip-auditor text-xs px-2 py-0.5 rounded-full bg-indigo-500/10 text-indigo-600 dark:text-indigo-300">' + nAud + ' auditor</span>';
}

// R4: "Last sign-in" line for the expanded card, from the directory snapshot.
function _tgaLastLoginHtml(email) {
  var d = _tgaDirectory[(email || '').toLowerCase()];
  if (!d || !d.last_login) return '';
  var when = d.last_login;
  try { var dt = new Date(d.last_login); if (!isNaN(dt)) when = (window._fmtDateTime ? _fmtDateTime(dt) : dt.toLocaleString()); } catch (e) {}
  return '<div class="text-xs text-gray-400 dark:text-gray-500 mb-3">Last sign-in: ' + esc(when) + '</div>';
}

// R5: "Copy access from…" picker — client-side prefill of the checkbox tree
// from an existing granted user's grants; admin still reviews + Saves.
function _tgaCopyFromHtml(email) {
  var others = _tgaAccessUsers.filter(function(u){
    return u.email !== email && ((u.tool_resources || []).length || (u.auditor_resources || []).length);
  });
  if (!others.length) return '';
  var opts = ['<option value="">Copy access from…</option>'].concat(others.map(function(u){
    var nm = _tgaDisplayName(u.email);
    var label = nm ? (nm + ' (' + u.email + ')') : u.email;
    return '<option value="' + esc(u.email) + '">' + esc(label) + '</option>';
  })).join('');
  return '<div class="mb-3"><select onchange="tgaAccessCopyFrom(this)" class="text-xs px-2 py-1 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/40">' + opts + '</select></div>';
}

function _tgaAccessSetStatus(msg, isErr) {
  var el = document.getElementById('tga-access-status');
  if (!el) return;
  if (!msg) { el.classList.add('hidden'); el.textContent = ''; return; }
  el.classList.remove('hidden');
  el.textContent = msg;
  el.className = 'text-sm mb-3 ' + (isErr ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400');
}

function tgaAccessLoad() {
  var list = document.getElementById('tga-access-list');
  if (list) list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 py-4 text-center">Loading…</p>';
  // DVI-1373 P4: unify tool grants (/tool-access) and Auditor grants
  // (/auditor/access) into one per-email card. Merge the two stores by email.
  Promise.all([
    fetch('/tool-access').then(function(r){ return r.ok ? r.json() : {users: []}; }).catch(function(){ return {users: []}; }),
    fetch('/auditor/access').then(function(r){ return r.ok ? r.json() : {users: []}; }).catch(function(){ return {users: []}; }),
    fetch('/tool-access/directory').then(function(r){ return r.ok ? r.json() : {directory: {}}; }).catch(function(){ return {directory: {}}; }),
    fetch('/personnel').then(function(r){ return r.ok ? r.json() : {personnel: []}; }).catch(function(){ return {personnel: []}; })
  ]).then(function(res){
    // DVI-1452 P1: sync the qc.production.<id> leaves in the grantable tree to
    // the live segment registry so a newly-added segment is grantable here.
    _tgaPatchQcSegments((res[0] && res[0].qc_segments) || []);
    _tgaDirectory = (res[2] && res[2].directory) || {};
    _tgaPersonnelByEmail = {};
    ((res[3] && res[3].personnel) || []).forEach(function(p){
      var e = (p.email || '').toLowerCase();
      if (e) _tgaPersonnelByEmail[e] = p;
    });
    var byEmail = {};
    function slot(email){ if (!byEmail[email]) byEmail[email] = {email: email, tool_resources: [], auditor_resources: []}; return byEmail[email]; }
    ((res[0] && res[0].users) || []).forEach(function(u){ slot(u.email).tool_resources = u.resources || []; });
    ((res[1] && res[1].users) || []).forEach(function(u){ slot(u.email).auditor_resources = u.resources || []; });
    _tgaAccessUsers = Object.keys(byEmail).sort().map(function(e){ return byEmail[e]; });
    tgaAccessRender();
  }).catch(function(){
    if (list) list.innerHTML = '<p class="text-sm text-red-600 dark:text-red-400 py-4 text-center">Failed to load users.</p>';
  });
}

function tgaAccessRender() {
  var list = document.getElementById('tga-access-list');
  var empty = document.getElementById('tga-access-empty');
  var controls = document.getElementById('tga-access-controls');
  tgaAccessRenderGroupNote();
  if (!list) return;
  if (!_tgaAccessUsers.length) {
    list.innerHTML = '';
    if (empty) empty.classList.remove('hidden');
    if (controls) controls.classList.add('hidden');
    return;
  }
  if (empty) empty.classList.add('hidden');
  if (controls) controls.classList.remove('hidden');
  list.innerHTML = _tgaAccessUsers.map(_tgaAccessCard).join('');
  tgaAccessFilter();
}

// DVI-1432 R4: live substring filter over the user list by name + email.
function tgaAccessFilter() {
  var q = ((document.getElementById('tga-access-search') || {}).value || '').trim().toLowerCase();
  var cards = document.querySelectorAll('#tga-access-list .tga-res-card');
  for (var i = 0; i < cards.length; i++) {
    var key = cards[i].getAttribute('data-tga-search') || '';
    cards[i].classList.toggle('hidden', !!q && key.indexOf(q) === -1);
  }
}

// DVI-1432 R2: expand/collapse every currently-visible user card.
function tgaAccessExpandAll(open) {
  var cards = document.querySelectorAll('#tga-access-list .tga-res-card:not(.hidden)');
  for (var i = 0; i < cards.length; i++) {
    var body = cards[i].querySelector('.tga-access-body');
    var chev = cards[i].querySelector('.tga-card-chevron');
    if (body) body.classList.toggle('hidden', !open);
    if (chev) chev.classList.toggle('rotate-90', !!open);
  }
}

// Toggle one user card's body (collapse is hide/show, never a re-render, so
// in-progress checkbox edits survive collapse/expand — DVI-1432 item 1).
function tgaAccessToggle(header) {
  var card = header.closest('.tga-res-card');
  if (!card) return;
  var body = card.querySelector('.tga-access-body');
  var chev = header.querySelector('.tga-card-chevron');
  if (!body) return;
  var nowHidden = body.classList.toggle('hidden');
  if (chev) chev.classList.toggle('rotate-90', !nowHidden);
}

// Toggle one permission sub-section's leaf checkboxes (DVI-1432 item 3).
function tgaGroupToggle(header) {
  var body = header.parentNode.querySelector('.tga-grp-body');
  var chev = header.querySelector('.tga-grp-chevron');
  if (!body) return;
  var nowHidden = body.classList.toggle('hidden');
  if (chev) chev.classList.toggle('rotate-90', !nowHidden);
}

// Keep header count chips + per-group "N of M" counts in step with edits.
function tgaAccessUpdateCounts(card) {
  if (!card) return;
  var grps = card.querySelectorAll('.tga-grp');
  for (var i = 0; i < grps.length; i++) {
    var leaves = grps[i].querySelectorAll('input[data-res]');
    var n = 0;
    for (var j = 0; j < leaves.length; j++) { if (leaves[j].checked) n++; }
    var cnt = grps[i].querySelector('.tga-grp-count');
    if (cnt) cnt.textContent = n + ' of ' + leaves.length;
  }
  var chips = card.querySelector('.tga-access-chips');
  if (chips) {
    var t = card.querySelectorAll('input[data-res][data-domain="tool"]:checked').length;
    var a = card.querySelectorAll('input[data-res][data-domain="auditor"]:checked').length;
    chips.innerHTML = _tgaCountChips(t, a);
  }
}

// R5: prefill this card's checkbox tree from another user's current grants.
function tgaAccessCopyFrom(sel) {
  var srcEmail = sel.value;
  if (!srcEmail) return;
  var card = sel.closest('.tga-res-card');
  var src = _tgaAccessUsers.filter(function(u){ return u.email === srcEmail; })[0];
  if (!card || !src) return;
  var toolSet = {}; (src.tool_resources || []).forEach(function(k){ toolSet[k] = true; });
  var audSet = {}; (src.auditor_resources || []).forEach(function(k){ audSet[k] = true; });
  card.querySelectorAll('input[data-res][data-domain="tool"]').forEach(function(cb){ cb.checked = !!toolSet[cb.getAttribute('data-res')]; });
  card.querySelectorAll('input[data-res][data-domain="auditor"]').forEach(function(cb){ cb.checked = !!audSet[cb.getAttribute('data-res')]; });
  card.querySelectorAll('input[data-full-group]').forEach(function(full){
    var group = full.getAttribute('data-full-group');
    var scope = full.closest('[data-tga-tree]') || card;
    var leaves = scope.querySelectorAll('input[data-res][data-group="' + group + '"]');
    var all = true;
    leaves.forEach(function(l){ if (!l.checked) all = false; });
    full.checked = leaves.length > 0 && all;
  });
  tgaAccessUpdateCounts(card);
  sel.value = '';
  _tgaAccessSetStatus('Prefilled access from ' + srcEmail + ' — review and click Save.', false);
}

// DVI-1373 P4/Q4: render the read-only Azure-group -> capability reference at
// the top of the Access list. Per-user group membership is not resolvable in
// this admin view (it's snapshotted in each user's own session at login), so we
// surface the group map itself as context for group-derived access.
function tgaAccessRenderGroupNote() {
  var el = document.getElementById('tga-access-group-note');
  if (!el) return;
  if (!_tgaGroups || !_tgaGroups.length) { el.classList.add('hidden'); el.innerHTML = ''; return; }
  var items = _tgaGroups.map(function(g){
    var caps = (g.resources || []).map(esc).join(', ') || '(none)';
    return '<li><span class="font-medium">' + esc(g.name || g.group_id) + '</span>: ' + caps + '</li>';
  }).join('');
  el.classList.remove('hidden');
  el.innerHTML = '<div class="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-1">Azure AD group grants (read-only)</div>' +
    '<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Members of these groups also receive these tool capabilities at sign-in. They are not editable per-user here — change them in “Access by Azure AD group” below, or adjust the group’s membership in Azure.</p>' +
    '<ul class="text-xs text-gray-600 dark:text-gray-300 list-disc pl-5 space-y-0.5">' + items + '</ul>';
}

// Shared grouped resource -> capability checkbox tree, used by the per-user
// tool + Auditor sections and the per-Azure-group card (DVI-1373 P2/P3/P4).
// `tree` is a _*_RES_TREE array, `granted` a {leafKey:true} map of checked
// leaves, `domain` ('tool'|'auditor') tags the checkboxes + wraps the tree so a
// card can carry two independent trees (and Save can read each separately). The
// wrapper also scopes the "Full access" toggle so a group key shared across
// trees (e.g. 'dashboards') can't cross-select.
function _tgaResTreeHtml(tree, granted, domain) {
  tree = tree || _TOOL_ACCESS_RES_TREE;
  domain = domain || 'tool';
  var inner = tree.map(function(grp){
    var total = grp.children.length;
    var nGranted = grp.children.filter(function(ch){ return granted[ch.key]; }).length;
    var allChecked = total > 0 && nGranted === total;
    var kids = grp.children.map(function(ch){
      var checked = granted[ch.key] ? ' checked' : '';
      return '<label class="inline-flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">' +
        '<input type="checkbox" data-res="' + esc(ch.key) + '" data-group="' + esc(grp.key) + '" data-domain="' + esc(domain) + '"' + checked +
        ' onchange="tgaAccessSyncFull(this)" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">' +
        esc(ch.label) + '</label>';
    }).join('');
    // The Full-access toggle stays usable without expanding; its clicks must not
    // bubble up to the collapse handler on the header row (DVI-1432 item 3).
    var fullToggle = total > 1
      ? '<label onclick="event.stopPropagation()" class="inline-flex items-center gap-1.5 text-xs font-medium text-togen-teal cursor-pointer">' +
          '<input type="checkbox" data-full-group="' + esc(grp.key) + '"' + (allChecked ? ' checked' : '') +
          ' onclick="event.stopPropagation();tgaAccessToggleFull(this)" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">' +
          'Full access</label>'
      : '';
    // DVI-1522 (Amendment 2): an optional group note (e.g. QC's Kiln Metrics is
    // role-gated, not a grant here) renders inside the expandable body.
    var note = grp.note
      ? '<p class="text-xs italic text-gray-500 dark:text-gray-400 w-full mb-1">' + esc(grp.note) + '</p>'
      : '';
    return '<div class="mb-2 tga-grp" data-tga-group="' + esc(grp.key) + '">' +
      '<div class="tga-grp-header flex items-center gap-2 cursor-pointer select-none" onclick="tgaGroupToggle(this)">' +
        _tgaChevron('tga-grp-chevron') +
        '<span class="text-xs font-semibold text-gray-600 dark:text-gray-300">' + esc(grp.label) + '</span>' +
        '<span class="tga-grp-count text-xs text-gray-400 dark:text-gray-500">' + nGranted + ' of ' + total + '</span>' +
        fullToggle +
      '</div>' +
      '<div class="tga-grp-body hidden flex flex-wrap gap-x-4 gap-y-1 pl-5 mt-1">' + note + kids + '</div>' +
    '</div>';
  }).join('');
  return '<div data-tga-tree="' + esc(domain) + '">' + inner + '</div>';
}

function _tgaAccessCard(u) {
  var toolGranted = {}; (u.tool_resources || []).forEach(function(k){ toolGranted[k] = true; });
  var audGranted = {}; (u.auditor_resources || []).forEach(function(k){ audGranted[k] = true; });
  var emailAttr = esc(u.email);
  // Snapshot each domain's granted set so Save only POSTs a store that actually
  // changed (an empty POST to /tool-access or /auditor/access revokes + logs a
  // history row — we must not fire that for an untouched section).
  var origTool = (u.tool_resources || []).slice().sort().join(',');
  var origAud = (u.auditor_resources || []).slice().sort().join(',');
  var toolTree = _tgaResTreeHtml(_TOOL_ACCESS_RES_TREE, toolGranted, 'tool');
  var audTree = _tgaResTreeHtml(_AUDITOR_RES_TREE, audGranted, 'auditor');
  var sectHdr = 'mb-1 text-xs font-bold uppercase tracking-wide text-gray-500 dark:text-gray-400';
  // DVI-1432 item 2: "First Last — email"; falls back to email-only.
  var name = _tgaDisplayName(u.email);
  var nameHtml = name
    ? '<span class="text-sm font-semibold text-gray-800 dark:text-gray-100">' + esc(name) + '</span>' +
      '<span class="text-xs text-gray-500 dark:text-gray-400 ml-1.5">— ' + emailAttr + '</span>'
    : '<span class="text-sm font-semibold text-gray-800 dark:text-gray-100">' + emailAttr + '</span>';
  var searchKey = esc(((name || '') + ' ' + u.email).toLowerCase());
  var chips = _tgaCountChips((u.tool_resources || []).length, (u.auditor_resources || []).length);
  // Collapsed by default (item 1): the card renders fully but its body is hidden;
  // clicking the header hides/shows it (never re-renders), so edits survive.
  return '<div class="tga-res-card togen-inset rounded-xl border border-gray-200 dark:border-gray-700" data-tga-access-email="' + emailAttr + '" data-tga-search="' + searchKey + '" data-orig-tool="' + esc(origTool) + '" data-orig-auditor="' + esc(origAud) + '">' +
    '<div class="tga-access-header flex items-center justify-between gap-3 p-4 cursor-pointer select-none" onclick="tgaAccessToggle(this)">' +
      '<div class="flex items-center gap-2 min-w-0">' + _tgaChevron('tga-card-chevron') +
        '<div class="min-w-0 flex items-center flex-wrap">' + nameHtml + '</div>' +
      '</div>' +
      '<div class="tga-access-chips flex items-center gap-1.5 shrink-0">' + chips + '</div>' +
    '</div>' +
    '<div class="tga-access-body hidden px-4 pb-4">' +
      _tgaLastLoginHtml(u.email) +
      _tgaCopyFromHtml(u.email) +
      '<div class="' + sectHdr + '">Tool access</div>' + toolTree +
      '<div class="mt-4 ' + sectHdr + '">Auditor access</div>' + audTree +
      '<div class="flex items-center gap-2 mt-4">' +
        '<button onclick="tgaAccessSave(\'' + emailAttr + '\')" 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">Save</button>' +
        '<button onclick="tgaAccessRemove(\'' + emailAttr + '\')" class="togen-control px-3 py-1.5 text-xs font-medium rounded-lg text-red-600 dark:text-red-400 border border-red-300 dark:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors cursor-pointer">Remove</button>' +
      '</div>' +
    '</div>' +
  '</div>';
}

function _tgaAccessCardEl(email) {
  var cards = document.querySelectorAll('[data-tga-access-email]');
  for (var i = 0; i < cards.length; i++) {
    if (cards[i].getAttribute('data-tga-access-email') === email) return cards[i];
  }
  return null;
}

// "Full access" toggle: check/uncheck every leaf checkbox in the tool group.
// Works for both per-user and per-Azure-group cards (`.tga-res-card`).
function tgaAccessToggleFull(cb) {
  var scope = cb.closest('[data-tga-tree]') || cb.closest('.tga-res-card');
  var group = cb.getAttribute('data-full-group');
  if (!scope || !group) return;
  scope.querySelectorAll('input[data-res][data-group="' + group + '"]').forEach(function(leaf){
    leaf.checked = cb.checked;
  });
  tgaAccessUpdateCounts(cb.closest('.tga-res-card'));
}

// Keep a group's "Full access" toggle in sync when its leaves are edited by hand.
function tgaAccessSyncFull(leaf) {
  var scope = leaf.closest('[data-tga-tree]') || leaf.closest('.tga-res-card');
  var group = leaf.getAttribute('data-group');
  if (!scope || !group) return;
  var leaves = scope.querySelectorAll('input[data-res][data-group="' + group + '"]');
  var all = true;
  leaves.forEach(function(l){ if (!l.checked) all = false; });
  var full = scope.querySelector('input[data-full-group="' + group + '"]');
  if (full) full.checked = all;
  tgaAccessUpdateCounts(leaf.closest('.tga-res-card'));
}

function tgaAccessAddUser() {
  var inp = document.getElementById('tga-access-email');
  if (!inp) return;
  var email = (inp.value || '').trim().toLowerCase();
  if (!email || email.indexOf('@') === -1) { _tgaAccessSetStatus('Enter a valid email address.', true); return; }
  if (_tgaAccessUsers.some(function(u){ return u.email === email; })) {
    _tgaAccessSetStatus('That user is already listed.', true);
    return;
  }
  _tgaAccessUsers.push({email: email, tool_resources: [], auditor_resources: []});
  inp.value = '';
  _tgaAccessSetStatus('Tick tool and/or Auditor access for ' + email + ' and click Save.', false);
  tgaAccessRender();
  // Auto-expand the newly added card so the admin can set access right away.
  var card = _tgaAccessCardEl(email);
  if (card) {
    var body = card.querySelector('.tga-access-body');
    var chev = card.querySelector('.tga-card-chevron');
    if (body) body.classList.remove('hidden');
    if (chev) chev.classList.add('rotate-90');
  }
}

function tgaAccessSave(email) {
  var card = _tgaAccessCardEl(email);
  if (!card) return;
  var toolRes = [], audRes = [];
  card.querySelectorAll('input[data-res][data-domain="tool"]:checked').forEach(function(cb){ toolRes.push(cb.getAttribute('data-res')); });
  card.querySelectorAll('input[data-res][data-domain="auditor"]:checked').forEach(function(cb){ audRes.push(cb.getAttribute('data-res')); });
  var toolChanged = toolRes.slice().sort().join(',') !== (card.getAttribute('data-orig-tool') || '');
  var audChanged = audRes.slice().sort().join(',') !== (card.getAttribute('data-orig-auditor') || '');
  if (!toolChanged && !audChanged) { _tgaAccessSetStatus('No changes for ' + email + '.', false); return; }
  function post(url, resources){
    return fetch(url, {method: 'POST', headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({email: email, resources: resources})})
      .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); });
  }
  var calls = [];
  if (toolChanged) calls.push(post('/tool-access', toolRes));
  if (audChanged) calls.push(post('/auditor/access', audRes));
  Promise.all(calls).then(function(results){
    var bad = results.filter(function(x){ return !x.ok; })[0];
    if (bad) { _tgaAccessSetStatus((bad.d && bad.d.error) || 'Save failed.', true); return; }
    _tgaAccessSetStatus('Saved access for ' + email + '.', false);
    tgaAccessLoad();
  }).catch(function(err){ _tgaAccessSetStatus('Save failed: ' + err.message, true); });
}

function tgaAccessRemove(email) {
  function del(url){
    return fetch(url, {method: 'DELETE', headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({email: email})}).then(function(r){ return {ok: r.ok}; });
  }
  Promise.all([del('/tool-access'), del('/auditor/access')]).then(function(results){
    var bad = results.filter(function(x){ return !x.ok; })[0];
    if (bad) { _tgaAccessSetStatus('Remove failed.', true); return; }
    _tgaAccessSetStatus('Removed all access for ' + email + '.', false);
    tgaAccessLoad();
  }).catch(function(err){ _tgaAccessSetStatus('Remove failed: ' + err.message, true); });
}

// ===== DVI-1373 P3: Access by Azure AD group =====
var _tgaGroups = [];

function _tgaGroupsSetStatus(msg, isErr) {
  var el = document.getElementById('tga-groups-status');
  if (!el) return;
  if (!msg) { el.classList.add('hidden'); el.textContent = ''; return; }
  el.classList.remove('hidden');
  el.textContent = msg;
  el.className = 'text-sm mb-3 ' + (isErr ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400');
}

function tgaGroupsLoad() {
  var list = document.getElementById('tga-groups-list');
  if (list) list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 py-4 text-center">Loading…</p>';
  fetch('/tool-access/groups').then(function(r){ return r.json(); }).then(function(d){
    _tgaGroups = (d && d.groups) || [];
    tgaGroupsRender();
  }).catch(function(){
    if (list) list.innerHTML = '<p class="text-sm text-red-600 dark:text-red-400 py-4 text-center">Failed to load groups.</p>';
  });
}

function tgaGroupsRender() {
  var list = document.getElementById('tga-groups-list');
  var empty = document.getElementById('tga-groups-empty');
  var searchWrap = document.getElementById('tga-groups-search-wrap');
  if (!list) return;
  if (!_tgaGroups.length) {
    list.innerHTML = '';
    if (empty) empty.classList.remove('hidden');
    if (searchWrap) searchWrap.classList.add('hidden');
    tgaAccessRenderGroupNote();
    return;
  }
  if (empty) empty.classList.add('hidden');
  if (searchWrap) searchWrap.classList.remove('hidden');
  list.innerHTML = _tgaGroups.map(_tgaGroupCard).join('');
  tgaGroupsFilter();
  tgaAccessRenderGroupNote();  // keep the per-user read-only group context in sync
}

// DVI-1432 R3: live substring filter over the group cards by name + GUID.
function tgaGroupsFilter() {
  var q = ((document.getElementById('tga-groups-search') || {}).value || '').trim().toLowerCase();
  var cards = document.querySelectorAll('#tga-groups-list .tga-res-card');
  for (var i = 0; i < cards.length; i++) {
    var key = cards[i].getAttribute('data-tga-search') || '';
    cards[i].classList.toggle('hidden', !!q && key.indexOf(q) === -1);
  }
}

// DVI-1432 R3: collapse/expand one group card's permission tree.
function tgaGroupCardToggle(btn) {
  var card = btn.closest('.tga-res-card');
  var body = card && card.querySelector('.tga-access-body');
  var chev = btn.querySelector('.tga-card-chevron');
  if (!body) return;
  var nowHidden = body.classList.toggle('hidden');
  if (chev) chev.classList.toggle('rotate-90', !nowHidden);
}

function _tgaGroupCard(g) {
  var granted = {};
  (g.resources || []).forEach(function(k){ granted[k] = true; });
  var gid = esc(g.group_id);
  var nameAttr = esc(g.name || '');
  var groups = _tgaResTreeHtml(_TOOL_ACCESS_RES_TREE, granted, 'tool');
  var searchKey = esc(((g.name || '') + ' ' + g.group_id).toLowerCase());
  // Collapsed by default (R3), same tree renderer as the user cards.
  return '<div class="tga-res-card togen-inset rounded-xl border border-gray-200 dark:border-gray-700 p-4" data-tga-group-id="' + gid + '" data-tga-search="' + searchKey + '">' +
    '<div class="flex items-start justify-between mb-3 gap-3">' +
      '<div class="flex items-start gap-2 min-w-0 flex-1">' +
        '<button type="button" onclick="tgaGroupCardToggle(this)" class="mt-1 shrink-0 cursor-pointer">' + _tgaChevron('tga-card-chevron') + '</button>' +
        '<div class="min-w-0 flex-1">' +
          '<input type="text" data-tga-group-name value="' + nameAttr + '" placeholder="Group name (label)" class="w-full text-sm font-semibold text-gray-800 dark:text-gray-100 bg-transparent border-b border-transparent hover:border-gray-300 dark:hover:border-gray-600 focus:border-togen-teal focus:outline-none px-0 py-0.5">' +
          '<div class="text-xs text-gray-400 dark:text-gray-500 font-mono truncate mt-0.5">' + gid + '</div>' +
        '</div>' +
      '</div>' +
      '<div class="flex items-center gap-2 shrink-0">' +
        '<button onclick="tgaGroupSave(\'' + gid + '\')" 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">Save</button>' +
        '<button onclick="tgaGroupRemove(\'' + gid + '\')" class="togen-control px-3 py-1.5 text-xs font-medium rounded-lg text-red-600 dark:text-red-400 border border-red-300 dark:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors cursor-pointer">Remove</button>' +
      '</div>' +
    '</div>' +
    '<div class="tga-access-body hidden">' + groups + '</div></div>';
}

function _tgaGroupCardEl(gid) {
  var cards = document.querySelectorAll('[data-tga-group-id]');
  for (var i = 0; i < cards.length; i++) {
    if (cards[i].getAttribute('data-tga-group-id') === gid) return cards[i];
  }
  return null;
}

function tgaGroupAdd() {
  var idInp = document.getElementById('tga-group-id');
  var nameInp = document.getElementById('tga-group-name');
  if (!idInp) return;
  var gid = (idInp.value || '').trim().toLowerCase();
  var name = (nameInp && nameInp.value || '').trim();
  if (!gid) { _tgaGroupsSetStatus('Enter an Azure AD group Object ID (GUID).', true); return; }
  if (_tgaGroups.some(function(g){ return g.group_id === gid; })) { _tgaGroupsSetStatus('That group is already listed.', true); return; }
  _tgaGroups.push({group_id: gid, name: name, resources: [], added_at: '', added_by: ''});
  idInp.value = ''; if (nameInp) nameInp.value = '';
  _tgaGroupsSetStatus('Tick the capabilities for this group and click Save.', false);
  tgaGroupsRender();
}

function tgaGroupSave(gid) {
  var card = _tgaGroupCardEl(gid);
  if (!card) return;
  var resources = [];
  card.querySelectorAll('input[data-res]:checked').forEach(function(cb){ resources.push(cb.getAttribute('data-res')); });
  var nameEl = card.querySelector('input[data-tga-group-name]');
  var name = nameEl ? nameEl.value.trim() : '';
  fetch('/tool-access/groups', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({group_id: gid, name: name, resources: resources})
  }).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok) { _tgaGroupsSetStatus((res.d && res.d.error) || 'Save failed.', true); return; }
      _tgaGroupsSetStatus(res.d.removed ? 'Group removed (no capabilities granted).' : 'Saved group access.', false);
      tgaGroupsLoad();
    }).catch(function(err){ _tgaGroupsSetStatus('Save failed: ' + err.message, true); });
}

function tgaGroupRemove(gid) {
  fetch('/tool-access/groups', {
    method: 'DELETE', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({group_id: gid})
  }).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok) { _tgaGroupsSetStatus((res.d && res.d.error) || 'Remove failed.', true); return; }
      _tgaGroupsSetStatus('Removed group access.', false);
      tgaGroupsLoad();
    }).catch(function(err){ _tgaGroupsSetStatus('Remove failed: ' + err.message, true); });
}

function tgaUsersLoad() {
  fetch('/personnel').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){ if (!d) return; _tgaPersonnel = d.personnel || []; tgaUsersRender(); })
    .catch(function(){});
}

function tgaUsersRender() {
  var host = document.getElementById('tga-users-list');
  if (!host) return;
  if (!_tgaPersonnel.length) {
    host.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No personnel yet.</p>';
    return;
  }
  host.innerHTML = _tgaPersonnel.map(function(p){
    var name = _tpEsc((p.first_name || '') + ' ' + (p.last_name || '')).trim();
    var tag = (p.dev_eui || '').trim();
    var meta = [];
    if (p.email) meta.push(_tpEsc(p.email));
    meta.push(tag ? 'tag ' + _tpEsc(tag) : 'no tag');
    var desigBadge = ((p.designations || []).indexOf('driver') !== -1)
      ? ' <span class="ml-1.5 inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-togen-teal/15 text-togen-teal align-middle">Driver</span>' : '';
    return '<div class="flex items-center justify-between gap-3 py-2.5 border-b border-gray-100 dark:border-gray-800 last:border-0">' +
      '<div class="min-w-0"><div class="font-medium text-gray-800 dark:text-gray-100 truncate">' + (name || '&mdash;') + desigBadge + '</div>' +
      '<div class="text-xs text-gray-500 dark:text-gray-400 truncate">' + meta.join(' · ') + '</div></div>' +
      '<div class="flex items-center gap-1.5 shrink-0">' +
        '<button onclick="tgaUsersOpenEdit(\'' + p.id + '\')" class="togen-control 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">Edit</button>' +
        '<button onclick="tgaUsersDelete(\'' + p.id + '\')" class="togen-control px-2.5 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/30">Delete</button>' +
      '</div></div>';
  }).join('');
}

function tgaUsersOpenAdd() {
  _tgaUsersEditId = null;
  document.getElementById('tga-users-modal-title').textContent = 'Add Person';
  document.getElementById('tga-users-first').value = '';
  document.getElementById('tga-users-last').value = '';
  document.getElementById('tga-users-email').value = '';
  document.getElementById('tga-users-tag').value = '';
  document.getElementById('tga-users-desig-driver').checked = false;
  var st = document.getElementById('tga-users-status'); st.classList.add('hidden'); st.textContent = '';
  document.getElementById('tga-users-modal').classList.remove('hidden');
  document.getElementById('tga-users-first').focus();
}

function tgaUsersOpenEdit(pid) {
  var p = _tgaPersonnel.filter(function(x){ return x.id === pid; })[0];
  if (!p) return;
  _tgaUsersEditId = pid;
  document.getElementById('tga-users-modal-title').textContent = 'Edit Person';
  document.getElementById('tga-users-first').value = p.first_name || '';
  document.getElementById('tga-users-last').value = p.last_name || '';
  document.getElementById('tga-users-email').value = p.email || '';
  document.getElementById('tga-users-tag').value = p.dev_eui || '';
  document.getElementById('tga-users-desig-driver').checked =
    (p.designations || []).indexOf('driver') !== -1;
  var st = document.getElementById('tga-users-status'); st.classList.add('hidden'); st.textContent = '';
  document.getElementById('tga-users-modal').classList.remove('hidden');
  document.getElementById('tga-users-first').focus();
}

function tgaUsersCloseModal() {
  document.getElementById('tga-users-modal').classList.add('hidden');
}

function tgaUsersSave() {
  var first = document.getElementById('tga-users-first').value.trim();
  var last = document.getElementById('tga-users-last').value.trim();
  var st = document.getElementById('tga-users-status');
  if (!first || !last) {
    st.textContent = 'First and last name are required.'; st.className = 'text-sm text-red-500'; st.classList.remove('hidden'); return;
  }
  var desig = [];
  if (document.getElementById('tga-users-desig-driver').checked) desig.push('driver');
  var body = {
    first_name: first, last_name: last,
    email: document.getElementById('tga-users-email').value.trim(),
    dev_eui: document.getElementById('tga-users-tag').value.trim(),
    designations: desig
  };
  var url = _tgaUsersEditId ? '/personnel/' + encodeURIComponent(_tgaUsersEditId) : '/personnel';
  st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500 dark:text-gray-400'; st.classList.remove('hidden');
  fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
    .then(function(r){ return r.json().then(function(j){ return { ok: r.ok, j: j }; }); })
    .then(function(res){
      if (!res.ok) { st.textContent = res.j.error || 'Save failed.'; st.className = 'text-sm text-red-500'; return; }
      tgaUsersCloseModal(); tgaUsersLoad();
    })
    .catch(function(){ st.textContent = 'Save failed.'; st.className = 'text-sm text-red-500'; });
}

function tgaUsersDelete(pid) {
  if (!confirm('Remove this person from the roster?')) return;
  fetch('/personnel/' + encodeURIComponent(pid), { method: 'DELETE' })
    .then(function(r){ if (!r.ok) throw new Error(); return r.json(); })
    .then(function(){ tgaUsersLoad(); })
    .catch(function(){ alert('Failed to delete.'); });
}

// ===== DVI-1136: Togen Admin → Services → ThingPark (LoRaWAN) =====
var _thingparkDevices = [];
var _thingparkDeviceKinds = ['sensor', 'tag', 'other'];
var _thingparkServicesLoaded = false;

function _tpEsc(s) {
  return String(s == null ? '' : s)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

function _tpFmtTime(ts) {
  if (!ts) return '—';
  var d = new Date(ts);
  if (isNaN(d.getTime())) return _tpEsc(ts);
  return d.toLocaleString();
}

function togenadminServicesInit() {
  togenadminServiceSubtab('thingpark');
  if (_thingparkServicesLoaded) return;
  _thingparkServicesLoaded = true;
  thingparkLoadHealth();
  thingparkLoadDevices();
  thingparkLoadConfig();
  idenciaAdminLoad();
  powerbiAdminLoad();
  netsuiteAdminLoad();
  paychexAdminLoad();
}

function togenadminServiceSubtab(name) {
  var tabs = document.querySelectorAll('[id^="tga-svc-subtab-"]');
  var panels = document.querySelectorAll('[id^="tga-svc-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');
  }
  for (var j = 0; j < panels.length; j++) panels[j].classList.add('hidden');
  var at = document.getElementById('tga-svc-subtab-' + name);
  var ap = document.getElementById('tga-svc-panel-' + name);
  if (at) { at.classList.add('border-togen-teal', 'text-togen-teal'); at.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400'); }
  if (ap) ap.classList.remove('hidden');
}

function thingparkLoadHealth() {
  var el = document.getElementById('thingpark-health');
  if (el) el.textContent = 'Loading…';
  fetch('/thingpark/health').then(function(r){ return r.json(); }).then(function(d){
    if (!el) return;
    if (!d || !d.ok) { el.textContent = (d && d.error) || 'Failed to load ingest health.'; return; }
    var h = d.health || {};
    var tokenChip = d.token_configured
      ? '<span class="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">Ingest enabled</span>'
      : '<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">Disabled — token not set (503)</span>';
    var errLine = h.last_error
      ? _tpEsc(h.last_error) + (h.last_error_at ? ' <span class="text-gray-400">(' + _tpFmtTime(h.last_error_at) + ')</span>' : '')
      : 'none';
    var rows = ''
      + '<div class="flex flex-wrap items-center gap-2 mb-3">' + tokenChip + '</div>'
      + '<dl class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1">'
      + '<div class="flex justify-between gap-4"><dt>Last successful ingest</dt><dd class="text-gray-700 dark:text-gray-200">' + _tpFmtTime(h.last_ok || h.last_run) + '</dd></div>'
      + '<div class="flex justify-between gap-4"><dt>Uplinks stored</dt><dd class="text-gray-700 dark:text-gray-200">' + (h.uplinks != null ? _tpEsc(h.uplinks) : '—') + '</dd></div>'
      + '<div class="flex justify-between gap-4"><dt>Devices seen</dt><dd class="text-gray-700 dark:text-gray-200">' + (h.devices != null ? _tpEsc(h.devices) : '—') + '</dd></div>'
      + '<div class="flex justify-between gap-4"><dt>Last error</dt><dd class="text-gray-700 dark:text-gray-200">' + errLine + '</dd></div>'
      + '</dl>';
    el.innerHTML = rows;
  }).catch(function(){ if (el) el.textContent = 'Failed to load ingest health.'; });
}

function thingparkLoadDevices() {
  var el = document.getElementById('thingpark-devices');
  if (el) el.textContent = 'Loading…';
  fetch('/thingpark/devices').then(function(r){ return r.json(); }).then(function(d){
    if (!d || !d.ok) { if (el) el.textContent = (d && d.error) || 'Failed to load devices.'; return; }
    _thingparkDevices = d.devices || [];
    thingparkRenderDevices(d.stale_after_minutes);
  }).catch(function(){ if (el) el.textContent = 'Failed to load devices.'; });
}

function thingparkRenderDevices(staleMin) {
  var el = document.getElementById('thingpark-devices');
  if (!el) return;
  if (!_thingparkDevices.length) {
    el.innerHTML = '<span class="italic">No devices yet. Once a sensor or tag sends an uplink through the connector it will appear here.</span>';
    return;
  }
  var kindOpts = function(sel) {
    var out = '<option value=""' + (sel ? '' : ' selected') + '>—</option>';
    for (var i = 0; i < _thingparkDeviceKinds.length; i++) {
      var k = _thingparkDeviceKinds[i];
      out += '<option value="' + k + '"' + (sel === k ? ' selected' : '') + '>' + k.charAt(0).toUpperCase() + k.slice(1) + '</option>';
    }
    return out;
  };
  var rows = '';
  for (var i = 0; i < _thingparkDevices.length; i++) {
    var dv = _thingparkDevices[i];
    var statusChip;
    if (!dv.seen) statusChip = '<span class="px-1.5 py-0.5 rounded text-xs bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300">never seen</span>';
    else if (dv.stale) statusChip = '<span class="px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">stale</span>';
    else statusChip = '<span class="px-1.5 py-0.5 rounded text-xs bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">live</span>';
    var inp = 'w-full px-1.5 py-1 text-xs rounded 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';
    rows += '<tr class="border-b border-gray-100 dark:border-gray-800" data-eui="' + _tpEsc(dv.dev_eui) + '">'
      + '<td class="py-1.5 pr-3 font-mono text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">' + _tpEsc(dv.dev_eui) + (dv.registered ? '' : ' <span class="text-gray-400" title="auto-discovered, not yet saved">*</span>') + '</td>'
      + '<td class="py-1.5 pr-3"><input type="text" class="' + inp + ' tp-dev-name" value="' + _tpEsc(dv.name) + '" placeholder="Name"></td>'
      + '<td class="py-1.5 pr-3"><select class="' + inp + ' tp-dev-kind">' + kindOpts(dv.kind) + '</select></td>'
      + '<td class="py-1.5 pr-3"><input type="text" class="' + inp + ' tp-dev-location" value="' + _tpEsc(dv.location) + '" placeholder="Location"></td>'
      + '<td class="py-1.5 pr-3 text-center"><input type="checkbox" class="tp-dev-oversight"' + (dv.include_in_oversight ? ' checked' : '') + '></td>'
      + '<td class="py-1.5 pr-3 whitespace-nowrap">' + statusChip + '</td>'
      + '<td class="py-1.5 pr-3 text-xs whitespace-nowrap text-gray-500 dark:text-gray-400">' + _tpFmtTime(dv.last_seen) + '</td>'
      + '<td class="py-1.5 pr-3 text-xs text-gray-500 dark:text-gray-400">' + (dv.rssi != null ? _tpEsc(dv.rssi) : '—') + '</td>'
      + '<td class="py-1.5 pr-3 text-xs text-gray-500 dark:text-gray-400">' + (dv.battery != null ? _tpEsc(dv.battery) : '—') + '</td>'
      + '</tr>';
  }
  el.innerHTML = '<table class="min-w-full text-left">'
    + '<thead><tr class="text-xs uppercase tracking-wide text-gray-400 border-b border-gray-200 dark:border-gray-700">'
    + '<th class="py-1.5 pr-3">DevEUI</th><th class="py-1.5 pr-3">Name</th><th class="py-1.5 pr-3">Kind</th>'
    + '<th class="py-1.5 pr-3">Location</th><th class="py-1.5 pr-3">Oversight</th><th class="py-1.5 pr-3">Status</th>'
    + '<th class="py-1.5 pr-3">Last seen</th><th class="py-1.5 pr-3">RSSI</th><th class="py-1.5 pr-3">Battery</th></tr></thead>'
    + '<tbody>' + rows + '</tbody></table>'
    + '<p class="mt-2 text-xs text-gray-400">Stale after ' + _tpEsc(staleMin != null ? staleMin : '—') + ' min without an uplink. <span class="font-mono">*</span> = auto-discovered, not yet saved.</p>';
}

function thingparkSaveDevices() {
  var st = document.getElementById('thingpark-devices-status');
  var rows = document.querySelectorAll('#thingpark-devices tr[data-eui]');
  var devices = {};
  for (var i = 0; i < rows.length; i++) {
    var tr = rows[i];
    var eui = tr.getAttribute('data-eui');
    devices[eui] = {
      name: (tr.querySelector('.tp-dev-name') || {}).value || '',
      kind: (tr.querySelector('.tp-dev-kind') || {}).value || '',
      location: (tr.querySelector('.tp-dev-location') || {}).value || '',
      include_in_oversight: !!(tr.querySelector('.tp-dev-oversight') || {}).checked
    };
  }
  if (st) { st.textContent = 'Saving…'; st.className = 'block mt-3 text-sm text-gray-500'; }
  fetch('/thingpark/config', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({devices: devices})
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.ok) {
      if (st) { st.textContent = 'Devices saved.'; st.className = 'block mt-3 text-sm text-green-600 dark:text-green-400'; }
      thingparkLoadDevices();
    } else {
      if (st) { st.textContent = (d && d.error) || 'Save failed.'; st.className = 'block mt-3 text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Save failed.'; st.className = 'block mt-3 text-sm text-red-600 dark:text-red-400'; } });
}

function thingparkLoadConfig() {
  fetch('/thingpark/config').then(function(r){ return r.json(); }).then(function(d){
    if (!d || !d.ok) return;
    if (Array.isArray(d.device_kinds) && d.device_kinds.length) _thingparkDeviceKinds = d.device_kinds;
    var dx = d.dx_core || {};
    var set = function(id, v){ var e = document.getElementById(id); if (e) e.value = v || ''; };
    set('thingpark-dx-base', dx.base_url);
    set('thingpark-dx-tokenurl', dx.token_url);
    set('thingpark-dx-clientid', dx.client_id);
    var note = document.getElementById('thingpark-dx-secret-note');
    if (note) note.textContent = d.has_secret ? '(stored — leave blank to keep)' : '(not set)';
    var sm = document.getElementById('thingpark-stale-min');
    if (sm && d.settings) sm.value = d.settings.stale_after_minutes || 60;
  }).catch(function(){});
}

function thingparkSaveDxCore() {
  var st = document.getElementById('thingpark-dx-status');
  var val = function(id){ var e = document.getElementById(id); return e ? e.value.trim() : ''; };
  var body = {dx_core: {
    base_url: val('thingpark-dx-base'),
    token_url: val('thingpark-dx-tokenurl'),
    client_id: val('thingpark-dx-clientid')
  }};
  var secret = val('thingpark-dx-secret');
  if (secret) body.dx_core.client_secret = secret;
  if (st) { st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500'; }
  fetch('/thingpark/config', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body)
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.ok) {
      if (st) { st.textContent = 'Credentials saved.'; st.className = 'text-sm text-green-600 dark:text-green-400'; }
      var se = document.getElementById('thingpark-dx-secret'); if (se) se.value = '';
      thingparkLoadConfig();
    } else {
      if (st) { st.textContent = (d && d.error) || 'Save failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Save failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

function thingparkDxTest() {
  var st = document.getElementById('thingpark-dx-status');
  if (st) { st.textContent = 'Testing…'; st.className = 'text-sm text-gray-500'; }
  fetch('/thingpark/dx-test', {method: 'POST'}).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.ok) {
      if (st) { st.textContent = 'Success — token minted (' + _tpEsc(d.token_type || 'bearer') + ', expires in ' + _tpEsc(d.expires_in != null ? d.expires_in : '?') + 's).'; st.className = 'text-sm text-green-600 dark:text-green-400'; }
    } else {
      var msg = (d && d.error) || 'Test failed.';
      if (d && d.detail) msg += ' ' + d.detail;
      if (st) { st.textContent = msg; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Test failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

function thingparkSaveSettings() {
  var st = document.getElementById('thingpark-settings-status');
  var sm = document.getElementById('thingpark-stale-min');
  var v = sm ? parseInt(sm.value, 10) : 0;
  if (!v || v < 1) { if (st) { st.textContent = 'Enter a positive number.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } return; }
  if (st) { st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500'; }
  fetch('/thingpark/config', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({settings: {stale_after_minutes: v}})
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.ok) {
      if (st) { st.textContent = 'Saved.'; st.className = 'text-sm text-green-600 dark:text-green-400'; }
      thingparkLoadDevices();
    } else {
      if (st) { st.textContent = (d && d.error) || 'Save failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Save failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

// ===== DVI-1226 P1: Togen Admin → Services → Idencia =====
// Org service account + background sync pane. The password field is
// write-only: blank on save keeps the stored secret (mirrors ThingPark).
function idenciaAdminLoad() {
  fetch('/idencia/config').then(function(r){ return r.json(); }).then(function(d){
    if (!d || !d.ok) return;
    var u = document.getElementById('idencia-org-username');
    if (u) u.value = (d.org && d.org.username) || '';
    var oid = document.getElementById('idencia-org-id');
    if (oid) oid.value = (d.org && d.org.org_id) || '';
    var pw = document.getElementById('idencia-org-password');
    if (pw) { pw.value = ''; pw.placeholder = d.has_password ? 'Password set — leave blank to keep' : 'Enter password'; }
    var lu = document.getElementById('idencia-login-url');
    if (lu) lu.value = (d.endpoints && d.endpoints.login_url) || '';
    var gu = document.getElementById('idencia-graphql-url');
    if (gu) gu.value = (d.endpoints && d.endpoints.graphql_url) || '';
    var mr = document.getElementById(d.mode === 'web' ? 'idencia-mode-web' : 'idencia-mode-api');
    if (mr) mr.checked = true;
    var wu = document.getElementById('idencia-web-url');
    if (wu) wu.value = (d.endpoints && d.endpoints.web_url) || '';
    idenciaModeChanged();
    var en = document.getElementById('idencia-sync-enabled');
    if (en) en.checked = !!(d.settings && d.settings.sync_enabled);
    var iv = document.getElementById('idencia-sync-interval');
    if (iv) iv.value = (d.settings && d.settings.sync_interval_minutes) || 10;
    idenciaRenderSync(d.sync, d.cache);
  }).catch(function(){});
}

function idenciaRenderSync(sync, cache) {
  var el = document.getElementById('idencia-sync-status');
  if (!el) return;
  sync = sync || {};
  var last = sync.last || null;
  var rows = '';
  if (sync.running) {
    rows += '<div class="mb-2"><span class="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">Sync running…</span> <span class="text-xs text-gray-400">started ' + _tpEsc(sync.started_at || '') + '</span></div>';
  }
  if (!last) {
    rows += '<p>No sync has run yet.</p>';
  } else {
    var chip = last.status === 'ok'
      ? '<span class="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">OK</span>'
      : '<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300">Error</span>';
    rows += '<div class="flex items-center gap-2 flex-wrap">' + chip
      + '<span>Last sync ' + _tpEsc(last.at || '') + (last.actor ? ' (' + _tpEsc(last.actor) + ')' : '') + '</span></div>';
    if (last.error) rows += '<p class="mt-1 text-red-600 dark:text-red-400">' + _tpEsc(last.error) + '</p>';
    var counts = last.counts || {};
    var parts = [];
    for (var k in counts) parts.push(_tpEsc(k) + ': ' + _tpEsc(counts[k]));
    if (parts.length) rows += '<p class="mt-1 text-xs text-gray-400">' + parts.join(' · ') + '</p>';
    if (last.status !== 'ok' && sync.last_ok) {
      rows += '<p class="mt-1 text-xs text-gray-400">Last successful sync: ' + _tpEsc(sync.last_ok.at || '') + '</p>';
    }
  }
  if (cache && cache.length) {
    var cparts = cache.map(function(c){ return _tpEsc(c.cache_key) + ' (' + _tpEsc(c.fetched_at) + ')'; });
    rows += '<p class="mt-2 text-xs text-gray-400">Cached datasets: ' + cparts.join(' · ') + '</p>';
  }
  el.innerHTML = rows;
}

function idenciaModeChanged() {
  var web = !!((document.getElementById('idencia-mode-web') || {}).checked);
  var wb = document.getElementById('idencia-web-block');
  if (wb) wb.classList.toggle('hidden', !web);
  var ab = document.getElementById('idencia-api-block');
  if (ab) ab.classList.toggle('hidden', web);
}

function idenciaAdminMode() {
  return ((document.getElementById('idencia-mode-web') || {}).checked) ? 'web' : 'api';
}

function idenciaAdminSave() {
  var st = document.getElementById('idencia-org-status');
  if (st) { st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500'; }
  var pw = document.getElementById('idencia-org-password');
  var iv = parseInt((document.getElementById('idencia-sync-interval') || {}).value, 10);
  var body = {
    org: {
      username: (document.getElementById('idencia-org-username') || {}).value || '',
      org_id: (document.getElementById('idencia-org-id') || {}).value || ''
    },
    mode: idenciaAdminMode(),
    endpoints: {
      login_url: (document.getElementById('idencia-login-url') || {}).value || '',
      graphql_url: (document.getElementById('idencia-graphql-url') || {}).value || '',
      web_url: ((document.getElementById('idencia-web-url') || {}).value || '').trim()
    },
    settings: {
      sync_enabled: !!((document.getElementById('idencia-sync-enabled') || {}).checked),
      sync_interval_minutes: (iv && iv > 0) ? iv : 10
    }
  };
  if (pw && pw.value.trim()) body.org.password = pw.value.trim();
  fetch('/idencia/config', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body)
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.ok) {
      if (st) { st.textContent = 'Saved.'; st.className = 'text-sm text-green-600 dark:text-green-400'; }
      idenciaAdminLoad();
    } else {
      if (st) { st.textContent = (d && d.error) || 'Save failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Save failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

function idenciaAdminTest() {
  var st = document.getElementById('idencia-org-status');
  if (st) { st.textContent = 'Testing…'; st.className = 'text-sm text-gray-500'; }
  // Unsaved field values are sent so creds/endpoint can be tested before
  // saving; blanks fall back to the stored config server-side.
  var body = {
    username: ((document.getElementById('idencia-org-username') || {}).value || '').trim(),
    password: ((document.getElementById('idencia-org-password') || {}).value || '').trim(),
    login_url: ((document.getElementById('idencia-login-url') || {}).value || '').trim(),
    mode: idenciaAdminMode(),
    web_url: ((document.getElementById('idencia-web-url') || {}).value || '').trim()
  };
  fetch('/idencia/test', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body)
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.ok) {
      var msg = 'Connected — Idencia accepted the credentials.';
      if (d.mode === 'web') {
        msg = d.grid_ok ? 'Connected — web sign-in OK and data is readable.'
                        : 'Signed in, but the data grids could not be read — the Idencia site may have changed.';
      }
      if (st) { st.textContent = msg; st.className = 'text-sm ' + (d.mode === 'web' && !d.grid_ok ? 'text-amber-600 dark:text-amber-400' : 'text-green-600 dark:text-green-400'); }
    } else {
      if (st) { st.textContent = (d && d.error) || 'Test failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Test failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

var _idenciaSyncTimer = null;
function idenciaSyncNow() {
  var st = document.getElementById('idencia-sync-run-status');
  if (st) { st.textContent = 'Starting…'; st.className = 'text-sm text-gray-500'; }
  fetch('/idencia/sync', {method: 'POST'}).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.started) {
      if (st) { st.textContent = 'Sync running…'; }
      idenciaSyncWatch();
    } else {
      if (st) { st.textContent = (d && d.error) || 'Failed to start sync.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Failed to start sync.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

function idenciaSyncWatch() {
  if (_idenciaSyncTimer) clearInterval(_idenciaSyncTimer);
  _idenciaSyncTimer = setInterval(function(){
    fetch('/idencia/sync-status').then(function(r){ return r.json(); }).then(function(d){
      if (!d || !d.ok) return;
      idenciaRenderSync(d.sync, d.cache);
      if (!d.sync || !d.sync.running) {
        clearInterval(_idenciaSyncTimer); _idenciaSyncTimer = null;
        var st = document.getElementById('idencia-sync-run-status');
        if (st) {
          var last = d.sync && d.sync.last;
          if (last && last.status === 'ok') { st.textContent = 'Sync complete.'; st.className = 'text-sm text-green-600 dark:text-green-400'; }
          else { st.textContent = 'Sync failed — see status below.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
        }
      }
    }).catch(function(){});
  }, 3000);
}

// ===== DVI-1243 P1: Togen Admin → Services → Power BI =====
// Org service principal + vocab sync pane. The client-secret field is
// write-only: blank on save keeps the stored secret (mirrors ThingPark).
function powerbiAdminLoad() {
  fetch('/powerbi/config').then(function(r){ return r.json(); }).then(function(d){
    if (!d || !d.ok) return;
    var t = document.getElementById('powerbi-tenant-id');
    if (t) t.value = (d.org && d.org.tenant_id) || '';
    var c = document.getElementById('powerbi-client-id');
    if (c) c.value = (d.org && d.org.client_id) || '';
    var sec = document.getElementById('powerbi-client-secret');
    if (sec) { sec.value = ''; sec.placeholder = d.has_secret ? 'Secret set — leave blank to keep' : 'Enter client secret'; }
    var ws = document.getElementById('powerbi-workspaces');
    if (ws) ws.value = (d.workspaces || []).join('\n');
    var en = document.getElementById('powerbi-sync-enabled');
    if (en) en.checked = !!(d.settings && d.settings.sync_enabled);
    var iv = document.getElementById('powerbi-sync-interval');
    if (iv) iv.value = (d.settings && d.settings.sync_interval_minutes) || 30;
    powerbiRenderSync(d.sync, d.cache);
    _powerbiQueries = d.queries || [];
    powerbiRenderQueries();
  }).catch(function(){});
}

// ===== Reporting query library (DVI-1243 P3, D3-A) =====
// The whole library rides POST /powerbi/config {queries} — a small admin list,
// so no per-row routes. Workspace/dataset pickers need the synced vocab; until
// a sync completes the section says so instead of offering raw-GUID entry.
var _powerbiQueries = [];
var _powerbiQVocab = null;

function powerbiRenderQueries() {
  var host = document.getElementById('powerbi-queries-list');
  if (!host) return;
  if (!_powerbiQueries.length) {
    host.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 italic">No saved queries yet.</p>';
    return;
  }
  host.innerHTML = _powerbiQueries.map(function(q){
    var params = (q.params || []).map(function(p){ return p.key; }).join(', ');
    return '<div class="flex items-start justify-between gap-3 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">' + _tpEsc(q.name) + '</div>' +
        '<div class="text-xs text-gray-400 dark:text-gray-500 truncate">' + _tpEsc(powerbiQVocabName('workspaces', q.workspace_id)) +
          ' &middot; ' + _tpEsc(powerbiQVocabName('datasets', q.dataset_id)) +
          (params ? ' &middot; params: ' + _tpEsc(params) : '') + '</div>' +
      '</div>' +
      '<div class="shrink-0 flex items-center gap-2">' +
        '<button type="button" onclick="powerbiQueryEdit(\'' + _tpEsc(q.id) + '\')" class="text-xs text-togen-teal hover:underline cursor-pointer">Edit</button>' +
        '<button type="button" onclick="powerbiQueryDelete(\'' + _tpEsc(q.id) + '\')" class="text-xs text-red-500 hover:underline cursor-pointer">Delete</button>' +
      '</div></div>';
  }).join('');
}

function powerbiQVocabName(kind, id) {
  var pool = (_powerbiQVocab || {})[kind] || [];
  for (var i = 0; i < pool.length; i++) { if (pool[i].id === id) return pool[i].name || id; }
  return id || '—';
}

function powerbiQVocabEnsure(cb) {
  if (_powerbiQVocab) { cb(_powerbiQVocab); return; }
  fetch('/powerbi/vocab').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(v){ _powerbiQVocab = v || {}; cb(_powerbiQVocab); })
    .catch(function(){ cb({}); });
}

function powerbiQueryOpenForm(q) {
  powerbiQVocabEnsure(function(voc){
    var st = document.getElementById('powerbi-q-status');
    var wss = voc.workspaces || [];
    if (!wss.length) {
      if (st) { st.textContent = ''; }
      alert('No Power BI vocabulary is cached yet — run a sync first (Background sync → Sync now).');
      return;
    }
    document.getElementById('powerbi-q-id').value = (q && q.id) || '';
    document.getElementById('powerbi-q-name').value = (q && q.name) || '';
    document.getElementById('powerbi-q-dax').value = (q && q.dax) || '';
    document.getElementById('powerbi-q-params').value = ((q && q.params) || [])
      .map(function(p){ return p.label && p.label !== p.key ? p.key + ':' + p.label : p.key; }).join(', ');
    var wsSel = document.getElementById('powerbi-q-ws');
    var opts = '<option value="">— Select a workspace —</option>' + wss.map(function(w){
      return '<option value="' + _tpEsc(w.id) + '">' + _tpEsc(w.name || w.id) + '</option>';
    }).join('');
    if (q && q.workspace_id && !wss.some(function(w){ return w.id === q.workspace_id; })) {
      opts += '<option value="' + _tpEsc(q.workspace_id) + '">' + _tpEsc(q.workspace_id) + ' (not in synced vocabulary)</option>';
    }
    wsSel.innerHTML = opts;
    wsSel.value = (q && q.workspace_id) || '';
    powerbiQueryWsChanged((q && q.dataset_id) || '');
    if (st) st.textContent = '';
    document.getElementById('powerbi-query-form').classList.remove('hidden');
    document.getElementById('powerbi-q-name').focus();
  });
}

function powerbiQueryAdd() { powerbiQueryOpenForm(null); }

function powerbiQueryEdit(id) {
  var q = _powerbiQueries.filter(function(x){ return x.id === id; })[0];
  if (q) powerbiQueryOpenForm(q);
}

function powerbiQueryWsChanged(keepDs) {
  var wsId = (document.getElementById('powerbi-q-ws') || {}).value || '';
  var dsSel = document.getElementById('powerbi-q-ds');
  if (!dsSel) return;
  var pool = ((_powerbiQVocab || {}).datasets || []).filter(function(d){ return d.workspace_id === wsId; });
  var opts = '<option value="">— Select a dataset —</option>' + pool.map(function(d){
    return '<option value="' + _tpEsc(d.id) + '">' + _tpEsc(d.name || d.id) + '</option>';
  }).join('');
  if (keepDs && !pool.some(function(d){ return d.id === keepDs; })) {
    opts += '<option value="' + _tpEsc(keepDs) + '">' + _tpEsc(keepDs) + ' (not in synced vocabulary)</option>';
  }
  dsSel.innerHTML = opts;
  dsSel.value = keepDs || '';
}

function powerbiQueryParamsParse(text) {
  // "key:Label, key2" -> [{key, label}]; keys are lowercased/scrubbed like the
  // server sanitizer so what the admin sees is what gets stored.
  var out = [];
  String(text || '').split(',').forEach(function(part){
    var seg = part.trim();
    if (!seg) return;
    var i = seg.indexOf(':');
    var key = (i > -1 ? seg.slice(0, i) : seg).trim().toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 40);
    var label = (i > -1 ? seg.slice(i + 1) : '').trim().slice(0, 100);
    if (key && out.length < 5) out.push({ key: key, label: label || key });
  });
  return out;
}

function powerbiQueryCancel() {
  document.getElementById('powerbi-query-form').classList.add('hidden');
}

function powerbiQuerySave() {
  var st = document.getElementById('powerbi-q-status');
  var entry = {
    id: document.getElementById('powerbi-q-id').value || '',
    name: (document.getElementById('powerbi-q-name').value || '').trim().slice(0, 100),
    workspace_id: document.getElementById('powerbi-q-ws').value || '',
    dataset_id: document.getElementById('powerbi-q-ds').value || '',
    dax: (document.getElementById('powerbi-q-dax').value || '').trim().slice(0, 4000),
    params: powerbiQueryParamsParse(document.getElementById('powerbi-q-params').value)
  };
  if (!entry.name || !entry.workspace_id || !entry.dataset_id || !entry.dax) {
    if (st) { st.textContent = 'Name, workspace, dataset and DAX are all required.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    return;
  }
  var next = _powerbiQueries.slice();
  var idx = -1;
  for (var i = 0; i < next.length; i++) { if (next[i].id === entry.id) { idx = i; break; } }
  if (idx > -1) next[idx] = entry; else next.push(entry);
  powerbiQueriesPost(next, st, function(){ powerbiQueryCancel(); });
}

function powerbiQueryDelete(id) {
  var q = _powerbiQueries.filter(function(x){ return x.id === id; })[0];
  if (!q || !confirm('Delete the report query "' + q.name + '"?')) return;
  powerbiQueriesPost(_powerbiQueries.filter(function(x){ return x.id !== id; }), null, null);
}

function powerbiQueriesPost(queries, st, onOk) {
  if (st) { st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500'; }
  fetch('/powerbi/config', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ queries: queries })
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.ok) {
      _powerbiQueries = d.queries || queries;
      powerbiRenderQueries();
      if (st) { st.textContent = 'Saved.'; st.className = 'text-sm text-green-600 dark:text-green-400'; }
      if (onOk) onOk();
    } else if (st) {
      st.textContent = (d && d.error) || 'Save failed.'; st.className = 'text-sm text-red-600 dark:text-red-400';
    }
  }).catch(function(){ if (st) { st.textContent = 'Save failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

function powerbiRenderSync(sync, cache) {
  var el = document.getElementById('powerbi-sync-status');
  if (!el) return;
  sync = sync || {};
  var last = sync.last || null;
  var rows = '';
  if (sync.running) {
    rows += '<div class="mb-2"><span class="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">Sync running…</span> <span class="text-xs text-gray-400">started ' + _tpEsc(sync.started_at || '') + '</span></div>';
  }
  if (!last) {
    rows += '<p>No sync has run yet.</p>';
  } else {
    var chip = last.status === 'ok'
      ? '<span class="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">OK</span>'
      : '<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300">Error</span>';
    rows += '<div class="flex items-center gap-2 flex-wrap">' + chip
      + '<span>Last sync ' + _tpEsc(last.at || '') + (last.actor ? ' (' + _tpEsc(last.actor) + ')' : '') + '</span></div>';
    if (last.error) rows += '<p class="mt-1 text-red-600 dark:text-red-400">' + _tpEsc(last.error) + '</p>';
    var counts = last.counts || {};
    var parts = [];
    for (var k in counts) parts.push(_tpEsc(k) + ': ' + _tpEsc(counts[k]));
    if (parts.length) rows += '<p class="mt-1 text-xs text-gray-400">' + parts.join(' · ') + '</p>';
    if (last.status !== 'ok' && sync.last_ok) {
      rows += '<p class="mt-1 text-xs text-gray-400">Last successful sync: ' + _tpEsc(sync.last_ok.at || '') + '</p>';
    }
  }
  if (cache && cache.length) {
    var cparts = cache.map(function(c){ return _tpEsc(c.cache_key) + ' (' + _tpEsc(c.fetched_at) + ')'; });
    rows += '<p class="mt-2 text-xs text-gray-400">Cached datasets: ' + cparts.join(' · ') + '</p>';
  }
  el.innerHTML = rows;
}

function powerbiWorkspacesList() {
  var raw = ((document.getElementById('powerbi-workspaces') || {}).value || '');
  return raw.split(/[\n,]+/).map(function(s){ return s.trim(); }).filter(Boolean);
}

function powerbiAdminSave() {
  var st = document.getElementById('powerbi-org-status');
  if (st) { st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500'; }
  var sec = document.getElementById('powerbi-client-secret');
  var iv = parseInt((document.getElementById('powerbi-sync-interval') || {}).value, 10);
  var body = {
    org: {
      tenant_id: ((document.getElementById('powerbi-tenant-id') || {}).value || '').trim(),
      client_id: ((document.getElementById('powerbi-client-id') || {}).value || '').trim()
    },
    workspaces: powerbiWorkspacesList(),
    settings: {
      sync_enabled: !!((document.getElementById('powerbi-sync-enabled') || {}).checked),
      sync_interval_minutes: (iv && iv > 0) ? iv : 30
    }
  };
  if (sec && sec.value.trim()) body.org.client_secret = sec.value.trim();
  fetch('/powerbi/config', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body)
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.ok) {
      if (st) { st.textContent = 'Saved.'; st.className = 'text-sm text-green-600 dark:text-green-400'; }
      powerbiAdminLoad();
    } else {
      if (st) { st.textContent = (d && d.error) || 'Save failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Save failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

function powerbiAdminTest() {
  var st = document.getElementById('powerbi-org-status');
  if (st) { st.textContent = 'Testing…'; st.className = 'text-sm text-gray-500'; }
  // Unsaved field values are sent so creds can be tested before saving;
  // blanks fall back to the stored config server-side.
  var body = {
    tenant_id: ((document.getElementById('powerbi-tenant-id') || {}).value || '').trim(),
    client_id: ((document.getElementById('powerbi-client-id') || {}).value || '').trim(),
    client_secret: ((document.getElementById('powerbi-client-secret') || {}).value || '').trim()
  };
  fetch('/powerbi/test', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body)
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.ok) {
      var msg = 'Connected — ' + d.workspaces_allowed + ' workspace' + (d.workspaces_allowed === 1 ? '' : 's') + ' available';
      if (d.workspaces_visible !== d.workspaces_allowed) msg += ' (' + d.workspaces_visible + ' visible before the allowlist)';
      if (d.sample && d.sample.length) msg += ': ' + d.sample.join(', ');
      msg += '.';
      if (d.workspaces_visible === 0) {
        msg = 'Signed in, but no workspaces are visible — add the service principal to the target workspace(s) and enable the Power BI tenant setting.';
      }
      if (st) { st.textContent = msg; st.className = 'text-sm ' + (d.workspaces_visible === 0 ? 'text-amber-600 dark:text-amber-400' : 'text-green-600 dark:text-green-400'); }
    } else {
      if (st) { st.textContent = (d && d.error) || 'Test failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Test failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

var _powerbiSyncTimer = null;
function powerbiSyncNow() {
  var st = document.getElementById('powerbi-sync-run-status');
  if (st) { st.textContent = 'Starting…'; st.className = 'text-sm text-gray-500'; }
  fetch('/powerbi/sync', {method: 'POST'}).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.started) {
      if (st) { st.textContent = 'Sync running…'; }
      powerbiSyncWatch();
    } else {
      if (st) { st.textContent = (d && d.error) || 'Failed to start sync.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Failed to start sync.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

function powerbiSyncWatch() {
  if (_powerbiSyncTimer) clearInterval(_powerbiSyncTimer);
  _powerbiSyncTimer = setInterval(function(){
    fetch('/powerbi/sync-status').then(function(r){ return r.json(); }).then(function(d){
      if (!d || !d.ok) return;
      powerbiRenderSync(d.sync, d.cache);
      if (!d.sync || !d.sync.running) {
        clearInterval(_powerbiSyncTimer); _powerbiSyncTimer = null;
        var st = document.getElementById('powerbi-sync-run-status');
        if (st) {
          var last = d.sync && d.sync.last;
          if (last && last.status === 'ok') { st.textContent = 'Sync complete.'; st.className = 'text-sm text-green-600 dark:text-green-400'; }
          else { st.textContent = 'Sync failed — see status below.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
        }
      }
    }).catch(function(){});
  }, 3000);
}

// ===== DVI-1462 P1: Togen Admin → Services → NetSuite =====
// Org service account (JWT client-credentials) + product-catalog sync pane. The
// private-key field is write-only: blank on save keeps the stored key (mirrors
// ThingPark / Power BI). Reuses _tpEsc.
function netsuiteAdminLoad() {
  fetch('/netsuite/config').then(function(r){ return r.json(); }).then(function(d){
    if (!d || !d.ok) return;
    var org = d.org || {};
    var a = document.getElementById('netsuite-account-id');
    if (a) a.value = org.account_id || '';
    var c = document.getElementById('netsuite-client-id');
    if (c) c.value = org.client_id || '';
    var cid = document.getElementById('netsuite-certificate-id');
    if (cid) cid.value = org.certificate_id || '';
    var alg = document.getElementById('netsuite-algorithm');
    if (alg) alg.value = org.algorithm || 'PS256';
    var pk = document.getElementById('netsuite-private-key');
    if (pk) { pk.value = ''; pk.placeholder = d.has_key ? 'Key set — leave blank to keep' : '-----BEGIN PRIVATE KEY-----'; }
    var f = document.getElementById('netsuite-item-filter');
    if (f) f.value = d.item_filter || '';
    var en = document.getElementById('netsuite-sync-enabled');
    if (en) en.checked = !!(d.settings && d.settings.sync_enabled);
    var iv = document.getElementById('netsuite-sync-interval');
    if (iv) iv.value = (d.settings && d.settings.sync_interval_minutes) || 1440;
    netsuiteRenderSync(d.sync, d.cache);
  }).catch(function(){});
}

function netsuiteRenderSync(sync, cache) {
  var el = document.getElementById('netsuite-sync-status');
  if (!el) return;
  sync = sync || {};
  var last = sync.last || null;
  var rows = '';
  if (sync.running) {
    rows += '<div class="mb-2"><span class="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">Sync running…</span> <span class="text-xs text-gray-400">started ' + _tpEsc(sync.started_at || '') + '</span></div>';
  }
  if (!last) {
    rows += '<p>No sync has run yet.</p>';
  } else {
    var chip = last.status === 'ok'
      ? '<span class="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">OK</span>'
      : '<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300">Error</span>';
    rows += '<div class="flex items-center gap-2 flex-wrap">' + chip
      + '<span>Last sync ' + _tpEsc(last.at || '') + (last.actor ? ' (' + _tpEsc(last.actor) + ')' : '') + '</span></div>';
    if (last.error) rows += '<p class="mt-1 text-red-600 dark:text-red-400">' + _tpEsc(last.error) + '</p>';
    var counts = last.counts || {};
    var parts = [];
    for (var k in counts) parts.push(_tpEsc(k) + ': ' + _tpEsc(counts[k]));
    if (parts.length) rows += '<p class="mt-1 text-xs text-gray-400">' + parts.join(' · ') + '</p>';
    if (last.status !== 'ok' && sync.last_ok) {
      rows += '<p class="mt-1 text-xs text-gray-400">Last successful sync: ' + _tpEsc(sync.last_ok.at || '') + '</p>';
    }
  }
  if (cache && cache.length) {
    var cparts = cache.map(function(c){ return _tpEsc(c.cache_key) + ' (' + _tpEsc(c.fetched_at) + ')'; });
    rows += '<p class="mt-2 text-xs text-gray-400">Cached: ' + cparts.join(' · ') + '</p>';
  }
  el.innerHTML = rows;
}

function netsuiteAdminSave() {
  var st = document.getElementById('netsuite-org-status');
  if (st) { st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500'; }
  var pk = document.getElementById('netsuite-private-key');
  var iv = parseInt((document.getElementById('netsuite-sync-interval') || {}).value, 10);
  var org = {
    account_id: ((document.getElementById('netsuite-account-id') || {}).value || '').trim(),
    client_id: ((document.getElementById('netsuite-client-id') || {}).value || '').trim(),
    certificate_id: ((document.getElementById('netsuite-certificate-id') || {}).value || '').trim(),
    algorithm: ((document.getElementById('netsuite-algorithm') || {}).value || 'PS256')
  };
  if (pk && pk.value.trim()) org.private_key = pk.value.trim();
  var body = {
    org: org,
    item_filter: ((document.getElementById('netsuite-item-filter') || {}).value || '').trim(),
    settings: {
      sync_enabled: !!((document.getElementById('netsuite-sync-enabled') || {}).checked),
      sync_interval_minutes: (iv && iv > 0) ? iv : 1440
    }
  };
  fetch('/netsuite/config', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body)
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.ok) {
      if (st) { st.textContent = 'Saved.'; st.className = 'text-sm text-green-600 dark:text-green-400'; }
      netsuiteAdminLoad();
    } else {
      if (st) { st.textContent = (d && d.error) || 'Save failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Save failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

function netsuiteAdminTest() {
  var st = document.getElementById('netsuite-org-status');
  if (st) { st.textContent = 'Testing…'; st.className = 'text-sm text-gray-500'; }
  // Unsaved field values are sent so creds can be tested before saving; blanks
  // fall back to the stored config server-side.
  var org = {
    account_id: ((document.getElementById('netsuite-account-id') || {}).value || '').trim(),
    client_id: ((document.getElementById('netsuite-client-id') || {}).value || '').trim(),
    certificate_id: ((document.getElementById('netsuite-certificate-id') || {}).value || '').trim(),
    algorithm: ((document.getElementById('netsuite-algorithm') || {}).value || 'PS256')
  };
  var pk = (document.getElementById('netsuite-private-key') || {}).value || '';
  if (pk.trim()) org.private_key = pk.trim();
  fetch('/netsuite/test', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ org: org })
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.ok) {
      var msg = 'Connected';
      if (typeof d.items_total === 'number') msg += ' — ' + d.items_total + ' item' + (d.items_total === 1 ? '' : 's') + ' visible';
      if (d.sample && d.sample.length) msg += ': ' + d.sample.join(', ');
      msg += '.';
      if (st) { st.textContent = msg; st.className = 'text-sm text-green-600 dark:text-green-400'; }
    } else {
      if (st) { st.textContent = (d && d.error) || 'Test failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Test failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

var _netsuiteSyncTimer = null;
function netsuiteSyncNow() {
  var st = document.getElementById('netsuite-sync-run-status');
  if (st) { st.textContent = 'Starting…'; st.className = 'text-sm text-gray-500'; }
  fetch('/netsuite/sync', {method: 'POST'}).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.started) {
      if (st) { st.textContent = 'Sync running…'; }
      netsuiteSyncWatch();
    } else {
      if (st) { st.textContent = (d && d.error) || 'Failed to start sync.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Failed to start sync.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

function netsuiteSyncWatch() {
  if (_netsuiteSyncTimer) clearInterval(_netsuiteSyncTimer);
  _netsuiteSyncTimer = setInterval(function(){
    fetch('/netsuite/sync-status').then(function(r){ return r.json(); }).then(function(d){
      if (!d || !d.ok) return;
      netsuiteRenderSync(d.sync, d.cache);
      if (!d.sync || !d.sync.running) {
        clearInterval(_netsuiteSyncTimer); _netsuiteSyncTimer = null;
        var st = document.getElementById('netsuite-sync-run-status');
        if (st) {
          var last = d.sync && d.sync.last;
          if (last && last.status === 'ok') { st.textContent = 'Sync complete.'; st.className = 'text-sm text-green-600 dark:text-green-400'; }
          else { st.textContent = 'Sync failed — see status below.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
        }
      }
    }).catch(function(){});
  }, 3000);
}

// ===== DVI-1462 P2: Togen Admin → Services → PayChex =====
// Org service app (OAuth 2.0 client-credentials) + roster sync + driver
// linkage (D5-A). The client-secret field is write-only: blank on save keeps
// the stored secret (mirrors ThingPark / Power BI / NetSuite). Reuses _tpEsc.
function paychexAdminLoad() {
  fetch('/paychex/config').then(function(r){ return r.json(); }).then(function(d){
    if (!d || !d.ok) return;
    var org = d.org || {};
    var c = document.getElementById('paychex-client-id');
    if (c) c.value = org.client_id || '';
    var cid = document.getElementById('paychex-company-id');
    if (cid) cid.value = org.company_id || '';
    var sec = document.getElementById('paychex-client-secret');
    if (sec) { sec.value = ''; sec.placeholder = d.has_secret ? 'Secret set — leave blank to keep' : 'Enter client secret'; }
    var en = document.getElementById('paychex-sync-enabled');
    if (en) en.checked = !!(d.settings && d.settings.sync_enabled);
    var iv = document.getElementById('paychex-sync-interval');
    if (iv) iv.value = (d.settings && d.settings.sync_interval_minutes) || 720;
    paychexRenderSync(d.sync, d.cache);
  }).catch(function(){});
  paychexDriverLinksLoad();
}

function paychexRenderSync(sync, cache) {
  var el = document.getElementById('paychex-sync-status');
  if (!el) return;
  sync = sync || {};
  var last = sync.last || null;
  var rows = '';
  if (sync.running) {
    rows += '<div class="mb-2"><span class="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">Sync running…</span> <span class="text-xs text-gray-400">started ' + _tpEsc(sync.started_at || '') + '</span></div>';
  }
  if (!last) {
    rows += '<p>No sync has run yet.</p>';
  } else {
    var chip = last.status === 'ok'
      ? '<span class="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">OK</span>'
      : '<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300">Error</span>';
    rows += '<div class="flex items-center gap-2 flex-wrap">' + chip
      + '<span>Last sync ' + _tpEsc(last.at || '') + (last.actor ? ' (' + _tpEsc(last.actor) + ')' : '') + '</span></div>';
    if (last.error) rows += '<p class="mt-1 text-red-600 dark:text-red-400">' + _tpEsc(last.error) + '</p>';
    var counts = last.counts || {};
    var parts = [];
    for (var k in counts) parts.push(_tpEsc(k) + ': ' + _tpEsc(counts[k]));
    if (parts.length) rows += '<p class="mt-1 text-xs text-gray-400">' + parts.join(' · ') + '</p>';
    if (last.status !== 'ok' && sync.last_ok) {
      rows += '<p class="mt-1 text-xs text-gray-400">Last successful sync: ' + _tpEsc(sync.last_ok.at || '') + '</p>';
    }
  }
  if (cache && cache.length) {
    var cparts = cache.map(function(c){ return _tpEsc(c.cache_key) + ' (' + _tpEsc(c.fetched_at) + ')'; });
    rows += '<p class="mt-2 text-xs text-gray-400">Cached: ' + cparts.join(' · ') + '</p>';
  }
  el.innerHTML = rows;
}

function paychexAdminSave() {
  var st = document.getElementById('paychex-org-status');
  if (st) { st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500'; }
  var sec = document.getElementById('paychex-client-secret');
  var iv = parseInt((document.getElementById('paychex-sync-interval') || {}).value, 10);
  var org = {
    client_id: ((document.getElementById('paychex-client-id') || {}).value || '').trim(),
    company_id: ((document.getElementById('paychex-company-id') || {}).value || '').trim()
  };
  if (sec && sec.value.trim()) org.client_secret = sec.value.trim();
  var body = {
    org: org,
    settings: {
      sync_enabled: !!((document.getElementById('paychex-sync-enabled') || {}).checked),
      sync_interval_minutes: (iv && iv > 0) ? iv : 720
    }
  };
  fetch('/paychex/config', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body)
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.ok) {
      if (st) { st.textContent = 'Saved.'; st.className = 'text-sm text-green-600 dark:text-green-400'; }
      paychexAdminLoad();
    } else {
      if (st) { st.textContent = (d && d.error) || 'Save failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Save failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

function paychexAdminTest() {
  var st = document.getElementById('paychex-org-status');
  if (st) { st.textContent = 'Testing…'; st.className = 'text-sm text-gray-500'; }
  // Unsaved field values are sent so creds can be tested before saving; blanks
  // fall back to the stored config server-side.
  var org = {
    client_id: ((document.getElementById('paychex-client-id') || {}).value || '').trim(),
    company_id: ((document.getElementById('paychex-company-id') || {}).value || '').trim()
  };
  var sec = (document.getElementById('paychex-client-secret') || {}).value || '';
  if (sec.trim()) org.client_secret = sec.trim();
  fetch('/paychex/test', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ org: org })
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.ok) {
      var msg = 'Connected';
      if (d.sample && d.sample.length) msg += ' — e.g. ' + d.sample.join(', ');
      msg += '.';
      if (st) { st.textContent = msg; st.className = 'text-sm text-green-600 dark:text-green-400'; }
    } else {
      if (st) { st.textContent = (d && d.error) || 'Test failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Test failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

var _paychexSyncTimer = null;
function paychexSyncNow() {
  var st = document.getElementById('paychex-sync-run-status');
  if (st) { st.textContent = 'Starting…'; st.className = 'text-sm text-gray-500'; }
  fetch('/paychex/sync', {method: 'POST'}).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.started) {
      if (st) { st.textContent = 'Sync running…'; }
      paychexSyncWatch();
    } else {
      if (st) { st.textContent = (d && d.error) || 'Failed to start sync.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Failed to start sync.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

function paychexSyncWatch() {
  if (_paychexSyncTimer) clearInterval(_paychexSyncTimer);
  _paychexSyncTimer = setInterval(function(){
    fetch('/paychex/sync-status').then(function(r){ return r.json(); }).then(function(d){
      if (!d || !d.ok) return;
      paychexRenderSync(d.sync, d.cache);
      if (!d.sync || !d.sync.running) {
        clearInterval(_paychexSyncTimer); _paychexSyncTimer = null;
        var st = document.getElementById('paychex-sync-run-status');
        if (st) {
          var last = d.sync && d.sync.last;
          if (last && last.status === 'ok') { st.textContent = 'Sync complete.'; st.className = 'text-sm text-green-600 dark:text-green-400'; paychexDriverLinksLoad(); }
          else { st.textContent = 'Sync failed — see status below.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
        }
      }
    }).catch(function(){});
  }, 3000);
}

// Driver linkage (D5-A): matched/unmatched Paychex worker → Personnel driver.
var _paychexPersonnel = [];
function paychexDriverLinksLoad() {
  var el = document.getElementById('paychex-driver-links');
  if (el) el.textContent = 'Loading…';
  fetch('/paychex/driver-links').then(function(r){ return r.json(); }).then(function(d){
    if (!d || !d.ok) { if (el) el.textContent = (d && d.error) || 'Failed to load driver links.'; return; }
    _paychexPersonnel = d.personnel || [];
    paychexRenderDriverLinks(d);
  }).catch(function(){ if (el) el.textContent = 'Failed to load driver links.'; });
}

function _paychexPersonOptions(selId) {
  var opts = '<option value="">— unmatched —</option>';
  for (var i = 0; i < _paychexPersonnel.length; i++) {
    var p = _paychexPersonnel[i];
    var lbl = p.name + (p.email ? ' <' + p.email + '>' : '') + (p.is_driver ? ' (driver)' : '');
    opts += '<option value="' + _tpEsc(p.id) + '"' + (p.id === selId ? ' selected' : '') + '>' + _tpEsc(lbl) + '</option>';
  }
  return opts;
}

function paychexRenderDriverLinks(d) {
  var el = document.getElementById('paychex-driver-links');
  if (!el) return;
  var links = d.links || [];
  if (!links.length) {
    el.innerHTML = '<p class="text-gray-500 dark:text-gray-400">No synced workers yet — run a roster sync.</p>';
    return;
  }
  var summary = '<p class="mb-3 text-xs text-gray-400">' + _tpEsc(d.matched) + ' of ' + _tpEsc(d.worker_count) + ' worker' + (d.worker_count === 1 ? '' : 's') + ' matched'
    + (d.as_of ? ' · roster as of ' + _tpEsc(d.as_of) : '') + '</p>';
  var rows = '<div class="overflow-x-auto"><table class="min-w-full text-sm"><thead><tr class="text-left text-gray-500 dark:text-gray-400">'
    + '<th class="py-1 pr-4">Worker</th><th class="py-1 pr-4">Email</th><th class="py-1 pr-4">Match</th><th class="py-1">Linked Togen person</th></tr></thead><tbody>';
  for (var i = 0; i < links.length; i++) {
    var lk = links[i]; var w = lk.worker || {}; var pid = (lk.person && lk.person.id) || '';
    var matchChip = lk.match === 'manual'
      ? '<span class="px-1.5 py-0.5 rounded text-xs bg-togen-teal/10 text-togen-teal">manual</span>'
      : (lk.match === 'email'
         ? '<span class="px-1.5 py-0.5 rounded text-xs bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">email</span>'
         : '<span class="px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">unmatched</span>');
    rows += '<tr class="border-t border-gray-100 dark:border-gray-800">'
      + '<td class="py-1 pr-4 text-gray-800 dark:text-gray-200">' + _tpEsc(w.name || w.worker_id || '') + '</td>'
      + '<td class="py-1 pr-4 text-gray-500 dark:text-gray-400">' + _tpEsc(w.email || '—') + '</td>'
      + '<td class="py-1 pr-4">' + matchChip + '</td>'
      + '<td class="py-1"><select onchange="paychexSetDriverLink(\'' + _tpEsc(w.worker_id) + '\', this.value)" '
      + 'class="px-2 py-1 text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200">'
      + _paychexPersonOptions(pid) + '</select></td></tr>';
  }
  rows += '</tbody></table></div>';
  el.innerHTML = summary + rows;
}

function paychexSetDriverLink(workerId, personId) {
  fetch('/paychex/driver-link', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ worker_id: workerId, person_id: personId })
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.ok) paychexRenderDriverLinks(d);
    else paychexDriverLinksLoad();
  }).catch(function(){ paychexDriverLinksLoad(); });
}

// ===== DVI-1082: Togen Admin → Testing → Authorized Testers =====
var _togenadminTesters = [];

function togenadminLoadTesters() {
  fetch('/wor-testers')
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d && d.testers) { _togenadminTesters = d.testers; togenadminRenderTesters(); }
    })
    .catch(function(){});
}

function togenadminRenderTesters() {
  var container = document.getElementById('togenadmin-testers-list');
  if (!container) return;
  if (!_togenadminTesters.length) {
    container.innerHTML = '<span class="text-xs text-gray-500 dark:text-gray-400 italic">No additional testers — only Togen Admins can create/view test WOs.</span>';
    return;
  }
  container.innerHTML = _togenadminTesters.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="togenadminTesterRemove(\'' + woradminEscapeAttr(em) + '\')" title="Remove" class="text-gray-400 hover:text-red-500 cursor-pointer">&times;</button>' +
      '</span>';
  }).join('');
}

function togenadminTesterAdd() {
  var input = document.getElementById('togenadmin-tester-input');
  if (!input) return;
  var email = (input.value || '').trim().toLowerCase();
  if (!email || email.indexOf('@') === -1) { alert('Enter a valid email address.'); return; }
  togenadminTesterMutate('add', email, input);
}

function togenadminTesterRemove(email) {
  togenadminTesterMutate('remove', email, null);
}

function togenadminTesterMutate(action, email, inputEl) {
  var status = document.getElementById('togenadmin-testers-status');
  if (status) { status.textContent = 'Saving…'; status.className = 'block mt-3 text-sm text-gray-500 dark:text-gray-400'; }
  fetch('/wor-testers', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({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;
      }
      _togenadminTesters = d.testers || [];
      if (inputEl) inputEl.value = '';
      togenadminRenderTesters();
      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'; }
    });
}

// ===== DVI-1084: Togen Admin → Testing → Quarantine & Deletion Log =====
function togenadminLoadDeletionAudit() {
  togenadminLoadDeletedList();
  togenadminLoadDeletionLog();
}

function togenadminLoadDeletedList() {
  var el = document.getElementById('togenadmin-deleted-list');
  if (!el) return;
  fetch('/wor-deleted-list')
    .then(function(r){ return r.json(); })
    .then(function(d){
      var items = (d && d.items) || [];
      if (!items.length) {
        el.innerHTML = '<span class="text-xs italic">No quarantined work orders.</span>';
        return;
      }
      el.innerHTML = items.map(function(it){
        var label = woradminEscapeHtml(it.filename || it.dir || '');
        var meta = [];
        if (it.wor_type) meta.push(woradminEscapeHtml(it.wor_type));
        if (it.original_status) meta.push(woradminEscapeHtml(it.original_status));
        if (it.is_test) meta.push('test');
        if (it.deleted_at) meta.push(woradminEscapeHtml(it.deleted_at));
        var reason = it.reason ? ('<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Reason: ' + woradminEscapeHtml(it.reason) + (it.deleted_by ? ' — ' + woradminEscapeHtml(it.deleted_by) : '') + '</div>') : '';
        return '<div class="flex items-start justify-between gap-3 p-2.5 rounded-lg border border-gray-200 dark:border-gray-700">' +
          '<div class="min-w-0"><div class="font-medium text-gray-800 dark:text-gray-100 truncate">' + label + '</div>' +
          '<div class="text-xs text-gray-500 dark:text-gray-400">' + meta.join(' · ') + '</div>' + reason + '</div>' +
          '<button type="button" onclick="togenadminRestoreDeleted(\'' + woradminEscapeAttr(it.dir || '') + '\')" ' +
          'class="togen-control shrink-0 px-2.5 py-1 text-xs font-medium rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors">Restore</button>' +
          '</div>';
      }).join('');
    })
    .catch(function(){ el.innerHTML = '<span class="text-xs text-red-500">Failed to load quarantine list.</span>'; });
}

function togenadminRestoreDeleted(dir) {
  if (!dir) return;
  if (!confirm('Restore this work order back to its original location and audit trail?')) return;
  fetch('/wor-restore', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({dir: dir})})
    .then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
    .then(function(res){
      if (!res.ok) { alert(res.d.error || 'Restore failed.'); return; }
      togenadminLoadDeletionAudit();
    })
    .catch(function(){ alert('Restore failed.'); });
}

function togenadminLoadDeletionLog() {
  var log = document.getElementById('togenadmin-deletion-log');
  var badge = document.getElementById('togenadmin-deletion-verify');
  if (!log) return;
  fetch('/wor-deletion-log')
    .then(function(r){ return r.json(); })
    .then(function(d){
      var v = d && d.verify;
      if (badge && v) {
        badge.textContent = v.ok ? ('Chain intact (' + v.entries + ')') : ('TAMPERED — ' + v.message);
        badge.className = 'px-2 py-0.5 rounded-full text-xs font-medium ' +
          (v.ok ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
                : 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300');
      }
      var entries = (d && d.entries) || [];
      if (!entries.length) { log.innerHTML = '<span class="text-gray-500 dark:text-gray-400 italic">No deletion-log entries yet.</span>'; return; }
      var actionColors = {delete:'text-red-600 dark:text-red-400', convert:'text-amber-600 dark:text-amber-400', convert_back:'text-togen-teal', restore:'text-green-600 dark:text-green-400'};
      log.innerHTML = entries.map(function(e){
        if (e._unparseable) return '<div class="p-2 rounded bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 font-mono break-all">Unparseable line: ' + woradminEscapeHtml(e._unparseable) + '</div>';
        var ac = actionColors[e.action] || 'text-gray-600 dark:text-gray-300';
        var reason = e.reason ? (' — ' + woradminEscapeHtml(e.reason)) : '';
        return '<div class="p-2 rounded border border-gray-200 dark:border-gray-700">' +
          '<span class="font-mono text-gray-400 dark:text-gray-500">#' + (e.seq != null ? e.seq : '?') + '</span> ' +
          '<span class="font-semibold ' + ac + '">' + woradminEscapeHtml(e.action || '') + '</span> ' +
          '<span class="text-gray-800 dark:text-gray-100">' + woradminEscapeHtml(e.wo_filename || '') + '</span>' + reason +
          '<div class="text-gray-500 dark:text-gray-400 mt-0.5">' + woradminEscapeHtml(e.ts || '') + (e.actor ? ' · ' + woradminEscapeHtml(e.actor) : '') +
          (e.history_rows_quarantined != null ? ' · ' + e.history_rows_quarantined + ' history rows' : '') + '</div>' +
          '<div class="font-mono text-[10px] text-gray-400 dark:text-gray-500 truncate" title="' + woradminEscapeAttr(e.entry_hash || '') + '">hash ' + woradminEscapeHtml((e.entry_hash || '').slice(0,16)) + '…</div>' +
          '</div>';
      }).join('');
    })
    .catch(function(){ log.innerHTML = '<span class="text-red-500">Failed to load deletion log.</span>'; });
}

// ===== DVI-1026c: Togen Admin → General → Domains (FQDN) pane =====
var _fqdnPollTimer = null;

function fqdnCopyText(id) {
  var el = document.getElementById(id);
  if (el && navigator.clipboard) navigator.clipboard.writeText(el.textContent);
}

function togenadminStopFqdnPoll() {
  if (_fqdnPollTimer) { clearInterval(_fqdnPollTimer); _fqdnPollTimer = null; }
}

var _FQDN_CHIP = {
  active:        {label: 'Active',        cls: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'},
  pending_dns:   {label: 'Pending DNS',   cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'},
  dns_validated: {label: 'DNS Validated', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'},
  issuing:       {label: 'Issuing…',      cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'},
  error:         {label: 'Error',         cls: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'}
};

function togenadminFqdnChip(status) {
  var c = _FQDN_CHIP[status] || {label: status || 'Unknown', cls: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'};
  return '<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium ' + c.cls + '">' + c.label + '</span>';
}

function togenadminLoadFqdns() {
  fetch('/togen-admin/fqdns')
    .then(function(r) { return r.ok ? r.json() : null; })
    .then(function(d) {
      if (!d) return;
      togenadminRenderFqdns(d.fqdns || []);
    })
    .catch(function() {});
}

function togenadminRenderFqdns(list) {
  var tb = document.getElementById('togenadmin-fqdn-tbody');
  if (!tb) return;
  if (!list.length) {
    tb.innerHTML = '<tr><td colspan="5" class="py-4 text-center text-gray-400 dark:text-gray-500">No domains configured.</td></tr>';
    togenadminStopFqdnPoll();
    return;
  }
  var esc = function(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); };
  var rows = '';
  var anyActive = false;
  list.forEach(function(e) {
    var isPrimary = !e.record_type && !e.record_target;
    var validated = (e.status === 'dns_validated' || e.status === 'issuing' || e.status === 'active');
    var certHtml;
    if (e.cert_expiry) {
      certHtml = '<span class="text-gray-700 dark:text-gray-200">Valid</span> <span class="text-xs text-gray-400">· expires ' + esc(e.cert_expiry) + '</span>';
    } else if (e.status === 'active') {
      certHtml = '<span class="text-gray-700 dark:text-gray-200">Active</span>';
    } else {
      certHtml = '<span class="text-gray-400 dark:text-gray-500">—</span>';
    }
    var actions = '';
    if (isPrimary) {
      actions = '<span class="text-xs text-gray-400 dark:text-gray-500">Primary</span>';
    } else if (!validated || e.status === 'pending_dns' || e.status === 'error') {
      actions = '<button onclick="togenadminValidateFqdn(\'' + esc(e.fqdn) + '\', this)" '
        + 'class="togen-control px-2.5 py-1 text-xs font-medium rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal hover:text-white transition-colors cursor-pointer">Validate</button>';
    }
    var errRow = e.last_error
      ? '<tr><td colspan="5" class="pb-2 pl-4 text-xs text-red-500 dark:text-red-400">' + esc(e.last_error) + '</td></tr>'
      : '';
    rows += '<tr class="border-b border-gray-100 dark:border-gray-800">'
      + '<td class="py-2 pr-4 font-medium text-gray-800 dark:text-gray-100 break-all">' + esc(e.fqdn) + '</td>'
      + '<td class="py-2 pr-4">' + togenadminFqdnChip(e.status) + '</td>'
      + '<td class="py-2 pr-4">' + (validated ? '<span class="text-green-600 dark:text-green-400">Yes</span>' : '<span class="text-gray-400 dark:text-gray-500">No</span>') + '</td>'
      + '<td class="py-2 pr-4">' + certHtml + '</td>'
      + '<td class="py-2 pr-4 text-right">' + actions + '</td>'
      + '</tr>' + errRow;
    if (e.status === 'issuing') anyActive = true;
  });
  tb.innerHTML = rows;
  togenadminStopFqdnPoll();
  if (anyActive) _fqdnPollTimer = setInterval(togenadminLoadFqdns, 4000);
}

function togenadminValidateFqdn(fqdn, btn) {
  if (btn) { btn.disabled = true; btn.textContent = 'Validating…'; }
  fetch('/togen-admin/fqdns/' + encodeURIComponent(fqdn) + '/validate', {method: 'POST'})
    .then(function(r) { return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res) {
      togenadminLoadFqdns();
      if (!res.ok && btn) { alert(res.d.error || 'Validation failed.'); }
    })
    .catch(function() { togenadminLoadFqdns(); });
}

function openAddFqdnModal() {
  document.getElementById('add-fqdn-step-input').classList.remove('hidden');
  document.getElementById('add-fqdn-step-dns').classList.add('hidden');
  var inp = document.getElementById('add-fqdn-input');
  inp.value = '';
  var st = document.getElementById('add-fqdn-status');
  st.classList.add('hidden');
  var btn = document.getElementById('add-fqdn-submit');
  btn.disabled = false; btn.textContent = 'Add';
  document.getElementById('add-fqdn-overlay').classList.remove('hidden');
  setTimeout(function() { inp.focus(); }, 50);
}

function closeAddFqdnModal() {
  document.getElementById('add-fqdn-overlay').classList.add('hidden');
  togenadminLoadFqdns();
}

function submitAddFqdn() {
  var inp = document.getElementById('add-fqdn-input');
  var st = document.getElementById('add-fqdn-status');
  var btn = document.getElementById('add-fqdn-submit');
  var fqdn = (inp.value || '').trim().toLowerCase();
  if (!fqdn) { st.textContent = 'Please enter a domain name.'; st.className = 'mt-2 text-xs font-medium text-red-500'; st.classList.remove('hidden'); return; }
  btn.disabled = true; btn.textContent = 'Adding…';
  st.classList.add('hidden');
  fetch('/togen-admin/fqdns', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({fqdn: fqdn})
  })
    .then(function(r) { return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res) {
      btn.disabled = false; btn.textContent = 'Add';
      if (!res.ok) {
        st.textContent = res.d.error || 'Failed to add domain.';
        st.className = 'mt-2 text-xs font-medium text-red-500';
        st.classList.remove('hidden');
        return;
      }
      var e = res.d.fqdn || {};
      document.getElementById('add-fqdn-dns-name').textContent = e.fqdn || fqdn;
      document.getElementById('add-fqdn-dns-type').textContent = e.record_type || '—';
      document.getElementById('add-fqdn-dns-host').textContent = e.fqdn || fqdn;
      document.getElementById('add-fqdn-dns-target').textContent = e.record_target || '—';
      document.getElementById('add-fqdn-step-input').classList.add('hidden');
      document.getElementById('add-fqdn-step-dns').classList.remove('hidden');
      togenadminLoadFqdns();
    })
    .catch(function() {
      btn.disabled = false; btn.textContent = 'Add';
      st.textContent = 'Network error. Please try again.';
      st.className = 'mt-2 text-xs font-medium text-red-500';
      st.classList.remove('hidden');
    });
}

// DVI-530 / DVI-952: Togen Admin → Assistant tab. Global enabled /
// accessible_to_users / per-provider company keys (masked) + available-
// system-wide toggles / default context md.
var _togenadminAssistantProviders = [];
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;
      _togenadminAssistantProviders = d.providers || [];
      togenadminRenderAssistantKeys(d.api_keys || {}, d.provider_enabled || {});
      togenadminRenderOcrModel(d.ocr_models || [], d.ocr_model || '', d.ocr_default_model || '');
    })
    .catch(function() {});
}

// DVI-956: OCR provider/model picker. Options are only providers with a company
// key (OCR runs on the org key). "" = Anthropic default.
function togenadminRenderOcrModel(models, selected, defaultModel) {
  var sel = document.getElementById('togenadmin-assistant-ocr-model');
  if (!sel) return;
  sel.innerHTML = '';
  var def = document.createElement('option');
  def.value = '';
  def.textContent = 'Anthropic default' + (defaultModel ? ' (' + defaultModel + ')' : '');
  sel.appendChild(def);
  models.forEach(function(m) {
    var opt = document.createElement('option');
    opt.value = m.id;
    opt.textContent = (m.provider_label || m.provider_id) + ' — ' + (m.label || m.id);
    sel.appendChild(opt);
  });
  sel.value = selected || '';
}

// DVI-952: one masked key field + docs link + "available system-wide" toggle
// per provider. keysState is {providerId:{configured,masked}}, enabledState is
// {providerId:bool} — both from /togen-admin/assistant.
function togenadminRenderAssistantKeys(keysState, enabledState) {
  var wrap = document.getElementById('togenadmin-assistant-keys');
  if (!wrap) return;
  enabledState = enabledState || {};
  wrap.innerHTML = '';
  _togenadminAssistantProviders.forEach(function(p) {
    var st = keysState[p.id] || {};
    var row = document.createElement('div');
    row.className = 'rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-2.5';

    var head = document.createElement('div');
    head.className = 'flex items-center justify-between gap-2 mb-1';
    var name = document.createElement('span');
    name.className = 'text-xs font-medium text-gray-700 dark:text-gray-300';
    name.textContent = p.label;
    var badge = document.createElement('span');
    if (st.configured) {
      badge.className = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-400';
      badge.innerHTML = '<span class="inline-block h-1.5 w-1.5 rounded-full bg-green-500"></span>Configured';
    } else {
      badge.className = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400';
      badge.innerHTML = '<span class="inline-block h-1.5 w-1.5 rounded-full bg-gray-400"></span>Not set';
    }
    head.appendChild(name); head.appendChild(badge);

    var input = document.createElement('input');
    input.type = 'password'; input.autocomplete = 'off';
    input.id = 'togenadmin-assistant-key-' + p.id;
    input.setAttribute('data-provider', p.id);
    input.className = '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';
    input.placeholder = st.configured ? (st.masked || 'Key set — leave blank to keep') : 'Enter API key';

    row.appendChild(head);
    row.appendChild(input);

    if (p.docs_url) {
      var docs = document.createElement('p');
      docs.className = 'text-xs mt-1';
      var a = document.createElement('a');
      a.href = p.docs_url; a.target = '_blank'; a.rel = 'noopener noreferrer';
      a.className = 'inline-flex items-center gap-1 text-togen-teal hover:underline';
      a.innerHTML = '<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>';
      var lbl = document.createElement('span');
      lbl.textContent = 'How to create a ' + (p.label || 'provider') + ' API key';
      a.appendChild(lbl);
      docs.appendChild(a);
      row.appendChild(docs);
    }

    var toggle = document.createElement('label');
    toggle.className = 'flex items-center gap-2 mt-2 cursor-pointer';
    var cb = document.createElement('input');
    cb.type = 'checkbox';
    cb.id = 'togenadmin-assistant-enabled-' + p.id;
    cb.setAttribute('data-provider', p.id);
    cb.className = 'h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer';
    cb.checked = (enabledState[p.id] !== false);
    var tlbl = document.createElement('span');
    tlbl.className = 'text-xs text-gray-600 dark:text-gray-400';
    tlbl.textContent = 'Available system-wide';
    toggle.appendChild(cb); toggle.appendChild(tlbl);
    row.appendChild(toggle);

    wrap.appendChild(row);
  });
}

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,
    ocr_model: (document.getElementById('togenadmin-assistant-ocr-model') || {}).value || '',
    api_keys: {},
    provider_enabled: {}
  };
  // Only send keys the admin actually typed; never send the bullet mask.
  _togenadminAssistantProviders.forEach(function(p) {
    var input = document.getElementById('togenadmin-assistant-key-' + p.id);
    if (input) {
      var v = input.value.trim();
      if (v) body.api_keys[p.id] = v;
    }
    var cb = document.getElementById('togenadmin-assistant-enabled-' + p.id);
    if (cb) body.provider_enabled[p.id] = cb.checked;
  });
  if (!Object.keys(body.api_keys).length) delete body.api_keys;  // omit → keep all existing keys
  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';
        togenadminLoadAssistant();  // refresh (also clears typed keys)
      } 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="togen-control 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="togen-control 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-1198: Togen Admin -> Agents -> Display pane (signage kiosks).
var _tgaDisplaySignageDashboards = [];
// DVI-1288 P1: populate a display "Columns" <select> with Auto + 2–24 (once,
// idempotent) and set it to the stored value. Shared by the admin Display edit
// modal and the User Settings → Agents modal. A stored int selects that number;
// "auto"/blank/unknown selects Auto.
// DVI-1288: append the 2–24 column options to a select once (idempotent — safe
// whether the select was authored with just "Auto" or also a present "Keep").
function _dashFillColNums(sel){
  if (!sel || sel.querySelector('option[value="2"]')) return;
  for (var n = 2; n <= 24; n++){
    var o = document.createElement('option');
    o.value = String(n); o.textContent = n + ' columns';
    sel.appendChild(o);
  }
}
function _dashColsFill(selectId, value){
  var sel = document.getElementById(selectId);
  if (!sel) return;
  _dashFillColNums(sel);
  var v = (value == null) ? 'auto' : String(value);
  sel.value = v;
  if (sel.selectedIndex < 0) sel.value = 'auto';   // unknown → Auto
}
// DVI-1288 P2: same pattern for the 1–24 row-count target selects.
function _dashRowsFill(selectId, value){
  var sel = document.getElementById(selectId);
  if (!sel) return;
  if (!sel.querySelector('option[value="1"]')){
    for (var n = 1; n <= 24; n++){
      var o = document.createElement('option');
      o.value = String(n); o.textContent = n + (n === 1 ? ' row' : ' rows');
      sel.appendChild(o);
    }
  }
  var v = (value == null) ? 'auto' : String(value);
  sel.value = v;
  if (sel.selectedIndex < 0) sel.value = 'auto';   // unknown → Auto
}

var _tgaDisplayEditingId = null;

// DVI-1217: shared Interface-options fieldset for the Display Edit + Edit
// Splash modals (mirrors User Settings → Interface). Each modal instance is
// keyed by an id prefix; _tgaIfaceCtx[prefix] carries the upload target.
var _tgaIfaceCtx = {};
var _TGA_IFACE_INPUT_CLS = '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';

function _tgaIfaceHtml(p) {
  var lbl = 'block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1';
  return '' +
    '<div class="grid grid-cols-2 gap-3">' +
      '<div><label class="' + lbl + '">Mode</label>' +
        '<select id="' + p + '-theme" class="' + _TGA_IFACE_INPUT_CLS + '">' +
          '<option value="dark">Dark</option><option value="light">Light</option>' +
        '</select></div>' +
      '<div><label class="' + lbl + '">Background</label>' +
        '<select id="' + p + '-bgmode" onchange="_tgaIfaceModeChanged(\'' + p + '\')" class="' + _TGA_IFACE_INPUT_CLS + '">' +
          '<option value="">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>' +
    '<div id="' + p + '-opacity-block" class="hidden mt-3">' +
      '<label class="' + lbl + '">Tile/Pane opacity (40&ndash;100%, blank = company default)</label>' +
      '<input id="' + p + '-opacity" type="number" min="40" max="100" step="5" class="' + _TGA_IFACE_INPUT_CLS + '"/>' +
    '</div>' +
    '<div id="' + p + '-bgimg-block" class="hidden mt-3">' +
      '<label class="' + lbl + '">Custom background image</label>' +
      '<div class="flex items-center gap-2">' +
        '<input id="' + p + '-bgimg-file" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"' +
        ' onchange="_tgaIfaceUploadBg(\'' + p + '\')" class="text-xs text-gray-600 dark:text-gray-300"/>' +
        '<button id="' + p + '-bgimg-remove" onclick="_tgaIfaceRemoveBg(\'' + p + '\')"' +
        ' class="togen-control hidden px-2 py-1 text-xs 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">Remove</button>' +
      '</div>' +
      '<p id="' + p + '-bgimg-status" class="mt-1 text-xs text-gray-500 dark:text-gray-400"></p>' +
    '</div>' +
    '<div class="mt-3"><label class="' + lbl + '">Side padding % (0&ndash;40, tile grid breathing room)</label>' +
      '<input id="' + p + '-padding" type="number" min="0" max="40" step="1" class="' + _TGA_IFACE_INPUT_CLS + '"/>' +
    '</div>';
}

function _tgaIfaceModeChanged(p) {
  var mode = document.getElementById(p + '-bgmode').value;
  // Opacity only takes effect over corporate/custom backgrounds (same rule as
  // User Settings); the image slot only exists for Custom.
  document.getElementById(p + '-opacity-block').classList.toggle('hidden', mode !== 'corporate' && mode !== 'custom');
  document.getElementById(p + '-bgimg-block').classList.toggle('hidden', mode !== 'custom');
}

function _tgaIfaceFill(p, iface, agentId, target, hasImg) {
  iface = iface || {};
  _tgaIfaceCtx[p] = { agentId: agentId, target: target };
  document.getElementById(p).innerHTML = _tgaIfaceHtml(p);
  document.getElementById(p + '-theme').value = (iface.theme === 'light') ? 'light' : 'dark';
  var mode = iface.bg_mode || '';
  document.getElementById(p + '-bgmode').value =
    ['togen_default','flat','corporate','custom'].indexOf(mode) >= 0 ? mode : '';
  document.getElementById(p + '-opacity').value = (iface.bg_opacity != null) ? iface.bg_opacity : '';
  document.getElementById(p + '-padding').value = (iface.side_padding != null) ? iface.side_padding : '';
  _tgaIfaceSetImgStatus(p, hasImg);
  _tgaIfaceModeChanged(p);
}

function _tgaIfaceSetImgStatus(p, hasImg) {
  document.getElementById(p + '-bgimg-status').textContent =
    hasImg ? 'An image is uploaded for this display.' : 'No image uploaded yet (PNG, JPEG, WebP, GIF or SVG — 8 MB max).';
  document.getElementById(p + '-bgimg-remove').classList.toggle('hidden', !hasImg);
}

function _tgaIfaceCollect(p) {
  var out = {
    theme: document.getElementById(p + '-theme').value,
    bg_mode: document.getElementById(p + '-bgmode').value
  };
  var op = document.getElementById(p + '-opacity').value;
  if (op !== '') out.bg_opacity = parseInt(op, 10);
  var pad = document.getElementById(p + '-padding').value;
  if (pad !== '') out.side_padding = parseInt(pad, 10);
  return out;
}

function _tgaIfaceUploadBg(p) {
  var ctx = _tgaIfaceCtx[p];
  var input = document.getElementById(p + '-bgimg-file');
  if (!ctx || !ctx.agentId || !input.files || !input.files.length) return;
  var fd = new FormData();
  fd.append('background', input.files[0]);
  fetch('/signage/agents/' + encodeURIComponent(ctx.agentId) + '/background?target=' + encodeURIComponent(ctx.target), {
    method: 'POST', body: fd
  })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      input.value = '';
      if (!res.ok) { alert('Error: ' + (res.d.error || 'upload failed')); return; }
      _tgaIfaceSetImgStatus(p, true);
      // The upload flips the stored mode to Custom (mirrors User Settings).
      document.getElementById(p + '-bgmode').value = 'custom';
      _tgaIfaceModeChanged(p);
    })
    .catch(function(e){ alert('Error: ' + e.message); });
}

function _tgaIfaceRemoveBg(p) {
  var ctx = _tgaIfaceCtx[p];
  if (!ctx || !ctx.agentId) return;
  if (!confirm('Remove the uploaded background image?')) return;
  fetch('/signage/agents/' + encodeURIComponent(ctx.agentId) + '/background?target=' + encodeURIComponent(ctx.target), { 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 || 'remove failed')); return; }
      _tgaIfaceSetImgStatus(p, false);
      if (document.getElementById(p + '-bgmode').value === 'custom') {
        document.getElementById(p + '-bgmode').value = '';
        _tgaIfaceModeChanged(p);
      }
    })
    .catch(function(e){ alert('Error: ' + e.message); });
}

// DVI-1227 P1: platform toggle in the enroll card.
function togenadminDisplayPlatformChange() {
  var isWin = document.getElementById('tga-display-plat-windows').checked;
  // If a result is already showing, re-compute the command for the new platform.
  var result = document.getElementById('tga-display-enroll-result');
  if (!result.classList.contains('hidden')) {
    document.getElementById('tga-display-enroll-linux').classList.toggle('hidden', isWin);
    document.getElementById('tga-display-enroll-windows').classList.toggle('hidden', !isWin);
  }
}

function togenadminDisplayEnroll() {
  var btn = document.getElementById('tga-display-enroll-btn');
  btn.disabled = true;
  fetch('/signage/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;
      if (!res.ok || !res.d.enrollment_token) { alert('Error: ' + (res.d.error || 'enrollment failed')); return; }
      var tok = res.d.enrollment_token;
      var base = window.location.origin;
      document.getElementById('tga-display-token').textContent = tok;
      var mins = Math.round((res.d.expires_in || 3600) / 60);
      document.getElementById('tga-display-token-ttl').textContent = 'Token expires in ' + mins + ' minutes and can only be used once. Run the command before it expires.';
      document.getElementById('tga-display-install-cmd').textContent = res.d.install_command || '';
      document.getElementById('tga-display-display-url').textContent = res.d.display_url || '';
      // DVI-1227 P1: Windows PowerShell command.
      var winCmd = 'iwr \'' + base + '/enroll/display-bootstrap.ps1?token=' + encodeURIComponent(tok) + '\' -UseBasicParsing | iex';
      document.getElementById('tga-display-install-cmd-win').textContent = winCmd;
      var isWin = document.getElementById('tga-display-plat-windows').checked;
      document.getElementById('tga-display-enroll-linux').classList.toggle('hidden', isWin);
      document.getElementById('tga-display-enroll-windows').classList.toggle('hidden', !isWin);
      document.getElementById('tga-display-enroll-result').classList.remove('hidden');
    })
    .catch(function(e){ btn.disabled = false; alert('Error: ' + e.message); });
}

function togenadminLoadDisplayAgents() {
  var loading = document.getElementById('tga-display-loading');
  var empty = document.getElementById('tga-display-empty');
  var list = document.getElementById('tga-display-list');
  loading.classList.remove('hidden'); empty.classList.add('hidden'); list.classList.add('hidden');
  fetch('/signage/agents')
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      loading.classList.add('hidden');
      _tgaDisplaySignageDashboards = (res.ok && res.d.signage_dashboards) || [];
      var agents = (res.ok && res.d.agents) || [];
      if (!agents.length) { empty.classList.remove('hidden'); return; }
      list.innerHTML = agents.map(function(a){
        var seen = a.last_seen_at ? new Date(a.last_seen_at * 1000).toLocaleString() : 'never';
        var st = a.online_status;
        var stateCls = st === 'online' ? 'text-green-600 dark:text-green-400'
          : (st === 'stale' ? 'text-amber-600 dark:text-amber-400' : 'text-gray-400');
        var stateTxt = a.status === 'revoked' ? 'revoked' : st;
        if (a.status === 'revoked') stateCls = 'text-red-500';
        var dash = a.dashboard_name
          ? esc(a.dashboard_name)
          : '<span class="italic text-gray-400 dark:text-gray-500">no dashboard assigned</span>';
        var presentRow = (a.present && a.present.dashboard_id)
          ? '<div class="text-xs text-togen-teal">▶ Presenting: ' + esc(a.present.dashboard_name || a.present.dashboard_id) +
            ' by ' + esc(a.present.presenter_name || a.present.presenter || 'a user') + '</div>'
          : (a.presentable ? '<div class="text-xs text-gray-400 dark:text-gray-500">Presentable' +
            (a.publish_anyone ? ' · anyone logged in'
              : ((a.allowed_presenters && a.allowed_presenters.length) ? ' · ' + a.allowed_presenters.length + ' presenter(s)' : '')) + '</div>' : '');
        var platBadge = a.platform === 'windows'
          ? ' · <span class="text-xs text-blue-600 dark:text-blue-400">Windows</span>'
          : '';
        var bits = [
          a.hostname ? 'Host: ' + esc(a.hostname) : '',
          a.local_ip ? 'IP: ' + esc(a.local_ip) : '',
          a.screen ? 'Screen: ' + esc(a.screen) : '',
          a.agent_version ? 'Agent: ' + esc(a.agent_version) : ''
        ].filter(Boolean).join(' · ');
        var id = esc(a.id);
        var revoked = a.status === 'revoked';
        var editBtn = revoked ? '' :
          '<button onclick="togenadminDisplayEditOpen(\'' + id + '\')" class="togen-control px-2.5 py-1 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 cursor-pointer">Edit</button>';
        var splashBtn = revoked ? '' :
          '<button onclick="togenadminDisplaySplashOpen(\'' + id + '\')" class="togen-control px-2.5 py-1 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 cursor-pointer">Edit Splash</button>';
        var revokeBtn = revoked ? '' :
          '<button onclick="togenadminDisplayRevoke(\'' + id + '\')" class="px-2.5 py-1 text-xs font-medium rounded-lg 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">Revoke</button>';
        var deleteBtn =
          '<button onclick="togenadminDisplayDelete(\'' + id + '\')" class="togen-control 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">' + esc(a.name || a.id) +
                ' · <span class="' + stateCls + '">' + esc(stateTxt) + '</span>' + platBadge + '</div>' +
              '<div class="text-xs text-gray-600 dark:text-gray-300">Dashboard: ' + dash + '</div>' +
              presentRow +
              (bits ? '<div class="text-xs text-gray-500 dark:text-gray-400 break-all">' + bits + '</div>' : '') +
              '<div class="text-xs text-gray-400 dark:text-gray-500">Last seen: ' + esc(seen) + '</div>' +
            '</div>' +
            '<div class="flex items-center gap-2 shrink-0">' + editBtn + splashBtn + revokeBtn + deleteBtn + '</div>' +
          '</div></li>';
      }).join('');
      list.classList.remove('hidden');
    })
    .catch(function(){ loading.classList.add('hidden'); empty.classList.remove('hidden'); });
}

function togenadminDisplayEditOpen(agentId) {
  _tgaDisplayEditingId = agentId;
  fetch('/signage/agents')
    .then(function(r){ return r.json(); })
    .then(function(d){
      var agents = d.agents || [];
      _tgaDisplaySignageDashboards = d.signage_dashboards || [];
      var a = agents.filter(function(x){ return x.id === agentId; })[0];
      if (!a) { alert('Display no longer exists.'); togenadminLoadDisplayAgents(); return; }
      document.getElementById('tga-display-edit-name').value = a.name || '';
      var sel = document.getElementById('tga-display-edit-dashboard');
      var opts = ['<option value="">— None (show waiting splash) —</option>'];
      _tgaDisplaySignageDashboards.forEach(function(dd){
        opts.push('<option value="' + esc(dd.id) + '">' + esc(dd.name || dd.id) + '</option>');
      });
      // Keep a stale assignment selectable even if it lost the signage flag.
      if (a.dashboard_id && !_tgaDisplaySignageDashboards.some(function(dd){ return dd.id === a.dashboard_id; })) {
        opts.push('<option value="' + esc(a.dashboard_id) + '">' + esc(a.dashboard_name || a.dashboard_id) + ' (no longer flagged)</option>');
      }
      sel.innerHTML = opts.join('');
      sel.value = a.dashboard_id || '';
      document.getElementById('tga-display-edit-none-hint').classList.toggle('hidden', _tgaDisplaySignageDashboards.length > 0);
      // P5 Present gating + active override.
      document.getElementById('tga-display-edit-presentable').checked = !!a.presentable;
      document.getElementById('tga-display-edit-publish-anyone').checked = !!a.publish_anyone;
      document.getElementById('tga-display-edit-presenters').value = (a.allowed_presenters || []).join('\n');
      // DVI-1322: the delegated Allow-Edit list.
      document.getElementById('tga-display-edit-editors').value = (a.allowed_editors || []).join('\n');
      togenadminDisplayPublishAnyoneChanged();
      var pbox = document.getElementById('tga-display-edit-present');
      if (a.present && a.present.dashboard_id) {
        var until = a.present.expires_at
          ? 'until ' + new Date(a.present.expires_at * 1000).toLocaleString()
          : 'until stopped';
        document.getElementById('tga-display-edit-present-info').innerHTML =
          'Presenting <span class="font-medium">' + esc(a.present.dashboard_name || a.present.dashboard_id) +
          '</span> by ' + esc(a.present.presenter_name || a.present.presenter || 'a user') + ' &middot; ' + esc(until);
        pbox.classList.remove('hidden');
      } else {
        pbox.classList.add('hidden');
      }
      // DVI-1220 P1: layout options + the agent-reported screen resolution
      // (context for picking an orientation override).
      var lay = a.layout || {};
      document.getElementById('tga-display-edit-fit').value = lay.fit || 'scale';
      document.getElementById('tga-display-edit-orientation').value = lay.orientation || 'auto';
      _dashColsFill('tga-display-edit-columns', lay.columns);   // DVI-1288 P1
      document.getElementById('tga-display-edit-zoom').value = String(lay.zoom || 100);
      document.getElementById('tga-display-edit-flow').value = lay.flow || 'row';   // DVI-1288 P2
      _dashRowsFill('tga-display-edit-rows', lay.rows);   // DVI-1288 P2
      var scr = document.getElementById('tga-display-edit-screen');
      scr.textContent = a.screen ? ('Reported screen: ' + a.screen) : '';
      scr.classList.toggle('hidden', !a.screen);
      // DVI-1217: per-display interface options.
      _tgaIfaceFill('tga-display-edit-iface', a.interface, a.id, 'interface',
                    !!(a.bg_images && a.bg_images.interface));
      // DVI-1227 P1: Windows window options — show the section only for Windows displays.
      var isWin = (a.platform === 'windows');
      document.getElementById('tga-display-edit-windows-section').classList.toggle('hidden', !isWin);
      if (isWin) {
        var win = a.window || {};
        var layer = win.layer || 'top';
        document.querySelectorAll('input[name="tga-display-win-layer"]').forEach(function(r){
          r.checked = (r.value === layer);
        });
        var monVal = (win.monitor === undefined || win.monitor === 'primary') ? 'primary' : String(win.monitor);
        var monSel = document.getElementById('tga-display-edit-win-monitor');
        // Add the monitor option dynamically if it's not in the default list.
        if (monVal !== 'primary' && !Array.from(monSel.options).some(function(o){ return o.value === monVal; })) {
          var opt = document.createElement('option');
          opt.value = monVal; opt.textContent = 'Monitor ' + monVal;
          monSel.appendChild(opt);
        }
        monSel.value = monVal;
        var geomSec = document.getElementById('tga-display-edit-win-geom-section');
        geomSec.classList.toggle('hidden', layer !== 'windowed');
        var windowed = win.windowed || {};
        document.getElementById('tga-display-edit-win-x').value      = windowed.x      !== undefined ? windowed.x      : 0;
        document.getElementById('tga-display-edit-win-y').value      = windowed.y      !== undefined ? windowed.y      : 0;
        document.getElementById('tga-display-edit-win-width').value  = windowed.width  !== undefined ? windowed.width  : 800;
        document.getElementById('tga-display-edit-win-height').value = windowed.height !== undefined ? windowed.height : 600;
        // Show/hide geom section when layer radio changes.
        document.querySelectorAll('input[name="tga-display-win-layer"]').forEach(function(r){
          r.onchange = function(){
            geomSec.classList.toggle('hidden', r.value !== 'windowed');
          };
        });
      }
      document.getElementById('tga-display-edit-modal').classList.remove('hidden');
    });
}

// DVI-1217: Edit Splash — text + a splash-specific interface override.
function togenadminDisplaySplashOpen(agentId) {
  _tgaDisplayEditingId = agentId;
  fetch('/signage/agents')
    .then(function(r){ return r.json(); })
    .then(function(d){
      var a = (d.agents || []).filter(function(x){ return x.id === agentId; })[0];
      if (!a) { alert('Display no longer exists.'); togenadminLoadDisplayAgents(); return; }
      var splash = a.splash || {};
      document.getElementById('tga-display-splash-text').value = splash.text || '';
      // Prefill from the splash override when set, else the display interface
      // (what the splash inherits until it gets its own settings).
      _tgaIfaceFill('tga-display-splash-iface', splash.interface || a.interface, a.id, 'splash',
                    !!(a.bg_images && a.bg_images.splash));
      document.getElementById('tga-display-splash-modal').classList.remove('hidden');
    });
}

function togenadminDisplaySplashClose() {
  document.getElementById('tga-display-splash-modal').classList.add('hidden');
  _tgaDisplayEditingId = null;
}

function togenadminDisplaySplashSave() {
  if (!_tgaDisplayEditingId) return;
  var btn = document.getElementById('tga-display-splash-save');
  btn.disabled = true;
  fetch('/signage/agents/' + encodeURIComponent(_tgaDisplayEditingId), {
    method: 'POST', headers: {'Content-Type':'application/json'},
    body: JSON.stringify({ splash: {
      text: document.getElementById('tga-display-splash-text').value,
      interface: _tgaIfaceCollect('tga-display-splash-iface')
    } })
  })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      btn.disabled = false;
      if (!res.ok) { alert('Error: ' + (res.d.error || 'save failed')); return; }
      togenadminDisplaySplashClose();
      togenadminLoadDisplayAgents();
    })
    .catch(function(e){ btn.disabled = false; alert('Error: ' + e.message); });
}

function togenadminDisplayForceRevert() {
  if (!_tgaDisplayEditingId) return;
  if (!confirm('Force-revert this display to its assigned dashboard now?')) return;
  fetch('/display/present/stop', {
    method: 'POST', headers: {'Content-Type':'application/json'},
    body: JSON.stringify({ display_id: _tgaDisplayEditingId })
  })
    .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 || 'revert failed')); return; }
      document.getElementById('tga-display-edit-present').classList.add('hidden');
      togenadminLoadDisplayAgents();
    })
    .catch(function(e){ alert('Error: ' + e.message); });
}

function togenadminDisplayEditClose() {
  document.getElementById('tga-display-edit-modal').classList.add('hidden');
  _tgaDisplayEditingId = null;
}

// ---- Org Default Display Settings (DVI-1321 P2) ---------------------------
function togenadminDisplayDefaultsOpen() {
  document.getElementById('tga-display-defaults-status').textContent = '';
  fetch('/display/defaults')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      var lay = (d && d.layout) || { fit:'scale', orientation:'auto', columns:'auto', zoom:100, flow:'row', rows:'auto' };
      document.getElementById('tga-display-defaults-fit').value = lay.fit || 'scale';
      document.getElementById('tga-display-defaults-orientation').value = lay.orientation || 'auto';
      _dashColsFill('tga-display-defaults-columns', lay.columns);
      document.getElementById('tga-display-defaults-zoom').value = String(lay.zoom || 100);
      document.getElementById('tga-display-defaults-flow').value = lay.flow || 'row';
      _dashRowsFill('tga-display-defaults-rows', lay.rows);
      document.getElementById('tga-display-defaults-use-interface').checked = !!(d && d.use_interface_default);
      document.getElementById('tga-display-defaults-modal').classList.remove('hidden');
    });
}
function togenadminDisplayDefaultsClose() {
  document.getElementById('tga-display-defaults-modal').classList.add('hidden');
}
function togenadminDisplayDefaultsSave() {
  var btn = document.getElementById('tga-display-defaults-save');
  var st = document.getElementById('tga-display-defaults-status');
  var payload = {
    layout: {
      fit: document.getElementById('tga-display-defaults-fit').value,
      orientation: document.getElementById('tga-display-defaults-orientation').value,
      columns: document.getElementById('tga-display-defaults-columns').value,
      zoom: parseInt(document.getElementById('tga-display-defaults-zoom').value, 10) || 100,
      flow: document.getElementById('tga-display-defaults-flow').value,
      rows: document.getElementById('tga-display-defaults-rows').value
    },
    use_interface_default: document.getElementById('tga-display-defaults-use-interface').checked
  };
  btn.disabled = true;
  fetch('/display/defaults', { method:'POST', headers:{'Content-Type':'application/json'},
    credentials:'same-origin', body: JSON.stringify(payload) })
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      btn.disabled = false;
      if (!d || !d.ok){ st.textContent = 'Could not save.'; st.className = 'text-xs text-red-500'; return; }
      st.textContent = 'Saved.'; st.className = 'text-xs text-green-600 dark:text-green-400';
      setTimeout(togenadminDisplayDefaultsClose, 600);
    }).catch(function(){ btn.disabled = false; st.textContent = 'Could not save.'; st.className = 'text-xs text-red-500'; });
}

// DVI-1245: "Allow any logged-in user to present" (the "*" sentinel). When on,
// the explicit presenter list is redundant, so grey it out for clarity.
function togenadminDisplayPublishAnyoneChanged() {
  var on = document.getElementById('tga-display-edit-publish-anyone').checked;
  var ta = document.getElementById('tga-display-edit-presenters');
  if (ta) { ta.disabled = on; ta.classList.toggle('opacity-50', on); }
}

function togenadminDisplayEditSave() {
  if (!_tgaDisplayEditingId) return;
  var name = document.getElementById('tga-display-edit-name').value.trim();
  if (!name) { alert('Display name cannot be blank.'); return; }
  var dashId = document.getElementById('tga-display-edit-dashboard').value;
  var presentable = document.getElementById('tga-display-edit-presentable').checked;
  var presenters = document.getElementById('tga-display-edit-presenters').value || '';
  var publishAnyone = document.getElementById('tga-display-edit-publish-anyone').checked;
  var btn = document.getElementById('tga-display-edit-save');
  btn.disabled = true;
  var payload = { name: name, dashboard_id: dashId,
                  presentable: presentable, allowed_presenters: presenters,
                  publish_anyone: publishAnyone,
                  allowed_editors: document.getElementById('tga-display-edit-editors').value || '',
                  layout: {
                    fit: document.getElementById('tga-display-edit-fit').value,
                    orientation: document.getElementById('tga-display-edit-orientation').value,
                    columns: document.getElementById('tga-display-edit-columns').value,
                    zoom: parseInt(document.getElementById('tga-display-edit-zoom').value, 10) || 100,
                    flow: document.getElementById('tga-display-edit-flow').value,
                    rows: document.getElementById('tga-display-edit-rows').value
                  },
                  interface: _tgaIfaceCollect('tga-display-edit-iface') };
  // DVI-1227 P1: include window settings for Windows displays.
  var winSection = document.getElementById('tga-display-edit-windows-section');
  if (!winSection.classList.contains('hidden')) {
    var selLayer = document.querySelector('input[name="tga-display-win-layer"]:checked');
    var layer = selLayer ? selLayer.value : 'top';
    var monRaw = document.getElementById('tga-display-edit-win-monitor').value;
    var monitor = (monRaw === 'primary') ? 'primary' : (parseInt(monRaw, 10) || 'primary');
    payload.window = {
      layer: layer, monitor: monitor,
      windowed: {
        x:      parseInt(document.getElementById('tga-display-edit-win-x').value,      10) || 0,
        y:      parseInt(document.getElementById('tga-display-edit-win-y').value,      10) || 0,
        width:  parseInt(document.getElementById('tga-display-edit-win-width').value,  10) || 800,
        height: parseInt(document.getElementById('tga-display-edit-win-height').value, 10) || 600
      }
    };
  }
  fetch('/signage/agents/' + encodeURIComponent(_tgaDisplayEditingId), {
    method: 'POST', headers: {'Content-Type':'application/json'},
    body: JSON.stringify(payload)
  })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      btn.disabled = false;
      if (!res.ok) { alert('Error: ' + (res.d.error || 'save failed')); return; }
      togenadminDisplayEditClose();
      togenadminLoadDisplayAgents();
    })
    .catch(function(e){ btn.disabled = false; alert('Error: ' + e.message); });
}

function togenadminDisplayRevoke(agentId) {
  if (!confirm('Revoke this display? Its device token stops working immediately and the screen will show an "unauthorized" message. It must be re-enrolled to return.')) return;
  fetch('/signage/agents/' + encodeURIComponent(agentId) + '/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')); togenadminLoadDisplayAgents(); })
    .catch(function(e){ alert('Error: ' + e.message); togenadminLoadDisplayAgents(); });
}

function togenadminDisplayDelete(agentId) {
  if (!confirm('Delete this display from the registry? This cannot be undone; the device would need to re-enroll.')) return;
  fetch('/signage/agents/' + encodeURIComponent(agentId), { 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')); togenadminLoadDisplayAgents(); })
    .catch(function(e){ alert('Error: ' + e.message); togenadminLoadDisplayAgents(); });
}

// 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="togen-control 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="togen-control 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="togen-control 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 te = document.getElementById('togenadmin-quote-takeoff-location');
      if (te) te.value = d.shared_quote_takeoff_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(),
      shared_quote_takeoff_location: document.getElementById('togenadmin-quote-takeoff-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;
      });
      var ret = (d && d.retention) || {};
      var rd = document.getElementById('togenadmin-history-retention-days');
      var mr = document.getElementById('togenadmin-history-max-rows');
      var ar = document.getElementById('togenadmin-history-archive');
      if (rd) rd.value = (ret.retention_days != null ? ret.retention_days : '');
      if (mr) mr.value = (ret.max_rows_per_tool != null ? ret.max_rows_per_tool : '');
      if (ar) ar.checked = ret.archive !== 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;
  });
  var rdRaw = ((document.getElementById('togenadmin-history-retention-days') || {}).value || '').trim();
  var mrRaw = ((document.getElementById('togenadmin-history-max-rows') || {}).value || '').trim();
  var ar = document.getElementById('togenadmin-history-archive');
  var retention = {
    retention_days: rdRaw === '' ? null : parseInt(rdRaw, 10),
    max_rows_per_tool: mrRaw === '' ? null : parseInt(mrRaw, 10),
    archive: ar ? ar.checked : true
  };
  fetch('/togen-admin/history', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ capture: cap, retention: retention })
  })
    .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 togenadminHistoryPruneNow() {
  var status = document.getElementById('togenadmin-history-prune-status');
  status.textContent = 'Pruning…';
  fetch('/togen-admin/history/prune', { method: 'POST' })
    .then(function(r) { return r.json(); })
    .then(function(d) {
      if (d && d.ok) {
        var t = d.telemetry || {};
        status.textContent = 'Done — ' + (t.total_rows != null ? t.total_rows.toLocaleString() : '?') +
          ' rows live, ' + (t.archive_files || 0) + ' archive file(s)';
      } else {
        status.textContent = (d && d.error) || 'Prune failed';
      }
    })
    .catch(function(e) { status.textContent = 'Prune 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': 'Kiln Metrics',
  'vision': 'Vision',
  'equipment-manuals': 'Equipment',
  'calendar': 'Calendar',
  'ocr-tool': 'OCR',
  'email': 'Email',
  'auditor': 'Auditor',
  'bots': 'Bots',
  'oversight': 'Oversight',
  'visitor-registry': 'Visitor Registry',
  'dashboards': 'Dashboards',
  'qc': 'Batch Production',
  'logistics': 'Logistics',
  'quality': 'QC'
};

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.getAttribute('data-bot-id') || '');
  });
  subRow.addEventListener('dragend', function(e) {
    e.stopPropagation();
    subRow.classList.remove('opacity-50');
    var kids = subWrap.children;
    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';
    });
}

// A draggable/removable separator wrapper for the admin Tools-Enabled list
// (DVI-1337), mirroring the User Settings separator row. Its data-tool-id is the
// separator token collected into the admin tool order on save.
function _togenadminMakeSeparatorWrap(container, sepId) {
  var wrap = document.createElement('div');
  wrap.setAttribute('data-tool-id', sepId || _mintSeparatorId());
  wrap.className = 'rounded-xl';
  var row = document.createElement('div');
  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 =
    _TOGENADMIN_DRAG_SVG +
    '<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="sep-row-summary text-xs font-medium uppercase tracking-wide text-gray-400 dark:text-gray-500 shrink-0 truncate max-w-[14rem]">Separator</span>' +
      '<span class="sep-row-badge hidden inline-flex items-center rounded-full bg-togen-teal/10 text-togen-teal px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide shrink-0">Collapsible</span>' +
      '<span class="h-px flex-1 bg-gray-300 dark:bg-gray-600"></span>' +
    '</div>' +
    _sepEditGearHtml() +
    '<button type="button" title="Remove separator" ' +
      'class="sep-row-remove 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>';
  row.querySelector('.sep-row-edit').addEventListener('click', function() { _sepEditOpen(wrap, 'admin'); });
  row.querySelector('.sep-row-remove').addEventListener('click', function() {
    if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
  });
  wrap.appendChild(row);
  _sepSeedElementProps(wrap, _adminSepProps(wrap.getAttribute('data-tool-id')));
  _sepUpdateRowSummary(wrap);
  _togenadminAttachToolDrag(wrap, container);
  return wrap;
}

// A draggable/removable website-item wrapper for the admin Tools-Enabled list
// (DVI-1344), mirroring the separator wrapper. Its data-tool-id is the web_
// token collected into the admin tool order + items map on save.
function _togenadminMakeWebItemWrap(container, itemId) {
  var wrap = document.createElement('div');
  wrap.setAttribute('data-tool-id', itemId || _mintWebItemId());
  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 + _webRowSummaryHtml();
  row.querySelector('.web-row-edit').addEventListener('click', function() { _webItemEditOpen(wrap, 'admin'); });
  row.querySelector('.web-row-remove').addEventListener('click', function() {
    if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
  });
  wrap.appendChild(row);
  _webSeedElementProps(wrap, _adminWebItemProps(wrap.getAttribute('data-tool-id')));
  _webUpdateRowSummary(wrap);
  _togenadminAttachToolDrag(wrap, container);
  return wrap;
}

// A draggable/removable dashboard-tile wrapper for the admin Tools-Enabled list
// (DVI-1346 P2), mirroring the website wrapper. Its data-tool-id is the dtile_
// token collected into the admin tool order + items map on save.
function _togenadminMakeDashTileWrap(container, itemId) {
  var wrap = document.createElement('div');
  wrap.setAttribute('data-tool-id', itemId || _mintDashItemId());
  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 + _dtileRowSummaryHtml();
  row.querySelector('.dash-row-edit').addEventListener('click', function() { _dtileEditOpen(wrap, 'admin'); });
  row.querySelector('.dash-row-remove').addEventListener('click', function() {
    if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
  });
  wrap.appendChild(row);
  _dtileSeedElementProps(wrap, _adminDashItemProps(wrap.getAttribute('data-tool-id')));
  _dtileUpdateRowSummary(wrap);
  _togenadminAttachToolDrag(wrap, container);
  return wrap;
}

// A draggable/removable embedded-dashboard wrapper for the admin Tools-Enabled
// list (DVI-1347 P3), mirroring the dashboard-tile wrapper. Its data-tool-id is
// the dashref_ token collected into the admin tool order + items map on save.
function _togenadminMakeDashRefWrap(container, itemId) {
  var wrap = document.createElement('div');
  wrap.setAttribute('data-tool-id', itemId || _mintDashRefId());
  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 + _dashRefRowSummaryHtml();
  row.querySelector('.dashref-row-edit').addEventListener('click', function() { _dashRefEditOpen(wrap, 'admin'); });
  row.querySelector('.dashref-row-remove').addEventListener('click', function() {
    if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
  });
  wrap.appendChild(row);
  _dashRefSeedElementProps(wrap, _adminDashRefProps(wrap.getAttribute('data-tool-id')));
  _dashRefUpdateRowSummary(wrap);
  _togenadminAttachToolDrag(wrap, container);
  return wrap;
}

// Tools-Enabled "+" menu (DVI-1337), mirroring the User Settings add menu.
function togenadminToggleAddMenu(e) {
  if (e) e.stopPropagation();
  var menu = document.getElementById('togenadmin-tools-add-menu');
  var btn = document.getElementById('togenadmin-tools-add-btn');
  if (!menu) return;
  var nowHidden = menu.classList.toggle('hidden');
  if (btn) btn.setAttribute('aria-expanded', nowHidden ? 'false' : 'true');
}

function togenadminCloseAddMenu() {
  var menu = document.getElementById('togenadmin-tools-add-menu');
  var btn = document.getElementById('togenadmin-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('togenadmin-tools-add-btn');
  var menu = document.getElementById('togenadmin-tools-add-menu');
  if (!menu || menu.classList.contains('hidden')) return;
  if (btn && btn.parentNode && btn.parentNode.contains(e.target)) return;
  togenadminCloseAddMenu();
});

function togenadminAddSeparator() {
  togenadminCloseAddMenu();
  var container = document.getElementById('togenadmin-tools-list');
  if (!container) return;
  var n = 0, wraps = container.querySelectorAll(':scope > [data-tool-id]');
  for (var i = 0; i < wraps.length; i++) {
    if (_isSeparatorToken(wraps[i].getAttribute('data-tool-id'))) n++;
  }
  if (n >= 10) {
    var status = document.getElementById('togenadmin-tools-save-status');
    if (status) {
      status.textContent = 'You can add up to 10 separators.';
      status.className = 'text-sm text-gray-500 dark:text-gray-400';
      setTimeout(function(){ status.textContent = ''; }, 3000);
    }
    return;
  }
  container.appendChild(_togenadminMakeSeparatorWrap(container));
}

function togenadminAddWebsite() {
  togenadminCloseAddMenu();
  var container = document.getElementById('togenadmin-tools-list');
  if (!container) return;
  var n = 0, wraps = container.querySelectorAll(':scope > [data-tool-id]');
  for (var i = 0; i < wraps.length; i++) {
    if (_isWebItemToken(wraps[i].getAttribute('data-tool-id'))) n++;
  }
  if (n >= 24) {
    var status = document.getElementById('togenadmin-tools-save-status');
    if (status) {
      status.textContent = 'You can add up to 24 website tiles.';
      status.className = 'text-sm text-gray-500 dark:text-gray-400';
      setTimeout(function(){ status.textContent = ''; }, 3000);
    }
    return;
  }
  var wrap = _togenadminMakeWebItemWrap(container);
  wrap.setAttribute('data-web-new', '1');
  container.appendChild(wrap);
  _webItemEditOpen(wrap, 'admin');
}

function togenadminAddDashTile() {
  togenadminCloseAddMenu();
  var container = document.getElementById('togenadmin-tools-list');
  if (!container) return;
  var n = 0, wraps = container.querySelectorAll(':scope > [data-tool-id]');
  for (var i = 0; i < wraps.length; i++) {
    if (_isDashItemToken(wraps[i].getAttribute('data-tool-id'))) n++;
  }
  if (n >= 12) {
    var status = document.getElementById('togenadmin-tools-save-status');
    if (status) {
      status.textContent = 'You can add up to 12 dashboard tiles.';
      status.className = 'text-sm text-gray-500 dark:text-gray-400';
      setTimeout(function(){ status.textContent = ''; }, 3000);
    }
    return;
  }
  var wrap = _togenadminMakeDashTileWrap(container);
  wrap.setAttribute('data-dash-new', '1');
  container.appendChild(wrap);
  _dtileEditOpen(wrap, 'admin');
}

function togenadminAddDashRef() {
  togenadminCloseAddMenu();
  var container = document.getElementById('togenadmin-tools-list');
  if (!container) return;
  var n = 0, wraps = container.querySelectorAll(':scope > [data-tool-id]');
  for (var i = 0; i < wraps.length; i++) {
    if (_isDashRefToken(wraps[i].getAttribute('data-tool-id'))) n++;
  }
  if (n >= 4) {
    var status = document.getElementById('togenadmin-tools-save-status');
    if (status) {
      status.textContent = 'You can embed up to 4 dashboards.';
      status.className = 'text-sm text-gray-500 dark:text-gray-400';
      setTimeout(function(){ status.textContent = ''; }, 3000);
    }
    return;
  }
  var wrap = _togenadminMakeDashRefWrap(container);
  wrap.setAttribute('data-dashref-new', '1');
  container.appendChild(wrap);
  _dashRefEditOpen(wrap, 'admin');
}

function togenadminRenderToolVisibility() {
  var container = document.getElementById('togenadmin-tools-list');
  if (!container) return;
  container.innerHTML = '';
  // Use saved order, falling back to default. Separator tokens (DVI-1337) are
  // kept in place; every known tool is appended if the saved order omits it.
  var ids = _toolOrder.filter(function(tid){ return _toolVisibilityLabels[tid] || _isSeparatorToken(tid) || _isWebItemToken(tid) || _isDashItemToken(tid) || _isDashRefToken(tid); });
  var allKnown = Object.keys(_toolVisibilityLabels);
  allKnown.forEach(function(tid){ if (ids.indexOf(tid) === -1) ids.push(tid); });

  ids.forEach(function(tid) {
    if (_isWebItemToken(tid)) { container.appendChild(_togenadminMakeWebItemWrap(container, tid)); return; }
    if (_isDashItemToken(tid)) { container.appendChild(_togenadminMakeDashTileWrap(container, tid)); return; }
    if (_isDashRefToken(tid)) { container.appendChild(_togenadminMakeDashRefWrap(container, tid)); return; }
    if (_isSeparatorToken(tid)) { container.appendChild(_togenadminMakeSeparatorWrap(container, tid)); return; }
    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);
    }

    // Bots carries its per-bot subitems (DVI-856), mirroring WO types above.
    if (tid === 'bots') {
      var botWrap = document.createElement('div');
      botWrap.id = 'togenadmin-bot-list';
      botWrap.className = 'ml-9 mt-2 space-y-2';
      var botIds = _botOrder.filter(function(b){ return _botLabels[b]; });
      Object.keys(_botLabels).forEach(function(b){ if (botIds.indexOf(b) === -1) botIds.push(b); });
      botIds.forEach(function(b) {
        var botChecked = _botVisibility[b] !== false;
        var botLabel = _botLabels[b] || b;
        var botRow = document.createElement('div');
        botRow.setAttribute('data-bot-id', b);
        botRow.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';
        botRow.innerHTML =
          _TOGENADMIN_DRAG_SVG +
          '<label class="flex items-center gap-3 flex-1 cursor-pointer">' +
            '<input type="checkbox" data-bot-id="' + b + '"' + (botChecked ? ' 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(botLabel) + '</span>' +
          '</label>';
        _togenadminAttachSubDrag(botRow, botWrap);
        botWrap.appendChild(botRow);
      });
      wrap.appendChild(botWrap);
    }

    // DVI-1496 P2: the Equipment tool carries its tile-surface sub-tools as
    // indented enable/disable subitems (Assets / Manuals / Oversight …),
    // exactly like the WO-type subitems under Work Order Request.
    if (tid === 'equipment-manuals') {
      var subWrap = document.createElement('div');
      subWrap.id = 'togenadmin-subtool-list';
      subWrap.className = 'ml-9 mt-2 space-y-2';
      subWrap.setAttribute('data-surface', 'equipment');
      var cat = (_tileSurfaces && _tileSurfaces.equipment && _tileSurfaces.equipment.catalog) || [];
      var adminVis = (_subtoolVisibilityAdmin && _subtoolVisibilityAdmin.equipment) || {};
      cat.forEach(function(c) {
        var subChecked = adminVis[c.id] !== false;
        var subRow = document.createElement('div');
        subRow.setAttribute('data-subtool', c.id);
        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';
        subRow.innerHTML =
          '<label class="flex items-center gap-3 flex-1 cursor-pointer">' +
            '<input type="checkbox" data-subtool="' + c.id + '"' + (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(c.title) + '</span>' +
          '</label>';
        subWrap.appendChild(subRow);
      });
      wrap.appendChild(subWrap);
    }

    // DVI-1522 (Amendment 1): the QC tool carries GROUP sub-item rows ("Batch
    // Production" / "Kiln Metrics") following the WOR model. Each group row's
    // checkbox controls all its member catalog tiles (the store stays per-catalog
    // id via data-subtool-ids fan-out).
    if (tid === 'quality') {
      var qsSubWrap = document.createElement('div');
      qsSubWrap.id = 'togenadmin-subtool-list';
      qsSubWrap.className = 'ml-9 mt-2 space-y-2';
      qsSubWrap.setAttribute('data-surface', 'quality');
      var qGroups = (_tileSurfaces && _tileSurfaces.quality && _tileSurfaces.quality.groups) || [];
      var qAdminVis = (_subtoolVisibilityAdmin && _subtoolVisibilityAdmin.quality) || {};
      qGroups.forEach(function(g) {
        var tiles = g.tiles || [];
        var allOn = tiles.length ? tiles.every(function(id){ return qAdminVis[id] !== false; }) : true;
        var gRow = document.createElement('div');
        gRow.setAttribute('data-subtool-group', g.id);
        gRow.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';
        gRow.innerHTML =
          '<label class="flex items-center gap-3 flex-1 cursor-pointer">' +
            '<input type="checkbox" data-subtool-ids="' + esc(tiles.join(',')) + '"' + (allOn ? ' 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(g.label) + '</span>' +
          '</label>';
        qsSubWrap.appendChild(gRow);
      });
      wrap.appendChild(qsSubWrap);
    }

    container.appendChild(wrap);
  });
  _dtileRefreshEditorLabels();
  _dashRefRefreshEditorLabels();
}

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 = [];
  var sepPayload = {};   // DVI-1340: admin separator label/justify/collapse props
  var itemsPayload = {}; // DVI-1344: admin website-tile props
  document.querySelectorAll('#togenadmin-tools-list > [data-tool-id]').forEach(function(w){
    var tid = w.getAttribute('data-tool-id');
    orderPayload.push(tid);
    if (_isWebItemToken(tid)) itemsPayload[tid] = _webReadElementProps(w);
    else if (_isDashItemToken(tid)) itemsPayload[tid] = _dtileReadElementProps(w);
    else if (_isDashRefToken(tid)) itemsPayload[tid] = _dashRefReadElementProps(w);
    else if (_isSeparatorToken(tid)) sepPayload[tid] = _sepReadElementProps(w);
  });

  // 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'));
  });

  // Bot subitems (visibility + order) live under the bots wrapper (DVI-856).
  var botVisPayload = {};
  document.querySelectorAll('#togenadmin-bot-list input[data-bot-id]').forEach(function(c){
    botVisPayload[c.getAttribute('data-bot-id')] = c.checked;
  });
  var botOrderPayload = [];
  document.querySelectorAll('#togenadmin-bot-list > [data-bot-id]').forEach(function(r){
    botOrderPayload.push(r.getAttribute('data-bot-id'));
  });

  // DVI-1496 P2: sub-tool visibility subitems under the Equipment tool row.
  var subtoolPayload = {};
  document.querySelectorAll('#togenadmin-subtool-list').forEach(function(w){
    var surface = w.getAttribute('data-surface');
    var m = {};
    w.querySelectorAll('input[data-subtool]').forEach(function(c){
      m[c.getAttribute('data-subtool')] = c.checked;
    });
    // DVI-1522 (Amendment 1): a group checkbox fans out to its member catalog ids.
    w.querySelectorAll('input[data-subtool-ids]').forEach(function(c){
      (c.getAttribute('data-subtool-ids') || '').split(',').forEach(function(id){ if (id) m[id] = c.checked; });
    });
    if (surface) subtoolPayload[surface] = m;
  });

  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 = 7, 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, separators: sepPayload, items: itemsPayload}, function(d){ _toolOrder = d.order; if (d.separators) _adminToolSeparators = d.separators; if (d.items) _adminToolItems = d.items; });
  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; });
  post('/togen-admin/bot-visibility', {bots: botVisPayload}, function(d){ _botVisibility = d.bots; });
  post('/togen-admin/bot-order', {order: botOrderPayload}, function(d){ _botOrder = d.order; });
  post('/togen-admin/subtool-visibility', {surfaces: subtoolPayload}, function(d){
    if (d.surfaces) { _subtoolVisibilityAdmin = d.surfaces; _emRecomputeVisibility(); if (typeof _qsRecomputeVisibility === 'function') _qsRecomputeVisibility(); }
  });
}

// ===== 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="togen-control 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="togen-control 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="togen-control 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));
  // DVI-1087: top bar / tab strips follow pane alpha; interactive bar surfaces
  // (Search/Assistant field + panel) are floored at 0.85 for readability.
  document.documentElement.style.setProperty('--togen-bar-alpha', String(Math.max(alpha, 0.85)));
  // DVI-1329: nested inset cards/list rows back at the pane alpha but floored at
  // 0.72 so content stays legible over a busy custom background.
  document.documentElement.style.setProperty('--togen-inset-alpha', String(Math.max(alpha, 0.72)));
  document.documentElement.classList.toggle('togen-glass', alpha < 1);
}

// ===== 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 = window._ssoUserEmail;
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.
// DVI-1305 P1: optional opts.accent tints markdown headings with a curated,
// dark-mode-safe color (no free color picker — the DVI-1286 legibility rule);
// GitHub-style pipe tables are also supported. opts is optional, so existing
// callers (the News feed) render exactly as before.
function renderNewsMarkdown(src, opts) {
  opts = opts || {};
  var accent = _dashTextAccentColor(opts.accent);
  var accentStyle = accent ? ' style="color:' + accent + '"' : '';
  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;
  }
  function isTableSep(t) {
    return t.indexOf('-') !== -1 && /^\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?$/.test(t);
  }
  function splitRow(t) {
    return t.replace(/^\|/, '').replace(/\|$/, '').split('|').map(function(c){ return c.trim(); });
  }
  function colAligns(sep) {
    return splitRow(sep).map(function(c){
      var l = c.charAt(0) === ':', r = c.charAt(c.length - 1) === ':';
      if (l && r) return 'center'; if (r) return 'right'; if (l) return 'left'; return '';
    });
  }
  function renderTable(header, aligns, rows) {
    function cell(tag, txt, idx) {
      var a = aligns[idx], st = a ? ' style="text-align:' + a + '"' : '';
      return '<' + tag + ' class="border border-gray-300 dark:border-gray-600 px-2 py-1"' + st + '>' + inline(txt) + '</' + tag + '>';
    }
    var th = '<tr>' + header.map(function(c, idx){ return cell('th', c, idx); }).join('') + '</tr>';
    var tb = rows.map(function(r){
      return '<tr>' + header.map(function(_, idx){ return cell('td', r[idx] || '', idx); }).join('') + '</tr>';
    }).join('');
    return '<table class="dash-md-table border-collapse text-sm my-2"><thead>' + th + '</thead><tbody>' + tb + '</tbody></table>';
  }
  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; }
  for (var i = 0; i < lines.length; i++) {
    var t = lines[i].trim();
    if (!t) { flushPara(); flushList(); continue; }
    // GitHub-style table: a header row with a pipe, immediately followed by a
    // separator row of dashes/colons. Consume subsequent pipe rows as the body.
    if (t.indexOf('|') !== -1 && i + 1 < lines.length && isTableSep(lines[i + 1].trim())) {
      flushPara(); flushList();
      var aligns = colAligns(lines[i + 1].trim());
      var header = splitRow(t);
      var rows = [], j = i + 2;
      for (; j < lines.length; j++) {
        var rt = lines[j].trim();
        if (!rt || rt.indexOf('|') === -1) break;
        rows.push(splitRow(rt));
      }
      out.push(renderTable(header, aligns, rows));
      i = j - 1;
      continue;
    }
    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"' + accentStyle + '>' + inline(h[2]) + '</h' + (lvl + 2) + '>');
      continue;
    }
    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]));
      continue;
    }
    flushList();
    para.push(inline(t));
  }
  flushPara(); flushList();
  return out.join('') || '';
}

// ===== QC TOOL (DVI-1326 P1: Batch Tickets) =====
// Manual capture of daily concrete batch tickets against versioned Mixture
// recipes. Targets are per-yd3 dosing scaled by the batch's yards; an
// out-of-range Actual warns (amber) but NEVER blocks the save — the server
// snapshots targets and flags exceptions authoritatively. Mixtures/Materials
// management is admin-only (board decision 2026-08-08).
// ===== LOGISTICS (DVI-1387 / DVI-1397 P1) =====
var _logisticsInitialized = false;
var _logisticsMeta = null;       // /logistics/meta payload (caps + feature catalogs)
var _logisticsCaps = { resources: false, settings: false };
var _logisticsPeople = [];       // roster for the driver picker
var _logisticsVehicleKind = 'truck';  // which resource the vehicle modal edits
var _logisticsVehicleEditId = null;
var _logisticsDriverEditId = null;
var _logisticsEventEditId = null;
var _logisticsLoadEditId = null;
var _logisticsLoadEvents = [];        // working event timeline in the load modal
var _logisticsLoadSel = { driver: {}, truck: {}, trailer: {} }; // resource_id -> true
var _logisticsAvail = { driver: [], truck: [], trailer: [] };  // last availability fetch
var _logisticsEventCatalog = [];      // /logistics/events for the timeline picker
var _logisticsAvailSeq = 0;           // guards out-of-order availability responses
var _logisticsLtlEditId = null;       // built-load being edited in the LTL modal
var _logisticsLtlCatalog = [];        // /logistics/ltl-loads for the Add Load picker
var _logisticsTab = 'loads';          // active top-level Logistics tab
var _logisticsOvTab = 'plan';         // active Oversight subtab (plan | map)

function logisticsInit() {
  logisticsResSubtab('drivers');
  fetch('/logistics/meta').then(function (r) { return r.json(); }).then(function (d) {
    _logisticsMeta = d || {};
    _logisticsCaps = (d && d.caps) || { loads: false, resources: false, settings: false };
    if (_isToGenAdmin) { _logisticsCaps.loads = true; _logisticsCaps.resources = true; _logisticsCaps.settings = true; }
    // Reveal add buttons + the Settings tab per the caller's leaves.
    if (_logisticsCaps.loads) {
      var lb = document.getElementById('logistics-load-add-btn');
      if (lb) lb.classList.remove('hidden');
      var lbb = document.getElementById('logistics-ltl-add-btn');
      if (lbb) lbb.classList.remove('hidden');
    }
    if (_logisticsCaps.resources) {
      ['driver', 'truck', 'trailer'].forEach(function (k) {
        var b = document.getElementById('logistics-' + k + '-add-btn');
        if (b) b.classList.remove('hidden');
      });
    }
    if (_logisticsCaps.settings) {
      var st = document.getElementById('logistics-tab-btn-settings');
      if (st) st.classList.remove('hidden');
    }
    logisticsLoadLoads();
  }).catch(function () {
    document.getElementById('logistics-loads-list').innerHTML =
      '<p class="text-sm text-red-500">Could not load Logistics.</p>';
  });
}

function logisticsSwitchTab(tab) {
  _logisticsTab = tab;
  ['loads', 'calendar', 'ltl', 'oversight', 'resources', 'settings'].forEach(function (t) {
    var pane = document.getElementById('logistics-tab-' + t);
    var btn = document.getElementById('logistics-tab-btn-' + t);
    if (!pane || !btn) return;
    if (t === tab) {
      pane.classList.remove('hidden');
      btn.classList.add('border-togen-teal', 'text-togen-teal');
      btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
    } else {
      pane.classList.add('hidden');
      btn.classList.remove('border-togen-teal', 'text-togen-teal');
      btn.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400');
    }
  });
  if (tab === 'loads') logisticsLoadLoads();
  if (tab === 'calendar') logisticsCalInit();
  if (tab === 'ltl') logisticsLoadLtl();
  if (tab === 'oversight') logisticsOvSubtab(_logisticsOvTab || 'plan');
  if (tab === 'resources') logisticsResSubtab('drivers');
  if (tab === 'settings') logisticsSetSubtab('events');
}

function _logisticsSubtab(prefix, panes, tab, onShow) {
  panes.forEach(function (t) {
    var pane = document.getElementById(prefix + '-pane-' + t);
    var btn = document.getElementById(prefix + '-subtab-' + t);
    if (!pane || !btn) return;
    if (t === tab) {
      pane.classList.remove('hidden');
      btn.classList.add('border-togen-teal', 'text-togen-teal');
      btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
    } else {
      pane.classList.add('hidden');
      btn.classList.remove('border-togen-teal', 'text-togen-teal');
      btn.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400');
    }
  });
  if (onShow) onShow(tab);
}

function logisticsResSubtab(tab) {
  _logisticsSubtab('logistics-res', ['drivers', 'trucks', 'trailers'], tab, function (t) {
    if (t === 'drivers') logisticsLoadDrivers();
    if (t === 'trucks') logisticsLoadVehicles('truck');
    if (t === 'trailers') logisticsLoadVehicles('trailer');
  });
}

function logisticsSetSubtab(tab) {
  _logisticsSubtab('logistics-set', ['events', 'notifications'], tab, function (t) {
    if (t === 'events') logisticsLoadEvents();
  });
}

function logisticsOvSubtab(tab) {
  _logisticsOvTab = tab;
  _logisticsSubtab('logistics-ov', ['plan', 'map'], tab, function (t) {
    if (t === 'plan') logovInit();
  });
}

// ===== LOGISTICS OVERSIGHT (DVI-1401 P5) — read-only Plan viewer =====
// A self-contained read-only viewer mirroring the Equipment Oversight tab
// (emov*): renders the admin-picked focused plan (`logistics_plan`) with its
// facility location tags. No live data layer this phase — the truck GPS layer
// arrives with the Samsara integration on the Map subtab. Reuses
// /oversight/config + /oversight/data (layers= empty → location tags only).
var _logovCfg = null, _logovPlan = null, _logovData = null;
var _logovZoom = 1, _logovPanX = 0, _logovPanY = 0;
var _logovFull = false, _logovBound = false;

function _logovActive() {
  return _activeTool === 'logistics' && _logisticsTab === 'oversight' && _logisticsOvTab === 'plan';
}

function logovInit() {
  if (!_logovBound) {
    _logovBound = true;
    logovBindStage();
    document.addEventListener('keydown', function (e) {
      if (e.key === 'Escape' && _logovActive() && _logovFull) logovExitFull();
    });
    window.addEventListener('resize', function () {
      if (_logovActive() &&
          !document.getElementById('logov-viewer-wrap').classList.contains('hidden')) {
        _ovSizeStage('logov-stage', 'logov-viewer-wrap', _logovFull);
        logovApplyTransform();
      }
    });
  }
  logovLoad();
}

function logovLoad() {
  var empty = document.getElementById('logov-empty');
  var wrap = document.getElementById('logov-viewer-wrap');
  var admin = document.getElementById('logov-empty-admin');
  if (admin) admin.classList.toggle('hidden', !_isToGenAdmin);
  fetch('/oversight/config').then(function (r) { return r.ok ? r.json() : null; })
    .then(function (d) {
      if (!d) return;
      _logovCfg = d;
      var pid = d.logistics_plan || '';
      _logovPlan = pid ? ((d.plans || []).filter(function (p) { return p.id === pid; })[0] || null) : null;
      logovRenderPicker(pid);
      if (!pid || !_logovPlan) {
        empty.classList.remove('hidden'); wrap.classList.add('hidden');
        return;
      }
      empty.classList.add('hidden');
      var t = document.getElementById('logov-title');
      if (t) t.textContent = _logovPlan.name || 'Oversight';
      logovShowPlan();
    }).catch(function () {});
}

function logovRenderPicker(cur) {
  var picker = document.getElementById('logistics-ov-plan-picker');
  if (!picker) return;
  if (!_isToGenAdmin) { picker.classList.add('hidden'); return; }
  picker.classList.remove('hidden');
  var sel = document.getElementById('logistics-ov-plan-sel');
  var facs = {};
  (_logovCfg.facilities || []).forEach(function (f) { facs[f.id] = f.name; });
  var opts = '<option value="">— None —</option>';
  (_logovCfg.plans || []).forEach(function (p) {
    var fname = facs[p.facility];
    var label = (fname ? fname + ' · ' : '') + (p.name || p.id);
    opts += '<option value="' + oversightEsc(p.id) + '"' + (p.id === cur ? ' selected' : '') + '>' + oversightEsc(label) + '</option>';
  });
  sel.innerHTML = opts;
}

function logovSavePlan() {
  if (!_isToGenAdmin) return;
  var sel = document.getElementById('logistics-ov-plan-sel');
  var plan = sel ? sel.value : '';
  var st = document.getElementById('logistics-ov-plan-status');
  if (st) st.textContent = 'Saving…';
  fetch('/oversight/logistics-plan', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ plan: plan })
  })
    .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); } _logovPlan = null; logovLoad(); })
    .catch(function (err) { if (st) st.textContent = 'Save failed: ' + err.message; });
}

// theme-aware style resolution (auto only — mirrors emovResolveStyle)
function logovResolveStyle(plan) {
  if (!plan || !plan.images) return null;
  var imaged = Object.keys(plan.images);
  if (!imaged.length) return null;
  var isDark = document.documentElement.classList.contains('dark');
  var want = isDark ? _logovCfg.style_default_dark : _logovCfg.style_default_light;
  if (want && plan.images[want]) return want;
  var ordered = (_logovCfg.styles || []).map(function (s) { return s.id; })
    .filter(function (sid) { return plan.images[sid]; });
  return ordered.length ? ordered[0] : imaged[0];
}

function logovShowPlan() {
  var plan = _logovPlan;
  var wrap = document.getElementById('logov-viewer-wrap');
  var noimg = document.getElementById('logov-noimg');
  var stage = document.getElementById('logov-stage');
  wrap.classList.remove('hidden');
  var sid = logovResolveStyle(plan);
  if (!sid) {
    noimg.classList.remove('hidden');
    stage.classList.add('hidden');
    document.getElementById('logov-markers').innerHTML = '';
    return;
  }
  noimg.classList.add('hidden');
  stage.classList.remove('hidden');
  _ovSizeStage('logov-stage', 'logov-viewer-wrap', _logovFull);
  var img = document.getElementById('logov-plan-img');
  var dims = plan.images[sid] || {};
  var wantSrc = '/oversight/plan-image/' + encodeURIComponent(plan.id) +
    '?style=' + encodeURIComponent(sid) + '&v=' + (dims.w || 0) + 'x' + (dims.h || 0);
  if (img.getAttribute('data-src') !== wantSrc) {
    img.setAttribute('data-src', wantSrc);
    img.onload = function () { _ovSizeStage('logov-stage', 'logov-viewer-wrap', _logovFull); logovZoomReset(); };
    img.src = wantSrc;
  } else {
    logovZoomReset();
  }
  // layers= empty → /oversight/data returns location tags only (always emitted).
  fetch('/oversight/data?plan=' + encodeURIComponent(plan.id) + '&layers=')
    .then(function (r) { return r.ok ? r.json() : null; })
    .then(function (d) { if (!d) return; _logovData = d; logovRenderMarkers(); })
    .catch(function () {});
}

// re-resolve Auto style on theme flip while this subtab is active
function logovThemeChanged() {
  if (_logovActive() && _logovPlan) logovShowPlan();
}

function logovRenderMarkers() {
  var host = document.getElementById('logov-markers');
  var placements = oversightSortLocationFirst((_logovData && _logovData.placements) || []);
  host.innerHTML = '';
  placements.forEach(function (pl) {
    if (!oversightIsLocation(pl)) return;  // location tags only this phase
    var el = document.createElement('div');
    el.className = 'logov-marker';
    el.style.position = 'absolute';
    el.style.left = (pl.x * 100) + '%';
    el.style.top = (pl.y * 100) + '%';
    el.style.transform = 'translate(-50%,-50%) scale(' + (1 / _logovZoom) + ')';
    el.style.transformOrigin = 'center center';
    el.appendChild(oversightLocationChip(pl));
    host.appendChild(el);
  });
}

// ---- pan / zoom (read-only, mirrors emov) ----
function logovApplyTransform() {
  var pan = document.getElementById('logov-pan');
  pan.style.transform = 'translate(' + _logovPanX + 'px,' + _logovPanY + 'px) scale(' + _logovZoom + ')';
  var inv = 1 / _logovZoom;
  var ms = document.querySelectorAll('#logov-markers .logov-marker');
  for (var i = 0; i < ms.length; i++) ms[i].style.transform = 'translate(-50%,-50%) scale(' + inv + ')';
}

function logovZoomReset() {
  var stage = document.getElementById('logov-stage');
  var img = document.getElementById('logov-plan-img');
  var iw = img.naturalWidth || img.width, ih = img.naturalHeight || img.height;
  if (!iw || !ih) { _logovZoom = 1; _logovPanX = 0; _logovPanY = 0; logovApplyTransform(); return; }
  var sw = stage.clientWidth, sh = stage.clientHeight;
  _logovZoom = Math.min((sw - 8) / iw, (sh - 8) / ih);
  if (!(_logovZoom > 0)) _logovZoom = Math.min(sw / iw, sh / ih);
  _logovPanX = (sw - iw * _logovZoom) / 2;
  _logovPanY = (sh - ih * _logovZoom) / 2;
  logovApplyTransform();
}

function logovZoomBy(f) {
  var stage = document.getElementById('logov-stage');
  logovZoomAt(stage.clientWidth / 2, stage.clientHeight / 2, f);
}

function logovZoomAt(sx, sy, f) {
  var z2 = Math.max(0.05, Math.min(20, _logovZoom * f));
  var k = z2 / _logovZoom;
  _logovPanX = sx - (sx - _logovPanX) * k;
  _logovPanY = sy - (sy - _logovPanY) * k;
  _logovZoom = z2;
  logovApplyTransform();
}

function logovBindStage() {
  var stage = document.getElementById('logov-stage');
  if (!stage) return;
  var panning = false, lastX = 0, lastY = 0;
  stage.addEventListener('pointerdown', function (e) {
    panning = true; lastX = e.clientX; lastY = e.clientY;
    stage.setPointerCapture(e.pointerId);
  });
  stage.addEventListener('pointermove', function (e) {
    if (!panning) return;
    _logovPanX += (e.clientX - lastX);
    _logovPanY += (e.clientY - lastY);
    lastX = e.clientX; lastY = e.clientY;
    logovApplyTransform();
  });
  function endPan(e) { if (panning) { panning = false; try { stage.releasePointerCapture(e.pointerId); } catch (_) {} } }
  stage.addEventListener('pointerup', endPan);
  stage.addEventListener('pointercancel', endPan);
  stage.addEventListener('wheel', function (e) {
    e.preventDefault();
    var rect = stage.getBoundingClientRect();
    logovZoomAt(e.clientX - rect.left, e.clientY - rect.top, e.deltaY < 0 ? 1.1 : 0.9);
  }, { passive: false });
}

function logovToggleFull() {
  _logovFull = !_logovFull;
  _ovApplyFull(_logovFull, 'logov-viewer-wrap');
  document.getElementById('logov-header').classList.toggle('hidden', _logovFull);
  document.getElementById('logistics-tabbar').classList.toggle('hidden', _logovFull);
  document.getElementById('logistics-ov-plan-picker').classList.toggle('hidden', _logovFull || !_isToGenAdmin);
  document.getElementById('logov-full-btn').title = _logovFull ? 'Exit full view' : 'Full view';
  _ovSetFullIcon('logov-full-icon', _logovFull);
  _ovSizeStage('logov-stage', 'logov-viewer-wrap', _logovFull);
  logovZoomReset();
}

function logovExitFull() {
  if (_logovFull) logovToggleFull();
}

function logisticsCloseModal(id) {
  var m = document.getElementById(id);
  if (m) m.classList.add('hidden');
}

// ---- date-range window rows (shared by driver + vehicle modals) ------------

function logisticsWinAdd(hostId, win) {
  var host = document.getElementById(hostId);
  if (!host) return;
  win = win || {};
  var row = document.createElement('div');
  row.className = 'logistics-win-row flex items-center gap-2';
  row.innerHTML =
    '<input type="date" class="logistics-win-start px-2 py-1 rounded 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" value="' + esc(win.start_date || '') + '">' +
    '<span class="text-xs text-gray-400">to</span>' +
    '<input type="date" class="logistics-win-end px-2 py-1 rounded 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" value="' + esc(win.end_date || '') + '">' +
    '<input type="text" placeholder="note" class="logistics-win-note flex-1 px-2 py-1 rounded 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" value="' + esc(win.note || '') + '">' +
    '<button type="button" onclick="this.closest(\'.logistics-win-row\').remove()" class="togen-control px-2 py-1 text-xs rounded border border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30">&times;</button>';
  host.appendChild(row);
}

function _logisticsCollectWindows(hostId) {
  var out = [];
  document.querySelectorAll('#' + hostId + ' .logistics-win-row').forEach(function (row) {
    var start = row.querySelector('.logistics-win-start').value;
    var end = row.querySelector('.logistics-win-end').value;
    var note = row.querySelector('.logistics-win-note').value;
    if (start) out.push({ start_date: start, end_date: end || start, note: note });
  });
  return out;
}

function _logisticsWinSummary(windows) {
  if (!windows || !windows.length) return '';
  return windows.map(function (w) {
    return w.start_date === w.end_date ? esc(w.start_date)
      : esc(w.start_date) + '–' + esc(w.end_date);
  }).join(', ');
}

// ---- Drivers ---------------------------------------------------------------

function logisticsLoadDrivers() {
  var host = document.getElementById('logistics-drivers-list');
  if (!host) return;
  fetch('/logistics/drivers').then(function (r) { return r.json(); }).then(function (d) {
    _logisticsPeople = d.people || [];
    var drivers = d.drivers || [];
    if (!drivers.length) {
      host.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No drivers yet.</p>';
      return;
    }
    host.innerHTML = drivers.map(function (dv) {
      var meta = [];
      if (dv.email) meta.push(esc(dv.email));
      meta.push('max ' + esc(dv.max_hours_day) + ' h/day · ' + esc(dv.max_hours_week) + ' h/wk');
      var win = _logisticsWinSummary(dv.windows);
      if (win) meta.push('off: ' + win);
      var warn = dv.missing_person ? ' <span class="text-xs text-amber-600 dark:text-amber-400">(personnel record missing)</span>' : '';
      var actions = _logisticsCaps.resources ?
        '<div class="flex items-center gap-1.5 shrink-0">' +
          '<button onclick="logisticsDriverOpen(\'' + esc(dv.person_id) + '\')" class="togen-control 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">Edit</button>' +
          '<button onclick="logisticsDriverDelete(\'' + esc(dv.person_id) + '\',\'' + esc((dv.name || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'")) + '\')" class="togen-control px-2.5 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/30">Remove</button>' +
        '</div>' : '';
      return '<div class="flex items-center justify-between gap-3 py-2.5 border-b border-gray-100 dark:border-gray-800 last:border-0">' +
        '<div class="min-w-0"><div class="font-medium text-gray-800 dark:text-gray-100 truncate">' + esc(dv.name || '—') + warn + '</div>' +
        '<div class="text-xs text-gray-500 dark:text-gray-400 truncate">' + meta.join(' · ') + '</div></div>' + actions + '</div>';
    }).join('');
  }).catch(function () {
    host.innerHTML = '<p class="text-sm text-red-500">Could not load drivers.</p>';
  });
}

function logisticsDriverOpen(personId) {
  _logisticsDriverEditId = personId;
  var editing = !!personId;
  document.getElementById('logistics-driver-modal-title').textContent = editing ? 'Edit Driver' : 'Add Driver';
  var block = document.getElementById('logistics-driver-person-block');
  var winHost = document.getElementById('logistics-driver-windows'); winHost.innerHTML = '';
  var st = document.getElementById('logistics-driver-status'); st.classList.add('hidden'); st.textContent = '';
  if (editing) {
    block.classList.add('hidden');
    fetch('/logistics/drivers').then(function (r) { return r.json(); }).then(function (d) {
      var dv = (d.drivers || []).filter(function (x) { return x.person_id === personId; })[0] || {};
      document.getElementById('logistics-driver-maxday').value = dv.max_hours_day != null ? dv.max_hours_day : '';
      document.getElementById('logistics-driver-maxweek').value = dv.max_hours_week != null ? dv.max_hours_week : '';
      (dv.windows || []).forEach(function (w) { logisticsWinAdd('logistics-driver-windows', w); });
    });
  } else {
    block.classList.remove('hidden');
    // Populate the existing-person select with roster members not already drivers.
    var sel = document.getElementById('logistics-driver-person');
    var avail = _logisticsPeople.filter(function (p) { return !p.is_driver; });
    sel.innerHTML = avail.length
      ? avail.map(function (p) { return '<option value="' + esc(p.id) + '">' + esc(p.name) + (p.email ? ' (' + esc(p.email) + ')' : '') + '</option>'; }).join('')
      : '<option value="">— no available personnel —</option>';
    document.querySelector('input[name="logistics-driver-mode"][value="existing"]').checked = true;
    logisticsDriverModeChanged();
    document.getElementById('logistics-driver-first').value = '';
    document.getElementById('logistics-driver-last').value = '';
    document.getElementById('logistics-driver-email').value = '';
    document.getElementById('logistics-driver-maxday').value = '11';
    document.getElementById('logistics-driver-maxweek').value = '60';
  }
  document.getElementById('logistics-driver-modal').classList.remove('hidden');
}

function logisticsDriverModeChanged() {
  var mode = (document.querySelector('input[name="logistics-driver-mode"]:checked') || {}).value;
  document.getElementById('logistics-driver-existing-block').classList.toggle('hidden', mode !== 'existing');
  document.getElementById('logistics-driver-new-block').classList.toggle('hidden', mode !== 'new');
}

function logisticsDriverSave() {
  var st = document.getElementById('logistics-driver-status');
  var body = {
    max_hours_day: document.getElementById('logistics-driver-maxday').value,
    max_hours_week: document.getElementById('logistics-driver-maxweek').value,
    windows: _logisticsCollectWindows('logistics-driver-windows')
  };
  var url, method = 'POST';
  if (_logisticsDriverEditId) {
    url = '/logistics/drivers/' + encodeURIComponent(_logisticsDriverEditId);
  } else {
    url = '/logistics/drivers';
    var mode = (document.querySelector('input[name="logistics-driver-mode"]:checked') || {}).value;
    if (mode === 'existing') {
      var pid = document.getElementById('logistics-driver-person').value;
      if (!pid) { st.textContent = 'Pick a person.'; st.className = 'text-sm text-red-500'; st.classList.remove('hidden'); return; }
      body.person_id = pid;
    } else {
      var first = document.getElementById('logistics-driver-first').value.trim();
      var last = document.getElementById('logistics-driver-last').value.trim();
      if (!first || !last) { st.textContent = 'Enter first and last name.'; st.className = 'text-sm text-red-500'; st.classList.remove('hidden'); return; }
      body.first_name = first; body.last_name = last;
      body.email = document.getElementById('logistics-driver-email').value.trim();
    }
  }
  st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500 dark:text-gray-400'; st.classList.remove('hidden');
  fetch(url, { method: method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
    .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
    .then(function (res) {
      if (!res.ok) { st.textContent = res.j.error || 'Save failed.'; st.className = 'text-sm text-red-500'; return; }
      logisticsCloseModal('logistics-driver-modal'); logisticsLoadDrivers();
    })
    .catch(function () { st.textContent = 'Save failed.'; st.className = 'text-sm text-red-500'; });
}

function logisticsDriverDelete(personId, name) {
  if (!confirm('Remove ' + (name || 'this driver') + ' from the driver roster? The person stays in Personnel.')) return;
  fetch('/logistics/drivers/' + encodeURIComponent(personId), { method: 'DELETE' })
    .then(function (r) { if (!r.ok) throw new Error(); return r.json(); })
    .then(function () { logisticsLoadDrivers(); })
    .catch(function () { alert('Failed to remove driver.'); });
}

// ---- Trucks / trailers -----------------------------------------------------

function logisticsLoadVehicles(kind) {
  var host = document.getElementById('logistics-' + kind + 's-list');
  if (!host) return;
  fetch('/logistics/' + kind + 's').then(function (r) { return r.json(); }).then(function (d) {
    var rows = d[kind + 's'] || [];
    if (!rows.length) {
      host.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No ' + kind + 's yet.</p>';
      return;
    }
    var featLabels = {};
    ((kind === 'truck' ? (_logisticsMeta.truck_features || []) : (_logisticsMeta.trailer_features || []))).forEach(function (f) { featLabels[f.id] = f.label; });
    host.innerHTML = rows.map(function (v) {
      var numField = kind === 'truck' ? v.truck_number : v.trailer_number;
      var meta = [];
      if (numField) meta.push('#' + esc(numField));
      if (v.asset_number) meta.push('asset ' + esc(v.asset_number));
      if (v.gross_weight != null) meta.push(esc(v.gross_weight) + ' lb');
      meta.push(v.in_service ? 'in service' : 'out of service');
      var feats = (v.features || []).map(function (f) { return esc(featLabels[f] || f); });
      if (feats.length) meta.push(feats.join(', '));
      var win = _logisticsWinSummary(v.windows);
      if (win) meta.push('OOS: ' + win);
      var actions = _logisticsCaps.resources ?
        '<div class="flex items-center gap-1.5 shrink-0">' +
          '<button onclick="logistics' + (kind === 'truck' ? 'Truck' : 'Trailer') + 'Open(' + v.id + ')" class="togen-control 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">Edit</button>' +
          '<button onclick="logisticsVehicleDelete(\'' + kind + '\',' + v.id + ',\'' + esc((v.name || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'")) + '\')" class="togen-control px-2.5 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/30">Delete</button>' +
        '</div>' : '';
      return '<div class="flex items-center justify-between gap-3 py-2.5 border-b border-gray-100 dark:border-gray-800 last:border-0">' +
        '<div class="min-w-0"><div class="font-medium text-gray-800 dark:text-gray-100 truncate">' + esc(v.name) + '</div>' +
        '<div class="text-xs text-gray-500 dark:text-gray-400 truncate">' + meta.join(' · ') + '</div></div>' + actions + '</div>';
    }).join('');
  }).catch(function () {
    host.innerHTML = '<p class="text-sm text-red-500">Could not load ' + kind + 's.</p>';
  });
}

function logisticsTruckOpen(id) { logisticsVehicleOpen('truck', id); }
function logisticsTrailerOpen(id) { logisticsVehicleOpen('trailer', id); }

function logisticsVehicleOpen(kind, id) {
  _logisticsVehicleKind = kind;
  _logisticsVehicleEditId = id;
  var isTruck = kind === 'truck';
  var label = isTruck ? 'Truck' : 'Trailer';
  document.getElementById('logistics-vehicle-modal-title').textContent = (id ? 'Edit ' : 'Add ') + label;
  document.getElementById('logistics-vehicle-number-label').textContent = label + ' #';
  document.getElementById('logistics-vehicle-weight-label').textContent =
    isTruck ? 'Gross weight to haul (lb)' : 'Gross weight to carry (lb)';
  var st = document.getElementById('logistics-vehicle-status'); st.classList.add('hidden'); st.textContent = '';
  document.getElementById('logistics-vehicle-name').value = '';
  document.getElementById('logistics-vehicle-number').value = '';
  document.getElementById('logistics-vehicle-asset').value = '';
  document.getElementById('logistics-vehicle-weight').value = '';
  document.getElementById('logistics-vehicle-inservice').checked = true;
  document.getElementById('logistics-vehicle-windows').innerHTML = '';
  // Render the feature checkboxes for this kind.
  var feats = isTruck ? (_logisticsMeta.truck_features || []) : (_logisticsMeta.trailer_features || []);
  var fhost = document.getElementById('logistics-vehicle-features');
  var fblock = document.getElementById('logistics-vehicle-features-block');
  if (!feats.length) {
    fblock.classList.add('hidden'); fhost.innerHTML = '';
  } else {
    fblock.classList.remove('hidden');
    fhost.innerHTML = feats.map(function (f) {
      return '<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"><input type="checkbox" class="logistics-feature h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal" value="' + esc(f.id) + '"> ' + esc(f.label) + '</label>';
    }).join('');
  }
  var open = document.getElementById('logistics-vehicle-modal');
  if (id) {
    fetch('/logistics/' + kind + 's').then(function (r) { return r.json(); }).then(function (d) {
      var v = (d[kind + 's'] || []).filter(function (x) { return x.id === id; })[0];
      if (!v) return;
      document.getElementById('logistics-vehicle-name').value = v.name || '';
      document.getElementById('logistics-vehicle-number').value = (isTruck ? v.truck_number : v.trailer_number) || '';
      document.getElementById('logistics-vehicle-asset').value = v.asset_number || '';
      document.getElementById('logistics-vehicle-weight').value = v.gross_weight != null ? v.gross_weight : '';
      document.getElementById('logistics-vehicle-inservice').checked = !!v.in_service;
      (v.features || []).forEach(function (fid) {
        var cb = fhost.querySelector('.logistics-feature[value="' + fid + '"]');
        if (cb) cb.checked = true;
      });
      (v.windows || []).forEach(function (w) { logisticsWinAdd('logistics-vehicle-windows', w); });
    });
  }
  open.classList.remove('hidden');
}

function logisticsVehicleSave() {
  var kind = _logisticsVehicleKind;
  var st = document.getElementById('logistics-vehicle-status');
  var name = document.getElementById('logistics-vehicle-name').value.trim();
  if (!name) { st.textContent = 'A name is required.'; st.className = 'text-sm text-red-500'; st.classList.remove('hidden'); return; }
  var features = [];
  document.querySelectorAll('#logistics-vehicle-features .logistics-feature:checked').forEach(function (cb) { features.push(cb.value); });
  var body = {
    name: name,
    asset_number: document.getElementById('logistics-vehicle-asset').value.trim(),
    gross_weight: document.getElementById('logistics-vehicle-weight').value,
    in_service: document.getElementById('logistics-vehicle-inservice').checked,
    features: features,
    windows: _logisticsCollectWindows('logistics-vehicle-windows')
  };
  body[kind === 'truck' ? 'truck_number' : 'trailer_number'] = document.getElementById('logistics-vehicle-number').value.trim();
  var base = '/logistics/' + kind + 's';
  var url = _logisticsVehicleEditId ? base + '/' + _logisticsVehicleEditId : base;
  st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500 dark:text-gray-400'; st.classList.remove('hidden');
  fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
    .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
    .then(function (res) {
      if (!res.ok) { st.textContent = res.j.error || 'Save failed.'; st.className = 'text-sm text-red-500'; return; }
      logisticsCloseModal('logistics-vehicle-modal'); logisticsLoadVehicles(kind);
    })
    .catch(function () { st.textContent = 'Save failed.'; st.className = 'text-sm text-red-500'; });
}

function logisticsVehicleDelete(kind, id, name) {
  if (!confirm('Delete ' + (name || 'this ' + kind) + '?')) return;
  fetch('/logistics/' + kind + 's/' + id, { method: 'DELETE' })
    .then(function (r) { if (!r.ok) throw new Error(); return r.json(); })
    .then(function () { logisticsLoadVehicles(kind); })
    .catch(function () { alert('Failed to delete.'); });
}

// ---- Settings → Events -----------------------------------------------------

function logisticsLoadEvents() {
  var host = document.getElementById('logistics-events-list');
  if (!host) return;
  fetch('/logistics/events').then(function (r) { return r.json(); }).then(function (d) {
    var rows = d.events || [];
    if (!rows.length) {
      host.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No events yet.</p>';
      return;
    }
    host.innerHTML = rows.map(function (e) {
      var actions = _logisticsCaps.settings ?
        '<div class="flex items-center gap-1.5 shrink-0">' +
          '<button onclick="logisticsEventOpen(' + e.id + ')" class="togen-control 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">Edit</button>' +
          '<button onclick="logisticsEventDelete(' + e.id + ',\'' + esc((e.name || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'")) + '\')" class="togen-control px-2.5 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/30">Delete</button>' +
        '</div>' : '';
      return '<div class="flex items-center justify-between gap-3 py-2.5 border-b border-gray-100 dark:border-gray-800 last:border-0">' +
        '<div class="min-w-0"><div class="font-medium text-gray-800 dark:text-gray-100 truncate">' + esc(e.name) + '</div>' +
        '<div class="text-xs text-gray-500 dark:text-gray-400">' + esc(e.typical_minutes) + ' min</div></div>' + actions + '</div>';
    }).join('');
  }).catch(function () {
    host.innerHTML = '<p class="text-sm text-red-500">Could not load events.</p>';
  });
}

function logisticsEventOpen(id) {
  _logisticsEventEditId = id;
  document.getElementById('logistics-event-modal-title').textContent = id ? 'Edit Event' : 'Add Event';
  var st = document.getElementById('logistics-event-status'); st.classList.add('hidden'); st.textContent = '';
  document.getElementById('logistics-event-name').value = '';
  document.getElementById('logistics-event-minutes').value = '';
  if (id) {
    fetch('/logistics/events').then(function (r) { return r.json(); }).then(function (d) {
      var e = (d.events || []).filter(function (x) { return x.id === id; })[0];
      if (!e) return;
      document.getElementById('logistics-event-name').value = e.name || '';
      document.getElementById('logistics-event-minutes').value = e.typical_minutes != null ? e.typical_minutes : '';
    });
  }
  document.getElementById('logistics-event-modal').classList.remove('hidden');
}

function logisticsEventSave() {
  var st = document.getElementById('logistics-event-status');
  var name = document.getElementById('logistics-event-name').value.trim();
  if (!name) { st.textContent = 'A name is required.'; st.className = 'text-sm text-red-500'; st.classList.remove('hidden'); return; }
  var body = { name: name, typical_minutes: document.getElementById('logistics-event-minutes').value };
  var url = _logisticsEventEditId ? '/logistics/events/' + _logisticsEventEditId : '/logistics/events';
  st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500 dark:text-gray-400'; st.classList.remove('hidden');
  fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
    .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
    .then(function (res) {
      if (!res.ok) { st.textContent = res.j.error || 'Save failed.'; st.className = 'text-sm text-red-500'; return; }
      logisticsCloseModal('logistics-event-modal'); logisticsLoadEvents();
    })
    .catch(function () { st.textContent = 'Save failed.'; st.className = 'text-sm text-red-500'; });
}

function logisticsEventDelete(id, name) {
  if (!confirm('Delete event "' + (name || '') + '"?')) return;
  fetch('/logistics/events/' + id, { method: 'DELETE' })
    .then(function (r) { if (!r.ok) throw new Error(); return r.json(); })
    .then(function () { logisticsLoadEvents(); })
    .catch(function () { alert('Failed to delete.'); });
}

// ---- Load Manager (DVI-1398 P2) --------------------------------------------

var _LOGISTICS_STATUS_BADGE = {
  planned: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
  scheduled: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
  completed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
  cancelled: 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
};

// Plant-local wall-clock display. Dates format via the regional date_format
// (built at noon-local so a timezone offset never rolls the calendar day);
// times honor the 12h/24h preference WITHOUT a timezone conversion (the stored
// value is already plant-local, not an instant) — the DVI-1364 pitfall.
function _logisticsFmtDate(d) {
  if (!d || !/^\d{4}-\d{2}-\d{2}$/.test(d)) return esc(d || '');
  var p = d.split('-');
  return _fmtDate(new Date(+p[0], +p[1] - 1, +p[2], 12, 0, 0));
}
function _logisticsFmtTime(t) {
  if (!t || !/^\d{2}:\d{2}$/.test(t)) return '';
  var hh = parseInt(t.slice(0, 2), 10), mm = t.slice(3, 5);
  var pref = (typeof _regPrefs === 'function' ? _regPrefs() : {}) || {};
  if (pref.time_format === '24h') return t;
  var ap = hh < 12 ? 'AM' : 'PM'; var h12 = hh % 12; if (h12 === 0) h12 = 12;
  return h12 + ':' + mm + ' ' + ap;
}
function _logisticsFmtWindow(ld) {
  var s = _logisticsFmtDate(ld.start_date);
  if (ld.start_time) s += ' ' + _logisticsFmtTime(ld.start_time);
  var e = '';
  if (ld.end_date && ld.end_date !== ld.start_date) e = _logisticsFmtDate(ld.end_date);
  if (ld.end_time) e += (e ? ' ' : '') + _logisticsFmtTime(ld.end_time);
  return e ? (s + ' → ' + e) : s;
}
function _logisticsDur(mins) {
  mins = mins || 0;
  var h = Math.floor(mins / 60), m = mins % 60;
  if (h && m) return h + 'h ' + m + 'm';
  if (h) return h + 'h';
  return m + 'm';
}
function _logisticsConflictText(conflicts) {
  return (conflicts || []).map(function (c) {
    if (c.kind === 'load') return 'on ' + esc(c.load_name || ('load #' + c.load_id));
    if (c.kind === 'out_of_service' || c.kind === 'unavailable') {
      var r = c.start_date === c.end_date ? esc(c.start_date) : esc(c.start_date) + '–' + esc(c.end_date);
      return (c.kind === 'unavailable' ? 'off ' : 'OOS ') + r + (c.note ? ' (' + esc(c.note) + ')' : '');
    }
    if (c.kind === 'out_of_service_flag') return 'not in service';
    if (c.kind === 'inactive') return 'inactive';
    return 'unavailable';
  }).join('; ');
}

function logisticsLoadLoads() {
  var host = document.getElementById('logistics-loads-list');
  if (!host) return;
  var showCancelled = document.getElementById('logistics-loads-show-cancelled');
  var q = (showCancelled && showCancelled.checked) ? '' : '?include_cancelled=0';
  fetch('/logistics/loads' + q).then(function (r) { return r.json(); }).then(function (d) {
    var loads = d.loads || [];
    if (!loads.length) {
      host.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No loads yet.</p>';
      return;
    }
    host.innerHTML = loads.map(function (ld) {
      var badge = _LOGISTICS_STATUS_BADGE[ld.status] || _LOGISTICS_STATUS_BADGE.planned;
      var title = esc(ld.name || ('Load #' + ld.id));
      var meta = [];
      var win = _logisticsFmtWindow(ld);
      if (win) meta.push(win);
      if (ld.ltl_load_name) meta.push('LTL: ' + esc(ld.ltl_load_name));
      if (ld.destination) meta.push('→ ' + esc(ld.destination));
      meta.push(_logisticsDur(ld.duration_minutes) + ' est.');
      if (ld.budget_amount != null) meta.push('$' + esc(ld.budget_amount));
      var assigns = (ld.assignments || []).map(function (a) {
        var warn = (a.over_day || a.over_week) ? ' <span class="text-amber-600 dark:text-amber-400" title="over max hours">&#9888;</span>' : '';
        return '<span class="inline-block px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 mr-1">' + esc(a.name) + warn + '</span>';
      }).join('');
      var actions = '';
      if (_logisticsCaps.loads) {
        actions = '<div class="flex items-center gap-1.5 shrink-0">' +
          '<button onclick="logisticsLoadOpen(' + ld.id + ')" class="togen-control 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">Edit</button>';
        if (ld.status !== 'cancelled') {
          actions += '<button onclick="logisticsLoadSetStatus(' + ld.id + ',\'cancelled\')" class="togen-control px-2.5 py-1 text-xs font-medium rounded-lg border border-amber-300 dark:border-amber-700 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/30">Cancel</button>';
        }
        actions += '<button onclick="logisticsLoadDelete(' + ld.id + ',\'' + esc((ld.name || ('Load #' + ld.id)).replace(/\\/g, '\\\\').replace(/'/g, "\\'")) + '\')" class="togen-control px-2.5 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/30">Delete</button>' +
          '</div>';
      }
      return '<div class="flex items-start justify-between gap-3 py-3 border-b border-gray-100 dark:border-gray-800 last:border-0">' +
        '<div class="min-w-0">' +
          '<div class="flex items-center gap-2 mb-0.5"><span class="font-medium text-gray-800 dark:text-gray-100 truncate">' + title + '</span>' +
          '<span class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ' + badge + '">' + esc(ld.status) + '</span></div>' +
          '<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">' + meta.join(' · ') + '</div>' +
          (assigns ? '<div class="text-xs">' + assigns + '</div>' : '<div class="text-xs text-gray-400 italic">no resources assigned</div>') +
        '</div>' + actions + '</div>';
    }).join('');
  }).catch(function () {
    host.innerHTML = '<p class="text-sm text-red-500">Could not load loads.</p>';
  });
}

function logisticsLoadEnsureCatalog(cb) {
  fetch('/logistics/events').then(function (r) { return r.json(); }).then(function (d) {
    _logisticsEventCatalog = d.events || [];
    var sel = document.getElementById('logistics-load-event-pick');
    if (sel) {
      sel.innerHTML = _logisticsEventCatalog.length
        ? _logisticsEventCatalog.map(function (e) { return '<option value="' + e.id + '">' + esc(e.name) + ' (' + esc(e.typical_minutes) + 'm)</option>'; }).join('')
        : '<option value="">— no events configured —</option>';
    }
    if (cb) cb();
  }).catch(function () { if (cb) cb(); });
}

function logisticsLoadOpen(id) {
  _logisticsLoadEditId = id;
  _logisticsLoadEvents = [];
  _logisticsLoadSel = { driver: {}, truck: {}, trailer: {} };
  _logisticsAvail = { driver: [], truck: [], trailer: [] };
  document.getElementById('logistics-load-modal-title').textContent = id ? 'Edit Load' : 'Add Load';
  var st = document.getElementById('logistics-load-status-msg'); st.classList.add('hidden'); st.textContent = '';
  // reset fields
  ['name', 'destination', 'miles', 'budget', 'cost-notes', 'start-date', 'start-time', 'end-date', 'end-time'].forEach(function (f) {
    var el = document.getElementById('logistics-load-' + f); if (el) el.value = '';
  });
  document.getElementById('logistics-load-travel').value = '0';
  document.getElementById('logistics-load-status').value = 'planned';
  document.getElementById('logistics-load-show-unavail').checked = false;
  var nameEl = document.getElementById('logistics-load-name');
  if (nameEl) nameEl.dataset.ltlAuto = '';
  if (!id) logisticsLoadEnsureLtl(null, null);
  ['drivers', 'trucks', 'trailers'].forEach(function (k) {
    document.getElementById('logistics-load-avail-' + k).innerHTML = '';
  });
  document.getElementById('logistics-load-avail-hint').classList.remove('hidden');
  logisticsLoadRenderEvents();
  logisticsLoadRecalcDuration();
  logisticsLoadEnsureCatalog(function () {
    if (id) {
      fetch('/logistics/loads/' + id).then(function (r) { return r.json(); }).then(function (d) {
        var ld = d.load; if (!ld) return;
        logisticsLoadEnsureLtl(ld.ltl_load_id, ld.ltl_load_name);
        document.getElementById('logistics-load-name').value = ld.name || '';
        document.getElementById('logistics-load-status').value = ld.status || 'planned';
        document.getElementById('logistics-load-start-date').value = ld.start_date || '';
        document.getElementById('logistics-load-start-time').value = ld.start_time || '';
        document.getElementById('logistics-load-end-date').value = ld.end_date || '';
        document.getElementById('logistics-load-end-time').value = ld.end_time || '';
        document.getElementById('logistics-load-destination').value = ld.destination || '';
        document.getElementById('logistics-load-miles').value = ld.miles != null ? ld.miles : '';
        document.getElementById('logistics-load-budget').value = ld.budget_amount != null ? ld.budget_amount : '';
        document.getElementById('logistics-load-cost-notes').value = ld.cost_notes || '';
        document.getElementById('logistics-load-travel').value = ld.travel_minutes != null ? ld.travel_minutes : 0;
        _logisticsLoadEvents = (ld.events || []).map(function (e) { return { event_type_id: e.event_type_id, name: e.name, minutes: e.minutes }; });
        (ld.assignments || []).forEach(function (a) {
          if (_logisticsLoadSel[a.resource_type]) _logisticsLoadSel[a.resource_type][String(a.resource_id)] = true;
        });
        logisticsLoadRenderEvents();
        logisticsLoadRecalcDuration();
        logisticsLoadFetchAvailability();
      });
    }
  });
  document.getElementById('logistics-load-modal').classList.remove('hidden');
}

function logisticsLoadWindowChanged() {
  logisticsLoadFetchAvailability();
}

function logisticsLoadAddEvent() {
  var sel = document.getElementById('logistics-load-event-pick');
  var eid = sel ? parseInt(sel.value, 10) : NaN;
  var et = _logisticsEventCatalog.filter(function (e) { return e.id === eid; })[0];
  if (!et) return;
  _logisticsLoadEvents.push({ event_type_id: et.id, name: et.name, minutes: et.typical_minutes });
  logisticsLoadRenderEvents();
  logisticsLoadRecalcDuration();
}

function logisticsLoadRenderEvents() {
  var host = document.getElementById('logistics-load-events');
  if (!host) return;
  if (!_logisticsLoadEvents.length) {
    host.innerHTML = '<p class="text-xs text-gray-400 dark:text-gray-500 italic">No events yet — add steps to compute the load duration.</p>';
    return;
  }
  host.innerHTML = _logisticsLoadEvents.map(function (e, i) {
    return '<div class="flex items-center gap-2">' +
      '<span class="text-xs text-gray-400 w-5 text-right">' + (i + 1) + '.</span>' +
      '<span class="flex-1 text-sm text-gray-800 dark:text-gray-100 truncate">' + esc(e.name) + '</span>' +
      '<input type="number" min="0" step="1" value="' + esc(e.minutes) + '" onchange="logisticsLoadEventMinutes(' + i + ', this.value)" class="w-20 px-2 py-1 rounded 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"><span class="text-xs text-gray-400">min</span>' +
      '<button type="button" onclick="logisticsLoadMoveEvent(' + i + ',-1)" class="togen-control px-1.5 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700">&uarr;</button>' +
      '<button type="button" onclick="logisticsLoadMoveEvent(' + i + ',1)" class="togen-control px-1.5 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700">&darr;</button>' +
      '<button type="button" onclick="logisticsLoadRemoveEvent(' + i + ')" class="togen-control px-1.5 py-1 text-xs rounded border border-red-300 dark:border-red-700 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30">&times;</button>' +
      '</div>';
  }).join('');
}

function logisticsLoadEventMinutes(i, val) {
  if (_logisticsLoadEvents[i]) {
    var n = parseInt(val, 10); _logisticsLoadEvents[i].minutes = isNaN(n) || n < 0 ? 0 : n;
    logisticsLoadRecalcDuration();
  }
}
function logisticsLoadMoveEvent(i, dir) {
  var j = i + dir;
  if (j < 0 || j >= _logisticsLoadEvents.length) return;
  var tmp = _logisticsLoadEvents[i]; _logisticsLoadEvents[i] = _logisticsLoadEvents[j]; _logisticsLoadEvents[j] = tmp;
  logisticsLoadRenderEvents();
}
function logisticsLoadRemoveEvent(i) {
  _logisticsLoadEvents.splice(i, 1);
  logisticsLoadRenderEvents();
  logisticsLoadRecalcDuration();
}

function logisticsLoadDurationMinutes() {
  var total = 0;
  _logisticsLoadEvents.forEach(function (e) { total += (parseInt(e.minutes, 10) || 0); });
  total += (parseInt(document.getElementById('logistics-load-travel').value, 10) || 0);
  return total;
}

function logisticsLoadRecalcDuration() {
  var mins = logisticsLoadDurationMinutes();
  var el = document.getElementById('logistics-load-duration');
  if (el) el.textContent = 'Duration ≈ ' + _logisticsDur(mins);
  logisticsLoadRenderHoursWarn();
}

function logisticsLoadFetchAvailability() {
  var sd = document.getElementById('logistics-load-start-date').value;
  var hint = document.getElementById('logistics-load-avail-hint');
  if (!sd) {
    hint.classList.remove('hidden');
    ['drivers', 'trucks', 'trailers'].forEach(function (k) { document.getElementById('logistics-load-avail-' + k).innerHTML = ''; });
    return;
  }
  hint.classList.add('hidden');
  var params = new URLSearchParams({
    start_date: sd,
    start_time: document.getElementById('logistics-load-start-time').value || '',
    end_date: document.getElementById('logistics-load-end-date').value || '',
    end_time: document.getElementById('logistics-load-end-time').value || ''
  });
  if (_logisticsLoadEditId) params.set('exclude', _logisticsLoadEditId);
  var seq = ++_logisticsAvailSeq;
  fetch('/logistics/availability?' + params.toString()).then(function (r) { return r.json(); }).then(function (d) {
    if (seq !== _logisticsAvailSeq) return;  // a newer request superseded this one
    _logisticsAvail = { driver: d.drivers || [], truck: d.trucks || [], trailer: d.trailers || [] };
    logisticsLoadRenderAvailability();
  }).catch(function () {});
}

function logisticsLoadRenderAvailability() {
  var showUnavail = document.getElementById('logistics-load-show-unavail').checked;
  [['driver', 'drivers'], ['truck', 'trucks'], ['trailer', 'trailers']].forEach(function (pair) {
    var type = pair[0], host = document.getElementById('logistics-load-avail-' + pair[1]);
    if (!host) return;
    var rows = _logisticsAvail[type] || [];
    var html = rows.map(function (res) {
      var rid = String(res.resource_id);
      var selected = !!_logisticsLoadSel[type][rid];
      // Hide unavailable resources unless the toggle is on OR it's already selected.
      if (!res.available && !showUnavail && !selected) return '';
      var reason = res.available ? '' : ' <span class="text-[10px] text-red-500 dark:text-red-400">' + _logisticsConflictText(res.conflicts) + '</span>';
      var cls = res.available ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500';
      var dis = (!res.available && !selected) ? ' disabled' : '';
      return '<label class="flex items-start gap-2 text-sm ' + cls + '">' +
        '<input type="checkbox"' + (selected ? ' checked' : '') + dis + ' onchange="logisticsLoadToggleResource(\'' + type + '\',\'' + esc(rid) + '\',this.checked)" class="mt-0.5 h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal">' +
        '<span class="min-w-0"><span class="truncate">' + esc(res.name) + '</span>' + reason + '</span></label>';
    }).join('');
    host.innerHTML = html || '<p class="text-xs text-gray-400 dark:text-gray-500 italic">' + (rows.length ? 'none available' : 'none') + '</p>';
  });
  logisticsLoadRenderHoursWarn();
}

function logisticsLoadToggleResource(type, id, checked) {
  if (checked) _logisticsLoadSel[type][String(id)] = true;
  else delete _logisticsLoadSel[type][String(id)];
  logisticsLoadRenderHoursWarn();
}

function logisticsLoadRenderHoursWarn() {
  var box = document.getElementById('logistics-load-hours-warn');
  if (!box) return;
  var dur = logisticsLoadDurationMinutes();
  var warns = [];
  (_logisticsAvail.driver || []).forEach(function (dv) {
    var rid = String(dv.resource_id);
    if (!_logisticsLoadSel.driver[rid]) return;
    var day = (dv.assigned_day_minutes || 0) + dur;
    var week = (dv.assigned_week_minutes || 0) + dur;
    var maxDay = dv.max_hours_day != null ? dv.max_hours_day * 60 : null;
    var maxWeek = dv.max_hours_week != null ? dv.max_hours_week * 60 : null;
    if (maxDay != null && day > maxDay) warns.push(esc(dv.name) + ': ' + _logisticsDur(day) + '/day over ' + dv.max_hours_day + 'h');
    else if (maxWeek != null && week > maxWeek) warns.push(esc(dv.name) + ': ' + _logisticsDur(week) + '/week over ' + dv.max_hours_week + 'h');
  });
  if (!warns.length) { box.classList.add('hidden'); box.innerHTML = ''; return; }
  box.classList.remove('hidden');
  box.innerHTML = '&#9888; Driver hours (planning aid only): ' + warns.join(' · ');
}

function logisticsLoadCollectAssignments() {
  var out = [];
  ['driver', 'truck', 'trailer'].forEach(function (type) {
    Object.keys(_logisticsLoadSel[type]).forEach(function (rid) {
      out.push({ resource_type: type, resource_id: rid });
    });
  });
  return out;
}

function logisticsLoadSave() {
  var st = document.getElementById('logistics-load-status-msg');
  var sd = document.getElementById('logistics-load-start-date').value;
  if (!sd) { st.textContent = 'A start date is required.'; st.className = 'text-sm text-red-500'; st.classList.remove('hidden'); return; }
  var ltlPick = document.getElementById('logistics-load-ltl-pick');
  var body = {
    name: document.getElementById('logistics-load-name').value.trim(),
    ltl_load_id: (ltlPick && ltlPick.value) ? ltlPick.value : null,
    status: document.getElementById('logistics-load-status').value,
    start_date: sd,
    start_time: document.getElementById('logistics-load-start-time').value || '',
    end_date: document.getElementById('logistics-load-end-date').value || '',
    end_time: document.getElementById('logistics-load-end-time').value || '',
    destination: document.getElementById('logistics-load-destination').value.trim(),
    miles: document.getElementById('logistics-load-miles').value,
    budget_amount: document.getElementById('logistics-load-budget').value,
    cost_notes: document.getElementById('logistics-load-cost-notes').value.trim(),
    travel_minutes: document.getElementById('logistics-load-travel').value,
    events: _logisticsLoadEvents.map(function (e) { return { event_type_id: e.event_type_id, name: e.name, minutes: e.minutes }; }),
    assignments: logisticsLoadCollectAssignments()
  };
  var url = _logisticsLoadEditId ? '/logistics/loads/' + _logisticsLoadEditId : '/logistics/loads';
  st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500 dark:text-gray-400'; st.classList.remove('hidden');
  fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
    .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
    .then(function (res) {
      if (res.status === 409 && res.j.conflicts) {
        // A resource became unavailable since the picker loaded — name each and refresh.
        var names = res.j.conflicts.map(function (b) { return esc(b.name) + ' (' + _logisticsConflictText(b.conflicts) + ')'; }).join('; ');
        st.innerHTML = 'Could not save: ' + names + '. Availability refreshed.'; st.className = 'text-sm text-red-500';
        logisticsLoadFetchAvailability();
        return;
      }
      if (!res.ok) { st.textContent = res.j.error || 'Save failed.'; st.className = 'text-sm text-red-500'; return; }
      logisticsCloseModal('logistics-load-modal'); logisticsLoadLoads();
    })
    .catch(function () { st.textContent = 'Save failed.'; st.className = 'text-sm text-red-500'; });
}

function logisticsLoadSetStatus(id, status) {
  var verb = status === 'cancelled' ? 'Cancel' : 'Update';
  if (status === 'cancelled' && !confirm('Cancel this load? Its resources are freed for other loads.')) return;
  fetch('/logistics/loads/' + id + '/status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: status }) })
    .then(function (r) { if (!r.ok) throw new Error(); return r.json(); })
    .then(function () { logisticsLoadLoads(); })
    .catch(function () { alert(verb + ' failed.'); });
}

function logisticsLoadDelete(id, name) {
  if (!confirm('Delete "' + (name || 'this load') + '"? This removes it from the loads list.')) return;
  fetch('/logistics/loads/' + id, { method: 'DELETE' })
    .then(function (r) { if (!r.ok) throw new Error(); return r.json(); })
    .then(function () { logisticsLoadLoads(); })
    .catch(function () { alert('Failed to delete load.'); });
}

// ---- LTL built loads (DVI-1399 P3) -----------------------------------------

function logisticsLoadLtl() {
  var host = document.getElementById('logistics-ltl-list');
  if (!host) return;
  fetch('/logistics/ltl-loads').then(function (r) { return r.json(); }).then(function (d) {
    _logisticsLtlCatalog = d.ltl_loads || [];
    if (!_logisticsLtlCatalog.length) {
      host.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No built loads yet.</p>';
      return;
    }
    host.innerHTML = _logisticsLtlCatalog.map(function (b) {
      var actions = _logisticsCaps.loads ?
        '<div class="flex items-center gap-1.5 shrink-0">' +
          '<button onclick="logisticsLtlOpen(' + b.id + ')" class="togen-control 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">Edit</button>' +
          '<button onclick="logisticsLtlDelete(' + b.id + ',\'' + esc((b.name || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'")) + '\')" class="togen-control px-2.5 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/30">Delete</button>' +
        '</div>' : '';
      var notes = b.notes ? '<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 whitespace-pre-wrap">' + esc(b.notes) + '</div>' : '';
      return '<div class="flex items-start justify-between gap-3 py-3 border-b border-gray-100 dark:border-gray-800 last:border-0">' +
        '<div class="min-w-0"><div class="font-medium text-gray-800 dark:text-gray-100 truncate">' + esc(b.name || '—') + '</div>' + notes + '</div>' +
        actions + '</div>';
    }).join('');
  }).catch(function () {
    host.innerHTML = '<p class="text-sm text-red-500">Could not load built loads.</p>';
  });
}

function logisticsLtlOpen(id) {
  _logisticsLtlEditId = id;
  document.getElementById('logistics-ltl-modal-title').textContent = id ? 'Edit Built Load' : 'Load Builder';
  var st = document.getElementById('logistics-ltl-status'); st.classList.add('hidden'); st.textContent = '';
  var nm = document.getElementById('logistics-ltl-name');
  var nt = document.getElementById('logistics-ltl-notes');
  nm.value = ''; nt.value = '';
  if (id) {
    var b = _logisticsLtlCatalog.filter(function (x) { return x.id === id; })[0] || {};
    nm.value = b.name || '';
    nt.value = b.notes || '';
  }
  document.getElementById('logistics-ltl-modal').classList.remove('hidden');
  nm.focus();
}

function logisticsLtlSave() {
  var st = document.getElementById('logistics-ltl-status');
  var name = document.getElementById('logistics-ltl-name').value.trim();
  if (!name) { st.textContent = 'A load name is required.'; st.className = 'text-sm text-red-500'; st.classList.remove('hidden'); return; }
  var body = { name: name, notes: document.getElementById('logistics-ltl-notes').value.trim() };
  var url = _logisticsLtlEditId ? '/logistics/ltl-loads/' + _logisticsLtlEditId : '/logistics/ltl-loads';
  st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500 dark:text-gray-400'; st.classList.remove('hidden');
  fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
    .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
    .then(function (res) {
      if (!res.ok) { st.textContent = res.j.error || 'Save failed.'; st.className = 'text-sm text-red-500'; return; }
      logisticsCloseModal('logistics-ltl-modal'); logisticsLoadLtl();
    })
    .catch(function () { st.textContent = 'Save failed.'; st.className = 'text-sm text-red-500'; });
}

function logisticsLtlDelete(id, name) {
  if (!confirm('Delete built load "' + (name || 'this load') + '"? Loads already using it keep their reference.')) return;
  fetch('/logistics/ltl-loads/' + id, { method: 'DELETE' })
    .then(function (r) { if (!r.ok) throw new Error(); return r.json(); })
    .then(function () { logisticsLoadLtl(); })
    .catch(function () { alert('Failed to delete built load.'); });
}

// Populate the Add Load "Built load (LTL)" picker; keep a referenced-but-deleted
// built load selectable so editing such a load doesn't drop its origin.
function logisticsLoadEnsureLtl(selectedId, selectedName, cb) {
  fetch('/logistics/ltl-loads').then(function (r) { return r.json(); }).then(function (d) {
    _logisticsLtlCatalog = d.ltl_loads || [];
    var sel = document.getElementById('logistics-load-ltl-pick');
    if (sel) {
      var opts = '<option value="">— ad hoc (no built load) —</option>';
      var seen = false;
      _logisticsLtlCatalog.forEach(function (b) {
        if (String(b.id) === String(selectedId)) seen = true;
        opts += '<option value="' + b.id + '">' + esc(b.name) + '</option>';
      });
      if (selectedId && !seen) {
        opts += '<option value="' + esc(selectedId) + '">' + esc(selectedName || ('Built load #' + selectedId)) + ' (removed)</option>';
      }
      sel.innerHTML = opts;
      sel.value = selectedId ? String(selectedId) : '';
    }
    if (cb) cb();
  }).catch(function () { if (cb) cb(); });
}

// When a built load is picked, prefill the load name (only when the name field
// is empty or still matches the previously-picked built load's name).
function logisticsLoadLtlPicked() {
  var sel = document.getElementById('logistics-load-ltl-pick');
  var nameEl = document.getElementById('logistics-load-name');
  if (!sel || !nameEl) return;
  var b = _logisticsLtlCatalog.filter(function (x) { return String(x.id) === sel.value; })[0];
  if (b && (!nameEl.value.trim() || nameEl.dataset.ltlAuto === '1')) {
    nameEl.value = b.name;
    nameEl.dataset.ltlAuto = '1';
  } else if (!sel.value && nameEl.dataset.ltlAuto === '1') {
    nameEl.value = '';
    nameEl.dataset.ltlAuto = '';
  }
}

// ---- Calendar tab (DVI-1400 P4) --------------------------------------------
// A read-only month/week view of scheduled loads over the existing
// /logistics/loads feed (no new route). Loads carry a plant-local wall-date
// window (start_date..end_date); a load is placed on every calendar day it
// spans. Dates/times render via the DVI-1364 regional helpers. Clicking a load
// clicks through to it in Load Manager (opens the edit modal for editors, else
// lands the viewer on the Load Manager list).

var _logisticsCal = { view: 'month', anchor: null, loads: [], showCancelled: false };

var _LOGISTICS_CAL_CHIP = {
  planned: 'bg-blue-50 text-blue-700 border-blue-300 dark:bg-blue-900/30 dark:text-blue-200 dark:border-blue-700',
  scheduled: 'bg-teal-50 text-teal-700 border-teal-300 dark:bg-teal-900/30 dark:text-teal-200 dark:border-teal-700',
  completed: 'bg-green-50 text-green-700 border-green-300 dark:bg-green-900/30 dark:text-green-200 dark:border-green-700',
  cancelled: 'bg-gray-100 text-gray-500 border-gray-300 line-through dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700'
};
var _LOGISTICS_CAL_WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

// Local YYYY-MM-DD for a Date (never UTC — a plant wall-date must not roll).
function _logisticsCalIso(dt) {
  return dt.getFullYear() + '-' + String(dt.getMonth() + 1).padStart(2, '0') +
         '-' + String(dt.getDate()).padStart(2, '0');
}
// 'YYYY-MM-DD' -> Date at noon local (noon avoids DST edge day-rolls).
function _logisticsCalParse(s) {
  var p = String(s || '').split('-');
  return new Date(+p[0], +p[1] - 1, +p[2], 12, 0, 0);
}

function logisticsCalInit() {
  if (!_logisticsCal.anchor) {
    var t = new Date();
    _logisticsCal.anchor = new Date(t.getFullYear(), t.getMonth(), t.getDate(), 12, 0, 0);
  }
  var host = document.getElementById('logistics-cal-body');
  if (host && !_logisticsCal.loads.length) host.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading…</p>';
  fetch('/logistics/loads?include_cancelled=1').then(function (r) { return r.json(); }).then(function (d) {
    _logisticsCal.loads = d.loads || [];
    logisticsCalRender();
  }).catch(function () {
    if (host) host.innerHTML = '<p class="text-sm text-red-500">Could not load the schedule.</p>';
  });
}

function logisticsCalSetView(v) {
  _logisticsCal.view = (v === 'week') ? 'week' : 'month';
  ['month', 'week'].forEach(function (k) {
    var b = document.getElementById('logistics-cal-view-' + k);
    if (!b) return;
    if (k === _logisticsCal.view) { b.classList.add('bg-white/25'); b.classList.remove('hover:bg-white/15'); }
    else { b.classList.remove('bg-white/25'); b.classList.add('hover:bg-white/15'); }
  });
  logisticsCalRender();
}

function logisticsCalToggleCancelled() {
  var c = document.getElementById('logistics-cal-show-cancelled');
  _logisticsCal.showCancelled = !!(c && c.checked);
  logisticsCalRender();
}

function logisticsCalNav(delta) {
  var a = _logisticsCal.anchor;
  if (_logisticsCal.view === 'week') {
    _logisticsCal.anchor = new Date(a.getFullYear(), a.getMonth(), a.getDate() + delta * 7, 12, 0, 0);
  } else {
    _logisticsCal.anchor = new Date(a.getFullYear(), a.getMonth() + delta, 1, 12, 0, 0);
  }
  logisticsCalRender();
}

function logisticsCalToday() {
  var t = new Date();
  _logisticsCal.anchor = new Date(t.getFullYear(), t.getMonth(), t.getDate(), 12, 0, 0);
  logisticsCalRender();
}

// Loads shown on the calendar: those with a start date, excluding cancelled
// unless the toggle is on.
function _logisticsCalVisible() {
  return (_logisticsCal.loads || []).filter(function (ld) {
    if (!ld.start_date) return false;
    if (ld.status === 'cancelled' && !_logisticsCal.showCancelled) return false;
    return true;
  });
}

// Loads spanning a given YYYY-MM-DD, sorted by start time then name.
function _logisticsCalOnDay(iso) {
  return _logisticsCalVisible().filter(function (ld) {
    var end = ld.end_date || ld.start_date;
    return ld.start_date <= iso && iso <= end;
  }).sort(function (a, b) {
    var at = a.start_time || '', bt = b.start_time || '';
    if (at !== bt) return at < bt ? -1 : 1;
    return (a.name || '') < (b.name || '') ? -1 : 1;
  });
}

function logisticsCalRender() {
  var host = document.getElementById('logistics-cal-body');
  if (!host) return;
  // Count loads with no date so the operator knows the calendar isn't lying.
  var undated = (_logisticsCal.loads || []).filter(function (ld) {
    if (!ld.start_date) return ld.status !== 'cancelled' || _logisticsCal.showCancelled;
    return false;
  }).length;
  var un = document.getElementById('logistics-cal-unscheduled');
  if (un) {
    if (undated) { un.textContent = undated + ' load' + (undated === 1 ? '' : 's') + ' without a scheduled date are not shown.'; un.classList.remove('hidden'); }
    else un.classList.add('hidden');
  }
  if (_logisticsCal.view === 'week') logisticsCalRenderWeek(host);
  else logisticsCalRenderMonth(host);
}

// A clickable load chip. `cont` marks a day that is a continuation of a
// multi-day load (not its start day).
function _logisticsCalChip(ld, cont) {
  var cls = _LOGISTICS_CAL_CHIP[ld.status] || _LOGISTICS_CAL_CHIP.planned;
  var time = (!cont && ld.start_time) ? _logisticsFmtTime(ld.start_time) + ' ' : '';
  var label = (cont ? '↳ ' : '') + time + (ld.name || ('Load #' + ld.id));
  var tip = (ld.name || ('Load #' + ld.id)) + ' — ' + ld.status +
            (ld.destination ? ' → ' + ld.destination : '');
  return '<button type="button" onclick="logisticsCalOpenLoad(' + ld.id + ')" title="' + esc(tip) +
    '" class="togen-control block w-full text-left truncate px-1.5 py-0.5 mb-0.5 rounded border text-[11px] leading-tight ' + cls + '">' +
    esc(label) + '</button>';
}

function logisticsCalRenderMonth(host) {
  var a = _logisticsCal.anchor, y = a.getFullYear(), m = a.getMonth();
  var title = document.getElementById('logistics-cal-title');
  if (title) title.textContent = a.toLocaleDateString([], { month: 'long', year: 'numeric' });
  var todayIso = _logisticsCalIso(new Date());
  var first = new Date(y, m, 1);
  var offset = first.getDay();
  var daysInMonth = new Date(y, m + 1, 0).getDate();
  var cells = Math.ceil((offset + daysInMonth) / 7) * 7;
  var head = '<div class="grid grid-cols-7 gap-px mb-px">' +
    _LOGISTICS_CAL_WEEKDAYS.map(function (w) {
      return '<div class="text-center text-[11px] font-semibold text-gray-500 dark:text-gray-400 py-1">' + w + '</div>';
    }).join('') + '</div>';
  var body = '<div class="grid grid-cols-7 gap-px bg-gray-200 dark:bg-gray-700 rounded-lg overflow-hidden">';
  for (var i = 0; i < cells; i++) {
    var cd = new Date(y, m, 1 - offset + i, 12, 0, 0);
    var iso = _logisticsCalIso(cd);
    var inMonth = cd.getMonth() === m;
    var isToday = iso === todayIso;
    var dayLoads = _logisticsCalOnDay(iso);
    var cellCls = inMonth ? 'bg-white dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-800/60';
    var numCls = inMonth ? 'text-gray-700 dark:text-gray-200' : 'text-gray-300 dark:text-gray-600';
    if (isToday) numCls = 'text-white bg-togen-teal rounded-full w-5 h-5 inline-flex items-center justify-center';
    var chips = '';
    var shown = dayLoads.slice(0, 3);
    shown.forEach(function (ld) { chips += _logisticsCalChip(ld, ld.start_date !== iso); });
    if (dayLoads.length > 3) {
      chips += '<button type="button" onclick="logisticsCalMore(\'' + iso + '\')" class="togen-control block w-full text-left px-1.5 text-[11px] text-togen-teal hover:underline">+' + (dayLoads.length - 3) + ' more</button>';
    }
    body += '<div class="' + cellCls + ' min-h-[92px] p-1 align-top">' +
      '<div class="text-[11px] font-medium mb-0.5 ' + numCls + '">' + cd.getDate() + '</div>' +
      chips + '</div>';
  }
  body += '</div>';
  host.innerHTML = head + body;
}

// Jump to the week containing a given day (used by "+N more").
function logisticsCalMore(iso) {
  _logisticsCal.anchor = _logisticsCalParse(iso);
  logisticsCalSetView('week');
}

function logisticsCalRenderWeek(host) {
  var a = _logisticsCal.anchor;
  var weekStart = new Date(a.getFullYear(), a.getMonth(), a.getDate() - a.getDay(), 12, 0, 0);
  var weekEnd = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + 6, 12, 0, 0);
  var title = document.getElementById('logistics-cal-title');
  if (title) title.textContent = _logisticsFmtDate(_logisticsCalIso(weekStart)) + ' – ' + _logisticsFmtDate(_logisticsCalIso(weekEnd));
  var todayIso = _logisticsCalIso(new Date());
  var out = '<div class="grid grid-cols-1 sm:grid-cols-7 gap-px bg-gray-200 dark:bg-gray-700 rounded-lg overflow-hidden">';
  for (var i = 0; i < 7; i++) {
    var cd = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + i, 12, 0, 0);
    var iso = _logisticsCalIso(cd);
    var isToday = iso === todayIso;
    var dayLoads = _logisticsCalOnDay(iso);
    var hdrCls = isToday ? 'text-togen-teal' : 'text-gray-600 dark:text-gray-300';
    var items = dayLoads.length ? dayLoads.map(function (ld) {
      return _logisticsCalWeekItem(ld, ld.start_date !== iso);
    }).join('') : '<p class="text-[11px] text-gray-400 italic px-1 py-1">—</p>';
    out += '<div class="bg-white dark:bg-gray-900 min-h-[120px] p-1.5">' +
      '<div class="text-[11px] font-semibold mb-1 ' + hdrCls + '">' + _LOGISTICS_CAL_WEEKDAYS[i] + ' ' +
        esc(_logisticsFmtDate(iso)) + '</div>' + items + '</div>';
  }
  out += '</div>';
  host.innerHTML = out;
}

function _logisticsCalWeekItem(ld, cont) {
  var cls = _LOGISTICS_CAL_CHIP[ld.status] || _LOGISTICS_CAL_CHIP.planned;
  var time = (!cont && ld.start_time) ? _logisticsFmtTime(ld.start_time) : (cont ? '↳' : '');
  var sub = [];
  if (ld.destination) sub.push('→ ' + ld.destination);
  var names = (ld.assignments || []).map(function (a) { return a.name; });
  if (names.length) sub.push(names.join(', '));
  return '<button type="button" onclick="logisticsCalOpenLoad(' + ld.id + ')" ' +
    'class="togen-control block w-full text-left px-1.5 py-1 mb-1 rounded border text-[11px] leading-tight ' + cls + '">' +
    (time ? '<span class="font-semibold">' + esc(time) + '</span> ' : '') +
    '<span class="font-medium">' + esc(ld.name || ('Load #' + ld.id)) + '</span>' +
    (sub.length ? '<span class="block text-[10px] opacity-80 truncate">' + esc(sub.join(' · ')) + '</span>' : '') +
    '</button>';
}

// Click-through: land in Load Manager. Editors get the edit modal; a
// view-only user lands on the Load Manager list (the modal is edit-gated).
function logisticsCalOpenLoad(id) {
  logisticsSwitchTab('loads');
  if (_logisticsCaps && _logisticsCaps.loads) logisticsLoadOpen(id);
}

var _qcInitialized = false;
var _qcMeta = null;              // /qc/meta payload
var _qcEditingBatchId = null;    // null while capturing a new ticket
var _qcEditingBatch = null;      // loaded batch when editing
var _qcMixEditingId = null;
var _qcMixIncluded = {};          // DVI-1440 P2: checked material ids — leaf + aggregate (mid -> true)
var _qcMixAggCapture = {};        // DVI-1440 review #2: aggregate id -> true when captured directly (a capture line, not summed)
var _qcMatEditingId = null;

// Consumption units offered per capture unit (mirrors qc_store._UNIT_FACTORS).
var QC_UNIT_PAIRS = { lb: ['lb', 'ton'], oz: ['oz', 'lb', 'gal'], gal: ['gal'] };

function qcToday() {
  var d = new Date();
  return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') +
         '-' + String(d.getDate()).padStart(2, '0');
}

// Trim float noise for display (up to 2 decimals, thousands separators).
function qcFmt(v) {
  if (v === null || v === undefined || v === '') return '—';
  var n = Number(v);
  if (!isFinite(n)) return '—';
  return n.toLocaleString(undefined, { maximumFractionDigits: 2 });
}

function qcInit() {
  document.getElementById('qc-batch-date').value = qcToday();
  document.getElementById('qc-report-date').value = qcToday();
  document.getElementById('qc-range-end').value = qcToday();
  document.getElementById('qc-range-start').value = qcDaysAgo(29);
  qcRefreshMeta(function () {
    // A Daily-Production-only operator lacks the base `qc` (Batch Tickets) leaf;
    // land them straight on the Daily Production tab (DVI-1410 P3).
    var caps = (_qcMeta && _qcMeta.caps) || {};
    if (caps.batches) {
      qcLoadBatches();
    } else if ((_qcMeta.daily_production || []).length) {
      qcSwitchTab('daily');
    }
  });
}

// YYYY-MM-DD n days before today (local), for the range default.
function qcDaysAgo(n) {
  var d = new Date();
  d.setDate(d.getDate() - n);
  return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') +
         '-' + String(d.getDate()).padStart(2, '0');
}

function qcRefreshMeta(cb) {
  fetch('/qc/meta').then(function (r) { return r.json(); }).then(function (data) {
    _qcMeta = data;
    ['qc-segment', 'qc-report-segment', 'qc-m-segment'].forEach(function (id) {
      var sel = document.getElementById(id);
      if (!sel) return;
      var prev = sel.value;
      sel.innerHTML = (data.segments || []).map(function (s) {
        return '<option value="' + esc(s.id) + '">' + esc(s.label) + '</option>';
      }).join('');
      if (prev) sel.value = prev;
    });
    // Range report plant select adds an "All plants" option (per-plant split).
    var rseg = document.getElementById('qc-range-segment');
    if (rseg) {
      var rprev = rseg.value;
      rseg.innerHTML = '<option value="">All plants</option>' +
        (data.segments || []).map(function (s) {
          return '<option value="' + esc(s.id) + '">' + esc(s.label) + '</option>';
        }).join('');
      if (rprev) rseg.value = rprev;
    }
    // Reveal each management tab per the caller's granular QC capabilities
    // (DVI-1373 P2). The route decorators remain the real boundary.
    var caps = data.caps || {};
    var capTab = {reports: 'reports', mixtures: 'mixtures', 'import': 'import', settings: 'settings'};
    Object.keys(capTab).forEach(function(cap){
      var btn = document.getElementById('qc-tab-btn-' + capTab[cap]);
      if (btn && caps[cap]) btn.classList.remove('hidden');
    });
    // The Scan Tickets (OCR import) tab rides the same qc.import capability as
    // the xlsx importer (DVI-1374).
    var scanBtn = document.getElementById('qc-tab-btn-scan');
    if (scanBtn && caps['import']) scanBtn.classList.remove('hidden');
    // The File Browser tab (SharePoint PDFs → Scan Tickets) rides qc.filebrowser
    // (DVI-1383 P3).
    var fbBtn = document.getElementById('qc-tab-btn-filebrowser');
    if (fbBtn && caps['filebrowser']) fbBtn.classList.remove('hidden');
    // Daily Production tab (DVI-1410 P3): shown when the user may capture at
    // least one enabled+authorized segment. A Daily-Production-only operator
    // lacks base `qc`, so hide Batch Tickets entirely for them.
    var dailyBtn = document.getElementById('qc-tab-btn-daily');
    if (dailyBtn && (data.daily_production || []).length) dailyBtn.classList.remove('hidden');
    var batchBtn = document.getElementById('qc-tab-btn-batches');
    if (batchBtn) batchBtn.classList.toggle('hidden', !caps['batches']);
    if (cb) cb();
  }).catch(function () {
    document.getElementById('qc-batch-list').innerHTML =
      '<p class="text-sm text-red-500">Could not load Batch Production configuration.</p>';
  });
}

var _QC_TABS = ['batches', 'daily', 'reports', 'mixtures', 'import', 'scan', 'filebrowser', 'settings'];
function qcSwitchTab(tab) {
  _QC_TABS.forEach(function (t) {
    var pane = document.getElementById('qc-tab-' + t);
    var btn = document.getElementById('qc-tab-btn-' + t);
    if (!pane || !btn) return;
    if (t === tab) {
      pane.classList.remove('hidden');
      btn.classList.add('border-togen-teal', 'text-togen-teal');
      btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
    } else {
      pane.classList.add('hidden');
      btn.classList.remove('border-togen-teal', 'text-togen-teal');
      btn.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400');
    }
  });
  if (tab === 'daily') qcDailyInit();
  if (tab === 'reports') { qcLoadReport(); qcLoadRangeReport(); }
  if (tab === 'mixtures') qcLoadMixtures();
  if (tab === 'import') qcImportInit();
  if (tab === 'scan') qcScanInit();
  if (tab === 'filebrowser') qcFbInit();
  if (tab === 'settings') {
    qcSettingsSubtab('materials');
    qcLoadMaterials(); qcSegmentsLoad(); qcReportOptionsLoad(); qcOcrBackendLoad();
    qcFbPathsLoad(); qcDpConfigLoad(); qcBatchFieldsLoad(); qcCustomFieldsLoad(); qcTplLoad();
    qcScanSettingsLoad();
  }
}

// DVI-1424: Settings sub-tabs (Materials / Reporting / OCR / Files / Daily
// Production) — pure reorganization of the Settings pane's cards. Visibility
// rides the same qc.settings leaf that reveals the Settings tab; no new access
// surface. Each pane's data is eager-loaded when the Settings tab opens, so
// switching sub-tabs just shows/hides.
var _QC_SETTINGS_SUBTABS = ['materials', 'segments', 'reporting', 'ocr', 'files', 'daily', 'batchtickets'];
function qcSettingsSubtab(name) {
  _QC_SETTINGS_SUBTABS.forEach(function (t) {
    var pane = document.getElementById('qc-set-pane-' + t);
    var btn = document.getElementById('qc-set-btn-' + t);
    if (!pane || !btn) return;
    if (t === name) {
      pane.classList.remove('hidden');
      btn.classList.add('border-togen-teal', 'text-togen-teal');
      btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
    } else {
      pane.classList.add('hidden');
      btn.classList.remove('border-togen-teal', 'text-togen-teal');
      btn.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400');
    }
  });
}

// ---- Production Segments (Settings → Segments, DVI-1452 P1) -----------------

var _qcSegments = [];

function _qcSegStatus(msg, kind) {
  var st = document.getElementById('qc-seg-status');
  if (!st) return;
  st.textContent = msg;
  st.className = 'text-sm ' + (kind === 'err' ? 'text-red-500'
    : kind === 'ok' ? 'text-green-600 dark:text-green-400'
    : 'text-gray-500 dark:text-gray-400');
  st.classList.remove('hidden');
}

function qcSegmentsLoad() {
  var host = document.getElementById('qc-seg-list');
  if (!host) return;
  fetch('/qc/segments').then(function (r) { return r.json(); })
    .then(function (d) { _qcSegments = d.segments || []; qcSegmentsRender(); })
    .catch(function () {
      host.innerHTML = '<p class="text-sm text-red-500">Could not load segments.</p>';
    });
}

function qcSegmentsRender() {
  var host = document.getElementById('qc-seg-list');
  if (!host) return;
  if (!_qcSegments.length) {
    host.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No segments yet — add one.</p>';
    return;
  }
  host.innerHTML = _qcSegments.map(function (s, i) {
    var badge = !s.active
      ? '<span class="text-[11px] px-2 py-0.5 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300">Inactive</span>'
      : (s.in_use
        ? '<span class="text-[11px] px-2 py-0.5 rounded-full bg-togen-teal/15 text-togen-teal">In use</span>'
        : '');
    var del = s.in_use
      ? '<button disabled title="Referenced by batches or mixtures — deactivate instead" class="px-2 py-1 text-xs rounded-lg text-gray-300 dark:text-gray-600 cursor-not-allowed">Delete</button>'
      : '<button onclick="qcSegmentDelete(\'' + esc(s.id) + '\')" class="px-2 py-1 text-xs rounded-lg text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30">Delete</button>';
    return '<div class="flex items-center gap-3 flex-wrap rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-2.5" data-qc-seg="' + esc(s.id) + '">' +
      '<div class="flex flex-col mr-1">' +
        '<button onclick="qcSegmentMove(\'' + esc(s.id) + '\',-1)"' + (i === 0 ? ' disabled' : '') + ' class="text-xs leading-none text-gray-400 hover:text-togen-teal disabled:opacity-30" title="Move up">▲</button>' +
        '<button onclick="qcSegmentMove(\'' + esc(s.id) + '\',1)"' + (i === _qcSegments.length - 1 ? ' disabled' : '') + ' class="text-xs leading-none text-gray-400 hover:text-togen-teal disabled:opacity-30" title="Move down">▼</button>' +
      '</div>' +
      '<div class="flex-1 min-w-0">' +
        '<div class="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">' + esc(s.label) + '</div>' +
        '<div class="text-[11px] text-gray-400 dark:text-gray-500 font-mono">' + esc(s.id) + '</div>' +
      '</div>' +
      badge +
      '<label class="inline-flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">' +
        '<input type="checkbox" ' + (s.active ? 'checked' : '') + ' onchange="qcSegmentToggleActive(\'' + esc(s.id) + '\', this.checked)"> Active</label>' +
      '<button onclick="qcSegmentRename(\'' + esc(s.id) + '\')" class="px-2 py-1 text-xs rounded-lg text-togen-teal hover:bg-togen-teal/10">Rename</button>' +
      del +
    '</div>';
  }).join('');
}

function _qcSegFind(id) {
  for (var i = 0; i < _qcSegments.length; i++) if (_qcSegments[i].id === id) return _qcSegments[i];
  return null;
}

function qcSegmentAdd() {
  var label = window.prompt('New Production Segment name (e.g. Precast Yard):', '');
  if (label == null) return;
  label = label.trim();
  if (!label) return;
  _qcSegStatus('Adding…');
  fetch('/qc/segments', { method: 'POST', headers: { 'Content-Type': 'application/json' },
                          body: JSON.stringify({ label: label }) })
    .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
    .then(function (res) {
      if (!res.ok) { _qcSegStatus(res.d.error || 'Add failed.', 'err'); return; }
      _qcSegments = res.d.segments || []; qcSegmentsRender();
      _qcSegStatus('Added.', 'ok'); qcRefreshMeta();
    }).catch(function () { _qcSegStatus('Add failed.', 'err'); });
}

function qcSegmentRename(id) {
  var seg = _qcSegFind(id); if (!seg) return;
  var label = window.prompt('Rename segment:', seg.label);
  if (label == null) return;
  label = label.trim();
  if (!label || label === seg.label) return;
  _qcSegmentPost(id, { label: label }, 'Renamed.');
}

function qcSegmentToggleActive(id, active) {
  _qcSegmentPost(id, { active: !!active }, active ? 'Activated.' : 'Deactivated.');
}

function qcSegmentMove(id, dir) {
  var idx = -1;
  for (var i = 0; i < _qcSegments.length; i++) if (_qcSegments[i].id === id) { idx = i; break; }
  var j = idx + dir;
  if (idx < 0 || j < 0 || j >= _qcSegments.length) return;
  var order = _qcSegments.map(function (s) { return s.id; });
  var tmp = order[idx]; order[idx] = order[j]; order[j] = tmp;
  _qcSegStatus('Saving order…');
  fetch('/qc/segments/reorder', { method: 'POST', headers: { 'Content-Type': 'application/json' },
                                  body: JSON.stringify({ order: order }) })
    .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
    .then(function (res) {
      if (!res.ok) { _qcSegStatus(res.d.error || 'Save failed.', 'err'); return; }
      _qcSegments = res.d.segments || []; qcSegmentsRender();
      _qcSegStatus('Order saved.', 'ok'); qcRefreshMeta();
    }).catch(function () { _qcSegStatus('Save failed.', 'err'); });
}

function _qcSegmentPost(id, body, okMsg) {
  _qcSegStatus('Saving…');
  fetch('/qc/segments/' + encodeURIComponent(id), {
    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) { _qcSegStatus(res.d.error || 'Save failed.', 'err'); qcSegmentsLoad(); return; }
      _qcSegments = res.d.segments || []; qcSegmentsRender();
      _qcSegStatus(okMsg || 'Saved.', 'ok'); qcRefreshMeta();
    }).catch(function () { _qcSegStatus('Save failed.', 'err'); });
}

function qcSegmentDelete(id) {
  var seg = _qcSegFind(id); if (!seg) return;
  if (!window.confirm('Delete segment "' + seg.label + '"? This is only allowed when no batches or mixtures reference it.')) return;
  _qcSegStatus('Deleting…');
  fetch('/qc/segments/' + 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) { _qcSegStatus(res.d.error || 'Delete failed.', 'err'); return; }
      _qcSegments = res.d.segments || []; qcSegmentsRender();
      _qcSegStatus('Deleted.', 'ok'); qcRefreshMeta();
    }).catch(function () { _qcSegStatus('Delete failed.', 'err'); });
}

// ---- QC Reporting options (Settings → Reporting, DVI-1424) ------------------

var _qcReportOptions = null;

function qcReportOptionsLoad() {
  var host = document.getElementById('qc-ro-materials');
  if (!host) return;
  fetch('/qc/report-options').then(function (r) { return r.json(); })
    .then(function (d) {
      _qcReportOptions = d.options || {exclude_material_ids: [], show_zero_capture: false};
      var zc = document.getElementById('qc-ro-show-zero');
      if (zc) zc.checked = !!_qcReportOptions.show_zero_capture;
      qcReportOptionsRender(d.materials || [], _qcReportOptions);
    })
    .catch(function () {
      host.innerHTML = '<p class="text-sm text-red-500">Could not load report options.</p>';
    });
}

function qcReportOptionsRender(materials, options) {
  var host = document.getElementById('qc-ro-materials');
  if (!host) return;
  var excluded = {};
  (options.exclude_material_ids || []).forEach(function (id) { excluded[String(id)] = true; });
  if (!materials.length) {
    host.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No materials defined yet.</p>';
    return;
  }
  host.innerHTML = materials.map(function (m) {
    var checked = excluded[String(m.id)] ? '' : ' checked';
    var agg = m.is_aggregate
      ? ' <span class="inline-flex px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-togen-teal/10 text-togen-teal align-middle">Aggregate</span>'
      : '';
    var inactive = m.active ? '' : ' <span class="text-xs text-gray-400">· inactive</span>';
    var modeSel = m.is_aggregate
      ? '<select data-ro-mode="' + m.id + '" class="ml-auto 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-xs focus:outline-none focus:ring-2 focus:ring-togen-teal">' +
          '<option value="both"' + (m.aggregate_report_mode === 'both' ? ' selected' : '') + '>Show total and children</option>' +
          '<option value="aggregate_only"' + (m.aggregate_report_mode === 'aggregate_only' ? ' selected' : '') + '>Show total only</option>' +
        '</select>'
      : '';
    return '<div class="flex items-center gap-2 py-1.5 border-b border-gray-100 dark:border-gray-800">' +
      '<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">' +
        '<input type="checkbox" data-ro-include="' + m.id + '"' + checked + ' class="rounded border-gray-300 text-togen-teal focus:ring-togen-teal">' +
        esc(m.name) + agg + inactive +
      '</label>' + modeSel +
    '</div>';
  }).join('');
}

function _qcRoStatus(msg, isErr) {
  var st = document.getElementById('qc-ro-status');
  if (!st) return;
  st.textContent = msg;
  st.className = 'text-sm ' + (isErr ? 'text-red-500' : 'text-green-600 dark:text-green-400');
  st.classList.remove('hidden');
}

function qcReportOptionsSave() {
  var exclude = [];
  document.querySelectorAll('#qc-ro-materials input[data-ro-include]').forEach(function (cb) {
    if (!cb.checked) exclude.push(parseInt(cb.getAttribute('data-ro-include'), 10));
  });
  var modes = {};
  document.querySelectorAll('#qc-ro-materials select[data-ro-mode]').forEach(function (sel) {
    modes[sel.getAttribute('data-ro-mode')] = sel.value;
  });
  var showZero = !!(document.getElementById('qc-ro-show-zero') || {}).checked;
  _qcRoStatus('Saving…', false);
  var st = document.getElementById('qc-ro-status');
  if (st) st.className = 'text-sm text-gray-500 dark:text-gray-400';
  fetch('/qc/report-options', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({exclude_material_ids: exclude, show_zero_capture: showZero, aggregate_modes: modes})
  }).then(function (r) { return r.json().then(function (d) { return {ok: r.ok, d: d}; }); })
    .then(function (res) {
      if (!res.ok) { _qcRoStatus((res.d && res.d.error) || 'Save failed.', true); return; }
      _qcRoStatus('Saved.', false);
      qcReportOptionsLoad();
    })
    .catch(function () { _qcRoStatus('Save failed.', true); });
}

// ---- QC Batch Ticket field tunables (Settings → Batch Tickets, DVI-1441) ----
// Per-segment control of which optional Batch Ticket fields the capture forms
// show. Default all-enabled; a hidden field is display-only (the server still
// accepts it). Field keys mirror qc_store.BATCH_TICKET_TUNABLE_FIELDS.

var _QC_TUNABLE_FIELD_KEYS = ['batch_number', 'rock_moisture', 'sand_moisture', 'initials', 'notes'];
var _qcBatchFields = null;

function qcBatchFieldsLoad() {
  var host = document.getElementById('qc-btf-list');
  if (!host) return;
  fetch('/qc/batch-ticket-fields').then(function (r) { return r.json(); })
    .then(function (d) {
      _qcBatchFields = d.fields || {};
      qcBatchFieldsRender(d.segments || [], d.tunable_fields || []);
    })
    .catch(function () {
      host.innerHTML = '<p class="text-sm text-red-500">Could not load Batch Ticket field settings.</p>';
    });
}

function qcBatchFieldsRender(segments, fields) {
  var host = document.getElementById('qc-btf-list');
  if (!host) return;
  if (!segments.length || !fields.length) {
    host.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No segments.</p>';
    return;
  }
  host.innerHTML = segments.map(function (s) {
    var seg = _qcBatchFields[s.id] || {};
    var boxes = fields.map(function (f) {
      var on = seg[f.key] !== false;   // default enabled
      return '<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">' +
        '<input type="checkbox" data-btf-seg="' + esc(s.id) + '" data-btf-field="' + esc(f.key) + '"' + (on ? ' checked' : '') +
        ' class="rounded border-gray-300 text-togen-teal focus:ring-togen-teal">' + esc(f.label) + '</label>';
    }).join('');
    return '<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 mb-3">' + esc(s.label) + '</h3>' +
      '<div class="flex flex-wrap gap-x-6 gap-y-2">' + boxes + '</div></div>';
  }).join('');
}

function qcBatchFieldsSave() {
  var config = {};
  document.querySelectorAll('#qc-btf-list input[data-btf-seg]').forEach(function (cb) {
    var seg = cb.getAttribute('data-btf-seg');
    var f = cb.getAttribute('data-btf-field');
    (config[seg] = config[seg] || {})[f] = cb.checked;
  });
  var st = document.getElementById('qc-btf-status');
  st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500 dark:text-gray-400'; st.classList.remove('hidden');
  fetch('/qc/batch-ticket-fields', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({fields: config})
  }).then(function (r) { return r.json().then(function (d) { return {ok: r.ok, d: d}; }); })
    .then(function (res) {
      if (!res.ok) { st.textContent = (res.d && res.d.error) || 'Save failed.'; st.className = 'text-sm text-red-500'; return; }
      _qcBatchFields = res.d.fields || {};
      st.textContent = 'Saved.'; st.className = 'text-sm text-green-600 dark:text-green-400';
      qcRefreshMeta();   // capture forms pick up the new visibility immediately
    })
    .catch(function () { st.textContent = 'Save failed.'; st.className = 'text-sm text-red-500'; });
}

// ---- Scan Tickets settings (DVI-1460 P3) -----------------------------------

function qcScanSettingsLoad() {
  var sizeEl = document.getElementById('qc-scan-thumb-size');
  if (!sizeEl) return;
  fetch('/qc/scan-settings').then(function (r) { return r.json(); })
    .then(function (d) {
      var s = d.settings || {};
      var ts = s.thumb_size || 150;
      sizeEl.value = ts;
      var val = document.getElementById('qc-scan-thumb-size-val');
      if (val) val.textContent = ts + 'px';
      var ocr = document.getElementById('qc-scan-auto-ocr');
      if (ocr) ocr.checked = s.auto_ocr !== false;
    })
    .catch(function () { /* leave defaults */ });
}

function qcScanSettingsSave() {
  var sizeEl = document.getElementById('qc-scan-thumb-size');
  var ocrEl = document.getElementById('qc-scan-auto-ocr');
  var st = document.getElementById('qc-scan-settings-status');
  var cfg = {
    thumb_size: sizeEl ? parseInt(sizeEl.value, 10) : 150,
    auto_ocr: ocrEl ? !!ocrEl.checked : true
  };
  if (st) { st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500 dark:text-gray-400'; st.classList.remove('hidden'); }
  fetch('/qc/scan-settings', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({settings: cfg})
  }).then(function (r) { return r.json().then(function (d) { return {ok: r.ok, d: d}; }); })
    .then(function (res) {
      if (!res.ok) { if (st) { st.textContent = (res.d && res.d.error) || 'Save failed.'; st.className = 'text-sm text-red-500'; } return; }
      if (st) { st.textContent = 'Saved.'; st.className = 'text-sm text-green-600 dark:text-green-400'; }
      qcRefreshMeta();   // Scan Tickets picks up the new thumb size / auto-OCR immediately
    })
    .catch(function () { if (st) { st.textContent = 'Save failed.'; st.className = 'text-sm text-red-500'; } });
}

// Show/hide the tunable fields on a capture form ('qc-b' modal / 'qc-s' scan)
// for the given segment. A field defaults visible when unspecified so an
// install with no config is unchanged. Reads _qcMeta.batch_ticket_fields.
function _qcApplyFieldVisibility(prefix, segment) {
  var cfg = (_qcMeta && _qcMeta.batch_ticket_fields) || {};
  var seg = cfg[segment] || {};
  _QC_TUNABLE_FIELD_KEYS.forEach(function (f) {
    var el = document.getElementById(prefix + '-field-' + f);
    if (!el) return;
    el.classList.toggle('hidden', seg[f] === false);
  });
}

// ---- Custom Batch Ticket fields (DVI-1452 P2) -------------------------------
// Shared between the Batch Ticket modal ('qc-b-custom') and the Scan Tickets
// form ('qc-s-custom'). Renders the active custom fields scoped to the given
// segment (a field with no segments applies to all), prefilled from `values`
// ({field_id: stored_value}). Inputs carry data-qc-cf="<id>" so collect can
// gather them id-keyed (survives a label rename).

// Fields the given segment shows (field.segments empty => all segments).
function _qcCustomFieldsFor(segment) {
  var defs = (_qcMeta && _qcMeta.custom_fields) || [];
  return defs.filter(function (f) {
    return !f.segments || !f.segments.length || f.segments.indexOf(segment) >= 0;
  });
}

function _qcRenderCustomFields(hostId, segment, values) {
  var host = document.getElementById(hostId);
  if (!host) return;
  values = values || {};
  var fields = _qcCustomFieldsFor(segment);
  if (!fields.length) { host.innerHTML = ''; return; }
  host.innerHTML = fields.map(function (f) {
    var v = values.hasOwnProperty(f.id) ? values[f.id] : '';
    var req = f.required ? ' <span class="text-amber-500" title="Recommended — the ticket still saves if left blank">•</span>' : '';
    var lbl = '<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">' + esc(f.label) + req + '</label>';
    var inpCls = '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';
    var attr = 'data-qc-cf="' + esc(f.id) + '"';
    var inp;
    if (f.type === 'checkbox') {
      return '<div class="flex items-end pb-1"><label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">' +
        '<input type="checkbox" ' + attr + (v && v !== '0' ? ' checked' : '') +
        ' class="rounded border-gray-300 text-togen-teal focus:ring-togen-teal">' + esc(f.label) + req + '</label></div>';
    } else if (f.type === 'dropdown') {
      var opts = '<option value="">—</option>' + (f.choices || []).map(function (c) {
        return '<option value="' + esc(c) + '"' + (String(v) === c ? ' selected' : '') + '>' + esc(c) + '</option>';
      }).join('');
      inp = '<select ' + attr + ' class="' + inpCls + '">' + opts + '</select>';
    } else {
      var t = f.type === 'number' ? 'number' : (f.type === 'date' ? 'date' : 'text');
      var step = f.type === 'number' ? ' step="any"' : '';
      inp = '<input type="' + t + '"' + step + ' ' + attr + ' value="' + esc(String(v)) + '" class="' + inpCls + '">';
    }
    return '<div>' + lbl + inp + '</div>';
  }).join('');
}

function _qcCollectCustomValues(hostId) {
  var host = document.getElementById(hostId);
  var out = {};
  if (!host) return out;
  host.querySelectorAll('[data-qc-cf]').forEach(function (el) {
    var id = el.getAttribute('data-qc-cf');
    out[id] = el.type === 'checkbox' ? (el.checked ? '1' : '') : el.value;
  });
  return out;
}

// Compact "label: value" summary for a captured batch's custom values (batch
// list row / detail). Resolves labels from the live registry, skips blanks.
function _qcCustomValueSummary(values) {
  if (!values) return '';
  var defs = (_qcMeta && _qcMeta.custom_fields) || [];
  var byId = {};
  defs.forEach(function (f) { byId[f.id] = f; });
  var parts = [];
  Object.keys(values).forEach(function (id) {
    var v = values[id];
    if (v === '' || v == null) return;
    var f = byId[id];
    var label = f ? f.label : id;
    var disp = (f && f.type === 'checkbox') ? '✓' : String(v);
    parts.push(esc(label) + ': ' + esc(disp));
  });
  return parts.join(' · ');
}

// ---- Custom Fields manager (Settings → Batch Tickets, DVI-1452 P2) ----------

var _qcCustomFields = [];
var _qcCfSegments = [];
var _qcCfEditingId = null;

function qcCustomFieldsLoad() {
  var host = document.getElementById('qc-cf-list');
  if (!host) return;
  fetch('/qc/custom-fields').then(function (r) { return r.json(); })
    .then(function (d) {
      _qcCustomFields = d.fields || [];
      _qcCfSegments = d.segments || [];
      qcCustomFieldsRender();
    })
    .catch(function () {
      host.innerHTML = '<p class="text-sm text-red-500">Could not load custom fields.</p>';
    });
}

function _qcCfTypeLabel(t) {
  return { text: 'Text', number: 'Number', date: 'Date',
           dropdown: 'Dropdown', checkbox: 'Checkbox' }[t] || t;
}

function qcCustomFieldsRender() {
  var host = document.getElementById('qc-cf-list');
  if (!host) return;
  if (!_qcCustomFields.length) {
    host.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No custom fields yet. Use <strong>+ Add Field</strong> to create one.</p>';
    return;
  }
  var segLabel = {};
  _qcCfSegments.forEach(function (s) { segLabel[s.id] = s.label; });
  host.innerHTML = _qcCustomFields.map(function (f) {
    var scope = (f.segments && f.segments.length)
      ? f.segments.map(function (s) { return esc(segLabel[s] || s); }).join(', ')
      : 'All segments';
    var badges = '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">' + esc(_qcCfTypeLabel(f.type)) + '</span>';
    if (f.required) badges += ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">Required</span>';
    if (!f.active) badges += ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400">Inactive</span>';
    var delBtn = f.in_use
      ? '<span class="text-xs text-gray-400" title="Has captured values — deactivate instead">In use</span>'
      : '<button onclick="qcCustomFieldDelete(\'' + esc(f.id) + '\')" class="togen-control px-2 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/30">Delete</button>';
    return '<div class="rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between gap-3">' +
      '<div class="min-w-0">' +
        '<div class="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">' + esc(f.label) + '</div>' +
        '<div class="mt-1 flex flex-wrap items-center gap-2">' + badges + '<span class="text-xs text-gray-500 dark:text-gray-400">' + scope + '</span></div>' +
      '</div>' +
      '<div class="flex items-center gap-2 whitespace-nowrap">' +
        '<button onclick="qcCustomFieldEdit(\'' + esc(f.id) + '\')" class="togen-control px-2 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">Edit</button>' +
        delBtn +
      '</div></div>';
  }).join('');
}

function qcCustomFieldTypeChanged() {
  var t = document.getElementById('qc-cf-type').value;
  document.getElementById('qc-cf-choices-wrap').classList.toggle('hidden', t !== 'dropdown');
}

function qcCustomFieldEdit(fieldId) {
  _qcCfEditingId = fieldId;
  var f = fieldId ? _qcCustomFields.find(function (x) { return x.id === fieldId; }) : null;
  document.getElementById('qc-cf-modal-title').textContent = f ? 'Edit Custom Field' : 'New Custom Field';
  document.getElementById('qc-cf-label').value = f ? f.label : '';
  document.getElementById('qc-cf-type').value = f ? f.type : 'text';
  document.getElementById('qc-cf-required').checked = f ? !!f.required : false;
  document.getElementById('qc-cf-active').checked = f ? !!f.active : true;
  document.getElementById('qc-cf-choices').value = f && f.choices ? f.choices.join('\n') : '';
  var segs = (f && f.segments) || [];
  document.getElementById('qc-cf-segments').innerHTML = _qcCfSegments.map(function (s) {
    return '<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">' +
      '<input type="checkbox" data-qc-cf-seg="' + esc(s.id) + '"' + (segs.indexOf(s.id) >= 0 ? ' checked' : '') +
      ' class="rounded border-gray-300 text-togen-teal focus:ring-togen-teal">' + esc(s.label) + '</label>';
  }).join('') || '<span class="text-xs text-gray-400">No segments defined.</span>';
  qcCustomFieldTypeChanged();
  var st = document.getElementById('qc-cf-modal-status'); st.classList.add('hidden');
  document.getElementById('qc-cf-modal').classList.remove('hidden');
}

function qcCustomFieldModalClose() {
  document.getElementById('qc-cf-modal').classList.add('hidden');
  _qcCfEditingId = null;
}

function qcCustomFieldSave() {
  var choices = (document.getElementById('qc-cf-choices').value || '')
    .split('\n').map(function (s) { return s.trim(); }).filter(Boolean);
  var segs = [];
  document.querySelectorAll('#qc-cf-segments input[data-qc-cf-seg]').forEach(function (cb) {
    if (cb.checked) segs.push(cb.getAttribute('data-qc-cf-seg'));
  });
  var body = {
    label: document.getElementById('qc-cf-label').value,
    type: document.getElementById('qc-cf-type').value,
    required: document.getElementById('qc-cf-required').checked,
    active: document.getElementById('qc-cf-active').checked,
    choices: choices, segments: segs
  };
  var st = document.getElementById('qc-cf-modal-status');
  st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500 dark:text-gray-400'; st.classList.remove('hidden');
  var url = _qcCfEditingId ? '/qc/custom-fields/' + encodeURIComponent(_qcCfEditingId) : '/qc/custom-fields';
  fetch(url, { 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) { st.textContent = (res.d && res.d.error) || 'Save failed.'; st.className = 'text-sm text-red-500'; return; }
      _qcCustomFields = res.d.fields || [];
      qcCustomFieldsRender();
      qcCustomFieldModalClose();
      qcRefreshMeta();   // capture forms pick up the new field immediately
    })
    .catch(function () { st.textContent = 'Save failed (network).'; st.className = 'text-sm text-red-500'; });
}

function qcCustomFieldDelete(fieldId) {
  if (!confirm('Delete this custom field? (Only allowed when it has no captured values.)')) return;
  fetch('/qc/custom-fields/' + encodeURIComponent(fieldId), { method: 'DELETE' })
    .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
    .then(function (res) {
      var st = document.getElementById('qc-cf-status');
      if (!res.ok) { st.textContent = (res.d && res.d.error) || 'Delete failed.'; st.className = 'text-sm text-red-500'; st.classList.remove('hidden'); return; }
      _qcCustomFields = res.d.fields || [];
      qcCustomFieldsRender();
      qcRefreshMeta();
    })
    .catch(function () {});
}

// ---- QC File Browser: SharePoint path config (Settings, DVI-1383 P2) --------

function qcFbPathsLoad() {
  var host = document.getElementById('qc-fb-paths-list');
  if (!host) return;
  fetch('/qc/filebrowser/paths').then(function (r) { return r.json(); })
    .then(function (d) { qcFbPathsRender(d.segments || [], d.paths || {}); })
    .catch(function () {
      host.innerHTML = '<p class="text-sm text-red-500">Could not load SharePoint paths.</p>';
    });
}

function qcFbPathsRender(segments, paths) {
  var host = document.getElementById('qc-fb-paths-list');
  host.innerHTML = segments.map(function (s) {
    var ent = paths[s.id] || {};
    return '<div class="rounded-xl border border-gray-200 dark:border-gray-700 p-4" data-fb-seg="' + esc(s.id) + '">' +
      '<div class="flex items-center justify-between mb-2">' +
        '<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">' + esc(s.label) + '</h3>' +
        (ent.share_url ? '<span class="text-xs text-green-700 dark:text-green-400">Configured</span>' : '<span class="text-xs text-gray-400">Not set</span>') +
      '</div>' +
      '<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">SharePoint folder link</label>' +
      '<input id="qc-fb-url-' + esc(s.id) + '" type="url" value="' + esc(ent.share_url || '') + '" placeholder="https://icastinc.sharepoint.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">' +
      '<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mt-2 mb-1">Label (optional)</label>' +
      '<input id="qc-fb-label-' + esc(s.id) + '" type="text" value="' + esc(ent.label || '') + '" placeholder="' + esc(s.label) + '" ' +
        '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 gap-3 mt-3">' +
        '<button onclick="qcFbPathSave(\'' + esc(s.id) + '\')" class="px-4 py-1.5 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-lg text-xs transition-colors">Save</button>' +
        '<button onclick="qcFbPathTest(\'' + esc(s.id) + '\')" class="togen-control px-4 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">Test</button>' +
        '<span id="qc-fb-status-' + esc(s.id) + '" class="text-xs hidden"></span>' +
      '</div>' +
    '</div>';
  }).join('');
}

function _qcFbStatus(seg, msg, isErr) {
  var el = document.getElementById('qc-fb-status-' + seg);
  if (!el) return;
  el.textContent = msg;
  el.className = 'text-xs ' + (isErr ? 'text-red-500' : 'text-green-600 dark:text-green-400');
  el.classList.remove('hidden');
}

function qcFbPathSave(seg) {
  var url = (document.getElementById('qc-fb-url-' + seg) || {}).value || '';
  var label = (document.getElementById('qc-fb-label-' + seg) || {}).value || '';
  _qcFbStatus(seg, 'Saving…', false);
  fetch('/qc/filebrowser/paths', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({segment: seg, share_url: url.trim(), label: label.trim()})
  }).then(function (r) { return r.json().then(function (d) { return {ok: r.ok, d: d}; }); })
    .then(function (res) {
      if (!res.ok) { _qcFbStatus(seg, res.d.error || 'Save failed.', true); return; }
      _qcFbStatus(seg, url.trim() ? 'Saved.' : 'Cleared.', false);
      qcFbPathsLoad();
    }).catch(function () { _qcFbStatus(seg, 'Save failed.', true); });
}

function qcFbPathTest(seg) {
  var url = (document.getElementById('qc-fb-url-' + seg) || {}).value || '';
  if (!url.trim()) { _qcFbStatus(seg, 'Enter a link first.', true); return; }
  _qcFbStatus(seg, 'Testing…', false);
  fetch('/qc/filebrowser/list?share_url=' + encodeURIComponent(url.trim()))
    .then(function (r) { return r.json().then(function (d) { return {ok: r.ok, d: d}; }); })
    .then(function (res) {
      if (!res.ok) { _qcFbStatus(seg, res.d.error || 'Could not list the folder.', true); return; }
      var nf = (res.d.folders || []).length, np = (res.d.files || []).length;
      _qcFbStatus(seg, 'OK — ' + nf + ' folder(s), ' + np + ' file(s).', false);
    }).catch(function () { _qcFbStatus(seg, 'Could not list the folder.', true); });
}

// ---- QC Daily Production (DVI-1410 P3) --------------------------------------
// Settings: enable a segment + assign its mixture. Tab: a mixture-driven daily
// form (Total Yards + one input per ingredient) that upserts one entry per day.

var _qcDpConfig = { segments: [], config: {} };

function qcDpConfigLoad() {
  var host = document.getElementById('qc-dp-config-list');
  if (!host) return;
  fetch('/qc/daily-production/config').then(function (r) { return r.json(); })
    .then(function (d) {
      _qcDpConfig = { segments: d.segments || [], config: d.config || {} };
      qcDpConfigRender();
    }).catch(function () {
      host.innerHTML = '<p class="text-sm text-red-500">Could not load Daily Production config.</p>';
    });
}

function qcDpConfigRender() {
  var host = document.getElementById('qc-dp-config-list');
  host.innerHTML = (_qcDpConfig.segments || []).map(function (s) {
    var ent = _qcDpConfig.config[s.id] || {};
    var opts = '<option value="">— none —</option>' + (s.mixtures || []).map(function (m) {
      return '<option value="' + m.id + '"' + (ent.mixture_id === m.id ? ' selected' : '') + '>' + esc(m.name) + '</option>';
    }).join('');
    return '<div class="rounded-xl border border-gray-200 dark:border-gray-700 p-4" data-dp-seg="' + esc(s.id) + '">' +
      '<div class="flex items-center justify-between gap-4 flex-wrap">' +
        '<label class="inline-flex items-center gap-2 text-sm font-semibold text-gray-800 dark:text-gray-100">' +
          '<input type="checkbox" class="qc-dp-enabled" ' + (ent.enabled ? 'checked' : '') + '> ' + esc(s.label) +
        '</label>' +
        '<div><label class="text-xs text-gray-500 dark:text-gray-400 mr-2">Assigned mixture</label>' +
          '<select class="qc-dp-mixsel 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">' + opts + '</select></div>' +
      '</div>' +
      ((s.mixtures || []).length ? '' : '<p class="text-xs text-amber-600 dark:text-amber-400 mt-2">No mixtures for this segment yet — add one in the Mixtures tab.</p>') +
    '</div>';
  }).join('') || '<p class="text-sm text-gray-500 dark:text-gray-400">No segments.</p>';
}

function qcDpConfigSave() {
  var config = {};
  Array.prototype.forEach.call(document.querySelectorAll('[data-dp-seg]'), function (el) {
    var seg = el.getAttribute('data-dp-seg');
    var mid = el.querySelector('.qc-dp-mixsel').value;
    config[seg] = { enabled: el.querySelector('.qc-dp-enabled').checked,
                    mixture_id: mid ? parseInt(mid, 10) : null };
  });
  var st = document.getElementById('qc-dp-config-status');
  st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500 dark:text-gray-400'; st.classList.remove('hidden');
  fetch('/qc/daily-production/config', { method: 'POST', headers: { 'Content-Type': 'application/json' },
                                         body: JSON.stringify({ config: config }) })
    .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
    .then(function (res) {
      if (!res.ok) { st.textContent = res.d.error || 'Save failed.'; st.className = 'text-sm text-red-500'; return; }
      _qcDpConfig.config = res.d.config || {};
      st.textContent = 'Saved.'; st.className = 'text-sm text-green-600 dark:text-green-400';
      qcDpConfigRender();
    }).catch(function () { st.textContent = 'Save failed.'; st.className = 'text-sm text-red-500'; });
}

// ---- Daily Production tab ----

var _qcDaily = { view: null };

function qcDailyInit() {
  var sel = document.getElementById('qc-dp-segment');
  var dp = (_qcMeta && _qcMeta.daily_production) || [];
  var prev = sel.value;
  sel.innerHTML = dp.map(function (d) {
    return '<option value="' + esc(d.segment) + '">' + esc(d.label) + '</option>';
  }).join('');
  if (prev && dp.some(function (d) { return d.segment === prev; })) sel.value = prev;
  var dt = document.getElementById('qc-dp-date');
  if (!dt.value) dt.value = qcToday();
  if (!dp.length) {
    document.getElementById('qc-dp-mixture').textContent = '';
    document.getElementById('qc-dp-form').innerHTML =
      '<p class="text-sm text-gray-500 dark:text-gray-400">No Production Segments are enabled for Daily Production yet. An admin enables one in Batch Production → Settings → Daily Production.</p>';
    return;
  }
  qcDailyLoad();
}

function _qcDailyStatus(msg, cls) {
  var st = document.getElementById('qc-dp-status');
  if (!msg) { st.classList.add('hidden'); return; }
  st.textContent = msg;
  st.className = 'text-sm ' + (cls || 'text-gray-500 dark:text-gray-400');
  st.classList.remove('hidden');
}

function qcDailyLoad() {
  var seg = document.getElementById('qc-dp-segment').value;
  var date = document.getElementById('qc-dp-date').value;
  if (!seg || !date) return;
  _qcDailyStatus('');
  _qcDailyBanner('');
  var form = document.getElementById('qc-dp-form');
  form.innerHTML = '<p class="text-sm text-gray-400">Loading…</p>';
  fetch('/qc/daily-production?segment=' + encodeURIComponent(seg) + '&date=' + encodeURIComponent(date))
    .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
    .then(function (res) {
      if (!res.ok) { form.innerHTML = '<p class="text-sm text-red-500">' + esc(res.d.error || 'Could not load.') + '</p>'; return; }
      _qcDaily.view = res.d.view;
      qcDailyRenderForm();
    }).catch(function () { form.innerHTML = '<p class="text-sm text-red-500">Could not load.</p>'; });
}

function qcDailyRenderForm() {
  var v = _qcDaily.view || {};
  var mixEl = document.getElementById('qc-dp-mixture');
  var form = document.getElementById('qc-dp-form');
  // State (a): no mixture assigned to this segment — point the admin at Settings.
  if (!v.mixture) {
    mixEl.textContent = '';
    form.innerHTML = '<p class="text-sm text-amber-600 dark:text-amber-400">This segment has no assigned mixture recipe. An admin assigns a mixture in Batch Production → Settings → Daily Production.</p>';
    return;
  }
  mixEl.innerHTML = 'Mixture: <span class="font-semibold text-gray-700 dark:text-gray-200">' + esc(v.mixture.name) + '</span>';
  // A mixture is assigned but there are no materials to capture at all.
  if (!(v.ingredients || []).length) {
    form.innerHTML = '<p class="text-sm text-amber-600 dark:text-amber-400">No active materials are configured to capture. An admin adds materials in Batch Production → Settings → Materials.</p>';
    return;
  }
  // State (b): mixture assigned but no per-yd³ targets — a NOTICE, not a blocker.
  // Capture still proceeds; the target/range cells show "—" and nothing flags.
  var notice = '';
  if (!v.has_targets) {
    notice = '<div class="rounded-lg border border-amber-300 dark:border-amber-500/50 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 text-sm text-amber-700 dark:text-amber-300 mb-4">' +
      'This mixture has no per-yd³ targets yet, so nothing is range-checked — but you can still record the day&#39;s totals below. An admin can add targets in the Mixtures tab.</div>';
  }
  var entry = v.entry || {};
  var lineByMat = {};
  (entry.lines || []).forEach(function (l) { lineByMat[l.material_id] = l; });
  function dpAttr(x) { return (x == null) ? '' : x; }
  var rows = (v.ingredients || []).map(function (ing) {
    var cur = lineByMat[ing.material_id];
    var val = (cur && cur.actual != null) ? cur.actual : '';
    return '<tr class="border-b border-gray-100 dark:border-gray-800" data-mat="' + ing.material_id +
      '" data-target="' + dpAttr(ing.target) + '" data-minus="' + dpAttr(ing.minus) + '" data-plus="' + dpAttr(ing.plus) + '">' +
      '<td class="py-2 pr-3 text-sm text-gray-700 dark:text-gray-200 qc-dp-name">' + esc(ing.name) + '</td>' +
      '<td class="py-2 pr-3"><div class="flex items-center gap-1 qc-dp-actual-wrap">' +
        '<input type="number" inputmode="decimal" step="any" min="0" value="' + val + '" oninput="qcDailyCheckRow(this)" ' +
        'class="qc-dp-actual w-28 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">' +
        '<span class="text-xs text-gray-400 qc-dp-unit">' + esc(ing.unit) + '</span></div></td>' +
      '<td class="py-2 text-xs qc-dp-range qc-dp-range-cell text-gray-400"></td></tr>';
  }).join('');
  var stamp = _qcDailyStampText(entry);
  form.innerHTML = notice +
    '<div class="flex flex-wrap items-end gap-4 mb-4">' +
      '<div><label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Total Yards</label>' +
        '<input id="qc-dp-yards" type="number" inputmode="decimal" step="any" min="0" value="' + (entry.yards != null ? entry.yards : '') + '" oninput="qcDailyRecheck()" ' +
        'class="w-32 px-3 py-2.5 sm: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-base sm:text-sm"></div>' +
      '<div><label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Operator initials</label>' +
        '<input id="qc-dp-initials" type="text" maxlength="20" value="' + esc(entry.initials || '') + '" ' +
        'class="w-28 px-3 py-2.5 sm: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-base sm:text-sm"></div>' +
    '</div>' +
    '<div class="overflow-x-auto"><table class="qc-dp-table 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 font-medium">Ingredient</th><th class="py-2 pr-3 font-medium">Total for the day</th><th class="py-2 font-medium">Range (scaled by yards)</th>' +
      '</tr></thead><tbody id="qc-dp-lines">' + rows + '</tbody></table></div>' +
    '<div class="mt-4"><label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Notes</label>' +
      '<textarea id="qc-dp-notes" rows="2" class="w-full px-3 py-2.5 sm: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-base sm:text-sm">' + esc(entry.notes || '') + '</textarea></div>' +
    '<div class="flex flex-col sm:flex-row sm:items-center gap-3 mt-5">' +
      '<button onclick="qcDailySave()" class="w-full sm:w-auto px-5 py-3 sm:py-2 bg-togen-teal hover:bg-togen-teal-dark text-white font-semibold rounded-xl text-base sm:text-sm transition-colors">Submit day&#39;s totals</button>' +
      '<span id="qc-dp-saved-stamp" class="text-xs text-gray-500 dark:text-gray-400">' + esc(stamp) + '</span>' +
    '</div>';
  qcDailyRecheck();
}

// Persistent "Last saved <time> by <initials>" stamp text for a captured entry.
// Prefers the operator initials; falls back to the recording identity (updated_by).
function _qcDailyStampText(entry) {
  if (!entry || !entry.id) return '';
  var who = ((entry.initials || entry.updated_by || '') + '').trim();
  var when = _qcDailyFmtTs(entry.updated_at);
  var s = 'Last saved';
  if (when) s += ' ' + when;
  if (who) s += ' by ' + who;
  return s;
}

// qc.db stores timestamps as UTC "YYYY-MM-DD HH:MM:SS". Parse as UTC and render
// in the viewer's locale/timezone (via the DVI-1364 regional helper when present).
function _qcDailyFmtTs(ts) {
  if (!ts) return '';
  var iso = ('' + ts).replace(' ', 'T');
  if (!/[zZ]|[+\-]\d\d:?\d\d$/.test(iso)) iso += 'Z';
  var d = new Date(iso);
  if (isNaN(d.getTime())) return '' + ts;
  if (typeof _fmtDateTime === 'function') { var f = _fmtDateTime(d); if (f) return f; }
  return d.toLocaleString();
}

// Segment label from the picker (for the success banner text).
function qcDailySegLabel() {
  var sel = document.getElementById('qc-dp-segment');
  if (sel && sel.selectedIndex >= 0 && sel.options[sel.selectedIndex]) {
    return sel.options[sel.selectedIndex].text;
  }
  return sel ? sel.value : '';
}

// Prominent, loud submit feedback. kind: 'success' | 'error' (empty clears).
function _qcDailyBanner(msg, kind) {
  var b = document.getElementById('qc-dp-banner');
  if (!b) return;
  if (!msg) { b.className = 'hidden'; b.innerHTML = ''; return; }
  if (kind === 'error') {
    b.className = 'rounded-xl border-2 border-red-400 dark:border-red-500 bg-red-50 dark:bg-red-900/30 px-4 py-3 text-red-700 dark:text-red-200 font-semibold text-sm flex items-center gap-2';
    b.innerHTML = '<span class="text-xl leading-none">&#9888;</span><span>' + esc(msg) + '</span>';
  } else {
    b.className = 'rounded-xl border-2 border-green-400 dark:border-green-500 bg-green-50 dark:bg-green-900/30 px-4 py-3 text-green-800 dark:text-green-100 font-semibold text-base flex items-center gap-2';
    b.innerHTML = '<span class="text-2xl leading-none">&#10003;</span><span>' + esc(msg) + '</span>';
  }
  try { b.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } catch (e) {}
}

function qcDailyRecheck() {
  Array.prototype.forEach.call(document.querySelectorAll('#qc-dp-lines tr'), function (tr) {
    qcDailyCheckRow(tr.querySelector('.qc-dp-actual'));
  });
}

// Live amber out-of-range warning (never blocks), mirroring Batch Tickets. The
// mixture's per-yd³ target/-/+ are scaled by the entered Total Yards.
function qcDailyCheckRow(input) {
  if (!input) return;
  var tr = input.closest('tr');
  var cell = tr.querySelector('.qc-dp-range');
  var yards = parseFloat((document.getElementById('qc-dp-yards') || {}).value) || 0;
  var targetAttr = tr.getAttribute('data-target');
  var target = parseFloat(targetAttr) || 0;
  var minus = parseFloat(tr.getAttribute('data-minus')) || 0;
  var plus = parseFloat(tr.getAttribute('data-plus')) || 0;
  input.classList.remove('border-amber-500');
  cell.className = 'py-2 text-xs qc-dp-range text-gray-400';
  // Target-less ingredient (mixture has no per-yd³ targets): show "—", no check.
  if (targetAttr === '' || targetAttr == null) { cell.textContent = '—'; return; }
  if (!target) { cell.textContent = ''; return; }         // no target => no check
  // Target-only ingredient (no tolerances, DVI-1420): show the scaled target,
  // never flag out of range.
  if (minus === 0 && plus === 0) {
    if (!yards) { cell.textContent = ''; return; }
    cell.textContent = 'target ' + (target * yards).toFixed(1);
    return;
  }
  if (!yards) { cell.textContent = 'enter yards'; return; }
  var min = Math.max(0, (target - minus) * yards), max = (target + plus) * yards;
  var okRange = min.toFixed(1) + '–' + max.toFixed(1);
  var actual = input.value === '' ? null : parseFloat(input.value);
  if (actual == null || isNaN(actual)) { cell.textContent = 'target ' + okRange; return; }
  if (actual < min || actual > max) {
    cell.className = 'py-2 text-xs qc-dp-range text-amber-600 dark:text-amber-400';
    cell.textContent = 'Out of range (' + okRange + ')';
    input.classList.add('border-amber-500');
  } else {
    cell.className = 'py-2 text-xs qc-dp-range text-green-600 dark:text-green-400';
    cell.textContent = 'OK (' + okRange + ')';
  }
}

function qcDailySave() {
  var seg = document.getElementById('qc-dp-segment').value;
  var date = document.getElementById('qc-dp-date').value;
  var lines = [];
  Array.prototype.forEach.call(document.querySelectorAll('#qc-dp-lines tr'), function (tr) {
    var inp = tr.querySelector('.qc-dp-actual');
    lines.push({ material_id: parseInt(tr.getAttribute('data-mat'), 10),
                 actual: inp.value === '' ? null : inp.value });
  });
  _qcDailyStatus('Saving…');
  _qcDailyBanner('');
  fetch('/qc/daily-production', { method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ segment: seg, date: date,
      yards: (document.getElementById('qc-dp-yards') || {}).value || '',
      initials: (document.getElementById('qc-dp-initials') || {}).value || '',
      notes: (document.getElementById('qc-dp-notes') || {}).value || '',
      lines: lines }) })
    .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
    .then(function (res) {
      if (!res.ok) {
        _qcDailyStatus('');
        _qcDailyBanner(res.d.error || 'Save failed — the day’s totals were NOT saved.', 'error');
        return;
      }
      _qcDaily.view = res.d.view;
      _qcDailyStatus('');
      qcDailyRenderForm();
      _qcDailyBanner('Submitted — day’s totals saved for ' +
                     qcDailySegLabel() + ', ' + date, 'success');
    }).catch(function () {
      _qcDailyStatus('');
      _qcDailyBanner('Network error — the day’s totals were NOT saved. Try again.', 'error');
    });
}

// ---- QC File Browser tab: browse SharePoint PDFs + Open in Scan Tickets ----
// DVI-1383 P3. A subtab per configured Production Segment, breadcrumb folder
// navigation, inline PDF preview, and a per-file Workflow menu whose "Open in
// Scan Tickets" entry downloads the PDF into the scan pipeline. Built as a
// self-contained component (`_qcFb` + qcFb* fns) so Phase 4 (the File Browser
// tool tab) can render the same view.

// `pfx` selects which mount the component renders into (DVI-1383 P4): the QC
// tool's File Browser tab uses "qc-fb-*" ids; the File Browser tool's QC tab
// uses "fbqc-*" ids. Only one is visible at a time, so the shared state is safe.
var _qcFb = { segs: [], seg: null, stack: [], folders: [], files: [], pfx: 'qc-fb' };

function _qcFbEl(name) { return document.getElementById(_qcFb.pfx + '-' + name); }

// Entry point for the File Browser tool's QC tab: ensure /qc/meta (for the
// import capability) is loaded, then render the shared browser under "fbqc-*".
function fbQcInit() {
  if (_qcMeta) { qcFbInit('fbqc'); return; }
  qcRefreshMeta(function () { qcFbInit('fbqc'); });
}

// Icons.
var _QC_FB_FOLDER_SVG = '<svg class="w-4 h-4 text-togen-teal flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"/></svg>';
var _QC_FB_PDF_SVG = '<svg class="w-4 h-4 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M7 3h7l5 5v13a1 1 0 01-1 1H7a1 1 0 01-1-1V4a1 1 0 011-1z"/><path stroke-linecap="round" stroke-linejoin="round" d="M14 3v5h5"/></svg>';
var _QC_FB_SHEET_SVG = '<svg class="w-4 h-4 text-green-600 dark:text-green-500 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M7 3h7l5 5v13a1 1 0 01-1 1H7a1 1 0 01-1-1V4a1 1 0 011-1z"/><path stroke-linecap="round" stroke-linejoin="round" d="M14 3v5h5M9 13h6M9 16h6M9 10h2"/></svg>';

function qcFbCanImport() {
  return !!(_qcMeta && _qcMeta.caps && _qcMeta.caps['import']);
}

// ---- File Browser sort / search / type filter (DVI-1410 P1) ----------------
// Controls are rendered once per init into `<pfx>-controls`; their values live
// in the DOM (persisting across folder navigation) and are read at render time
// so a re-render never resets the search box focus. Default = Newest first
// (the ticket ask). Folders + files are grouped (folders first) and the chosen
// sort applies within each group over the CURRENTLY loaded listing.
function qcFbRenderControls() {
  var host = _qcFbEl('controls');
  if (!host) return;
  var p = _qcFb.pfx;
  var selCls = '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';
  host.innerHTML =
    '<div class="flex flex-wrap items-center gap-2 py-2">' +
      '<input id="' + p + '-q" type="search" oninput="qcFbApplyControls()" placeholder="Search this folder…" ' +
        'class="flex-1 min-w-[10rem] px-3 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">' +
      '<label class="text-xs text-gray-500 dark:text-gray-400">Sort</label>' +
      '<select id="' + p + '-sort" onchange="qcFbApplyControls()" class="' + selCls + '">' +
        '<option value="newest">Newest first</option>' +
        '<option value="oldest">Oldest first</option>' +
        '<option value="name_az">Name A→Z</option>' +
        '<option value="name_za">Name Z→A</option>' +
        '<option value="largest">Largest</option>' +
        '<option value="smallest">Smallest</option>' +
      '</select>' +
      '<select id="' + p + '-type" onchange="qcFbApplyControls()" class="' + selCls + '">' +
        '<option value="all">All files</option>' +
        '<option value="pdf">PDFs</option>' +
        '<option value="sheet">Spreadsheets</option>' +
      '</select>' +
    '</div>';
}

function qcFbApplyControls() { qcFbRenderListing(); }

function _qcFbCtl(name, dflt) {
  var el = _qcFbEl(name);
  return el ? el.value : dflt;
}

function _qcFbName(x) { return (x.name || '').toLowerCase(); }
function _qcFbMod(x) { return x.modified ? (Date.parse(x.modified) || 0) : 0; }

function _qcFbFileKind(f) {
  if (f.kind) return f.kind;
  var n = _qcFbName(f);
  if (n.slice(-4) === '.pdf') return 'pdf';
  if (n.slice(-5) === '.xlsx' || n.slice(-5) === '.xlsm') return 'sheet';
  return 'other';
}

function _qcFbCompare(sort) {
  return function (a, b) {
    switch (sort) {
      case 'oldest': return _qcFbMod(a) - _qcFbMod(b);
      case 'name_az': return _qcFbName(a).localeCompare(_qcFbName(b));
      case 'name_za': return _qcFbName(b).localeCompare(_qcFbName(a));
      case 'largest': return (b.size || 0) - (a.size || 0);
      case 'smallest': return (a.size || 0) - (b.size || 0);
      default: return _qcFbMod(b) - _qcFbMod(a);  // newest
    }
  };
}

function qcFbInit(prefix) {
  _qcFb.pfx = prefix || 'qc-fb';
  var listing = _qcFbEl('listing');
  fetch('/qc/filebrowser/segments').then(function (r) { return r.json(); })
    .then(function (d) {
      _qcFb.segs = d.segments || [];
      if (!_qcFb.segs.length) {
        _qcFbEl('subtabs').innerHTML = '';
        _qcFbEl('breadcrumb').innerHTML = '';
        if (_qcFbEl('controls')) _qcFbEl('controls').innerHTML = '';
        listing.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 p-4">No Production Segments have a SharePoint path configured. Set one in Batch Production → Settings → Files.</p>';
        qcFbClearPreview();
        return;
      }
      qcFbRenderControls();
      var keep = _qcFb.segs.some(function (s) { return s.id === _qcFb.seg; }) ? _qcFb.seg : _qcFb.segs[0].id;
      qcFbSelectSegment(keep);
    }).catch(function () {
      listing.innerHTML = '<p class="text-sm text-red-500 p-4">Could not load File Browser segments.</p>';
    });
}

function qcFbRenderSubtabs() {
  var bar = _qcFbEl('subtabs');
  bar.innerHTML = (_qcFb.segs || []).map(function (s) {
    var active = s.id === _qcFb.seg;
    return '<button onclick="qcFbSelectSegment(\'' + esc(s.id) + '\')" ' +
      'class="px-3 py-1.5 text-sm font-medium border-b-2 -mb-px ' +
      (active ? 'border-togen-teal text-togen-teal'
              : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300') +
      '">' + esc(s.label) + '</button>';
  }).join('');
}

function qcFbSelectSegment(seg) {
  _qcFb.seg = seg;
  _qcFb.stack = [];
  qcFbRenderSubtabs();
  qcFbClearPreview();
  qcFbLoadCurrent();
}

function qcFbSegLabel() {
  var s = (_qcFb.segs || []).filter(function (x) { return x.id === _qcFb.seg; })[0];
  return s ? s.label : 'Root';
}

function qcFbLoadCurrent() {
  var listing = _qcFbEl('listing');
  listing.innerHTML = '<p class="text-sm text-gray-400 p-4">Loading…</p>';
  var url;
  if (_qcFb.stack.length) {
    var top = _qcFb.stack[_qcFb.stack.length - 1];
    url = '/qc/filebrowser/list?item_id=' + encodeURIComponent(top.item_id) +
          '&drive_id=' + encodeURIComponent(top.drive_id);
  } else {
    url = '/qc/filebrowser/list?segment=' + encodeURIComponent(_qcFb.seg);
  }
  fetch(url).then(function (r) { return r.json().then(function (d) { return {ok: r.ok, d: d}; }); })
    .then(function (res) {
      if (!res.ok) {
        listing.innerHTML = '<p class="text-sm text-red-500 p-4">' + esc(res.d.error || 'Could not list the folder.') + '</p>';
        return;
      }
      _qcFb.folders = res.d.folders || [];
      _qcFb.files = res.d.files || [];
      qcFbRenderBreadcrumb();
      qcFbRenderListing();
    }).catch(function () {
      listing.innerHTML = '<p class="text-sm text-red-500 p-4">Could not list the folder.</p>';
    });
}

function qcFbRenderBreadcrumb() {
  var bc = _qcFbEl('breadcrumb');
  var parts = ['<button onclick="qcFbCrumb(-1)" class="hover:text-togen-teal">' + esc(qcFbSegLabel()) + '</button>'];
  _qcFb.stack.forEach(function (n, i) {
    parts.push('<span class="text-gray-300 dark:text-gray-600">/</span>');
    parts.push('<button onclick="qcFbCrumb(' + i + ')" class="hover:text-togen-teal">' + esc(n.label) + '</button>');
  });
  bc.innerHTML = parts.join(' ');
}

function qcFbCrumb(i) {
  _qcFb.stack = i < 0 ? [] : _qcFb.stack.slice(0, i + 1);
  qcFbLoadCurrent();
}

function qcFbEnter(i) {
  var f = _qcFb.folders[i];
  if (!f) return;
  _qcFb.stack.push({ label: f.name, drive_id: f.drive_id, item_id: f.id });
  qcFbLoadCurrent();
}

function qcFbRenderListing() {
  var listing = _qcFbEl('listing');
  var canImport = qcFbCanImport();
  var q = (_qcFbCtl('q', '') || '').trim().toLowerCase();
  var sort = _qcFbCtl('sort', 'newest');
  var typ = _qcFbCtl('type', 'all');
  var cmp = _qcFbCompare(sort);
  var hadItems = (_qcFb.folders.length + _qcFb.files.length) > 0;

  // Filter to display index-lists (keep the source arrays intact so the onclick
  // handlers' indices stay valid), then sort within each group.
  var fIdx = [];
  _qcFb.folders.forEach(function (f, i) {
    if (q && _qcFbName(f).indexOf(q) < 0) return;
    fIdx.push(i);
  });
  fIdx.sort(function (a, b) { return cmp(_qcFb.folders[a], _qcFb.folders[b]); });

  var xIdx = [];
  _qcFb.files.forEach(function (f, i) {
    if (q && _qcFbName(f).indexOf(q) < 0) return;
    if (typ !== 'all' && _qcFbFileKind(f) !== typ) return;
    xIdx.push(i);
  });
  xIdx.sort(function (a, b) { return cmp(_qcFb.files[a], _qcFb.files[b]); });

  var rows = [];
  fIdx.forEach(function (i) {
    var f = _qcFb.folders[i];
    rows.push('<button onclick="qcFbEnter(' + i + ')" class="w-full text-left px-4 py-2.5 flex items-center gap-2 hover:bg-gray-50 dark:hover:bg-gray-800">' +
      _QC_FB_FOLDER_SVG +
      '<span class="text-sm text-gray-700 dark:text-gray-200 truncate">' + esc(f.name) + '</span>' +
      (f.child_count != null ? '<span class="text-xs text-gray-400 ml-auto">' + f.child_count + '</span>' : '') +
      '</button>');
  });
  xIdx.forEach(function (i) {
    var f = _qcFb.files[i];
    var kind = _qcFbFileKind(f);
    var isPdf = kind === 'pdf';
    var isSheet = kind === 'sheet';
    // "Open in Import" — spreadsheets in the Pipe Plant path, for qc.import
    // holders (DVI-1410 P2). "Open in Scan Tickets" stays PDF-only (OCR).
    var canPipeImport = canImport && isSheet && _qcFb.seg === 'pipe';
    var menu = '<details class="relative flex-shrink-0">' +
      '<summary class="togen-control cursor-pointer list-none px-2.5 py-1 rounded-lg border border-gray-300 dark:border-gray-600 text-xs font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">Workflow ▾</summary>' +
      '<div class="absolute right-0 z-10 mt-1 w-56 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">' +
        (canImport && isPdf ? '<button onclick="qcFbOpenInScan(' + i + ')" class="block w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700">Open in Scan Tickets</button>' : '') +
        (canPipeImport ? '<button onclick="qcFbOpenInImport(' + i + ')" class="block w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700">Open in Import (Pipe Mix Log)</button>' : '') +
        (canImport && isSheet && !canPipeImport ? '<span class="block px-3 py-1.5 text-xs text-gray-400">Open in Import is available in the Pipe Plant path</span>' : '') +
        (f.web_url ? '<a href="' + esc(f.web_url) + '" target="_blank" rel="noopener" class="block px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700">Open in SharePoint</a>' : '') +
        ((!canImport || (!isPdf && !isSheet)) && !f.web_url ? '<span class="block px-3 py-1.5 text-xs text-gray-400">No actions available</span>' : '') +
      '</div></details>';
    rows.push('<div class="px-4 py-2.5 flex items-center gap-2">' +
      (isSheet ? _QC_FB_SHEET_SVG : _QC_FB_PDF_SVG) +
      '<button onclick="qcFbPreview(' + i + ')" class="text-sm text-gray-700 dark:text-gray-200 hover:text-togen-teal text-left truncate flex-1" title="' + esc(f.name) + '">' + esc(f.name) + '</button>' +
      '<span class="text-xs text-gray-400 flex-shrink-0">' + (f.size != null ? _fbFormatBytes(f.size) : '') + '</span>' +
      menu +
    '</div>');
  });
  if (!rows.length) {
    rows.push('<p class="text-sm text-gray-400 p-4">' +
      (hadItems ? 'No items match your search or filter.' : 'This folder has no subfolders or files.') + '</p>');
  }
  listing.innerHTML = rows.join('');
}

function qcFbClearPreview() {
  var frame = _qcFbEl('preview-frame');
  if (frame) frame.src = 'about:blank';
  var nm = _qcFbEl('preview-name');
  if (nm) nm.textContent = 'Select a PDF to preview';
}

function qcFbPreview(i) {
  var f = _qcFb.files[i];
  if (!f) return;
  _qcFbEl('preview-name').textContent = f.name;
  var frame = _qcFbEl('preview-frame');
  if (_qcFbFileKind(f) !== 'pdf') {
    // Spreadsheets have no inline preview — point the operator at the workflow.
    frame.src = 'data:text/html,' + encodeURIComponent(
      '<body style="margin:0;font-family:system-ui,sans-serif;display:flex;align-items:center;' +
      'justify-content:center;height:100vh;color:#6b7280;text-align:center;padding:1rem;">' +
      '<div>No inline preview for spreadsheets.<br>Use <b>Workflow ▾ → Open in Import</b> (Pipe Plant) ' +
      'or <b>Open in SharePoint</b>.</div></body>');
    return;
  }
  frame.src =
    '/qc/filebrowser/file?drive_id=' + encodeURIComponent(f.drive_id) +
    '&item_id=' + encodeURIComponent(f.id);
}

// DVI-1410 P2 — hand a Pipe Plant .xlsx to the QC Import tab, already analyzed.
// Mirrors qcFbOpenInScan's cross-tool handoff (fbqc mount brings up the QC tool).
function qcFbOpenInImport(i) {
  var f = _qcFb.files[i];
  if (!f) return;
  if (_activeTool !== 'qc') openTool('qc', 'Batch Production');
  qcSwitchTab('import');
  qcImportFromSharePoint(f.drive_id, f.id, f.name);
}

function qcFbOpenInScan(i) {
  var f = _qcFb.files[i];
  if (!f) return;
  var listing = _qcFbEl('listing');
  var note = document.createElement('p');
  note.className = 'text-xs text-togen-teal px-4 py-2';
  note.textContent = 'Opening "' + f.name + '" in Scan Tickets…';
  listing.insertBefore(note, listing.firstChild);
  fetch('/qc/scan/import-sharepoint', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ drive_id: f.drive_id, item_id: f.id })
  }).then(function (r) { return r.json().then(function (d) { return {ok: r.ok, d: d}; }); })
    .then(function (res) {
      if (!res.ok) {
        note.className = 'text-xs text-red-500 px-4 py-2';
        note.textContent = res.d.error || 'Could not open in Scan Tickets.';
        return;
      }
      if (note.parentNode) note.parentNode.removeChild(note);
      qcScanLoadImported(res.d.scan_id, res.d.files, _qcFb.seg);
    }).catch(function () {
      note.className = 'text-xs text-red-500 px-4 py-2';
      note.textContent = 'Could not open in Scan Tickets.';
    });
}

// Load a server-side-downloaded PDF (from the File Browser) into the Scan
// Tickets page queue and jump to the workspace — same queue shape finish() builds.
function qcScanLoadImported(scanId, files, seg) {
  // When invoked from the File Browser tool's QC tab (DVI-1383 P4), the QC tool
  // isn't the active tool — bring it up so the Scan Tickets workspace is
  // visible. _showTool runs qcInit() on first open; _qcMeta is already loaded
  // (fbQcInit fetched it), so the scan segment select populates correctly.
  if (_activeTool !== 'qc') openTool('qc', 'Batch Production');
  var fileDescs = (files || []).map(function (f) {
    return { scanId: scanId, fileId: f.id, name: f.name, pages: f.pages };
  });
  qcScanBuildPages(fileDescs);
  if (!_qcScan.pages.length) return;
  qcSwitchTab('scan');
  var segSel = document.getElementById('qc-scan-segment');
  if (segSel && seg) segSel.value = seg;
  qcScanUpdateTemplateBadge();
  qcScanFillMixtures();
  qcScanShowReview();
}

// ---------------------------------------------------------- batch tickets --

function qcLoadBatches() {
  var date = document.getElementById('qc-batch-date').value || qcToday();
  var seg = document.getElementById('qc-segment').value || 'wet_cast';
  fetch('/qc/batches?date=' + encodeURIComponent(date) + '&segment=' + encodeURIComponent(seg))
    .then(function (r) { return r.json(); })
    .then(function (data) { qcRenderBatches(data.batches || []); })
    .catch(function () {
      document.getElementById('qc-batch-list').innerHTML =
        '<p class="text-sm text-red-500">Could not load batches.</p>';
    });
}

function qcRenderBatches(items) {
  var el = document.getElementById('qc-batch-list');
  if (!items.length) {
    el.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No batch tickets captured for this day yet. Use <strong>New Batch Ticket</strong> to start.</p>';
    return;
  }
  var canManage = _qcMeta && _qcMeta.caps && _qcMeta.caps.settings;  // batch delete = qc.settings (DVI-1373 P2)
  var totalYd = 0;
  var rows = items.map(function (b) {
    totalYd += (b.yards || 0);
    var oor = b.n_out_of_range
      ? '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">' + b.n_out_of_range + ' out of range</span>'
      : '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300">OK</span>';
    // DVI-1452 P2: compact custom-values summary under the batch number.
    var cvSummary = _qcCustomValueSummary(b.custom_values);
    return '<tr class="border-b border-gray-100 dark:border-gray-800">' +
      '<td class="py-2 pr-3 font-medium text-gray-800 dark:text-gray-100">' + esc(b.batch_number || ('#' + b.id)) +
        (cvSummary ? '<div class="text-xs font-normal text-gray-400 dark:text-gray-500 mt-0.5">' + cvSummary + '</div>' : '') + '</td>' +
      '<td class="py-2 pr-3 text-gray-600 dark:text-gray-300">' + esc(b.mixture_name || '—') + (b.mixture_version ? ' <span class="text-xs text-gray-400">v' + b.mixture_version + '</span>' : '') + '</td>' +
      '<td class="py-2 pr-3 text-gray-600 dark:text-gray-300">' + qcFmt(b.yards) + '</td>' +
      '<td class="py-2 pr-3 text-gray-600 dark:text-gray-300">' + b.n_lines + '</td>' +
      '<td class="py-2 pr-3">' + oor + '</td>' +
      '<td class="py-2 pr-3 text-xs text-gray-400 dark:text-gray-500">' + esc(b.initials || b.created_by || '') + '</td>' +
      '<td class="py-2 text-right whitespace-nowrap">' +
        '<button onclick="qcBatchEdit(' + b.id + ')" class="togen-control px-2 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">Edit</button>' +
        (canManage ? ' <button onclick="qcBatchDelete(' + b.id + ')" class="togen-control px-2 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/30">Delete</button>' : '') +
      '</td></tr>';
  }).join('');
  el.innerHTML =
    '<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">' + items.length + ' batch' + (items.length === 1 ? '' : 'es') + ' · ' + qcFmt(totalYd) + ' yd&sup3; total</p>' +
    '<div class="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 font-medium">Batch #</th><th class="py-2 pr-3 font-medium">Mixture</th>' +
    '<th class="py-2 pr-3 font-medium">Yards</th><th class="py-2 pr-3 font-medium">Materials</th>' +
    '<th class="py-2 pr-3 font-medium">Range</th><th class="py-2 pr-3 font-medium">By</th><th></th>' +
    '</tr></thead><tbody>' + rows + '</tbody></table></div>';
}

function qcBatchOpen(batch) {
  _qcEditingBatch = batch || null;
  _qcEditingBatchId = batch ? batch.id : null;
  document.getElementById('qc-batch-modal-title').textContent =
    batch ? 'Edit Batch Ticket' : 'New Batch Ticket';
  document.getElementById('qc-b-date').value =
    batch ? batch.batch_date : (document.getElementById('qc-batch-date').value || qcToday());
  document.getElementById('qc-b-number').value = batch ? (batch.batch_number || '') : '';
  document.getElementById('qc-b-yards').value = batch ? (batch.yards || '') : '';
  document.getElementById('qc-b-rock-moist').value = batch && batch.rock_moisture !== null ? batch.rock_moisture : '';
  document.getElementById('qc-b-sand-moist').value = batch && batch.sand_moisture !== null ? batch.sand_moisture : '';
  document.getElementById('qc-b-initials').value = batch ? (batch.initials || '') : '';
  document.getElementById('qc-b-notes').value = batch ? (batch.notes || '') : '';
  var seg = document.getElementById('qc-segment').value || 'wet_cast';
  var mixSel = document.getElementById('qc-b-mixture');
  var mixes = (_qcMeta && _qcMeta.mixtures || []).filter(function (m) {
    return m.segment === (batch ? batch.segment : seg);
  });
  mixSel.innerHTML = '<option value="">(no mixture)</option>' + mixes.map(function (m) {
    return '<option value="' + m.id + '">' + esc(m.name) + '</option>';
  }).join('');
  mixSel.value = batch && batch.mixture_id ? String(batch.mixture_id) : '';
  var st = document.getElementById('qc-batch-status');
  st.classList.add('hidden');
  // DVI-1441: hide fields disabled for this segment (display-only; the batch
  // keeps any existing value for a hidden field since the input persists).
  var _bSeg = batch ? batch.segment : seg;
  _qcApplyFieldVisibility('qc-b', _bSeg);
  // DVI-1452 P2: render this segment's custom fields, prefilled from the batch.
  _qcRenderCustomFields('qc-b-custom', _bSeg, (batch && batch.custom_values) || {});
  qcBatchRenderLines();
  document.getElementById('qc-batch-modal').classList.remove('hidden');
}

function qcBatchEdit(id) {
  fetch('/qc/batches/' + id).then(function (r) { return r.json(); }).then(function (data) {
    if (data.batch) qcBatchOpen(data.batch);
  });
}

function qcBatchClose() {
  document.getElementById('qc-batch-modal').classList.add('hidden');
  _qcEditingBatch = null;
  _qcEditingBatchId = null;
}

// Client-side preview of the scaled targets (the server recomputes and
// snapshots authoritatively on save).
// DVI-1440 P2: a mixture's effective capture membership. Returns a Set of leaf
// material ids to show, or null meaning "no filter — show all materials". An
// explicit membership list wins (D3-A); an unset list (legacy) falls back to the
// D4-A default = materials that currently have targets; a legacy mixture with no
// targets resolves to null (all) so capture-without-targets still works.
function _qcEffectiveIncluded(mix) {
  if (!mix) return null;
  if (mix.included_materials != null) return new Set(mix.included_materials);
  var withTargets = (mix.targets || []).filter(function (t) {
    return (t.target || 0) > 0 || (t.minus || 0) > 0 || (t.plus || 0) > 0;
  });
  if (!withTargets.length) return null;   // legacy + no targets -> show all
  var s = new Set();
  withTargets.forEach(function (t) { s.add(t.material_id); });
  return s;
}

// DVI-1440 review #2: the ordered materials to capture for a mixture. Leaf
// materials in membership, PLUS any aggregate the mixture captures directly
// (aggregate_capture) as its own line — with that aggregate's constituent
// materials suppressed (they are not captured when the total is). No mixture
// selected -> all leaf materials (unchanged). Mirrors qc_store._capture_plan.
function _qcCaptureMaterials(mix) {
  var leaves = (_qcMeta && _qcMeta.materials) || [];
  var aggs = (_qcMeta && _qcMeta.aggregates) || [];
  var incl = _qcEffectiveIncluded(mix);                 // Set or null (=all)
  var direct = new Set((mix && mix.aggregate_capture) || []);
  var suppressed = new Set();
  aggs.forEach(function (a) {
    if (direct.has(a.id)) (a.children || []).forEach(function (c) { suppressed.add(c.id); });
  });
  var out = [];
  leaves.forEach(function (m) {
    if (suppressed.has(m.id)) return;
    if (incl && !incl.has(m.id)) return;
    out.push({ id: m.id, name: m.name, capture_unit: m.capture_unit });
  });
  aggs.forEach(function (a) {
    if (!direct.has(a.id)) return;
    if (incl && !incl.has(a.id)) return;
    out.push({ id: a.id, name: a.name, capture_unit: a.capture_unit, is_aggregate: true });
  });
  return out;
}

function _qcSelectedTargets() {
  var mid = parseInt(document.getElementById('qc-b-mixture').value, 10);
  var yards = parseFloat(document.getElementById('qc-b-yards').value) || 0;
  var out = {};
  if (!mid || !_qcMeta) return out;
  var mix = (_qcMeta.mixtures || []).find(function (m) { return m.id === mid; });
  if (!mix) return out;
  (mix.targets || []).forEach(function (t) {
    // A target-only line (no tolerances, DVI-1420) has no acceptable range:
    // its target still shows, but it is never flagged out of range.
    var hasRange = (t.minus || 0) > 0 || (t.plus || 0) > 0;
    out[t.material_id] = {
      target: t.target * yards,
      hasRange: hasRange,
      min: Math.max(0, (t.target - (t.minus || 0)) * yards),
      max: (t.target + (t.plus || 0)) * yards
    };
  });
  return out;
}

function qcBatchRenderLines() {
  var wrap = document.getElementById('qc-batch-lines');
  if (!_qcMeta) { wrap.innerHTML = ''; return; }
  // Preserve typed actuals across a mixture/yards change re-render.
  var typed = {};
  wrap.querySelectorAll('input[data-qc-mid]').forEach(function (inp) {
    typed[inp.getAttribute('data-qc-mid')] = inp.value;
  });
  var targets = _qcSelectedTargets();
  // DVI-1440 P2 + review #2: the selected mixture's capture lines (included
  // leaf materials + any directly-captured aggregate total). No mixture
  // selected -> all materials, unchanged.
  var bmid = parseInt(document.getElementById('qc-b-mixture').value, 10);
  var bmix = (_qcMeta.mixtures || []).find(function (m) { return m.id === bmid; });
  var mats = _qcCaptureMaterials(bmix);
  // An edited batch may reference a since-deactivated / de-selected material —
  // keep its row so the historical value stays visible and editable.
  if (_qcEditingBatch) {
    (_qcEditingBatch.lines || []).forEach(function (ln) {
      if (!mats.some(function (m) { return m.id === ln.material_id; })) {
        mats.push({ id: ln.material_id, name: ln.material_name, capture_unit: ln.unit });
      }
    });
  }
  var rows = mats.map(function (m) {
    var t = targets[m.id];
    var existing = typed.hasOwnProperty(String(m.id))
      ? typed[String(m.id)]
      : (_qcEditingBatch
          ? (function () {
              var ln = (_qcEditingBatch.lines || []).find(function (l) { return l.material_id === m.id; });
              return ln && ln.actual !== null && ln.actual !== undefined ? ln.actual : '';
            })()
          : '');
    return '<tr class="border-b border-gray-100 dark:border-gray-800">' +
      '<td class="py-1.5 pr-3 text-gray-700 dark:text-gray-200">' + esc(m.name) + ' <span class="text-xs text-gray-400">(' + esc(m.capture_unit || '') + ')</span></td>' +
      '<td class="py-1.5 pr-3 text-gray-500 dark:text-gray-400">' + (t ? qcFmt(t.target) : '—') + '</td>' +
      '<td class="py-1.5 pr-3 text-xs text-gray-400 dark:text-gray-500">' + (t && t.hasRange ? qcFmt(t.min) + ' – ' + qcFmt(t.max) : '') + '</td>' +
      '<td class="py-1.5"><input type="number" step="any" min="0" data-qc-mid="' + m.id + '" value="' + esc(String(existing)) + '" oninput="qcLineCheck(this)"' +
      ' class="w-28 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"></td>' +
      '</tr>';
  }).join('');
  wrap.innerHTML =
    '<div class="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 font-medium">Material</th><th class="py-2 pr-3 font-medium">Target</th>' +
    '<th class="py-2 pr-3 font-medium">Acceptable range</th><th class="py-2 font-medium">Actual</th>' +
    '</tr></thead><tbody>' + rows + '</tbody></table></div>';
  wrap.querySelectorAll('input[data-qc-mid]').forEach(function (inp) { qcLineCheck(inp); });
}

function qcLineCheck(inp) {
  var targets = _qcSelectedTargets();
  var t = targets[parseInt(inp.getAttribute('data-qc-mid'), 10)];
  var v = inp.value === '' ? null : parseFloat(inp.value);
  // Target-only lines (t.hasRange false) are never flagged out of range.
  var bad = !!(t && t.hasRange && v !== null && isFinite(v) && (v < t.min || v > t.max));
  inp.classList.toggle('border-amber-500', bad);
  inp.classList.toggle('ring-2', bad);
  inp.classList.toggle('ring-amber-400', bad);
  var anyBad = false;
  document.querySelectorAll('#qc-batch-lines input[data-qc-mid]').forEach(function (i) {
    if (i.classList.contains('border-amber-500')) anyBad = true;
  });
  document.getElementById('qc-batch-oor-note').classList.toggle('hidden', !anyBad);
}

function qcBatchCollect() {
  var lines = [];
  document.querySelectorAll('#qc-batch-lines input[data-qc-mid]').forEach(function (inp) {
    lines.push({ material_id: parseInt(inp.getAttribute('data-qc-mid'), 10), actual: inp.value });
  });
  return {
    batch_date: document.getElementById('qc-b-date').value,
    // An edited batch keeps its own segment; a new one takes the list filter.
    segment: (_qcEditingBatch && _qcEditingBatch.segment) ||
             document.getElementById('qc-segment').value || 'wet_cast',
    batch_number: document.getElementById('qc-b-number').value,
    mixture_id: document.getElementById('qc-b-mixture').value || null,
    yards: document.getElementById('qc-b-yards').value,
    rock_moisture: document.getElementById('qc-b-rock-moist').value,
    sand_moisture: document.getElementById('qc-b-sand-moist').value,
    initials: document.getElementById('qc-b-initials').value,
    notes: document.getElementById('qc-b-notes').value,
    lines: lines,
    custom_values: _qcCollectCustomValues('qc-b-custom')  // DVI-1452 P2
  };
}

function qcBatchSave(saveNext) {
  var body = qcBatchCollect();
  var st = document.getElementById('qc-batch-status');
  var url = _qcEditingBatchId ? '/qc/batches/' + _qcEditingBatchId : '/qc/batches';
  fetch(url, { 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) {
        st.textContent = res.d.error || 'Save failed.';
        st.className = 'text-sm text-red-500';
        st.classList.remove('hidden');
        return;
      }
      qcLoadBatches();
      if (saveNext && !_qcEditingBatchId) {
        // Rapid entry: keep date/mixture/yards/moistures, clear actuals +
        // notes, bump a numeric batch number.
        var numEl = document.getElementById('qc-b-number');
        var n = parseInt(numEl.value, 10);
        numEl.value = isFinite(n) ? String(n + 1) : '';
        document.getElementById('qc-b-notes').value = '';
        // DVI-1452 P2: reset custom fields for the next batch.
        _qcRenderCustomFields('qc-b-custom',
          document.getElementById('qc-segment').value || 'wet_cast', {});
        document.querySelectorAll('#qc-batch-lines input[data-qc-mid]').forEach(function (inp) {
          inp.value = '';
          qcLineCheck(inp);
        });
        st.textContent = 'Saved — ready for the next batch.';
        st.className = 'text-sm text-green-600 dark:text-green-400';
        st.classList.remove('hidden');
      } else {
        qcBatchClose();
      }
    })
    .catch(function () {
      st.textContent = 'Save failed (network).';
      st.className = 'text-sm text-red-500';
      st.classList.remove('hidden');
    });
}

function qcBatchDelete(id) {
  if (!confirm('Delete this batch ticket? It is removed from lists and totals (soft delete).')) return;
  fetch('/qc/batches/' + id, { method: 'DELETE' }).then(function () { qcLoadBatches(); });
}

// ------------------------------------------------- scan tickets (OCR import) --
// DVI-1374: drag/drop PDF(s); each page = one batch ticket. OCR auto-fills the
// New Batch Ticket form; Save & Next advances through the whole page queue.

var _qcScan = { scanId: null, queue: [], idx: 0, wired: false, region: null,
                pages: [], viewIdx: -1, fromReview: false, thumbSize: 150,
                preprocessing: false, preprocessCancel: false };

function qcScanInit() {
  // Populate the segment select from meta (default wet_cast).
  var seg = document.getElementById('qc-scan-segment');
  if (seg) {
    var prev = seg.value;
    seg.innerHTML = ((_qcMeta && _qcMeta.segments) || []).map(function (s) {
      return '<option value="' + esc(s.id) + '">' + esc(s.label) + '</option>';
    }).join('');
    if (prev) seg.value = prev;
  }
  qcScanUpdateTemplateBadge();
  qcScanFillMixtures();
  if (!_qcScan.wired) {
    _qcScan.wired = true;
    var dz = document.getElementById('qc-scan-dropzone');
    var fin = document.getElementById('qc-scan-file');
    if (dz && fin) {
      dz.addEventListener('click', function () { fin.click(); });
      fin.addEventListener('change', function () {
        if (fin.files && fin.files.length) qcScanUpload(fin.files);
        fin.value = '';
      });
      ['dragenter', 'dragover'].forEach(function (ev) {
        dz.addEventListener(ev, function (e) {
          e.preventDefault();
          dz.classList.add('border-togen-teal', 'bg-togen-teal/5');
        });
      });
      ['dragleave', 'drop'].forEach(function (ev) {
        dz.addEventListener(ev, function (e) {
          e.preventDefault();
          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) {
          qcScanUpload(e.dataTransfer.files);
        }
      });
    }
    var ov = document.getElementById('qc-scan-region-ov');
    if (ov) ov.addEventListener('pointerdown', qcScanRegionPointerDown);
    // DVI-1460 P1: keyboard nav for the full-page viewer (Esc close, arrows page).
    document.addEventListener('keydown', function (e) {
      var v = document.getElementById('qc-scan-viewer');
      if (!v || v.classList.contains('hidden')) return;
      if (e.key === 'Escape') { qcScanViewerClose(); e.preventDefault(); }
      else if (e.key === 'ArrowLeft') { qcScanViewerNav(-1); e.preventDefault(); }
      else if (e.key === 'ArrowRight') { qcScanViewerNav(1); e.preventDefault(); }
    });
  }
  // DVI-1441: apply per-segment field visibility on tab open (before any page
  // render), so a disabled field is hidden immediately.
  _qcApplyFieldVisibility('qc-s', (seg && seg.value) || 'wet_cast');
}

var _qcScanUploadSeq = 0;

function qcScanCloseUploadPanel() {
  var panel = document.getElementById('qc-scan-upload-panel');
  if (panel) panel.classList.add('hidden');
}

// DVI-1383 P1: rich per-file upload with progress bars, status chips and an
// aggregate header — mirrors the File Browser fbUploadFiles pattern. Each PDF
// is uploaded in its own /qc/scan/upload request (the route mints one scan_id
// per call), so each file carries its own scanId into the page queue.
function qcScanUpload(fileList) {
  var st = document.getElementById('qc-scan-upload-status');
  var jobs = [];
  for (var i = 0; i < fileList.length; i++) {
    if (/\.pdf$/i.test(fileList[i].name)) {
      jobs.push({ id: 'qcup-' + (++_qcScanUploadSeq), file: fileList[i],
                  name: fileList[i].name, size: fileList[i].size,
                  status: 'queued', scanId: null, fileId: null, pages: 0, error: null });
    }
  }
  if (!jobs.length) {
    st.textContent = 'Please choose PDF file(s).';
    st.className = 'text-sm text-red-500';
    st.classList.remove('hidden');
    return;
  }
  st.classList.add('hidden');

  var total = jobs.length;
  var completed = 0;
  var failures = [];

  var panel = document.getElementById('qc-scan-upload-panel');
  var rowsEl = document.getElementById('qc-scan-upload-rows');
  var titleEl = document.getElementById('qc-scan-upload-panel-title');
  var closeBtn = document.getElementById('qc-scan-upload-panel-close');
  rowsEl.innerHTML = '';
  closeBtn.classList.add('hidden');
  panel.classList.remove('hidden');

  function updateHeader() {
    titleEl.textContent = 'Uploading… (' + completed + '/' + total + ' complete)';
  }
  updateHeader();

  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="' + esc(job.name) + '">' + esc(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="qc-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="qc-up-bar h-full bg-togen-teal transition-all duration-150" style="width:0%"></div>' +
        '</div>' +
        '<span class="qc-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('.qc-up-badge');
    b.textContent = text;
    b.className = 'qc-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('.qc-up-bar').style.width = pct + '%';
    row.querySelector('.qc-up-pct').textContent = pct + '%';
  }

  function finish() {
    var failed = failures.length;
    var ok = total - failed;
    // DVI-1460 P1: assemble the review page-list in file order, skipping failed
    // uploads, then show the thumbnail grid (not the workspace directly).
    var fileDescs = [];
    jobs.forEach(function (job) {
      if (job.status !== 'done') return;
      fileDescs.push({ scanId: job.scanId, fileId: job.fileId,
                       name: job.name, pages: job.pages });
    });
    qcScanBuildPages(fileDescs);

    if (failed === 0) {
      titleEl.textContent = 'Uploaded ' + ok + ' of ' + total;
    } else {
      titleEl.textContent = 'Uploaded ' + ok + ' of ' + total + ' — ' + failed + ' failed: ' + failures.join(', ');
    }
    closeBtn.classList.remove('hidden');

    if (!_qcScan.pages.length) {
      // Nothing usable uploaded — stay on the upload screen so the failure
      // list is visible; if everything succeeded but yielded no pages, note it.
      if (failed === 0) {
        st.textContent = 'No pages found in the upload.';
        st.className = 'text-sm text-red-500';
        st.classList.remove('hidden');
      }
      return;
    }
    // Move into the review grid; the upload panel is left in place (its close
    // button dismisses it) so any failures stay visible.
    qcScanShowReview();
  }

  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('files', job.file);

    var xhr = new XMLHttpRequest();
    xhr.open('POST', '/qc/scan/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) {}
      var f = (data && data.files && data.files.length) ? data.files[0] : null;
      if (xhr.status >= 200 && xhr.status < 300 && data && !data.error && f) {
        job.status = 'done';
        job.scanId = data.scan_id;
        job.fileId = f.id;
        job.pages = f.pages;
        setProgress(job, 100);
        setBadge(job, 'Done · ' + f.pages + ' pg', '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('.qc-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();
}

// ------------------------------------------ DVI-1460 P1: page review grid --

// Build the flat per-page review list from uploaded/imported files. Each page
// starts selected, unrotated, no form; the operator deselects mis-scans before
// beginning the per-page workspace.
function qcScanBuildPages(fileDescs) {
  var pages = [];
  (fileDescs || []).forEach(function (f) {
    for (var p = 0; p < f.pages; p++) {
      pages.push({ scanId: f.scanId, fileId: f.fileId, name: f.name,
                   page: p, pages: f.pages, selected: true, rot: 0,
                   blank: false, status: null, _form: null });
    }
  });
  _qcScan.pages = pages;
  _qcScan.scanId = null;
  _qcScan.queue = [];
  _qcScan.idx = 0;
  _qcScan.fromReview = false;
  // DVI-1460 P3: a fresh page set is a fresh scan — allow auto-OCR-on-load to
  // fire once (the one-shot guard keeps Back-to-pages from re-triggering it).
  _qcScan.autoOcrDone = false;
}

function qcScanShowReview() {
  qcScanViewerClose();
  document.getElementById('qc-scan-upload').classList.add('hidden');
  document.getElementById('qc-scan-workspace').classList.add('hidden');
  document.getElementById('qc-scan-review').classList.remove('hidden');
  // DVI-1460 P2: reset any leftover pre-process progress UI (badges are rebuilt
  // by qcScanRenderThumbs; hide the bar + restore the cancel button).
  _qcScan.preprocessing = false; _qcScan.preprocessCancel = false;
  var pbar = document.getElementById('qc-scan-preprocess-bar'); if (pbar) pbar.classList.add('hidden');
  var pcancel = document.getElementById('qc-scan-preprocess-cancel'); if (pcancel) pcancel.classList.remove('hidden');
  var pbtn = document.getElementById('qc-scan-preprocess-btn'); if (pbtn) pbtn.disabled = false;
  qcScanApplyThumbSize();
  qcScanRenderThumbs();
  qcScanMaybeAutoOcr();
}

// DVI-1460 P3: size the thumbnail grid from the admin setting (falls back to
// 150px). Applied on every review render so a Settings change takes effect on
// the next scan without a reload.
function qcScanApplyThumbSize() {
  var wrap = document.getElementById('qc-scan-thumbs');
  if (!wrap) return;
  var s = (_qcMeta && _qcMeta.scan_settings) || {};
  var ts = parseInt(s.thumb_size, 10);
  if (!(ts >= 100 && ts <= 320)) ts = 150;
  _qcScan.thumbSize = ts;
  wrap.style.gridTemplateColumns = 'repeat(auto-fill,minmax(' + ts + 'px,1fr))';
}

// DVI-1460 P3: when the auto-OCR-on-load setting is on, kick off Pre-process OCR
// once for a fresh scan. Guarded so it never re-fires on Back-to-pages or when a
// run is already in flight / pages are already pre-filled.
function qcScanMaybeAutoOcr() {
  if (_qcScan.autoOcrDone || _qcScan.preprocessing) return;
  var s = (_qcMeta && _qcMeta.scan_settings) || {};
  if (s.auto_ocr === false) return;
  var anySelected = _qcScan.pages.some(function (p) { return p.selected; });
  var anyForm = _qcScan.pages.some(function (p) { return p._form; });
  if (!anySelected || anyForm) return;
  _qcScan.autoOcrDone = true;
  qcScanPreprocess();
}

function qcScanThumbUrl(pg) {
  return '/qc/scan/page?scan_id=' + encodeURIComponent(pg.scanId) +
         '&file_id=' + encodeURIComponent(pg.fileId) +
         '&page=' + pg.page + '&thumb=1&rot=' + (pg.rot || 0) +
         '&_=' + Date.now();
}

function qcScanRenderThumbs() {
  var wrap = document.getElementById('qc-scan-thumbs');
  if (!wrap) return;
  wrap.innerHTML = _qcScan.pages.map(function (pg, i) {
    var label = esc(pg.name) + (pg.pages > 1 ? ' — p' + (pg.page + 1) + '/' + pg.pages : '');
    return '<div id="qc-thumb-' + i + '" class="qc-thumb relative rounded-xl border-2 ' +
        (pg.selected ? 'border-togen-teal' : 'border-gray-200 dark:border-gray-700 opacity-60') +
        ' bg-white dark:bg-gray-800 overflow-hidden transition-colors">' +
      '<div class="relative cursor-pointer" onclick="qcScanOpenViewer(' + i + ')" title="Open full-size">' +
        '<img data-qc-thumb="' + i + '" src="' + qcScanThumbUrl(pg) + '" alt="page" ' +
             'class="block w-full h-auto bg-gray-100 dark:bg-gray-900" onload="qcScanThumbLoaded(' + i + ', this)">' +
        '<span data-qc-thumb-blank="' + i + '" class="' + (pg.blank ? '' : 'hidden') + ' absolute top-1 left-1 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-amber-500 text-white">blank?</span>' +
        '<span data-qc-thumb-status="' + i + '" class="hidden absolute bottom-1 left-1 right-1 text-[10px] font-semibold px-1.5 py-0.5 rounded text-center"></span>' +
      '</div>' +
      '<div class="flex items-center justify-between gap-1 px-2 py-1.5">' +
        '<label class="flex items-center gap-1.5 text-[11px] text-gray-600 dark:text-gray-300 min-w-0 cursor-pointer">' +
          '<input type="checkbox" ' + (pg.selected ? 'checked' : '') + ' onchange="qcScanToggleSelect(' + i + ')" class="h-3.5 w-3.5 rounded border-gray-300 text-togen-teal focus:ring-togen-teal">' +
          '<span class="truncate" title="' + label + '">' + label + '</span>' +
        '</label>' +
        '<button type="button" onclick="qcScanRotate(' + i + ')" title="Rotate 90°" class="flex-shrink-0 text-gray-400 hover:text-togen-teal text-sm px-1">&#8635;</button>' +
      '</div>' +
    '</div>';
  }).join('');
  qcScanUpdateReviewCount();
}

// R1 blank-page hint: sample the loaded thumbnail (same-origin PNG → canvas not
// tainted) and flag near-empty pages so the operator can quickly deselect blanks.
function qcScanThumbLoaded(i, img) {
  var pg = _qcScan.pages[i];
  if (!pg) return;
  try {
    var cw = 60, ch = Math.max(1, Math.round(cw * (img.naturalHeight / (img.naturalWidth || 1))));
    var cv = document.createElement('canvas'); cv.width = cw; cv.height = ch;
    var ctx = cv.getContext('2d');
    ctx.drawImage(img, 0, 0, cw, ch);
    var d = ctx.getImageData(0, 0, cw, ch).data;
    var nonWhite = 0, tot = cw * ch;
    for (var p = 0; p < d.length; p += 4) {
      if (d[p] < 235 || d[p + 1] < 235 || d[p + 2] < 235) nonWhite++;
    }
    pg.blank = tot > 0 && (nonWhite / tot) < 0.006;
  } catch (e) { pg.blank = false; }
  var badge = document.querySelector('[data-qc-thumb-blank="' + i + '"]');
  if (badge) badge.classList.toggle('hidden', !pg.blank);
}

function qcScanToggleSelect(i) {
  var pg = _qcScan.pages[i];
  if (!pg) return;
  pg.selected = !pg.selected;
  qcScanUpdateThumbState(i);
  qcScanUpdateReviewCount();
}

function qcScanUpdateThumbState(i) {
  var pg = _qcScan.pages[i];
  var el = document.getElementById('qc-thumb-' + i);
  if (!pg || !el) return;
  el.classList.toggle('border-togen-teal', pg.selected);
  el.classList.toggle('border-gray-200', !pg.selected);
  el.classList.toggle('dark:border-gray-700', !pg.selected);
  el.classList.toggle('opacity-60', !pg.selected);
  var cb = el.querySelector('input[type=checkbox]');
  if (cb) cb.checked = pg.selected;
}

function qcScanSelectAll() { _qcScan.pages.forEach(function (p, i) { p.selected = true; qcScanUpdateThumbState(i); }); qcScanUpdateReviewCount(); }
function qcScanSelectNone() { _qcScan.pages.forEach(function (p, i) { p.selected = false; qcScanUpdateThumbState(i); }); qcScanUpdateReviewCount(); }
function qcScanSelectInvert() { _qcScan.pages.forEach(function (p, i) { p.selected = !p.selected; qcScanUpdateThumbState(i); }); qcScanUpdateReviewCount(); }

function qcScanUpdateReviewCount() {
  var n = _qcScan.pages.length;
  var sel = _qcScan.pages.filter(function (p) { return p.selected; }).length;
  var el = document.getElementById('qc-scan-review-count');
  if (el) el.textContent = n + ' page' + (n === 1 ? '' : 's') + ' · ' + sel + ' selected';
  var begin = document.getElementById('qc-scan-begin-btn');
  if (begin) begin.disabled = sel === 0;
}

// R2 rotate: cycle the page 90° and re-render its thumbnail (+ the viewer if open).
function qcScanRotate(i) {
  var pg = _qcScan.pages[i];
  if (!pg) return;
  pg.rot = ((pg.rot || 0) + 90) % 360;
  var img = document.querySelector('img[data-qc-thumb="' + i + '"]');
  if (img) img.src = qcScanThumbUrl(pg);
  if (_qcScan.viewIdx === i) qcScanViewerRender();
}

// ---- full-page viewer (lightbox) ----

function qcScanOpenViewer(i) {
  if (i < 0 || i >= _qcScan.pages.length) return;
  _qcScan.viewIdx = i;
  document.getElementById('qc-scan-viewer').classList.remove('hidden');
  qcScanViewerRender();
}

function qcScanViewerRender() {
  var i = _qcScan.viewIdx;
  var pg = _qcScan.pages[i];
  if (!pg) return;
  document.getElementById('qc-scan-viewer-title').textContent =
    pg.name + (pg.pages > 1 ? ' — p' + (pg.page + 1) + '/' + pg.pages : '');
  document.getElementById('qc-scan-viewer-pos').textContent =
    'Page ' + (i + 1) + ' of ' + _qcScan.pages.length;
  var img = document.getElementById('qc-scan-viewer-img');
  img.src = '/qc/scan/page?scan_id=' + encodeURIComponent(pg.scanId) +
            '&file_id=' + encodeURIComponent(pg.fileId) +
            '&page=' + pg.page + '&w=1400&rot=' + (pg.rot || 0) + '&_=' + Date.now();
  var sel = document.getElementById('qc-scan-viewer-select');
  if (pg.selected) {
    sel.innerHTML = '&#10003; Selected';
    sel.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg bg-togen-teal text-white transition-colors';
  } else {
    sel.textContent = 'Deselected';
    sel.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg bg-gray-600 text-gray-100 hover:bg-gray-500 transition-colors';
  }
}

function qcScanViewerNav(delta) {
  var n = _qcScan.pages.length;
  if (!n) return;
  var i = _qcScan.viewIdx + delta;
  if (i < 0) i = 0;
  if (i >= n) i = n - 1;
  _qcScan.viewIdx = i;
  qcScanViewerRender();
}

function qcScanViewerToggleSelect() {
  var pg = _qcScan.pages[_qcScan.viewIdx];
  if (!pg) return;
  pg.selected = !pg.selected;
  qcScanViewerRender();
  qcScanUpdateThumbState(_qcScan.viewIdx);
  qcScanUpdateReviewCount();
}

function qcScanViewerRotate() { qcScanRotate(_qcScan.viewIdx); }

function qcScanViewerClose() {
  _qcScan.viewIdx = -1;
  var v = document.getElementById('qc-scan-viewer');
  if (v) v.classList.add('hidden');
}

// Begin the per-page workspace from the SELECTED pages only (D2), carrying each
// page's rotation and any previously-entered form (R3).
function qcScanBegin() {
  var selCount = _qcScan.pages.filter(function (p) { return p.selected; }).length;
  if (!selCount) { alert('Select at least one page to begin.'); return; }  // R4
  _qcScan.queue = _qcScan.pages
    .map(function (pg, idx) { return { pg: pg, idx: idx }; })
    .filter(function (o) { return o.pg.selected; })
    .map(function (o) {
      return { scanId: o.pg.scanId, fileId: o.pg.fileId, name: o.pg.name,
               page: o.pg.page, pages: o.pg.pages, rot: o.pg.rot || 0,
               pageIdx: o.idx, _form: o.pg._form || null };
    });
  _qcScan.idx = 0;
  _qcScan.fromReview = true;
  document.getElementById('qc-scan-review').classList.add('hidden');
  document.getElementById('qc-scan-upload').classList.add('hidden');
  document.getElementById('qc-scan-workspace').classList.remove('hidden');
  var bp = document.getElementById('qc-scan-backpages-btn');
  if (bp) bp.classList.remove('hidden');
  qcScanShowPage();
}

// DVI-1460 P2: pre-process OCR — OCR every selected page in the review grid up
// front and stash each page's pre-filled form on pg._form, so Begin → the
// per-page workspace restores the OCR'd values (no re-read) and the operator just
// reviews + saves. NOTHING is saved to the DB here: pre-processing only fills the
// in-memory forms; each ticket is still saved individually via Save & Next.

// The effective segment for OCR/template + name-matching (the workspace select).
function _qcScanEffectiveSegment() {
  var seg = document.getElementById('qc-scan-segment');
  return (seg && seg.value) || 'wet_cast';
}

// Set (or clear) a thumbnail's OCR status badge. kind: 'busy'|'ok'|'error'.
function qcScanSetThumbStatus(i, text, kind) {
  var el = document.querySelector('[data-qc-thumb-status="' + i + '"]');
  if (!el) return;
  if (!text) { el.className = 'hidden'; el.textContent = ''; return; }
  var tint = kind === 'ok' ? 'bg-togen-teal text-white'
           : kind === 'error' ? 'bg-red-500 text-white'
           : 'bg-gray-800/80 text-white';
  el.className = 'absolute bottom-1 left-1 right-1 text-[10px] font-semibold px-1.5 py-0.5 rounded text-center ' + tint;
  el.textContent = text;
}

// Pure transform: an /qc/scan/extract response → the same form shape
// qcScanCaptureForm produces / qcScanRestoreForm consumes, WITHOUT touching the
// shared workspace DOM (so we can hold N pages' forms at once). Mirrors the field
// mapping in qcScanFillFromExtract + the custom-field coercion in _qcScanFillCustom.
function _qcScanExtractToForm(data, seg) {
  var f = (data && data.fields) || {};
  var form = {
    date: (f.batch_date && _qcScanToISO(f.batch_date)) || qcToday(),
    number: f.batch_number || '',
    mixture: '',
    yards: f.yards ? _qcScanNum(f.yards) : '',
    rock: f.rock_moisture ? _qcScanNum(f.rock_moisture) : '',
    sand: f.sand_moisture ? _qcScanNum(f.sand_moisture) : '',
    initials: f.initials || '',
    notes: f.notes || '',
    lines: {}, custom: {}
  };
  var mixes = ((_qcMeta && _qcMeta.mixtures) || []).filter(function (m) { return m.segment === seg; });
  if (f.mixture) {
    var want = _qcNorm(f.mixture);
    var hit = mixes.find(function (m) { return _qcNorm(m.name) === want; }) ||
              mixes.find(function (m) { var n = _qcNorm(m.name); return n && (n.indexOf(want) >= 0 || want.indexOf(n) >= 0); });
    if (hit) form.mixture = String(hit.id);
  }
  var mats = (_qcMeta && _qcMeta.materials) || [];
  (data.materials || []).forEach(function (mm) {
    if (mm.actual === '' || mm.actual == null) return;
    var want = _qcNorm(mm.name);
    var mat = mats.find(function (m) { return _qcNorm(m.name) === want; }) ||
              mats.find(function (m) { var n = _qcNorm(m.name); return n && (n.indexOf(want) >= 0 || want.indexOf(n) >= 0); });
    if (mat) { var num = _qcScanNum(mm.actual); if (num !== '') form.lines[mat.id] = num; }
  });
  var cfById = {};
  ((_qcMeta && _qcMeta.custom_fields) || []).forEach(function (c) { cfById[c.id] = c; });
  var vals = data.custom_values || {};
  Object.keys(vals).forEach(function (fid) {
    var c = cfById[fid]; if (!c) return;
    var v = vals[fid]; if (v === '' || v == null) return;
    if (c.type === 'number') { var n = _qcScanNum(v); if (n !== '') form.custom[fid] = n; }
    else if (c.type === 'date') { var iso = _qcScanToISO(v); if (iso) form.custom[fid] = iso; }
    else if (c.type === 'text') { form.custom[fid] = String(v); }
    // dropdown / checkbox value-matching deferred (D9) — left for manual review.
  });
  return form;
}

function _qcScanPreprocessSetRunning(on) {
  _qcScan.preprocessing = on;
  var bar = document.getElementById('qc-scan-preprocess-bar');
  if (bar) bar.classList.toggle('hidden', !on);
  ['qc-scan-preprocess-btn', 'qc-scan-begin-btn'].forEach(function (id) {
    var b = document.getElementById(id); if (b) b.disabled = on;
  });
  // Freeze selection edits while a run is in flight.
  document.querySelectorAll('#qc-scan-thumbs input[type=checkbox]').forEach(function (cb) { cb.disabled = on; });
}

function _qcScanPreprocessProgress(done, total) {
  var fill = document.getElementById('qc-scan-preprocess-fill');
  var txt = document.getElementById('qc-scan-preprocess-text');
  if (fill) fill.style.width = (total ? Math.round(done / total * 100) : 0) + '%';
  if (txt) txt.textContent = 'Pre-processing ' + done + ' of ' + total + '…';
}

function qcScanPreprocessCancel() { _qcScan.preprocessCancel = true; }

function qcScanPreprocess() {
  if (_qcScan.preprocessing) return;
  var targets = _qcScan.pages
    .map(function (pg, idx) { return { pg: pg, idx: idx }; })
    .filter(function (o) { return o.pg.selected; });
  if (!targets.length) { alert('Select at least one page to pre-process.'); return; }
  var seg = _qcScanEffectiveSegment();
  _qcScan.preprocessCancel = false;
  _qcScanPreprocessSetRunning(true);
  _qcScanPreprocessProgress(0, targets.length);
  targets.forEach(function (o) { qcScanSetThumbStatus(o.idx, 'queued', 'busy'); });
  var ok = 0, failed = 0;

  function step(k) {
    if (_qcScan.preprocessCancel || k >= targets.length) {
      _qcScanPreprocessSetRunning(false);
      var txt = document.getElementById('qc-scan-preprocess-text');
      // Clear any still-"queued" badges on cancel.
      if (_qcScan.preprocessCancel) {
        targets.slice(k).forEach(function (o) { qcScanSetThumbStatus(o.idx, '', ''); });
      }
      var msg = (_qcScan.preprocessCancel ? 'Cancelled — ' : 'Done — ') +
                ok + ' page' + (ok === 1 ? '' : 's') + ' pre-filled' +
                (failed ? ', ' + failed + ' failed' : '') + '. Begin to review & save.';
      var bar = document.getElementById('qc-scan-preprocess-bar');
      if (bar) bar.classList.remove('hidden');  // keep the summary visible
      if (txt) txt.textContent = msg;
      var fill = document.getElementById('qc-scan-preprocess-fill');
      if (fill && !_qcScan.preprocessCancel) fill.style.width = '100%';
      var cancel = document.getElementById('qc-scan-preprocess-cancel');
      if (cancel) cancel.classList.add('hidden');  // nothing left to cancel
      return;
    }
    var o = targets[k], pg = o.pg;
    qcScanSetThumbStatus(o.idx, 'OCR…', 'busy');
    fetch('/qc/scan/extract', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ scan_id: pg.scanId, file_id: pg.fileId, page: pg.page,
                             segment: seg, rot: pg.rot || 0 })
    }).then(function (r) { return r.json(); }).then(function (data) {
      if (data.error && !(data.fields && Object.keys(data.fields).length)) {
        failed++; qcScanSetThumbStatus(o.idx, 'OCR failed', 'error');
      } else {
        pg._form = _qcScanExtractToForm(data, seg);
        ok++; qcScanSetThumbStatus(o.idx, 'OCR ✓', 'ok');
      }
    }).catch(function () {
      failed++; qcScanSetThumbStatus(o.idx, 'OCR failed', 'error');
    }).then(function () {
      _qcScanPreprocessProgress(k + 1, targets.length);
      step(k + 1);  // strictly sequential — one OCR call at a time
    });
  }
  step(0);
}

// R3: return to the thumbnail grid, stashing the current + all captured forms
// back onto their pages so selections and entered values survive the round-trip.
function qcScanBackToPages() {
  var cur = _qcScan.queue[_qcScan.idx];
  if (cur) cur._form = qcScanCaptureForm();
  _qcScan.queue.forEach(function (it) {
    if (typeof it.pageIdx === 'number' && _qcScan.pages[it.pageIdx]) {
      _qcScan.pages[it.pageIdx]._form = it._form || null;
    }
  });
  qcScanShowReview();
}

function qcScanReset() {
  _qcScan.scanId = null; _qcScan.queue = []; _qcScan.idx = 0; _qcScan.pages = [];
  qcScanViewerClose();
  qcScanDisarmRegion();
  document.getElementById('qc-scan-review').classList.add('hidden');
  document.getElementById('qc-scan-workspace').classList.add('hidden');
  document.getElementById('qc-scan-upload').classList.remove('hidden');
  document.getElementById('qc-scan-upload-status').classList.add('hidden');
  var bp = document.getElementById('qc-scan-backpages-btn'); if (bp) bp.classList.add('hidden');
  qcScanCloseUploadPanel();
}

function qcScanShowPage() {
  var item = _qcScan.queue[_qcScan.idx];
  if (!item) return;
  qcScanDisarmRegion();
  qcScanUpdateNav();
  document.getElementById('qc-scan-progress').textContent =
    'Page ' + (_qcScan.idx + 1) + ' of ' + _qcScan.queue.length;
  document.getElementById('qc-scan-filename').textContent =
    item.name + (item.pages > 1 ? ' — p' + (item.page + 1) + '/' + item.pages : '');
  var img = document.getElementById('qc-scan-img');
  img.src = '/qc/scan/page?scan_id=' + encodeURIComponent(item.scanId) +
            '&file_id=' + encodeURIComponent(item.fileId) +
            '&page=' + item.page + '&rot=' + (item.rot || 0) + '&_=' + Date.now();
  if (item._form) {
    // Page visited before — restore what was entered, don't re-OCR (edits preserved).
    qcScanRestoreForm(item._form);
    var pst = document.getElementById('qc-scan-preview-status');
    pst.textContent = 'Reviewing a page you already opened — your entries are preserved. Use “Re-run OCR” to re-read it.';
    pst.className = 'text-xs text-gray-400 dark:text-gray-500 mt-2';
    pst.classList.remove('hidden');
  } else {
    // First visit — reset the form for the new page, then OCR it.
    qcScanClearForm();
    qcScanOcrPage();
  }
}

function qcScanUpdateNav() {
  var prev = document.getElementById('qc-scan-prev');
  var next = document.getElementById('qc-scan-next');
  var back = document.getElementById('qc-scan-back-btn');
  var atStart = _qcScan.idx <= 0;
  var atEnd = _qcScan.idx >= _qcScan.queue.length - 1;
  if (prev) prev.disabled = atStart;
  if (back) back.disabled = atStart;
  if (next) next.disabled = atEnd;
}

// Snapshot the current form so cycling Prev/Next preserves what was entered.
function qcScanCaptureForm() {
  var lines = {};
  document.querySelectorAll('#qc-scan-lines input[data-qc-mid]').forEach(function (inp) {
    lines[inp.getAttribute('data-qc-mid')] = inp.value;
  });
  var mixEl = document.getElementById('qc-s-mixture');
  return {
    date: document.getElementById('qc-s-date').value,
    number: document.getElementById('qc-s-number').value,
    mixture: mixEl ? mixEl.value : '',
    yards: document.getElementById('qc-s-yards').value,
    rock: document.getElementById('qc-s-rock-moist').value,
    sand: document.getElementById('qc-s-sand-moist').value,
    initials: document.getElementById('qc-s-initials').value,
    notes: document.getElementById('qc-s-notes').value,
    lines: lines,
    custom: _qcCollectCustomValues('qc-s-custom')  // DVI-1452 P2
  };
}

function qcScanRestoreForm(f) {
  document.getElementById('qc-s-date').value = f.date || '';
  document.getElementById('qc-s-number').value = f.number || '';
  var mixEl = document.getElementById('qc-s-mixture'); if (mixEl) mixEl.value = f.mixture || '';
  document.getElementById('qc-s-yards').value = f.yards || '';
  document.getElementById('qc-s-rock-moist').value = f.rock || '';
  document.getElementById('qc-s-sand-moist').value = f.sand || '';
  document.getElementById('qc-s-initials').value = f.initials || '';
  document.getElementById('qc-s-notes').value = f.notes || '';
  document.getElementById('qc-scan-unmatched').classList.add('hidden');
  var _diagR = document.getElementById('qc-scan-ocr-diag'); if (_diagR) _diagR.classList.add('hidden');
  if (_qcScan) _qcScan.lastRegions = null;  // DVI-1442 fix #5: drop stale re-read state
  qcScanRenderLines();  // rebuilds rows from the restored mixture/yards
  // DVI-1452 P2: restore custom values (after render, which starts them blank).
  _qcRenderCustomFields('qc-s-custom',
    (document.getElementById('qc-scan-segment') || {}).value || 'wet_cast', f.custom || {});
  Object.keys(f.lines || {}).forEach(function (mid) {
    var inp = document.querySelector('#qc-scan-lines input[data-qc-mid="' + mid + '"]');
    if (inp) { inp.value = f.lines[mid]; qcScanLineCheck(inp); }
  });
}

function qcScanGoto(idx) {
  if (idx < 0 || idx >= _qcScan.queue.length || idx === _qcScan.idx) return;
  var cur = _qcScan.queue[_qcScan.idx];
  if (cur) cur._form = qcScanCaptureForm();
  _qcScan.idx = idx;
  qcScanShowPage();
}

function qcScanPrev() { qcScanGoto(_qcScan.idx - 1); }
function qcScanNext() { qcScanGoto(_qcScan.idx + 1); }

function qcScanClearForm() {
  ['qc-s-date', 'qc-s-number', 'qc-s-yards', 'qc-s-rock-moist',
   'qc-s-sand-moist', 'qc-s-initials', 'qc-s-notes'].forEach(function (id) {
    var el = document.getElementById(id); if (el) el.value = '';
  });
  document.getElementById('qc-s-date').value = qcToday();
  var mixSel = document.getElementById('qc-s-mixture');
  if (mixSel) mixSel.value = '';
  // Blank any material actuals so the previous page's values don't carry over
  // (qcScanRenderLines preserves whatever is already in the inputs).
  document.querySelectorAll('#qc-scan-lines input[data-qc-mid]').forEach(function (inp) { inp.value = ''; });
  document.getElementById('qc-scan-unmatched').classList.add('hidden');
  var _diagN = document.getElementById('qc-scan-ocr-diag'); if (_diagN) _diagN.classList.add('hidden');
  if (_qcScan) _qcScan.lastRegions = null;  // DVI-1442 fix #5: drop stale re-read state
  var st = document.getElementById('qc-scan-status'); if (st) st.classList.add('hidden');
  // DVI-1452 P2: blank custom fields so the previous page's values don't carry
  // over (qcScanRenderLines preserves whatever is already in the inputs).
  var _cw = document.getElementById('qc-s-custom'); if (_cw) _cw.innerHTML = '';
  qcScanRenderLines();
}

function qcScanUpdateTemplateBadge() {
  // DVI-1442: show which OCR region template (if any) the selected segment's
  // scans use, so the operator can tell at a glance that a template is active.
  var badge = document.getElementById('qc-scan-template-badge');
  var ovBtn = document.getElementById('qc-scan-tpl-ov-btn');
  var segSel = document.getElementById('qc-scan-segment');
  if (!badge) return;
  var seg = (segSel && segSel.value) || '';
  var name = seg && _qcMeta && _qcMeta.ocr_templates && _qcMeta.ocr_templates[seg];
  if (name) {
    // DVI-1442 prod-verify follow-up: the badge is clickable to re-run the
    // template OCR (e.g. after picking the Mixture).
    badge.textContent = 'Region template: ' + name + ' — click to re-run OCR';
    badge.classList.remove('hidden');
    if (ovBtn) ovBtn.classList.remove('hidden');
  } else {
    badge.classList.add('hidden');
    // No template for this segment — hide + clear the region-overlay affordance.
    if (ovBtn) { ovBtn.classList.add('hidden'); ovBtn.classList.remove('bg-togen-teal/10'); }
    if (_qcScan) _qcScan.tplOverlay = false;
    var ov = document.getElementById('qc-scan-tpl-ov');
    if (ov) ov.innerHTML = '';
  }
}

// DVI-1442 prod-verify follow-up: draw the assigned template's field rects over
// the scanned page so the operator can see how the template lines up with real
// scans (alignment/skew tuning). Each field shows its exact read box (solid) +
// the padded skew-tolerance margin the region OCR retries within (dashed).
function qcScanToggleTemplateOverlay() {
  _qcScan.tplOverlay = !_qcScan.tplOverlay;
  var btn = document.getElementById('qc-scan-tpl-ov-btn');
  if (btn) btn.classList.toggle('bg-togen-teal/10', _qcScan.tplOverlay);
  qcScanDrawTemplateOverlay();
}

function qcScanDrawTemplateOverlay() {
  var ov = document.getElementById('qc-scan-tpl-ov');
  if (!ov) return;
  ov.innerHTML = '';
  if (!_qcScan || !_qcScan.tplOverlay) return;
  var segSel = document.getElementById('qc-scan-segment');
  var seg = (segSel && segSel.value) || '';
  fetch('/qc/scan/template-regions?segment=' + encodeURIComponent(seg))
    .then(function (r) { return r.json(); })
    .then(function (data) {
      if (!_qcScan.tplOverlay) return;  // toggled off while fetching
      ov.innerHTML = '';
      var fields = (data && data.fields) || [];
      var pad = (data && typeof data.pad === 'number') ? data.pad : 0;
      fields.forEach(function (f) {
        var r = f.rect || {};
        if (pad > 0) {  // dashed skew-tolerance margin
          var px0 = Math.max(0, r.x0 - pad), py0 = Math.max(0, r.y0 - pad);
          var px1 = Math.min(1, r.x1 + pad), py1 = Math.min(1, r.y1 + pad);
          var padBox = document.createElement('div');
          padBox.className = 'absolute border border-dashed border-amber-400/80';
          padBox.style.cssText = 'pointer-events:none;left:' + (px0 * 100) + '%;top:' + (py0 * 100) +
            '%;width:' + ((px1 - px0) * 100) + '%;height:' + ((py1 - py0) * 100) + '%;';
          ov.appendChild(padBox);
        }
        var box = document.createElement('div');
        box.className = 'absolute border-2 border-togen-teal bg-togen-teal/10';
        box.style.cssText = 'pointer-events:none;left:' + (r.x0 * 100) + '%;top:' + (r.y0 * 100) +
          '%;width:' + ((r.x1 - r.x0) * 100) + '%;height:' + ((r.y1 - r.y0) * 100) + '%;';
        var lab = document.createElement('span');
        lab.className = 'absolute left-0 -top-4 text-[10px] font-semibold px-1 rounded bg-togen-teal text-white whitespace-nowrap';
        lab.textContent = f.label;
        box.appendChild(lab);
        ov.appendChild(box);
      });
    }).catch(function () {});
}

// DVI-1442 prod-verify fix #2: the Scan Tickets mixture <select> was never
// populated (only the batch modal filled its own), so it showed no options and
// couldn't be used. Fill it from meta, filtered to the selected segment, keeping
// the current pick when it's still valid.
function qcScanFillMixtures() {
  var mixSel = document.getElementById('qc-s-mixture');
  if (!mixSel) return;
  var segSel = document.getElementById('qc-scan-segment');
  var seg = (segSel && segSel.value) || 'wet_cast';
  var prev = mixSel.value;
  var mixes = ((_qcMeta && _qcMeta.mixtures) || []).filter(function (m) { return m.segment === seg; });
  mixSel.innerHTML = '<option value="">(no mixture)</option>' + mixes.map(function (m) {
    return '<option value="' + m.id + '">' + esc(m.name) + '</option>';
  }).join('');
  mixSel.value = prev && mixes.some(function (m) { return String(m.id) === prev; }) ? prev : '';
}

function qcScanSegmentChanged() {
  qcScanUpdateTemplateBadge();
  qcScanFillMixtures();
  qcScanRenderLines();
  qcScanDrawTemplateOverlay();  // template differs per segment — redraw if shown
}

function qcScanOcrPage(forceWhole) {
  var item = _qcScan.queue[_qcScan.idx];
  if (!item) return;
  var pst = document.getElementById('qc-scan-preview-status');
  pst.textContent = forceWhole ? 'Reading the whole page with OCR…' : 'Reading the page with OCR…';
  pst.className = 'text-xs text-togen-teal mt-2';
  pst.classList.remove('hidden');
  var segSel = document.getElementById('qc-scan-segment');
  fetch('/qc/scan/extract', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ scan_id: item.scanId, file_id: item.fileId, page: item.page,
                           segment: (segSel && segSel.value) || '',
                           rot: item.rot || 0,
                           force_wholepage: !!forceWhole })
  }).then(function (r) { return r.json(); }).then(function (data) {
    if (data.error && !(data.fields && Object.keys(data.fields).length)) {
      pst.textContent = 'OCR unavailable: ' + data.error + ' — enter the values manually.';
      pst.className = 'text-xs text-amber-600 dark:text-amber-400 mt-2';
      return;
    }
    qcScanFillFromExtract(data);
    var how;
    if (data.template && !data.template_fallback) {
      how = 'template "' + data.template + '"' +
            (typeof data.template_hits === 'number'
              ? ', ' + data.template_hits + ' of ' + data.template_regions + ' regions matched' : '');
    } else if (data.template && data.template_fallback) {
      how = 'template "' + data.template + '" matched nothing — used whole-page OCR';
    } else {
      how = 'whole-page OCR';
    }
    pst.textContent = 'OCR done (' + (data.engine || 'ocr') + ', ' + how + ') — check the values, then Save & Next.';
    pst.className = 'text-xs ' + (data.template && data.template_fallback
      ? 'text-amber-600 dark:text-amber-400' : 'text-gray-400 dark:text-gray-500') + ' mt-2';
  }).catch(function () {
    pst.textContent = 'OCR request failed — enter the values manually.';
    pst.className = 'text-xs text-amber-600 dark:text-amber-400 mt-2';
  });
}

function _qcNorm(s) { return String(s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); }

function qcScanFillFromExtract(data) {
  var f = data.fields || {};
  if (f.batch_date) { var iso = _qcScanToISO(f.batch_date); if (iso) document.getElementById('qc-s-date').value = iso; }
  if (f.batch_number) document.getElementById('qc-s-number').value = f.batch_number;
  if (f.yards) document.getElementById('qc-s-yards').value = _qcScanNum(f.yards);
  if (f.rock_moisture) document.getElementById('qc-s-rock-moist').value = _qcScanNum(f.rock_moisture);
  if (f.sand_moisture) document.getElementById('qc-s-sand-moist').value = _qcScanNum(f.sand_moisture);
  if (f.initials) document.getElementById('qc-s-initials').value = f.initials;
  if (f.notes) document.getElementById('qc-s-notes').value = f.notes;
  // Match mixture name → id within the chosen segment.
  var seg = document.getElementById('qc-scan-segment').value || 'wet_cast';
  var mixSel = document.getElementById('qc-s-mixture');
  if (f.mixture && mixSel) {
    var want = _qcNorm(f.mixture);
    var mixes = ((_qcMeta && _qcMeta.mixtures) || []).filter(function (m) { return m.segment === seg; });
    var hit = mixes.find(function (m) { return _qcNorm(m.name) === want; }) ||
              mixes.find(function (m) { var n = _qcNorm(m.name); return n && (n.indexOf(want) >= 0 || want.indexOf(n) >= 0); });
    if (hit) mixSel.value = String(hit.id);
  }
  qcScanRenderLines();
  // Match extracted materials → the rendered actual inputs by name.
  var unmatched = [];
  (data.materials || []).forEach(function (mm) {
    var want = _qcNorm(mm.name);
    var mat = ((_qcMeta && _qcMeta.materials) || []).find(function (m) { return _qcNorm(m.name) === want; }) ||
              ((_qcMeta && _qcMeta.materials) || []).find(function (m) { var n = _qcNorm(m.name); return n && (n.indexOf(want) >= 0 || want.indexOf(n) >= 0); });
    if (mat) {
      var inp = document.querySelector('#qc-scan-lines input[data-qc-mid="' + mat.id + '"]');
      if (inp && mm.actual !== '') { inp.value = _qcScanNum(mm.actual); qcScanLineCheck(inp); }
    } else if (mm.name) {
      unmatched.push(mm.name + (mm.actual !== '' ? ' (' + mm.actual + ')' : ''));
    }
  });
  var un = document.getElementById('qc-scan-unmatched');
  if (unmatched.length) {
    un.textContent = 'Unmatched OCR materials (enter manually if needed): ' + unmatched.join(', ');
    un.classList.remove('hidden');
  } else { un.classList.add('hidden'); }
  _qcScanFillCustom(data.custom_values || {});  // DVI-1452 P3
  qcScanRenderOcrDiag(data);
  // DVI-1442 prod-verify fix #5: load the segment's template rects so the Region
  // OCR details panel can offer a per-field "re-read from the template box" button
  // (an exact-rect re-OCR — the reliable path the operator gets by drawing a box),
  // then re-render the diag with those buttons.
  if ((data.regions || []).length) {
    _qcScanLoadTplRects(function () { qcScanRenderOcrDiag({ regions: _qcScan.lastRegions }); });
  }
}

// DVI-1452 P3: apply OCR-read values to the segment's custom Batch Ticket fields
// (rendered into #qc-s-custom by qcScanRenderLines just above). D9: text/number/
// date fill from the raw read; dropdown/checkbox value-matching is deferred, so
// those are left for manual selection (the raw read still shows in the Region
// OCR details panel as "read, not applied").
function _qcScanFillCustom(vals) {
  var byId = {};
  ((_qcMeta && _qcMeta.custom_fields) || []).forEach(function (f) { byId[f.id] = f; });
  Object.keys(vals).forEach(function (fid) {
    var f = byId[fid];
    if (!f) return;
    var el = document.querySelector('#qc-s-custom [data-qc-cf="' + fid + '"]');
    if (!el) return;
    var v = vals[fid];
    if (v === '' || v == null) return;
    if (f.type === 'number') { var n = _qcScanNum(v); if (n !== '') el.value = n; }
    else if (f.type === 'date') { var iso = _qcScanToISO(v); if (iso) el.value = iso; }
    else if (f.type === 'text') { el.value = String(v); }
    // dropdown / checkbox: deferred (D9) — not auto-applied.
  });
}

// DVI-1442 prod-verify fix #4: per-field Region OCR diagnostics. The board asked
// to "indicate on each field what the OCR actually matched … and what is being
// filled vs not filled" so template misses are debuggable now and ongoing. For
// each templated region we show the raw text it read and whether that value
// actually landed in the field (read from the live input after the fill pass),
// which cleanly separates "matched" (read text) from "filled" (applied).
var _QC_SCAN_HEADER_INPUTS = {
  batch_date: 'qc-s-date', batch_number: 'qc-s-number', mixture: 'qc-s-mixture',
  yards: 'qc-s-yards', rock_moisture: 'qc-s-rock-moist',
  sand_moisture: 'qc-s-sand-moist', initials: 'qc-s-initials', notes: 'qc-s-notes'
};

function _qcScanRegionInput(region) {
  if (region.kind === 'material') {
    return document.querySelector('#qc-scan-lines input[data-qc-mid="' + region.mid + '"]');
  }
  if (region.kind === 'custom') {  // DVI-1452 P3
    return document.querySelector('#qc-s-custom [data-qc-cf="' + region.cid + '"]');
  }
  var id = _QC_SCAN_HEADER_INPUTS[region.key];
  return id ? document.getElementById(id) : null;
}

function qcScanRenderOcrDiag(data) {
  var wrap = document.getElementById('qc-scan-ocr-diag');
  var body = document.getElementById('qc-scan-ocr-diag-body');
  var cnt = document.getElementById('qc-scan-ocr-diag-count');
  if (!wrap || !body) return;
  var regions = (data && data.regions) || [];
  if (!regions.length) { wrap.classList.add('hidden'); return; }
  if (_qcScan) _qcScan.lastRegions = regions;  // DVI-1442 fix #5: per-field re-run
  var tplRects = (_qcScan && _qcScan.tplRects) || {};
  var filled = 0, matched = 0;
  var rows = regions.map(function (r) {
    var raw = (r.raw || '').trim();
    var inp = _qcScanRegionInput(r);
    // A custom checkbox reflects state via .checked, not .value ("on" default).
    var val = inp
      ? (inp.type === 'checkbox' ? (inp.checked ? 'checked' : '')
         : String(inp.value || '').trim())
      : '';
    var isFilled = !!val;
    if (raw) matched++;
    if (isFilled) filled++;
    var status, cls;
    if (isFilled) { status = '&#10003; filled: ' + esc(val); cls = 'text-green-600 dark:text-green-400'; }
    else if (raw) { status = 'read, not applied'; cls = 'text-amber-600 dark:text-amber-400'; }
    else { status = 'no text read'; cls = 'text-gray-400 dark:text-gray-500'; }
    // DVI-1442 fix #5: a per-field button that re-reads THIS field from its exact
    // template rect (no skew-padding), so the operator can force the reliable
    // read the board saw when manually selecting the region — without redrawing.
    var action = tplRects[r.key]
      ? '<button type="button" onclick="qcScanRerunFieldRegion(\'' + r.key + '\')" title="Re-read this field from its template region" class="text-togen-teal hover:text-togen-teal-dark">&#8635; re-read</button>'
      : '';
    return '<tr class="border-t border-gray-200 dark:border-gray-700">' +
      '<td class="pr-3 py-1 whitespace-nowrap font-medium text-gray-600 dark:text-gray-300">' + esc(r.label || r.key) + '</td>' +
      '<td class="pr-3 py-1 text-gray-500 dark:text-gray-400">' + (raw ? esc(raw) : '<span class="italic">(blank)</span>') + '</td>' +
      '<td class="pr-3 py-1 ' + cls + '">' + status + '</td>' +
      '<td class="py-1 whitespace-nowrap">' + action + '</td></tr>';
  }).join('');
  body.innerHTML = '<table class="w-full text-left"><thead><tr class="text-gray-400 dark:text-gray-500">' +
    '<th class="pr-3 py-1 font-medium">Field</th><th class="pr-3 py-1 font-medium">OCR read</th>' +
    '<th class="pr-3 py-1 font-medium">Result</th><th class="py-1 font-medium"></th></tr></thead><tbody>' + rows + '</tbody></table>';
  if (cnt) cnt.textContent = '(' + filled + ' filled / ' + matched + ' read / ' + regions.length + ' regions)';
  wrap.classList.remove('hidden');
}

// DVI-1442 fix #5: fetch the segment's assigned template rects keyed by field so
// the Region OCR details panel can offer a per-field re-read. Same source as the
// template overlay; stored on _qcScan for the click handler.
function _qcScanLoadTplRects(cb) {
  if (!_qcScan) return;
  var segSel = document.getElementById('qc-scan-segment');
  var seg = (segSel && segSel.value) || '';
  fetch('/qc/scan/template-regions?segment=' + encodeURIComponent(seg))
    .then(function (r) { return r.json(); })
    .then(function (data) {
      var map = {};
      ((data && data.fields) || []).forEach(function (f) { if (f.rect) map[f.key] = f.rect; });
      _qcScan.tplRects = map;
      if (cb) cb();
    }).catch(function () {});
}

// Apply a re-read region's raw text to its field with the same type-aware coercion
// the OCR fill + manual region draw use (date -> ISO, numeric -> number, else raw).
function _qcScanApplyRegionValue(region, el, text, prev) {
  if (!el) return;
  if (el.type === 'checkbox') return;  // dropdown/checkbox value-matching deferred (D9)
  if (region.kind === 'material') { el.value = _qcScanNum(text) || prev; qcScanLineCheck(el); return; }
  if (region.kind === 'custom') {
    var f = ((_qcMeta && _qcMeta.custom_fields) || []).find(function (x) { return x.id === region.cid; });
    var ty = f ? f.type : 'text';
    if (ty === 'number') { var n = _qcScanNum(text); el.value = n !== '' ? n : prev; }
    else if (ty === 'date') { el.value = _qcScanToISO(text) || prev; }
    else { el.value = text || prev; }
    return;
  }
  if (region.key === 'batch_date') { el.value = _qcScanToISO(text) || prev; }
  else if (region.key === 'yards' || region.key === 'rock_moisture' || region.key === 'sand_moisture') {
    el.value = _qcScanNum(text) || prev;
  } else { el.value = text || prev; }
}

// DVI-1442 fix #5: re-read one field from its EXACT template rect (no skew-pad, so
// it can't drift into a neighbouring field's value the way the padded extraction
// can). This is the reliable read the board saw by manually selecting the region,
// now one click from the per-field diagnostics row.
function qcScanRerunFieldRegion(key) {
  var item = _qcScan && _qcScan.queue[_qcScan.idx];
  if (!item) return;
  var rect = (_qcScan.tplRects || {})[key];
  var region = (_qcScan.lastRegions || []).find(function (r) { return r.key === key; });
  if (!rect || !region) return;
  var el = _qcScanRegionInput(region);
  var isChk = el && el.type === 'checkbox';
  var prev = el ? (isChk ? '' : el.value) : '';
  if (el && !isChk) el.value = '…';
  fetch('/qc/scan/region', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ scan_id: item.scanId, file_id: item.fileId, page: item.page, rect: rect })
  }).then(function (r) { return r.json(); }).then(function (data) {
    var text = (data.text || '').trim();
    _qcScanApplyRegionValue(region, el, text, prev);
    region.raw = text;
    qcScanRenderOcrDiag({ regions: _qcScan.lastRegions });
  }).catch(function () { if (el && !isChk) el.value = prev; });
}

function _qcScanNum(v) {
  var m = String(v).replace(/,/g, '').match(/-?\d+(\.\d+)?/);
  return m ? m[0] : '';
}

function _qcScanToISO(v) {
  var s = String(v).trim();
  if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
  var m = s.match(/^(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{2,4})$/);  // M/D/Y (US)
  if (m) {
    var yr = m[3].length === 2 ? '20' + m[3] : m[3];
    var mo = ('0' + m[1]).slice(-2), da = ('0' + m[2]).slice(-2);
    if (+mo >= 1 && +mo <= 12 && +da >= 1 && +da <= 31) return yr + '-' + mo + '-' + da;
  }
  var d = new Date(s);
  if (!isNaN(d.getTime())) {
    return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2);
  }
  return '';
}

function _qcScanSelectedTargets() {
  var mid = parseInt(document.getElementById('qc-s-mixture').value, 10);
  var yards = parseFloat(document.getElementById('qc-s-yards').value) || 0;
  var out = {};
  if (!mid || !_qcMeta) return out;
  var mix = (_qcMeta.mixtures || []).find(function (m) { return m.id === mid; });
  if (!mix) return out;
  (mix.targets || []).forEach(function (t) {
    var hasRange = (t.minus || 0) > 0 || (t.plus || 0) > 0;
    out[t.material_id] = {
      target: t.target * yards,
      hasRange: hasRange,
      min: Math.max(0, (t.target - (t.minus || 0)) * yards),
      max: (t.target + (t.plus || 0)) * yards
    };
  });
  return out;
}

function qcScanRenderLines() {
  var wrap = document.getElementById('qc-scan-lines');
  if (!_qcMeta || !wrap) { if (wrap) wrap.innerHTML = ''; return; }
  // DVI-1441: hide fields disabled for the selected segment (display-only).
  var _sSeg = (document.getElementById('qc-scan-segment') || {}).value || 'wet_cast';
  _qcApplyFieldVisibility('qc-s', _sSeg);
  // DVI-1452 P2: render the segment's custom fields, preserving typed values
  // across a segment/mixture/yards re-render.
  _qcRenderCustomFields('qc-s-custom', _sSeg, _qcCollectCustomValues('qc-s-custom'));
  var typed = {};
  wrap.querySelectorAll('input[data-qc-mid]').forEach(function (inp) {
    typed[inp.getAttribute('data-qc-mid')] = inp.value;
  });
  var targets = _qcScanSelectedTargets();
  // DVI-1440 P2 + review #2: the selected mixture's capture lines (included leaf
  // materials + any directly-captured aggregate total). No mixture selected ->
  // all materials, unchanged.
  var smid = parseInt(document.getElementById('qc-s-mixture').value, 10);
  var smix = (_qcMeta.mixtures || []).find(function (m) { return m.id === smid; });
  var smats = _qcCaptureMaterials(smix);
  var rows = smats.map(function (m) {
    var t = targets[m.id];
    var existing = typed.hasOwnProperty(String(m.id)) ? typed[String(m.id)] : '';
    return '<tr class="border-b border-gray-100 dark:border-gray-800">' +
      '<td class="py-1.5 pr-3 text-gray-700 dark:text-gray-200">' + esc(m.name) + ' <span class="text-xs text-gray-400">(' + esc(m.capture_unit || '') + ')</span></td>' +
      '<td class="py-1.5 pr-3 text-gray-500 dark:text-gray-400">' + (t ? qcFmt(t.target) : '—') + '</td>' +
      '<td class="py-1.5 pr-3 text-xs text-gray-400 dark:text-gray-500">' + (t && t.hasRange ? qcFmt(t.min) + ' – ' + qcFmt(t.max) : '') + '</td>' +
      '<td class="py-1.5 whitespace-nowrap"><input type="number" step="any" min="0" data-qc-mid="' + m.id + '" value="' + esc(String(existing)) + '" oninput="qcScanLineCheck(this)"' +
      ' class="w-28 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">' +
      ' <button type="button" onclick="qcScanRegionField(\'mat:' + m.id + '\')" class="text-togen-teal text-xs align-middle" title="OCR a region into this field">◎</button></td>' +
      '</tr>';
  }).join('');
  wrap.innerHTML =
    '<div class="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 font-medium">Material</th><th class="py-2 pr-3 font-medium">Target</th>' +
    '<th class="py-2 pr-3 font-medium">Acceptable range</th><th class="py-2 font-medium">Actual</th>' +
    '</tr></thead><tbody>' + rows + '</tbody></table></div>';
  wrap.querySelectorAll('input[data-qc-mid]').forEach(function (inp) { qcScanLineCheck(inp); });
}

function qcScanLineCheck(inp) {
  var targets = _qcScanSelectedTargets();
  var t = targets[parseInt(inp.getAttribute('data-qc-mid'), 10)];
  var v = inp.value === '' ? null : parseFloat(inp.value);
  // Target-only lines (t.hasRange false) are never flagged out of range.
  var bad = !!(t && t.hasRange && v !== null && isFinite(v) && (v < t.min || v > t.max));
  inp.classList.toggle('border-amber-500', bad);
  inp.classList.toggle('ring-2', bad);
  inp.classList.toggle('ring-amber-400', bad);
  var anyBad = false;
  document.querySelectorAll('#qc-scan-lines input[data-qc-mid]').forEach(function (i) {
    if (i.classList.contains('border-amber-500')) anyBad = true;
  });
  document.getElementById('qc-scan-oor-note').classList.toggle('hidden', !anyBad);
}

function qcScanCollect() {
  var lines = [];
  document.querySelectorAll('#qc-scan-lines input[data-qc-mid]').forEach(function (inp) {
    lines.push({ material_id: parseInt(inp.getAttribute('data-qc-mid'), 10), actual: inp.value });
  });
  return {
    batch_date: document.getElementById('qc-s-date').value,
    segment: document.getElementById('qc-scan-segment').value || 'wet_cast',
    batch_number: document.getElementById('qc-s-number').value,
    mixture_id: document.getElementById('qc-s-mixture').value || null,
    yards: document.getElementById('qc-s-yards').value,
    rock_moisture: document.getElementById('qc-s-rock-moist').value,
    sand_moisture: document.getElementById('qc-s-sand-moist').value,
    initials: document.getElementById('qc-s-initials').value,
    notes: document.getElementById('qc-s-notes').value,
    lines: lines,
    custom_values: _qcCollectCustomValues('qc-s-custom')  // DVI-1452 P2
  };
}

function qcScanSave(advance) {
  var body = qcScanCollect();
  var st = document.getElementById('qc-scan-status');
  fetch('/qc/scan/save', {
    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) {
        st.textContent = res.d.error || 'Save failed.';
        st.className = 'text-sm text-red-500';
        st.classList.remove('hidden');
        return;
      }
      if (advance) { qcScanAdvance(); }
      else {
        st.textContent = 'Saved. Skip to advance, or edit and Save again to overwrite is not supported — use Save & Next.';
        st.className = 'text-sm text-green-600 dark:text-green-400';
        st.classList.remove('hidden');
      }
    })
    .catch(function () {
      st.textContent = 'Save failed (network).';
      st.className = 'text-sm text-red-500';
      st.classList.remove('hidden');
    });
}

function qcScanSkip() { qcScanAdvance(); }

function qcScanAdvance() {
  var cur = _qcScan.queue[_qcScan.idx];
  if (cur) cur._form = qcScanCaptureForm();
  _qcScan.idx += 1;
  if (_qcScan.idx >= _qcScan.queue.length) {
    qcScanFinish();
    return;
  }
  qcScanShowPage();
}

function qcScanFinish() {
  qcScanDisarmRegion();
  var n = _qcScan.queue.length;
  document.getElementById('qc-scan-workspace').classList.add('hidden');
  var bp = document.getElementById('qc-scan-backpages-btn'); if (bp) bp.classList.add('hidden');
  var up = document.getElementById('qc-scan-upload');
  up.classList.remove('hidden');
  var st = document.getElementById('qc-scan-upload-status');
  st.textContent = 'All ' + n + ' page' + (n === 1 ? '' : 's') + ' processed. Upload more tickets to continue.';
  st.className = 'text-sm text-green-600 dark:text-green-400';
  st.classList.remove('hidden');
  qcScanCloseUploadPanel();
  _qcScan.scanId = null; _qcScan.queue = []; _qcScan.idx = 0; _qcScan.pages = [];
}

// ---- region selection (draw a box → OCR into a field) ----

function qcScanArmRegion() { _qcScanArm('__pick__'); }
function qcScanRegionField(fieldId) { _qcScanArm(fieldId); }

function _qcScanArm(target) {
  _qcScan.region = { target: target };
  var ov = document.getElementById('qc-scan-region-ov');
  if (ov) { ov.style.pointerEvents = 'auto'; ov.style.cursor = 'crosshair'; ov.style.touchAction = 'none'; }
  var hint = document.getElementById('qc-scan-region-hint');
  if (hint) hint.classList.remove('hidden');
}

function qcScanDisarmRegion() {
  _qcScan.region = null;
  var ov = document.getElementById('qc-scan-region-ov');
  if (ov) { ov.style.pointerEvents = 'none'; ov.style.cursor = ''; var b = ov.querySelector('.qc-scan-box'); if (b) b.remove(); }
  var hint = document.getElementById('qc-scan-region-hint');
  if (hint) hint.classList.add('hidden');
}

// Shared draw-a-box on overlay `ov` from a pointerdown; calls onRect(fractionRect
// | null) with the 0-1 rect (null on a too-small tap). Used by both the Scan
// Tickets region OCR and the OCR Region Template builder (DVI-1442) — the overlay
// element + what happens with the rect are the only differences.
function _qcRegionCapture(ov, e, onRect) {
  e.preventDefault();
  var rect = ov.getBoundingClientRect();
  var startX = e.clientX, startY = e.clientY;
  var box = document.createElement('div');
  box.className = 'qc-scan-box absolute border-2 border-togen-teal bg-togen-teal/10';
  box.style.left = (startX - rect.left) + 'px';
  box.style.top = (startY - rect.top) + 'px';
  var old = ov.querySelector('.qc-scan-box'); if (old) old.remove();
  ov.appendChild(box);
  function draw(ev) {
    var x = Math.max(rect.left, Math.min(rect.right, ev.clientX));
    var y = Math.max(rect.top, Math.min(rect.bottom, ev.clientY));
    box.style.left = (Math.min(startX, x) - rect.left) + 'px';
    box.style.top = (Math.min(startY, y) - rect.top) + 'px';
    box.style.width = Math.abs(x - startX) + 'px';
    box.style.height = Math.abs(y - startY) + 'px';
  }
  function up(ev) {
    window.removeEventListener('pointermove', draw);
    window.removeEventListener('pointerup', up);
    var fr = {
      x0: (Math.min(startX, ev.clientX) - rect.left) / rect.width,
      y0: (Math.min(startY, ev.clientY) - rect.top) / rect.height,
      x1: (Math.max(startX, ev.clientX) - rect.left) / rect.width,
      y1: (Math.max(startY, ev.clientY) - rect.top) / rect.height
    };
    onRect((fr.x1 - fr.x0) < 0.01 || (fr.y1 - fr.y0) < 0.01 ? null : fr);
  }
  window.addEventListener('pointermove', draw);
  window.addEventListener('pointerup', up);
}

function qcScanRegionPointerDown(e) {
  if (!_qcScan.region) return;
  var target = _qcScan.region.target;
  _qcRegionCapture(e.currentTarget, e, function (fr) {
    qcScanDisarmRegion();
    if (!fr) return;  // ignore taps
    if (target === '__pick__') { qcScanFieldPickOpen(fr); }
    else { qcScanRunRegion(fr, target); }
  });
}

// Numeric ids for the QC batch fields the region OCR can read into.
var _QC_SCAN_REGION_FIELDS = [
  { id: 'qc-s-number', label: 'Batch #' },
  { id: 'qc-s-yards', label: 'Yards' },
  { id: 'qc-s-rock-moist', label: 'Rock moisture %' },
  { id: 'qc-s-sand-moist', label: 'Sand moisture %' },
  { id: 'qc-s-initials', label: 'Initials' },
  { id: 'qc-s-date', label: 'Date' },
  { id: 'qc-s-notes', label: 'Notes' }
];

function qcScanFieldPickOpen(fr) {
  _qcScan.pendingRect = fr;
  var body = document.getElementById('qc-scan-fieldpick-body');
  body.innerHTML = _QC_SCAN_REGION_FIELDS.map(function (f) {
    return '<button onclick="qcScanFieldPick(\'' + f.id + '\')" class="togen-control block w-full text-left px-3 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">' + esc(f.label) + '</button>';
  }).join('');
  document.getElementById('qc-scan-fieldpick').classList.remove('hidden');
}

function qcScanFieldPick(fieldId) {
  qcScanFieldPickClose();
  if (_qcScan.pendingRect) qcScanRunRegion(_qcScan.pendingRect, fieldId);
  _qcScan.pendingRect = null;
}

function qcScanFieldPickClose() {
  document.getElementById('qc-scan-fieldpick').classList.add('hidden');
}

function qcScanRunRegion(fr, fieldId) {
  var item = _qcScan.queue[_qcScan.idx];
  if (!item) return;
  var isMat = fieldId.indexOf('mat:') === 0;
  var el;
  if (isMat) {
    el = document.querySelector('#qc-scan-lines input[data-qc-mid="' + fieldId.slice(4) + '"]');
  } else {
    el = document.getElementById(fieldId);
  }
  var prev = el ? el.value : '';
  if (el) el.value = '…';
  fetch('/qc/scan/region', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ scan_id: item.scanId, file_id: item.fileId, page: item.page, rect: fr })
  }).then(function (r) { return r.json(); }).then(function (data) {
    var text = (data.text || '').trim();
    if (!el) return;
    if (fieldId === 'qc-s-date') { el.value = _qcScanToISO(text) || prev; }
    else if (isMat) { el.value = _qcScanNum(text) || prev; qcScanLineCheck(el); }
    else if (fieldId === 'qc-s-yards' || fieldId === 'qc-s-rock-moist' || fieldId === 'qc-s-sand-moist') {
      el.value = _qcScanNum(text) || prev;
    } else { el.value = text || prev; }
  }).catch(function () { if (el) el.value = prev; });
}

// ---- QC OCR backend (Settings) ----

var _qcOcrBackends = [];

function qcOcrBackendLoad() {
  fetch('/qc/ocr/backend').then(function (r) { return r.json(); }).then(function (d) {
    _qcOcrBackends = d.backends || [];
    var sel = document.getElementById('qc-ocr-backend-select');
    if (!sel) return;
    var gl = _qcOcrBackends.find(function (b) { return b.id === d.global_backend; });
    var glLabel = gl ? gl.label : d.global_backend;
    var opts = '<option value="">Company default (' + esc(glLabel) + ')</option>';
    opts += _qcOcrBackends.map(function (b) {
      return '<option value="' + esc(b.id) + '">' + esc(b.label) + '</option>';
    }).join('');
    sel.innerHTML = opts;
    sel.value = d.backend || '';
    qcOcrBackendDesc();
  }).catch(function () {});
}

function qcOcrBackendDesc() {
  var sel = document.getElementById('qc-ocr-backend-select');
  var desc = document.getElementById('qc-ocr-backend-desc');
  if (!sel || !desc) return;
  if (!sel.value) { desc.textContent = 'Follows the company OCR backend set in the OCR tool.'; return; }
  var b = _qcOcrBackends.find(function (x) { return x.id === sel.value; });
  desc.textContent = b ? (b.description || '') : '';
}

function qcOcrBackendSave() {
  var sel = document.getElementById('qc-ocr-backend-select');
  var st = document.getElementById('qc-ocr-backend-status');
  fetch('/qc/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) {
      st.textContent = res.ok ? 'Saved.' : (res.d.error || 'Save failed.');
      st.className = res.ok ? 'text-sm text-green-600 dark:text-green-400' : 'text-sm text-red-500';
      st.classList.remove('hidden');
    })
    .catch(function () {
      st.textContent = 'Save failed (network).';
      st.className = 'text-sm text-red-500';
      st.classList.remove('hidden');
    });
}

// ---- OCR Region Templates (DVI-1442 P4) — builder + assignment (Settings) ----

var _qcTpl = {
  templates: [],       // saved templates from /qc/ocr-templates
  headerFields: [],    // [{key,label}]
  materials: [],       // [{id,name}]
  customFields: [],    // [{key:'custom:<id>',label,type}]  (DVI-1452 P3)
  segments: [],        // [{id,label}]
  assignments: {},     // {segment: template_id}
  current: null,       // in-memory {id,name,fields:{key:{x0,y0,x1,y1}}} being edited
  sample: null         // {scanId, fileId, page}
};

function qcTplLoad() {
  if (!document.getElementById('qc-tpl-select')) return;
  fetch('/qc/ocr-templates').then(function (r) { return r.json(); })
    .then(function (d) {
      _qcTpl.templates = d.templates || [];
      _qcTpl.headerFields = d.header_fields || [];
      _qcTpl.materials = d.materials || [];
      _qcTpl.customFields = d.custom_fields || [];
      _qcTpl.segments = d.segments || [];
      _qcTpl.assignments = d.assignments || {};
      qcTplRenderSelect();
      qcTplRenderFieldOptions();
      qcTplAssignRender();
    })
    .catch(function () {
      var st = document.getElementById('qc-tpl-status');
      if (st) { st.textContent = 'Could not load templates.'; st.className = 'text-sm text-red-500'; st.classList.remove('hidden'); }
    });
}

function qcTplRenderSelect() {
  var sel = document.getElementById('qc-tpl-select');
  if (!sel) return;
  var opts = '<option value="">— Select a template —</option>';
  opts += _qcTpl.templates.map(function (t) {
    return '<option value="' + esc(t.id) + '">' + esc(t.name) + '</option>';
  }).join('');
  sel.innerHTML = opts;
  sel.value = (_qcTpl.current && _qcTpl.current.id) || '';
}

// The templatable field catalog: header fields (D7-A) + a row per active
// material + a row per active custom field (DVI-1452 P3).
function qcTplFieldCatalog() {
  var out = _qcTpl.headerFields.map(function (f) { return { key: f.key, label: f.label }; });
  _qcTpl.materials.forEach(function (m) {
    out.push({ key: 'mat:' + m.id, label: m.name + ' (material)' });
  });
  (_qcTpl.customFields || []).forEach(function (f) {
    out.push({ key: f.key, label: f.label + ' (custom)' });
  });
  return out;
}

function qcTplFieldLabel(key) {
  var hit = qcTplFieldCatalog().find(function (f) { return f.key === key; });
  return hit ? hit.label : key;
}

function qcTplRenderFieldOptions() {
  var sel = document.getElementById('qc-tpl-field');
  if (!sel) return;
  sel.innerHTML = qcTplFieldCatalog().map(function (f) {
    return '<option value="' + esc(f.key) + '">' + esc(f.label) + '</option>';
  }).join('');
}

function qcTplNew() {
  _qcTpl.current = { id: '', name: '', fields: {} };
  document.getElementById('qc-tpl-select').value = '';
  document.getElementById('qc-tpl-name').value = '';
  qcTplShowBuilder();
  var nm = document.getElementById('qc-tpl-name'); if (nm) nm.focus();
}

function qcTplSelect(id) {
  if (!id) { _qcTpl.current = null; document.getElementById('qc-tpl-builder').classList.add('hidden'); return; }
  var t = _qcTpl.templates.find(function (x) { return x.id === id; });
  if (!t) return;
  _qcTpl.current = { id: t.id, name: t.name, fields: JSON.parse(JSON.stringify(t.fields || {})) };
  document.getElementById('qc-tpl-name').value = t.name;
  qcTplShowBuilder();
}

function qcTplShowBuilder() {
  document.getElementById('qc-tpl-builder').classList.remove('hidden');
  qcTplRenderFieldOptions();
  qcTplRenderOverlay();
  qcTplRenderFieldList();
  var out = document.getElementById('qc-tpl-test-out'); if (out) out.classList.add('hidden');
}

function qcTplUploadSample(fileList) {
  var files = fileList && fileList.length ? [fileList[0]] : [];
  var st = document.getElementById('qc-tpl-sample-status');
  if (!files.length || !/\.pdf$/i.test(files[0].name)) { st.textContent = 'Choose a PDF.'; return; }
  st.textContent = 'Uploading…';
  var fd = new FormData();
  fd.append('files', files[0]);
  fetch('/qc/scan/upload', { 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.files || !res.d.files.length) { st.textContent = (res.d && res.d.error) || 'Upload failed.'; return; }
      _qcTpl.sample = { scanId: res.d.scan_id, fileId: res.d.files[0].id, page: 0 };
      var img = document.getElementById('qc-tpl-img');
      img.src = '/qc/scan/page?scan_id=' + encodeURIComponent(_qcTpl.sample.scanId) +
                '&file_id=' + encodeURIComponent(_qcTpl.sample.fileId) + '&page=0&_=' + Date.now();
      st.textContent = res.d.files[0].name;
      qcTplRenderOverlay();
    })
    .catch(function () { st.textContent = 'Upload failed (network).'; });
}

// Draw the boxed regions already in the current template (percentage-positioned
// so they track the sample image at any size).
function qcTplRenderOverlay() {
  var ov = document.getElementById('qc-tpl-region-ov');
  if (!ov) return;
  ov.innerHTML = '';
  var fields = (_qcTpl.current && _qcTpl.current.fields) || {};
  Object.keys(fields).forEach(function (k) {
    var r = fields[k];
    var box = document.createElement('div');
    box.className = 'absolute border-2 border-amber-500 bg-amber-400/20 pointer-events-none';
    box.style.left = (r.x0 * 100) + '%'; box.style.top = (r.y0 * 100) + '%';
    box.style.width = ((r.x1 - r.x0) * 100) + '%'; box.style.height = ((r.y1 - r.y0) * 100) + '%';
    var lbl = document.createElement('div');
    lbl.className = 'absolute left-0 -top-4 text-[10px] leading-none bg-amber-500 text-white px-1 py-0.5 rounded whitespace-nowrap';
    lbl.textContent = qcTplFieldLabel(k);
    box.appendChild(lbl);
    ov.appendChild(box);
  });
}

function qcTplRenderFieldList() {
  var host = document.getElementById('qc-tpl-fieldlist');
  if (!host) return;
  var fields = (_qcTpl.current && _qcTpl.current.fields) || {};
  var keys = Object.keys(fields);
  if (!keys.length) { host.innerHTML = '<p class="text-xs text-gray-400 dark:text-gray-500">No regions yet — pick a field and Draw region.</p>'; return; }
  host.innerHTML = keys.map(function (k) {
    return '<div class="flex items-center justify-between gap-2 text-sm text-gray-700 dark:text-gray-300 rounded-lg border border-gray-200 dark:border-gray-700 px-2 py-1">' +
      '<span>' + esc(qcTplFieldLabel(k)) + '</span>' +
      '<button onclick="qcTplRemoveField(\'' + esc(k).replace(/'/g, "\\'") + '\')" class="text-xs text-red-500 hover:text-red-600">Remove</button></div>';
  }).join('');
}

function qcTplDrawRegion() {
  if (!_qcTpl.current) return;
  if (!_qcTpl.sample) { var st = document.getElementById('qc-tpl-sample-status'); st.textContent = 'Upload a sample page first.'; return; }
  var fk = document.getElementById('qc-tpl-field').value;
  if (!fk) return;
  var ov = document.getElementById('qc-tpl-region-ov');
  ov.style.pointerEvents = 'auto'; ov.style.cursor = 'crosshair'; ov.style.touchAction = 'none';
  function pd(e) {
    ov.removeEventListener('pointerdown', pd);
    _qcRegionCapture(ov, e, function (fr) {
      ov.style.pointerEvents = 'none'; ov.style.cursor = '';
      if (fr) { _qcTpl.current.fields[fk] = fr; }
      qcTplRenderOverlay();
      qcTplRenderFieldList();
    });
  }
  ov.addEventListener('pointerdown', pd);
}

function qcTplRemoveField(key) {
  if (_qcTpl.current && _qcTpl.current.fields) { delete _qcTpl.current.fields[key]; }
  qcTplRenderOverlay();
  qcTplRenderFieldList();
}

function qcTplClearField() {
  var fk = document.getElementById('qc-tpl-field').value;
  if (fk) qcTplRemoveField(fk);
}

function qcTplTestRegion() {
  var out = document.getElementById('qc-tpl-test-out');
  out.classList.remove('hidden');
  if (!_qcTpl.sample) { out.textContent = 'Upload a sample page first.'; return; }
  var fk = document.getElementById('qc-tpl-field').value;
  var r = _qcTpl.current && _qcTpl.current.fields[fk];
  if (!r) { out.textContent = 'Draw a region for this field first.'; return; }
  out.textContent = 'Reading region…';
  fetch('/qc/scan/region', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ scan_id: _qcTpl.sample.scanId, file_id: _qcTpl.sample.fileId, page: _qcTpl.sample.page, rect: r })
  }).then(function (rr) { return rr.json(); }).then(function (d) {
    var t = (d.text || '').trim();
    out.textContent = t ? ('Read: “' + t + '”') : 'Read nothing — try a larger box.';
  }).catch(function () { out.textContent = 'Test failed.'; });
}

function qcTplSave() {
  if (!_qcTpl.current) return;
  var name = (document.getElementById('qc-tpl-name').value || '').trim();
  var st = document.getElementById('qc-tpl-status');
  st.classList.remove('hidden');
  if (!name) { st.textContent = 'Template name is required.'; st.className = 'text-sm text-red-500'; return; }
  st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500 dark:text-gray-400';
  fetch('/qc/ocr-templates', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ id: _qcTpl.current.id || '', name: name, fields: _qcTpl.current.fields })
  }).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
    .then(function (res) {
      if (!res.ok) { st.textContent = (res.d && res.d.error) || 'Save failed.'; st.className = 'text-sm text-red-500'; return; }
      var tpl = res.d.template;
      // refresh the list, keep editing the just-saved template
      var idx = _qcTpl.templates.findIndex(function (t) { return t.id === tpl.id; });
      if (idx >= 0) { _qcTpl.templates[idx] = tpl; } else { _qcTpl.templates.push(tpl); }
      _qcTpl.current = { id: tpl.id, name: tpl.name, fields: JSON.parse(JSON.stringify(tpl.fields || {})) };
      qcTplRenderSelect();
      qcTplAssignRender();
      st.textContent = 'Saved.'; st.className = 'text-sm text-green-600 dark:text-green-400';
    })
    .catch(function () { st.textContent = 'Save failed (network).'; st.className = 'text-sm text-red-500'; });
}

function qcTplDelete() {
  if (!_qcTpl.current || !_qcTpl.current.id) { _qcTpl.current = null; document.getElementById('qc-tpl-builder').classList.add('hidden'); return; }
  if (!confirm('Delete template “' + _qcTpl.current.name + '”? Any segment using it reverts to whole-page OCR.')) return;
  fetch('/qc/ocr-templates/delete', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ id: _qcTpl.current.id })
  }).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
    .then(function (res) {
      var st = document.getElementById('qc-tpl-status'); st.classList.remove('hidden');
      if (!res.ok) { st.textContent = (res.d && res.d.error) || 'Delete failed.'; st.className = 'text-sm text-red-500'; return; }
      _qcTpl.templates = _qcTpl.templates.filter(function (t) { return t.id !== _qcTpl.current.id; });
      _qcTpl.assignments = res.d.assignments || {};
      _qcTpl.current = null;
      document.getElementById('qc-tpl-builder').classList.add('hidden');
      qcTplRenderSelect(); qcTplAssignRender();
      st.textContent = 'Deleted.'; st.className = 'text-sm text-green-600 dark:text-green-400';
    })
    .catch(function () {});
}

function qcTplAssignRender() {
  var host = document.getElementById('qc-tpl-assign-list');
  if (!host) return;
  if (!_qcTpl.segments.length) { host.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No segments.</p>'; return; }
  host.innerHTML = _qcTpl.segments.map(function (s) {
    var cur = _qcTpl.assignments[s.id] || '';
    var opts = '<option value="">None (whole-page OCR)</option>' + _qcTpl.templates.map(function (t) {
      return '<option value="' + esc(t.id) + '"' + (t.id === cur ? ' selected' : '') + '>' + esc(t.name) + '</option>';
    }).join('');
    return '<div class="flex items-center justify-between gap-3">' +
      '<span class="text-sm text-gray-700 dark:text-gray-300">' + esc(s.label) + '</span>' +
      '<select data-qc-tpl-seg="' + esc(s.id) + '" class="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">' + opts + '</select></div>';
  }).join('');
}

function qcTplAssignSave() {
  var assigns = {};
  document.querySelectorAll('#qc-tpl-assign-list select[data-qc-tpl-seg]').forEach(function (sel) {
    if (sel.value) assigns[sel.getAttribute('data-qc-tpl-seg')] = sel.value;
  });
  var st = document.getElementById('qc-tpl-assign-status'); st.classList.remove('hidden');
  st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500 dark:text-gray-400';
  fetch('/qc/ocr-template-assignments', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ assignments: assigns })
  }).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
    .then(function (res) {
      if (!res.ok) { st.textContent = (res.d && res.d.error) || 'Save failed.'; st.className = 'text-sm text-red-500'; return; }
      _qcTpl.assignments = res.d.assignments || {};
      qcTplAssignRender();
      st.textContent = 'Saved.'; st.className = 'text-sm text-green-600 dark:text-green-400';
    })
    .catch(function () { st.textContent = 'Save failed (network).'; st.className = 'text-sm text-red-500'; });
}

// ---------------------------------------------------------------- reports --

// DVI-1423: one row renderer for the daily + range consumption tables. A
// report-only aggregate row is styled distinctly, shows no captured/batches/
// out-of-range figures (its value is computed = the sum of its children), and
// is never itemized when its aggregate folds children out ('aggregate_only').
function _qcMatReportRow(m) {
  var agg = !!m.is_aggregate;
  var nameCell = esc(m.name) + (agg
    ? ' <span class="inline-flex px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-togen-teal/10 text-togen-teal align-middle">Aggregate</span>'
    : '');
  var captured = agg ? '<span class="text-gray-400">—</span>'
                     : (qcFmt(m.captured_total) + ' ' + esc(m.capture_unit));
  var cons = (m.consumption_total === null || m.consumption_total === undefined)
    ? '—' : (qcFmt(m.consumption_total) + ' ' + esc(m.consumption_unit));
  var batches = agg ? '<span class="text-gray-400">—</span>' : m.n_captured;
  var oor = agg ? '<span class="text-gray-400">—</span>'
    : (m.n_out_of_range
        ? '<span class="text-amber-600 dark:text-amber-400 font-medium">' + m.n_out_of_range + '</span>'
        : '<span class="text-gray-600 dark:text-gray-300">0</span>');
  var trCls = 'border-b border-gray-100 dark:border-gray-800' + (agg ? ' bg-togen-teal/5' : '');
  var nameCls = agg ? 'py-2 pr-3 font-semibold text-togen-teal'
                    : 'py-2 pr-3 font-medium text-gray-800 dark:text-gray-100';
  var consCls = 'py-2 pr-3 text-gray-600 dark:text-gray-300' + (agg ? ' font-semibold' : '');
  return '<tr class="' + trCls + '">' +
    '<td class="' + nameCls + '">' + nameCell + '</td>' +
    '<td class="py-2 pr-3 text-gray-600 dark:text-gray-300">' + captured + '</td>' +
    '<td class="' + consCls + '">' + cons + '</td>' +
    '<td class="py-2 pr-3 text-gray-600 dark:text-gray-300">' + batches + '</td>' +
    '<td class="py-2">' + oor + '</td>' +
    '</tr>';
}

function qcLoadReport() {
  var date = document.getElementById('qc-report-date').value || qcToday();
  var seg = document.getElementById('qc-report-segment').value || 'wet_cast';
  fetch('/qc/report/daily?date=' + encodeURIComponent(date) + '&segment=' + encodeURIComponent(seg))
    .then(function (r) { return r.json(); })
    .then(function (data) {
      var rep = data.report;
      var el = document.getElementById('qc-report-body');
      if (!rep) { el.innerHTML = '<p class="text-sm text-red-500">Could not load report.</p>'; return; }
      var rows = (rep.materials || []).map(_qcMatReportRow).join('');
      el.innerHTML =
        '<p class="text-sm text-gray-700 dark:text-gray-200 mb-3"><strong>' + rep.batch_count + '</strong> batches · <strong>' + qcFmt(rep.total_yards) + '</strong> yd&sup3; produced</p>' +
        '<div class="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 font-medium">Material</th><th class="py-2 pr-3 font-medium">Captured</th>' +
        '<th class="py-2 pr-3 font-medium">Consumption</th><th class="py-2 pr-3 font-medium">Batches</th>' +
        '<th class="py-2 font-medium">Out of range</th></tr></thead><tbody>' + rows + '</tbody></table></div>';
    });
}

function qcReportExportCsv() {
  var date = document.getElementById('qc-report-date').value || qcToday();
  var seg = document.getElementById('qc-report-segment').value || 'wet_cast';
  window.location = '/qc/report/daily.csv?date=' + encodeURIComponent(date) +
                    '&segment=' + encodeURIComponent(seg);
}

// ---- date-range consumption totals (DVI-1326 P2) ----

function _qcRangeQuery() {
  var start = document.getElementById('qc-range-start').value;
  var end = document.getElementById('qc-range-end').value || qcToday();
  var seg = document.getElementById('qc-range-segment').value || '';
  var q = 'end=' + encodeURIComponent(end);
  if (start) q = 'start=' + encodeURIComponent(start) + '&' + q;
  if (seg) q += '&segment=' + encodeURIComponent(seg);
  return q;
}

function qcLoadRangeReport() {
  var el = document.getElementById('qc-range-body');
  if (!el) return;
  fetch('/qc/report/range?' + _qcRangeQuery())
    .then(function (r) { return r.json(); })
    .then(function (data) {
      var rep = data.report;
      if (!rep) { el.innerHTML = '<p class="text-sm text-red-500">' + esc(data.error || 'Could not load report.') + '</p>'; return; }
      // Per-plant sections when no plant filter; one section when filtered.
      var sections = rep.segment
        ? [{ label: (_qcSegLabel(rep.segment)), materials: rep.materials,
             batch_count: rep.batch_count, total_yards: rep.total_yards }]
        : rep.segments;
      var showPlant = !rep.segment;
      var html = '<p class="text-sm text-gray-700 dark:text-gray-200 mb-3"><strong>' + rep.batch_count + '</strong> batches · <strong>' + qcFmt(rep.total_yards) + '</strong> yd&sup3; produced' +
        (rep.start_date ? ' · ' + esc(rep.start_date) + ' → ' + esc(rep.end_date) : ' · through ' + esc(rep.end_date)) + '</p>';
      if (!sections || !sections.length) {
        el.innerHTML = html + '<p class="text-sm text-gray-500 dark:text-gray-400">No batches captured in this range.</p>';
        return;
      }
      sections.forEach(function (s) {
        // DVI-1424: row inclusion (zero-capture omission + per-material
        // "include in reports" exclusion) is applied server-side per the
        // Settings → Reporting options, so render whatever the server returns.
        var rows = (s.materials || []).map(_qcMatReportRow).join('');
        if (!rows) rows = '<tr><td colspan="5" class="py-2 text-sm text-gray-500 dark:text-gray-400">No materials captured.</td></tr>';
        if (showPlant) html += '<h3 class="text-xs font-bold uppercase tracking-wide text-togen-teal mt-4 mb-1">' + esc(s.label) + ' <span class="text-gray-400 font-medium normal-case">· ' + s.batch_count + ' batches · ' + qcFmt(s.total_yards) + ' yd&sup3;</span></h3>';
        html += '<div class="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 font-medium">Material</th><th class="py-2 pr-3 font-medium">Captured</th>' +
          '<th class="py-2 pr-3 font-medium">Consumption</th><th class="py-2 pr-3 font-medium">Batches</th>' +
          '<th class="py-2 font-medium">Out of range</th></tr></thead><tbody>' + rows + '</tbody></table></div>';
      });
      el.innerHTML = html;
    })
    .catch(function () {
      el.innerHTML = '<p class="text-sm text-red-500">Could not load report.</p>';
    });
}

function _qcSegLabel(id) {
  var s = (_qcMeta && _qcMeta.segments || []).find(function (x) { return x.id === id; });
  return s ? s.label : id;
}

function qcRangeExportCsv() {
  window.location = '/qc/report/range.csv?' + _qcRangeQuery();
}

// ------------------------------------------------------- mixtures (admin) --

function qcLoadMixtures() {
  fetch('/qc/mixtures').then(function (r) { return r.json(); }).then(function (data) {
    var el = document.getElementById('qc-mixture-list');
    var items = data.mixtures || [];
    if (!items.length) {
      el.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No mixtures yet.</p>';
      return;
    }
    el.innerHTML = '<div class="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 font-medium">Name</th><th class="py-2 pr-3 font-medium">Segment</th>' +
      '<th class="py-2 pr-3 font-medium">Mix code</th>' +
      '<th class="py-2 pr-3 font-medium">Version</th><th class="py-2 pr-3 font-medium">Targets</th>' +
      '<th class="py-2 pr-3 font-medium">Status</th><th></th></tr></thead><tbody>' +
      items.map(function (m) {
        var segLabel = (_qcMeta.segments.find(function (s) { return s.id === m.segment; }) || {}).label || m.segment;
        return '<tr class="border-b border-gray-100 dark:border-gray-800">' +
          '<td class="py-2 pr-3 font-medium text-gray-800 dark:text-gray-100">' + esc(m.name) + '</td>' +
          '<td class="py-2 pr-3 text-gray-600 dark:text-gray-300">' + esc(segLabel) + '</td>' +
          '<td class="py-2 pr-3 text-gray-600 dark:text-gray-300">' + (m.mix_code ? '<span class="font-mono">' + esc(m.mix_code) + '</span>' : '<span class="text-gray-300 dark:text-gray-600">—</span>') + '</td>' +
          '<td class="py-2 pr-3 text-gray-600 dark:text-gray-300">v' + m.version + '</td>' +
          '<td class="py-2 pr-3 text-gray-600 dark:text-gray-300">' + (m.targets || []).length + ' material' + ((m.targets || []).length === 1 ? '' : 's') + '</td>' +
          '<td class="py-2 pr-3">' + (m.active
            ? '<span class="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300">Active</span>'
            : '<span class="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400">Inactive</span>') +
            // DVI-1433: server-authoritative aggregate-vs-parts conflict flag.
            ((m.conflicts && m.conflicts.length)
              ? ' <span title="An aggregate target and its child targets disagree" class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">&#9888; targets conflict</span>'
              : '') + '</td>' +
          '<td class="py-2 text-right"><button onclick="qcMixtureEdit(' + m.id + ')" class="togen-control px-2 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">Edit</button></td>' +
          '</tr>';
      }).join('') + '</tbody></table></div>';
    el.setAttribute('data-loaded', '1');
    window._qcMixturesCache = items;
  });
}

function qcMixtureEdit(id) {
  var mix = (window._qcMixturesCache || []).find(function (m) { return m.id === id; });
  if (mix) qcMixtureOpen(mix);
}

function qcMixtureOpen(mix) {
  _qcMixEditingId = mix ? mix.id : null;
  document.getElementById('qc-mixture-modal-title').textContent = mix ? ('Edit Mixture — ' + mix.name + ' (v' + mix.version + ')') : 'Add Mixture';
  document.getElementById('qc-m-name').value = mix ? mix.name : '';
  var segSel = document.getElementById('qc-m-segment');
  segSel.value = mix ? mix.segment : (document.getElementById('qc-segment').value || 'wet_cast');
  segSel.disabled = !!mix;   // segment is part of the mixture's identity
  document.getElementById('qc-m-mixcode').value = mix ? (mix.mix_code || '') : '';
  document.getElementById('qc-m-active').checked = mix ? !!mix.active : true;
  var byMat = {};
  (mix && mix.targets || []).forEach(function (t) { byMat[t.material_id] = t; });
  // DVI-1440 P2: seed the per-material membership. Explicit list wins; a legacy
  // mixture with targets pre-checks its target materials (D4-A); a new mixture
  // (or legacy with no targets) pre-checks ALL active leaf + aggregate materials
  // so it captures/reports everything by default until an admin narrows it.
  // Membership covers report-only aggregate totals too (DVI-1440 review): an
  // aggregate is enable-able per mixture just like a leaf.
  var leafMats = (_qcMeta && _qcMeta.materials) || [];
  var aggregates = (_qcMeta && _qcMeta.aggregates) || [];
  _qcMixIncluded = {};
  var initIncl;
  if (mix && mix.included_materials != null) {
    initIncl = new Set(mix.included_materials);
    // Back-compat: a membership stored before aggregates were enable-able is
    // leaf-only. Pre-check any aggregate that still carries a target so it is
    // not silently dropped on the next save (unchecking is the way to drop it).
    aggregates.forEach(function (a) { if (byMat[a.id]) initIncl.add(a.id); });
  } else if (mix && (mix.targets || []).some(function (t) { return (t.target || 0) > 0 || (t.minus || 0) > 0 || (t.plus || 0) > 0; })) {
    initIncl = new Set();
    (mix.targets || []).forEach(function (t) { initIncl.add(t.material_id); });
  } else {
    initIncl = new Set(leafMats.map(function (m) { return m.id; }));
    aggregates.forEach(function (a) { initIncl.add(a.id); });
  }
  leafMats.forEach(function (m) { if (initIncl.has(m.id)) _qcMixIncluded[m.id] = true; });
  aggregates.forEach(function (a) { if (initIncl.has(a.id)) _qcMixIncluded[a.id] = true; });
  // DVI-1440 review #2: seed each aggregate's capture mode (direct vs summed).
  _qcMixAggCapture = {};
  var initDirect = new Set((mix && mix.aggregate_capture) || []);
  aggregates.forEach(function (a) { if (initDirect.has(a.id)) _qcMixAggCapture[a.id] = true; });
  // DVI-1433: map each aggregate child -> the aggregate names it belongs to,
  // so the per-material grid can mark a row as part of an aggregate.
  var childAgg = {};
  aggregates.forEach(function (agg) {
    (agg.children || []).forEach(function (c) {
      (childAgg[c.id] = childAgg[c.id] || []).push(agg.name);
    });
  });
  // Shared target/tolerance input builder (leaf rows AND aggregate blocks).
  // Every input keeps its data-qc-mt/data-qc-kind attrs so qcMixtureSave
  // collects it regardless of where it sits; oninput refreshes the ± badge and
  // re-runs the live conflict check.
  function qcMtInput(id, kind, val, cls) {
    return '<input type="number" step="any" min="0" data-qc-mt="' + id + '" data-qc-kind="' + kind + '"' +
           ' oninput="qcMixAdvChanged(' + id + '); qcMixConflictCheck()" value="' + (val || val === 0 ? esc(String(val)) : '') + '"' +
           ' class="' + cls + ' 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">';
  }
  function qcMtAdv(id, t) {
    var hasTol = (parseFloat(t.minus) || 0) > 0 || (parseFloat(t.plus) || 0) > 0;
    return '<button type="button" onclick="qcMixToggleAdv(' + id + ')" title="Optional − / + tolerances" class="togen-control inline-flex items-center gap-1 px-2 py-1 text-xs 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">' +
        '<span aria-hidden="true">&#9881;</span> Advanced' +
        '<span id="qc-mt-tol-badge-' + id + '" class="qc-mt-tol-badge text-togen-teal font-semibold' + (hasTol ? '' : ' hidden') + '">&plusmn;</span>' +
      '</button>' +
      '<div id="qc-mt-adv-' + id + '" class="qc-mt-adv hidden absolute right-0 z-30 mt-1 w-56 p-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-xl space-y-2">' +
        '<div class="text-xs font-medium text-gray-500 dark:text-gray-400">Acceptable tolerances (optional)</div>' +
        '<label class="block text-xs text-gray-500 dark:text-gray-400">&minus; tolerance / yd&sup3;' + qcMtInput(id, 'minus', t.minus, 'mt-1 w-full') + '</label>' +
        '<label class="block text-xs text-gray-500 dark:text-gray-400">+ tolerance / yd&sup3;' + qcMtInput(id, 'plus', t.plus, 'mt-1 w-full') + '</label>' +
        '<p class="text-[11px] text-gray-400 dark:text-gray-500 leading-snug">Leave both blank for a target-only line — the target still shows on batch tickets and reports, but the line is never flagged out of range.</p>' +
        '<button type="button" onclick="qcMixToggleAdv(' + id + ')" class="w-full px-2 py-1 text-xs rounded-lg bg-togen-teal hover:bg-togen-teal-dark text-white font-medium">Done</button>' +
      '</div>';
  }
  var aggSection = '';
  if (aggregates.length) {
    aggSection =
      '<div class="mt-4 border-t border-gray-200 dark:border-gray-700 pt-3">' +
      '<h4 class="text-sm font-bold text-gray-700 dark:text-gray-200">Aggregated totals</h4>' +
      '<p class="text-xs text-gray-400 dark:text-gray-500 mb-2">Set a per-yd&sup3; target on an aggregate as a whole and/or on its individual materials above. An amber flag appears when the whole and the sum of the parts disagree (converted to the shared unit) — it never blocks saving. Enable an aggregate under Included materials above; unchecking it removes its target.</p>' +
      aggregates.map(function (agg) {
        var t = byMat[agg.id] || {};
        var kids = (agg.children || []).map(function (c) { return esc(c.name); }).join(' + ') || '(no materials)';
        var aggHidden = _qcMixIncluded[agg.id] ? '' : ' style="display:none"';
        return '<div id="qc-agg-block-' + agg.id + '"' + aggHidden + ' class="mb-2 rounded-xl border border-gray-200 dark:border-gray-700 p-3">' +
          '<div class="flex items-start justify-between gap-3">' +
            '<div>' +
              '<div class="text-sm font-semibold text-gray-800 dark:text-gray-100">' + esc(agg.name) +
                ' <span class="text-xs font-normal text-gray-400">(total, ' + esc(agg.capture_unit) + '/yd&sup3;)</span></div>' +
              '<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Totals: ' + kids + '</div>' +
            '</div>' +
            '<div class="flex items-center gap-2 relative">' +
              qcMtInput(agg.id, 'target', t.target, 'w-24') + qcMtAdv(agg.id, t) +
            '</div>' +
          '</div>' +
          // DVI-1440 review #2: per-mixture capture mode for this aggregate.
          '<div class="mt-2 text-xs">' +
            '<label class="text-gray-500 dark:text-gray-400">Capture on batch tickets: ' +
              '<select onchange="qcMixAggCaptureChange(' + agg.id + ', this)" class="ml-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 text-xs px-1.5 py-1 focus:outline-none focus:ring-2 focus:ring-togen-teal">' +
                '<option value="constituents"' + (_qcMixAggCapture[agg.id] ? '' : ' selected') + '>its materials (summed into this total)</option>' +
                '<option value="direct"' + (_qcMixAggCapture[agg.id] ? ' selected' : '') + '>this total directly</option>' +
              '</select>' +
            '</label>' +
            '<p id="qc-agg-capmode-note-' + agg.id + '" class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">' + qcAggCapModeNote(!!_qcMixAggCapture[agg.id], kids) + '</p>' +
          '</div>' +
          '<div class="mt-2 flex items-center gap-2 flex-wrap">' +
            '<span id="qc-agg-parts-' + agg.id + '" class="text-xs text-gray-500 dark:text-gray-400"></span>' +
            '<span id="qc-agg-conflict-' + agg.id + '" class="hidden inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"></span>' +
          '</div>' +
        '</div>';
      }).join('') +
      '</div>';
  }
  // DVI-1440 P2: per-material membership checkbox list (ALL active leaf
  // materials + report-only aggregate totals). Only checked materials appear in
  // the target grid / Aggregated totals below and on capture forms. Unchecking a
  // material with a target warns + removes it.
  function qcInclCheckbox(id, name, badge) {
    var ck = _qcMixIncluded[id] ? ' checked' : '';
    return '<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">' +
      '<input type="checkbox" data-qc-incl="' + id + '" onchange="qcMixIncludeToggle(' + id + ', this)"' + ck +
      ' class="rounded border-gray-300 text-togen-teal focus:ring-togen-teal">' + esc(name) + (badge || '') + '</label>';
  }
  var aggBadge = ' <span class="inline-flex px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-togen-teal/10 text-togen-teal align-middle" title="Report-only aggregate total">Total</span>';
  var inclSection =
    '<div class="mb-4">' +
    '<h4 class="text-sm font-bold text-gray-700 dark:text-gray-200">Included materials</h4>' +
    '<p class="text-xs text-gray-400 dark:text-gray-500 mb-2">Check the materials this mixture uses. Only checked materials appear on its capture forms (Batch Tickets, Scan Tickets, Daily Production) and in the target grid below. Aggregate totals (report-only) can be enabled here too.</p>' +
    '<div class="flex flex-wrap gap-x-4 gap-y-1.5">' +
    leafMats.map(function (m) { return qcInclCheckbox(m.id, m.name, ''); }).join('') +
    aggregates.map(function (a) { return qcInclCheckbox(a.id, a.name, aggBadge); }).join('') +
    '</div></div>';
  var wrap = document.getElementById('qc-mixture-targets');
  wrap.innerHTML = inclSection +
    '<div class="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 font-medium">Material</th><th class="py-2 pr-3 font-medium">Target / yd&sup3;</th>' +
    '<th class="py-2 font-medium">Tolerances</th>' +
    '</tr></thead><tbody>' +
    (_qcMeta.materials || []).map(function (m) {
      var t = byMat[m.id] || {};
      var partOf = (childAgg[m.id] || []).length
        ? ' <span class="qc-mt-partof inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-togen-teal/10 text-togen-teal-dark dark:text-togen-teal" title="Part of aggregate: ' + esc(childAgg[m.id].join(', ')) + '">&#8627; ' + esc(childAgg[m.id].join(', ')) + '</span>'
        : '';
      var hidden = _qcMixIncluded[m.id] ? '' : ' style="display:none"';
      return '<tr id="qc-mt-row-' + m.id + '"' + hidden + ' class="border-b border-gray-100 dark:border-gray-800">' +
        '<td class="py-1.5 pr-3 text-gray-700 dark:text-gray-200">' + esc(m.name) + ' <span class="text-xs text-gray-400">(' + esc(m.capture_unit) + '/yd&sup3;)</span>' + partOf + '</td>' +
        '<td class="py-1.5 pr-3">' + qcMtInput(m.id, 'target', t.target, 'w-24') + '</td>' +
        '<td class="py-1.5 relative">' + qcMtAdv(m.id, t) + '</td>' +
        '</tr>';
    }).join('') + '</tbody></table></div>' +
    aggSection +
    '<p class="text-xs text-gray-400 dark:text-gray-500 mt-2">Leave a material\'s target at 0 / blank to exclude it. Set a target with no tolerances (Advanced) for a target-only line — tracked on reports but never flagged out of range. Changing targets creates a new recipe version.</p>';
  document.getElementById('qc-mixture-status').classList.add('hidden');
  document.getElementById('qc-mixture-modal').classList.remove('hidden');
  qcMixConflictCheck();
}

// DVI-1433 client conflict math (mirror of qc_store.mixture_target_conflicts).
var _QC_UNIT_FACTORS = {
  'lb|lb': 1, 'lb|ton': 1 / 2000, 'oz|oz': 1, 'oz|lb': 1 / 16,
  'oz|gal': 1 / 128, 'gal|gal': 1
};
function qcConvertAmount(v, cap, cons) {
  var f = _QC_UNIT_FACTORS[cap + '|' + cons];
  return (f === undefined || v === null || isNaN(v)) ? null : v * f;
}
function qcMixTargetVal(mid) {
  var inp = document.querySelector('#qc-mixture-targets input[data-qc-mt="' + mid + '"][data-qc-kind="target"]');
  var v = inp ? parseFloat(inp.value) : 0;
  return (isNaN(v) || v < 0) ? 0 : v;
}
function qcAggFmt(v, dec) {
  var d = (typeof dec === 'number') ? dec : 2;
  var p = Math.pow(10, d);
  return (Math.round(v * p) / p).toFixed(d);
}
// Recompute + render each aggregate's parts readout + conflict badge live.
function qcMixConflictCheck() {
  var aggs = (_qcMeta && _qcMeta.aggregates) || [];
  aggs.forEach(function (agg) {
    var badge = document.getElementById('qc-agg-conflict-' + agg.id);
    var partsEl = document.getElementById('qc-agg-parts-' + agg.id);
    if (!badge) return;
    var dec = (typeof agg.consumption_decimals === 'number') ? agg.consumption_decimals : 2;
    var whole = qcConvertAmount(qcMixTargetVal(agg.id), agg.capture_unit, agg.consumption_unit);
    var parts = 0, nChild = 0;
    (agg.children || []).forEach(function (c) {
      var cv = qcMixTargetVal(c.id);
      if (cv <= 0) return;
      var conv = qcConvertAmount(cv, c.capture_unit, c.consumption_unit);
      if (conv === null) return;
      parts += conv; nChild++;
    });
    if (partsEl) {
      partsEl.textContent = nChild
        ? ('Parts total: ' + qcAggFmt(parts, dec) + ' ' + agg.consumption_unit)
        : 'No individual targets set for the parts.';
    }
    if (whole === null || whole <= 0 || nChild === 0) { badge.classList.add('hidden'); return; }
    var delta = Math.abs(whole - parts);
    var tol = Math.max(1e-6, 0.005 * Math.max(whole, parts));
    if (delta > tol) {
      badge.innerHTML = '&#9888; Total ' + qcAggFmt(whole, dec) + ' ' + esc(agg.consumption_unit) +
        ' &ne; parts ' + qcAggFmt(parts, dec) + ' ' + esc(agg.consumption_unit) +
        ' (&Delta; ' + qcAggFmt(delta, dec) + ')';
      badge.classList.remove('hidden');
    } else {
      badge.classList.add('hidden');
    }
  });
}

// Toggle a material row's Advanced tolerances popover (one open at a time).
function qcMixToggleAdv(mid) {
  var el = document.getElementById('qc-mt-adv-' + mid);
  if (!el) return;
  var open = el.classList.contains('hidden');
  Array.prototype.forEach.call(document.querySelectorAll('.qc-mt-adv'), function (p) {
    p.classList.add('hidden');
  });
  if (open) el.classList.remove('hidden');
}

// Keep the ± badge on the Advanced button in sync with whether either
// tolerance is set (so a target-only line is visibly distinct at a glance).
function qcMixAdvChanged(mid) {
  var pop = document.getElementById('qc-mt-adv-' + mid);
  var badge = document.getElementById('qc-mt-tol-badge-' + mid);
  if (!pop || !badge) return;
  var any = false;
  Array.prototype.forEach.call(pop.querySelectorAll('input[data-qc-kind]'), function (i) {
    if ((parseFloat(i.value) || 0) > 0) any = true;
  });
  badge.classList.toggle('hidden', !any);
}

function qcMixtureClose() {
  document.getElementById('qc-mixture-modal').classList.add('hidden');
  _qcMixEditingId = null;
}

// DVI-1440 P2: toggle a material's membership (leaf row OR aggregate block).
// Checking reveals its target row/block; unchecking (after warning if it has a
// target) hides + clears it so the target is dropped on save (version bump).
function qcMixIncludeToggle(mid, cb) {
  var row = document.getElementById('qc-mt-row-' + mid)
         || document.getElementById('qc-agg-block-' + mid);
  if (cb.checked) {
    _qcMixIncluded[mid] = true;
    if (row) row.style.display = '';
    return;
  }
  var tInp = document.querySelector('#qc-mixture-targets input[data-qc-mt="' + mid + '"][data-qc-kind="target"]');
  var hasTarget = tInp && (parseFloat(tInp.value) || 0) > 0;
  if (hasTarget && !confirm('This material has a per-yd³ target. Unchecking it removes that target from the recipe (a new version is created). Continue?')) {
    cb.checked = true;   // revert
    return;
  }
  delete _qcMixIncluded[mid];
  delete _qcMixAggCapture[mid];   // a disabled aggregate can't be direct-capture
  if (row) row.style.display = 'none';
  // Clear its target + tolerance inputs so the sanitizer drops it on save.
  document.querySelectorAll('#qc-mixture-targets input[data-qc-mt="' + mid + '"]').forEach(function (i) { i.value = ''; });
  qcMixAdvChanged(mid);   // sync the ± badge
  qcMixConflictCheck();   // an aggregate parts readout may change
}

// DVI-1440 review #2: help text for an aggregate's capture mode.
function qcAggCapModeNote(direct, kids) {
  return direct
    ? 'Operators key this total directly on batch tickets; its materials (' + kids + ') are not captured on this mixture.'
    : 'Operators key each material; this total is reported as the sum of them.';
}

// DVI-1440 review #2: switch an aggregate between summed-from-constituents and
// captured-directly for this mixture.
function qcMixAggCaptureChange(aggId, sel) {
  var direct = sel.value === 'direct';
  if (direct) _qcMixAggCapture[aggId] = true; else delete _qcMixAggCapture[aggId];
  var note = document.getElementById('qc-agg-capmode-note-' + aggId);
  if (note) {
    var agg = ((_qcMeta && _qcMeta.aggregates) || []).find(function (a) { return a.id === aggId; });
    var kids = agg ? ((agg.children || []).map(function (c) { return c.name; }).join(' + ') || '(no materials)') : '';
    note.textContent = qcAggCapModeNote(direct, kids);
  }
}

function qcMixtureSave() {
  var targets = {};
  document.querySelectorAll('#qc-mixture-targets input[data-qc-mt]').forEach(function (inp) {
    var mid = inp.getAttribute('data-qc-mt');
    targets[mid] = targets[mid] || { material_id: parseInt(mid, 10) };
    targets[mid][inp.getAttribute('data-qc-kind')] = inp.value;
  });
  var body = {
    id: _qcMixEditingId,
    name: document.getElementById('qc-m-name').value,
    segment: document.getElementById('qc-m-segment').value,
    mix_code: document.getElementById('qc-m-mixcode').value,
    active: document.getElementById('qc-m-active').checked,
    included_materials: Object.keys(_qcMixIncluded).map(function (k) { return parseInt(k, 10); }),
    // DVI-1440 review #2: aggregates captured directly (only those still enabled).
    aggregate_capture: Object.keys(_qcMixAggCapture)
      .map(function (k) { return parseInt(k, 10); })
      .filter(function (id) { return _qcMixIncluded[id]; }),
    targets: Object.keys(targets).map(function (k) { return targets[k]; })
  };
  var st = document.getElementById('qc-mixture-status');
  fetch('/qc/mixtures', { 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) {
        st.textContent = res.d.error || 'Save failed.';
        st.className = 'text-sm text-red-500';
        st.classList.remove('hidden');
        return;
      }
      qcMixtureClose();
      qcLoadMixtures();
      qcRefreshMeta();   // capture forms pick up the new recipe immediately
    });
}

// ------------------------------------------------------ materials (admin) --

function qcLoadMaterials() {
  fetch('/qc/materials').then(function (r) { return r.json(); }).then(function (data) {
    var el = document.getElementById('qc-material-list');
    var items = data.materials || [];
    window._qcMaterialsCache = items;
    el.innerHTML = '<div class="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 font-medium">Name</th><th class="py-2 pr-3 font-medium">Capture</th>' +
      '<th class="py-2 pr-3 font-medium">Consumption</th><th class="py-2 pr-3 font-medium">Rounding</th>' +
      '<th class="py-2 pr-3 font-medium">Status</th><th></th></tr></thead><tbody>' +
      items.map(function (m) {
        return '<tr class="border-b border-gray-100 dark:border-gray-800">' +
          '<td class="py-2 pr-3 font-medium text-gray-800 dark:text-gray-100">' + esc(m.name) +
            (m.is_aggregate ? ' <span class="inline-flex px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-togen-teal/10 text-togen-teal align-middle" title="Report-only total of ' + (m.aggregate_children || []).length + ' material(s)">Aggregate</span>' : '') + '</td>' +
          '<td class="py-2 pr-3 text-gray-600 dark:text-gray-300">' + (m.is_aggregate ? '—' : esc(m.capture_unit)) + '</td>' +
          '<td class="py-2 pr-3 text-gray-600 dark:text-gray-300">' + esc(m.consumption_unit) + '</td>' +
          '<td class="py-2 pr-3 text-gray-600 dark:text-gray-300">' + (m.consumption_decimals === 0 ? 'whole units' : m.consumption_decimals + ' dp') + '</td>' +
          '<td class="py-2 pr-3">' + (m.active
            ? '<span class="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300">Active</span>'
            : '<span class="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400">Inactive</span>') + '</td>' +
          '<td class="py-2 text-right whitespace-nowrap">' +
            '<button onclick="qcMaterialEdit(' + m.id + ')" class="togen-control px-2 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">Edit</button> ' +
            '<button onclick="qcMaterialDelete(' + m.id + ')" class="togen-control px-2 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/30">Delete</button>' +
          '</td></tr>';
      }).join('') + '</tbody></table></div>';
  });
}

function qcMaterialEdit(id) {
  var mat = (window._qcMaterialsCache || []).find(function (m) { return m.id === id; });
  if (mat) qcMaterialOpen(mat);
}

function qcMaterialCaptureChanged(keep) {
  var cap = document.getElementById('qc-mat-capture').value;
  var sel = document.getElementById('qc-mat-consumption');
  sel.innerHTML = (QC_UNIT_PAIRS[cap] || [cap]).map(function (u) {
    return '<option value="' + u + '">' + u + '</option>';
  }).join('');
  if (keep) sel.value = keep;
  qcMaterialAggUnitChanged();
}

// DVI-1423 P6 — report-only aggregate config in the material editor.
var _qcAggSelected = {};   // child id (string) -> true, preserved across re-renders

function qcMaterialAggregateToggle() {
  var on = document.getElementById('qc-mat-is-aggregate').checked;
  document.getElementById('qc-mat-aggregate-section').classList.toggle('hidden', !on);
  if (on) qcMaterialRenderAggChildren();
}

function qcMaterialAggUnitChanged() {
  var box = document.getElementById('qc-mat-is-aggregate');
  if (box && box.checked) qcMaterialRenderAggChildren();
}

function qcMaterialAggChildToggle(cb) {
  var id = cb.getAttribute('data-agg-child');
  if (cb.checked) _qcAggSelected[id] = true; else delete _qcAggSelected[id];
}

function qcMaterialRenderAggChildren() {
  var host = document.getElementById('qc-mat-agg-children');
  if (!host) return;
  var unit = document.getElementById('qc-mat-consumption').value;
  document.getElementById('qc-mat-agg-unit').textContent = unit || '—';
  // Eligible children: leaf materials (not aggregates), not this material, that
  // consume in the same unit (no silent cross-dimension conversion).
  var eligible = (window._qcMaterialsCache || []).filter(function (m) {
    return !m.is_aggregate && m.id !== _qcMatEditingId && m.consumption_unit === unit;
  });
  if (!eligible.length) {
    host.innerHTML = '<p class="text-xs text-gray-500 dark:text-gray-400">No leaf materials consume in ' + esc(unit || 'this unit') + ' yet.</p>';
    return;
  }
  host.innerHTML = eligible.map(function (m) {
    var ck = _qcAggSelected[String(m.id)] ? ' checked' : '';
    return '<label class="flex items-center gap-2 text-gray-700 dark:text-gray-300">' +
      '<input type="checkbox" data-agg-child="' + m.id + '" onchange="qcMaterialAggChildToggle(this)"' + ck + ' class="rounded border-gray-300 text-togen-teal focus:ring-togen-teal">' +
      esc(m.name) + ' <span class="text-xs text-gray-400">(' + esc(m.consumption_unit) + ')</span>' +
      (m.active ? '' : ' <span class="text-xs text-gray-400">· inactive</span>') +
      '</label>';
  }).join('');
}

function qcMaterialOpen(mat) {
  _qcMatEditingId = mat ? mat.id : null;
  document.getElementById('qc-material-modal-title').textContent = mat ? 'Edit Material' : 'Add Material';
  document.getElementById('qc-mat-name').value = mat ? mat.name : '';
  document.getElementById('qc-mat-capture').value = mat ? mat.capture_unit : 'oz';
  qcMaterialCaptureChanged(mat ? mat.consumption_unit : null);
  document.getElementById('qc-mat-decimals').value = mat ? String(mat.consumption_decimals) : '0';
  document.getElementById('qc-mat-sort').value = mat ? mat.sort : '';
  document.getElementById('qc-mat-active').checked = mat ? !!mat.active : true;
  // DVI-1423 aggregate config.
  _qcAggSelected = {};
  ((mat && mat.aggregate_children) || []).forEach(function (id) { _qcAggSelected[String(id)] = true; });
  document.getElementById('qc-mat-is-aggregate').checked = !!(mat && mat.is_aggregate);
  document.getElementById('qc-mat-agg-mode').value = (mat && mat.aggregate_report_mode) || 'both';
  qcMaterialAggregateToggle();
  document.getElementById('qc-material-status').classList.add('hidden');
  document.getElementById('qc-material-modal').classList.remove('hidden');
}

function qcMaterialClose() {
  document.getElementById('qc-material-modal').classList.add('hidden');
  _qcMatEditingId = null;
}

function qcMaterialSave() {
  var body = {
    id: _qcMatEditingId,
    name: document.getElementById('qc-mat-name').value,
    capture_unit: document.getElementById('qc-mat-capture').value,
    consumption_unit: document.getElementById('qc-mat-consumption').value,
    consumption_decimals: document.getElementById('qc-mat-decimals').value,
    sort: document.getElementById('qc-mat-sort').value,
    active: document.getElementById('qc-mat-active').checked
  };
  // DVI-1423: an unchecked box sends [] so a former aggregate is cleared.
  if (document.getElementById('qc-mat-is-aggregate').checked) {
    var kids = [];
    document.querySelectorAll('#qc-mat-agg-children input[data-agg-child]:checked').forEach(function (i) {
      kids.push(parseInt(i.getAttribute('data-agg-child'), 10));
    });
    body.aggregate_children = kids;
    body.aggregate_report_mode = document.getElementById('qc-mat-agg-mode').value;
  } else {
    body.aggregate_children = [];
  }
  var st = document.getElementById('qc-material-status');
  fetch('/qc/materials', { 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) {
        st.textContent = res.d.error || 'Save failed.';
        st.className = 'text-sm text-red-500';
        st.classList.remove('hidden');
        return;
      }
      qcMaterialClose();
      qcLoadMaterials();
      qcRefreshMeta();
    });
}

function qcMaterialDelete(id) {
  if (!confirm('Delete this material? Deletion only succeeds when no batch or recipe references it — deactivate it otherwise.')) return;
  fetch('/qc/materials/' + id, { method: 'DELETE' })
    .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
    .then(function (res) {
      if (!res.ok) { alert(res.d.error || 'Delete failed.'); return; }
      qcLoadMaterials();
      qcRefreshMeta();
    });
}

// ------------------------------------------------- Pipe Mix Log import (P3) --

var _qcImport = { materials: [], mapping: {}, ignore: '__ignore__', yards: '__yards__', lastCols: [], source: null };

function qcImportInit() {
  document.getElementById('qc-import-mapping').innerHTML = '';
  document.getElementById('qc-import-report').innerHTML = '';
  _qcImport.source = null;  // default to local-upload mode
  qcImportFileChanged();
  fetch('/qc/import/pipe/mapping').then(function (r) { return r.json(); })
    .then(function (data) {
      _qcImport.materials = data.materials || [];
      _qcImport.mapping = data.mapping || {};
      _qcImport.ignore = data.ignore_value || '__ignore__';
      _qcImport.yards = data.yards_value || '__yards__';
    }).catch(function () {});
}

// DVI-1410 P2 — the File Browser "Open in Import" handoff: analyze a Pipe Plant
// .xlsx already sitting in SharePoint (identified by drive/item id), reusing the
// exact same mapping editor + Import the local upload uses. The final Import
// re-fetches server-side by these ids.
function qcImportFromSharePoint(driveId, itemId, name) {
  _qcImport.source = { mode: 'sharepoint', drive_id: driveId, item_id: itemId, name: name };
  var fi = document.getElementById('qc-import-file'); if (fi) fi.value = '';
  qcImportFileChanged();
  qcImportAnalyze();
}

function qcImportFileChanged() {
  // Picking a local file switches back to upload mode.
  if ((document.getElementById('qc-import-file').files || []).length) _qcImport.source = null;
  var ready = !!(document.getElementById('qc-import-file').files || []).length ||
              (_qcImport.source && _qcImport.source.mode === 'sharepoint');
  document.getElementById('qc-import-analyze-btn').disabled = !ready;
  document.getElementById('qc-import-run-btn').disabled = !ready;
}

function _qcImportFile() {
  var f = (document.getElementById('qc-import-file').files || [])[0];
  return f || null;
}

function _qcImportStatus(msg, cls) {
  var st = document.getElementById('qc-import-status');
  st.textContent = msg;
  st.className = 'text-sm ' + (cls || 'text-gray-500 dark:text-gray-400');
  st.classList.remove('hidden');
}

function _qcImportPost(analyze, cb) {
  var src = _qcImport.source;
  var fetchOpts, url;
  var srcNote = (src && src.mode === 'sharepoint') ? ' (SharePoint: ' + src.name + ')' : '';
  if (src && src.mode === 'sharepoint') {
    url = '/qc/import/pipe-sharepoint' + (analyze ? '?analyze=1' : '');
    fetchOpts = { method: 'POST', headers: { 'Content-Type': 'application/json' },
                  body: JSON.stringify({ drive_id: src.drive_id, item_id: src.item_id }) };
  } else {
    var f = _qcImportFile();
    if (!f) return;
    var fd = new FormData();
    fd.append('file', f);
    url = '/qc/import/pipe' + (analyze ? '?analyze=1' : '');
    fetchOpts = { method: 'POST', body: fd };
  }
  _qcImportStatus((analyze ? 'Analyzing…' : 'Importing…') + srcNote);
  fetch(url, fetchOpts)
    .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
    .then(function (res) {
      if (!res.ok) {
        _qcImportStatus(res.d.error || 'Import failed.', 'text-red-500');
        return;
      }
      cb(res.d.report);
    }).catch(function () {
      _qcImportStatus('Network error during import.', 'text-red-500');
    });
}

function qcImportAnalyze() {
  _qcImportPost(true, function (rep) {
    _qcImportStatus('Analyzed — review the column mapping, then Import.',
                    'text-gray-500 dark:text-gray-400');
    _qcImport.lastCols = rep.material_columns || [];
    qcImportRenderMapping(rep);
    qcImportRenderReport(rep, false);
  });
}

function qcImportRun() {
  _qcImportPost(false, function (rep) {
    _qcImportStatus('Imported ' + (rep.imported || 0) + ' new · updated ' +
      (rep.updated || 0) + ' batch(es) for ' + (rep.batch_date || '?') + '.',
      'text-green-600 dark:text-green-400');
    qcImportRenderReport(rep, true);
    // Point the Batch Tickets tab at the imported day/segment for review.
    if (rep.batch_date) {
      var bd = document.getElementById('qc-batch-date'); if (bd) bd.value = rep.batch_date;
      var sg = document.getElementById('qc-segment'); if (sg) sg.value = 'pipe';
    }
    qcLoadBatches();
  });
}

// Render one <select> per material column: Auto (default) / each material /
// Ignore. The value maps to what save_pipe_material_map expects.
function qcImportRenderMapping(rep) {
  var cols = rep.material_columns || [];
  if (!cols.length) { document.getElementById('qc-import-mapping').innerHTML = ''; return; }
  var resStatus = {};
  (rep.mapped || []).forEach(function (m) { resStatus[m.column] = m; });
  (rep.unmapped || []).forEach(function (m) { resStatus[m.column] = { column: m.column, unmapped: true }; });
  (rep.ignored || []).forEach(function (m) { resStatus[m.column] = { column: m.column, ignored: true }; });
  var matOpts = _qcImport.materials.map(function (mt) {
    return '<option value="' + mt.id + '">' + esc(mt.name) +
      (mt.active ? '' : ' (inactive)') + ' — ' + esc(mt.capture_unit) + '</option>';
  }).join('');
  var rows = cols.map(function (col) {
    var cur = _qcImport.mapping[col];  // undefined=auto, int=id, ignore
    var val = (cur === undefined || cur === null) ? '' : String(cur);
    var badge = '';
    var st = resStatus[col];
    if (st && st.material_name) badge = '<span class="text-xs text-green-600 dark:text-green-400">→ ' + esc(st.material_name) + (st.source === 'mapped_default' ? ' (default)' : '') + '</span>';
    else if (st && st.ignored) badge = '<span class="text-xs text-gray-400">ignored</span>';
    else badge = '<span class="text-xs text-amber-600 dark:text-amber-400">unmapped — not imported</span>';
    var sel = '<select data-col="' + esc(col) + '" class="qc-import-map-sel 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">' +
      '<option value="">Auto (match by name)</option>' + matOpts +
      '<option value="' + esc(_qcImport.yards) + '">Batch yards (yd³)</option>' +
      '<option value="' + esc(_qcImport.ignore) + '">Ignore this column</option>' +
      '</select>';
    return '<tr class="border-b border-gray-100 dark:border-gray-800">' +
      '<td class="py-2 pr-3 font-mono text-xs text-gray-700 dark:text-gray-200">' + esc(col) + '</td>' +
      '<td class="py-2 pr-3">' + sel + '</td>' +
      '<td class="py-2">' + badge + '</td></tr>';
  }).join('');
  document.getElementById('qc-import-mapping').innerHTML =
    '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 p-4">' +
    '<div class="flex items-center justify-between mb-2"><h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Column mapping</h3>' +
    '<button onclick="qcImportSaveMapping()" class="togen-control px-3 py-1.5 text-xs font-semibold rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal/10">Save mapping &amp; re-analyze</button></div>' +
    '<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">Each material quantity column is mapped onto a Material. <strong>Auto</strong> matches Fly Ash / Cement / Sand / Rock by name; unmapped columns are not imported. Choose <strong>Batch yards (yd³)</strong> for the per-batch yardage column if its header wasn\'t auto-detected, or <strong>Ignore</strong> to acknowledge a column you never want (e.g. a total you track elsewhere).</p>' +
    '<div class="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 font-medium">Mix Log column</th><th class="py-2 pr-3 font-medium">Maps to</th><th class="py-2 font-medium">Current</th></tr></thead><tbody>' +
    rows + '</tbody></table></div></div>';
  // Pre-select stored values.
  Array.prototype.forEach.call(document.querySelectorAll('.qc-import-map-sel'), function (sel) {
    var col = sel.getAttribute('data-col');
    var cur = _qcImport.mapping[col];
    sel.value = (cur === undefined || cur === null) ? '' : String(cur);
  });
}

function qcImportSaveMapping() {
  var mapping = {};
  Array.prototype.forEach.call(document.querySelectorAll('.qc-import-map-sel'), function (sel) {
    var col = sel.getAttribute('data-col');
    var v = sel.value;
    if (v === '') return;            // auto -> omit (cleared)
    mapping[col] = (v === _qcImport.ignore) ? _qcImport.ignore : v;
  });
  _qcImportStatus('Saving mapping…');
  fetch('/qc/import/pipe/mapping', { method: 'POST', headers: { 'Content-Type': 'application/json' },
                                     body: JSON.stringify({ mapping: mapping }) })
    .then(function (r) { return r.json(); })
    .then(function (d) {
      _qcImport.mapping = d.mapping || {};
      qcImportAnalyze();  // re-analyze so the badges + preview reflect the new map
    }).catch(function () { _qcImportStatus('Could not save mapping.', 'text-red-500'); });
}

function qcImportRenderReport(rep, committed) {
  var chip = function (label, val, cls) {
    return '<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ' + cls + '">' + label + ': ' + val + '</span>';
  };
  var head = committed
    ? chip('New', rep.imported || 0, 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300') +
      ' ' + chip('Updated', rep.updated || 0, 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300')
    : chip('Would add', rep.imported || 0, 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300') +
      ' ' + chip('Would update', rep.updated || 0, 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300');
  var extra = '';
  if (rep.skipped_no_date) extra += ' ' + chip('Skipped (no date)', rep.skipped_no_date, 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300');
  if ((rep.unmapped || []).length) extra += ' ' + chip('Unmapped columns', rep.unmapped.length, 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300');
  // DVI-1421 P3: per-batch yardage + mix-code resolution surfacing.
  if (rep.yards_total != null) extra += ' ' + chip('Total yards', rep.yards_total + ' yd³', 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300');
  if (rep.mixtures_matched) extra += ' ' + chip('Mixtures matched', rep.mixtures_matched, 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300');
  if (rep.yards_blank) extra += ' ' + chip('Yards blank → 0', rep.yards_blank, 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300');
  if ((rep.unmatched_mix_codes || []).length) extra += ' ' + chip('Unmatched mix codes', rep.unmatched_mix_codes.length, 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300');
  var unmapped = (rep.unmapped || []).length
    ? '<p class="text-xs text-amber-600 dark:text-amber-400 mt-3">Unmapped (not imported): ' +
        rep.unmapped.map(function (u) { return '<span class="font-mono">' + esc(u.column) + '</span>' + (u.sample != null ? ' (e.g. ' + esc(String(u.sample)) + ')' : ''); }).join(', ') +
        '. Map or Ignore them above, then Save &amp; re-analyze.</p>'
    : '';
  var yardsNote = rep.yards_column
    ? '<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">Batch yards from column <span class="font-mono">' + esc(rep.yards_column) + '</span>.' + (rep.yards_blank ? ' <span class="text-amber-600 dark:text-amber-400">' + rep.yards_blank + ' row(s) had no yardage and were stored as 0.</span>' : '') + '</p>'
    : '<p class="text-xs text-amber-600 dark:text-amber-400 mt-2">No per-batch yardage column detected — batches import with 0 yards (Consumption Totals will not reflect real usage). Map the yardage column to <strong>Batch yards</strong> above if its header wasn\'t recognized.</p>';
  var mixNote = (rep.unmatched_mix_codes || []).length
    ? '<p class="text-xs text-amber-600 dark:text-amber-400 mt-2">Unmatched recipe codes (batches imported without a mixture): ' +
        rep.unmatched_mix_codes.map(function (u) { return '<span class="font-mono">' + esc(String(u.code)) + '</span> ×' + u.count; }).join(', ') +
        '. Set the matching Mix code on a Pipe mixture, then re-upload.</p>'
    : '';
  document.getElementById('qc-import-report').innerHTML =
    '<div class="togen-inset 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 mb-2">' +
      (committed ? 'Import result' : 'Preview') + ' — ' + esc(rep.batch_date || '?') +
      ' <span class="text-xs font-normal text-gray-400">(' + (rep.total_rows || 0) + ' rows · ' + (rep.line_count || 0) + ' material lines)</span></h3>' +
    '<div class="flex flex-wrap gap-1.5">' + head + extra + '</div>' + unmapped + yardsNote + mixNote + '</div>';
}

// ===== DASHBOARDS TOOL (DVI-1192 P1) =====
// At-a-glance dashboards built from configurable tiles in a size-preset grid.
// Owner-only this phase (server enforces owner match); Text is the only tile
// type. Size presets map to (cols x rows) spans on a responsive 12-col grid —
// CSS clamps an over-wide span to the available columns, so the same map works
// down to the 4-column mobile grid with no per-breakpoint classes.
var _dashboardsInitialized = false;
var _dashboards = [];
var _dashSharedWithMe = [];       // dashboards shared with me (view-only, P3)
var _dashSystem = null;           // system default Oversight dashboard (view-only, P2)
var _dashDefaultId = '';
var _dashActiveId = '';
var _dashTileTypes = {};
var _dashMetricTypes = {};   // DVI-1312 P1 — Stat tile metric catalog (source picker)
var _dashCanManageSources = false;   // DVI-1312 P2 — admin: manage data sources
var _dashCanEditManual = false;      // DVI-1370 Q1-B — anyone: edit manual values
var _dashSourcesActiveTab = 'sources';   // DVI-1370 — Sources | Manual
var _dashDisplayDefaults = null;   // DVI-1321 P2 — org default display settings
var _dashSourcesDraft = [];          // in-editor working copy of the source list
var _dashSourceReportTypes = [];     // DVI-1315 P3 — report types for report_cell binding
var _dashEditMode = false;
var _dashEditingTileId = null;   // null while adding a brand-new tile
var _dashNewTileType = null;     // tile type chosen in the picker (add flow)
var _dashDragTileId = null;
var _dashKeydownBound = false;

var DASH_SIZES = {
  small:  { label: 'Small',  cols: 3,  rows: 1 },
  medium: { label: 'Medium', cols: 6,  rows: 1 },
  tall:   { label: 'Tall',   cols: 3,  rows: 2 },
  large:  { label: 'Large',  cols: 6,  rows: 2 },
  wide:   { label: 'Wide',   cols: 12, rows: 1 },
  list:   { label: 'List',   cols: 3,  rows: 3 },
  xlarge: { label: 'X-Large (full width, tall)', cols: 12, rows: 3 },
  custom: { label: 'Custom', cols: 6,  rows: 1 }   // DVI-1220 P2 (F3): real span lives in tile.span
};

// F3 custom size: clamp a tile's explicit span to the editor's bounds
// (width 1–12 columns, height 1–6 rows); malformed spans fall back to 6×1.
function dashCustomSpan(span) {
  span = span || {};
  return {
    cols: Math.max(1, Math.min(12, parseInt(span.cols, 10) || 6)),
    rows: Math.max(1, Math.min(6, parseInt(span.rows, 10) || 1))
  };
}

function dashboardsInit() {
  if (!_dashKeydownBound) {
    _dashKeydownBound = true;
    document.addEventListener('keydown', function(e) {
      if (e.key !== 'Escape' || _activeTool !== 'dashboards') return;
      if (!document.getElementById('dashboards-md-help-overlay').classList.contains('hidden')) { dashboardsCloseMdHelp(); return; }
      if (!document.getElementById('dashboards-sources-overlay').classList.contains('hidden')) { dashboardsSourcesClose(); return; }
      if (!document.getElementById('dashboards-present-overlay').classList.contains('hidden')) { dashboardsClosePresent(); return; }
      if (!document.getElementById('dashboards-share-overlay').classList.contains('hidden')) { dashboardsCloseShare(); return; }
      if (!document.getElementById('dashboards-tile-overlay').classList.contains('hidden')) { dashboardsCloseTileEditor(); return; }
      if (!document.getElementById('dashboards-typepicker-overlay').classList.contains('hidden')) { dashboardsCloseTypePicker(); return; }
      if (!document.getElementById('dashboards-group-overlay').classList.contains('hidden')) { dashboardsGroupClose(); return; }
    });
  }
  dashboardsLoad();
}

function dashboardsLoad(preferId) {
  fetch('/dashboards')
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { return; }
      _dashboards = d.dashboards || [];
      _dashSharedWithMe = d.shared_with_me || [];
      _dashSystem = d.system_dashboard || null;
      _dashDefaultId = d.default_id || '';
      _dashTileTypes = d.tile_types || {};
      _dashMetricTypes = d.metric_types || {};
      _dashCanManageSources = !!d.can_manage_sources;
      _dashCanEditManual = !!d.can_edit_manual_values;   // DVI-1370 Q1-B
      _dashDisplayDefaults = d.display_defaults || null;   // DVI-1321 P2
      // Choose the active dashboard: explicit pick > current > default > first.
      // Candidate ids include owned + shared-with-me + the system default so any
      // of them can stay selected across a reload.
      var ids = _dashboards.map(function(x){ return x.id; })
        .concat(_dashSharedWithMe.map(function(x){ return x.id; }))
        .concat(_dashSystem ? [_dashSystem.id] : []);
      var want = preferId || _dashActiveId || _dashDefaultId || (ids[0] || '');
      if (ids.indexOf(want) === -1) want = ids[0] || '';
      _dashActiveId = want;
      dashboardsRender();
    });
}

function dashboardsActive() {
  var id = _dashActiveId;
  return _dashboards.filter(function(x){ return x.id === id; })[0]
    || _dashSharedWithMe.filter(function(x){ return x.id === id; })[0]
    || ((_dashSystem && _dashSystem.id === id) ? _dashSystem : null)
    || null;
}

// True when the active dashboard is the read-only system default (P2).
function dashboardsActiveIsSystem() {
  return !!(_dashSystem && _dashActiveId === _dashSystem.id);
}

// True when the active dashboard is owned by the current user (mutable);
// false for a shared-with-me dashboard (view-only).
function dashboardsActiveIsOwned() {
  var id = _dashActiveId;
  return _dashboards.some(function(x){ return x.id === id; });
}

function dashboardsRender() {
  var hasAny = _dashboards.length > 0 || _dashSharedWithMe.length > 0 || !!_dashSystem;
  var active = dashboardsActive();
  var owned = !!active && dashboardsActiveIsOwned();
  var isSystem = dashboardsActiveIsSystem();
  document.getElementById('dashboards-empty').classList.toggle('hidden', hasAny);
  // Owner-only actions: shown only when an *owned* dashboard is selected.
  ['dashboards-default-btn','dashboards-rename-btn','dashboards-share-btn','dashboards-present-btn','dashboards-delete-btn','dashboards-edit-btn'].forEach(function(bid){
    document.getElementById(bid).classList.toggle('hidden', !owned);
  });
  // Shared-with-me: view-only badge. System default: its own badge + "Make a copy".
  document.getElementById('dashboards-shared-badge').classList.toggle('hidden', !(active && !owned && !isSystem));
  document.getElementById('dashboards-system-badge').classList.toggle('hidden', !isSystem);
  document.getElementById('dashboards-copy-btn').classList.toggle('hidden', !isSystem);
  // Fullscreen preview works for any viewable dashboard (owned, shared, system).
  document.getElementById('dashboards-fullscreen-btn').classList.toggle('hidden', !active);
  // Data-sources dialog: admins manage all sources; any signed-in user can
  // open it to edit manual values (DVI-1370 Q1-B) — so show it to everyone.
  document.getElementById('dashboards-sources-btn').classList.toggle('hidden', !(_dashCanManageSources || _dashCanEditManual));
  if (active && !owned) _dashEditMode = false;
  var editBtn = document.getElementById('dashboards-edit-btn');
  editBtn.textContent = _dashEditMode ? 'Done' : 'Edit';
  var defBtn = document.getElementById('dashboards-default-btn');
  if (active && active.id === _dashDefaultId) {
    defBtn.textContent = '★ Default';
    defBtn.classList.add('text-togen-teal');
  } else {
    defBtn.textContent = 'Set as default';
    defBtn.classList.remove('text-togen-teal');
  }
  dashboardsRenderTabs();
  dashboardsRenderEditbar();
  dashboardsRenderGrid();
}

function dashboardsRenderTabs() {
  var host = document.getElementById('dashboards-tabs');
  host.innerHTML = '';
  function tab(d, shared) {
    var btn = document.createElement('button');
    var isActive = d.id === _dashActiveId;
    btn.className = 'shrink-0 px-3 py-1.5 text-sm rounded-lg border transition-colors cursor-pointer ' +
      (isActive
        ? 'bg-togen-teal text-white border-togen-teal'
        : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700');
    var star = (!shared && d.id === _dashDefaultId) ? '★ ' : '';
    btn.textContent = star + (d.name || 'Untitled');
    if (shared && d.owner) btn.title = 'Shared by ' + d.owner;
    btn.onclick = function(){ dashboardsSelect(d.id); };
    host.appendChild(btn);
  }
  // System default Oversight dashboard first (P2), flagged with a ★ when it is
  // the active fallback default.
  if (_dashSystem) {
    var sysBtn = document.createElement('button');
    var sysActive = _dashSystem.id === _dashActiveId;
    sysBtn.className = 'shrink-0 px-3 py-1.5 text-sm rounded-lg border transition-colors cursor-pointer ' +
      (sysActive
        ? 'bg-togen-teal text-white border-togen-teal'
        : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700');
    var sysStar = (_dashSystem.id === _dashDefaultId) ? '★ ' : '';
    sysBtn.textContent = sysStar + (_dashSystem.name || 'Oversight');
    sysBtn.title = 'Default Oversight dashboard (view-only) — use “Make a copy” to customize';
    sysBtn.onclick = function(){ dashboardsSelect(_dashSystem.id); };
    host.appendChild(sysBtn);
  }
  _dashboards.forEach(function(d){ tab(d, false); });
  // "Shared with me" section — a labelled divider, then the shared tabs.
  if (_dashSharedWithMe.length) {
    var sep = document.createElement('span');
    sep.className = 'shrink-0 self-center pl-2 ml-1 pr-1 text-xs font-medium text-gray-400 dark:text-gray-500 border-l border-gray-300 dark:border-gray-600';
    sep.textContent = 'Shared with me';
    host.appendChild(sep);
    _dashSharedWithMe.forEach(function(d){ tab(d, true); });
  }
}

function dashboardsRenderEditbar() {
  var bar = document.getElementById('dashboards-editbar');
  var active = dashboardsActive();
  var show = _dashEditMode && !!active;
  bar.classList.toggle('hidden', !show);
  if (show) document.getElementById('dashboards-editbar-name').textContent = active.name || 'Untitled';
}

function dashboardsSelect(id) {
  if (id === _dashActiveId) return;
  _dashActiveId = id;
  _dashEditMode = false;
  dashboardsRender();
}

function dashboardsCreate() {
  var name = window.prompt('Name for the new dashboard:', 'My dashboard');
  if (name === null) return;
  name = name.trim();
  if (!name) return;
  fetch('/dashboards', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name: name })
  }).then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { alert(d.error); return; }
      dashboardsLoad(d.dashboard && d.dashboard.id);
    });
}

// Clone the read-only system Oversight dashboard into an editable personal one.
function dashboardsMakeCopy() {
  fetch('/dashboards/copy-system', { method: 'POST' })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { alert(d.error); return; }
      dashboardsLoad(d.dashboard && d.dashboard.id);
    })
    .catch(function(){ alert('Could not copy the Oversight dashboard.'); });
}

function dashboardsRename() {
  var active = dashboardsActive();
  if (!active) return;
  var name = window.prompt('Rename dashboard:', active.name || '');
  if (name === null) return;
  name = name.trim();
  if (!name) return;
  fetch('/dashboards/' + encodeURIComponent(active.id), {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name: name })
  }).then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { alert(d.error); return; }
      dashboardsLoad(active.id);
    });
}

function dashboardsDelete() {
  var active = dashboardsActive();
  if (!active) return;
  if (!window.confirm('Delete dashboard "' + (active.name || 'Untitled') + '"? This cannot be undone.')) return;
  fetch('/dashboards/' + encodeURIComponent(active.id), { method: 'DELETE' })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { alert(d.error); return; }
      _dashActiveId = '';
      dashboardsLoad();
    });
}

function dashboardsSetDefault() {
  var active = dashboardsActive();
  if (!active) return;
  fetch('/dashboards/' + encodeURIComponent(active.id) + '/default', { method: 'POST' })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { alert(d.error); return; }
      _dashDefaultId = d.default_id || active.id;
      dashboardsRender();
    });
}

function dashboardsToggleEdit() {
  if (!dashboardsActive()) return;
  _dashEditMode = !_dashEditMode;
  dashboardsRender();
}

// DVI-1217: open the active dashboard through the signage kiosk page in a new
// tab — the exact render a Display Agent shows, authorized by the session.
// DVI-1321 P1: clicking Fullscreen opens a settings dialog (zoom/columns/flow/
// fit/orientation/rows + "use my interface") instead of jumping straight to the
// kiosk. If the user configured a virtual Display Agent for this dashboard we
// keep the DVI-1245 behavior and open ?agent= directly (that display carries its
// own interface/layout, edited in User Settings → Agents). Otherwise we open the
// Fullscreen settings dialog for the plain ?dash= preview path.
function dashboardsOpenFullscreen() {
  if (!_dashActiveId) return;
  var activeId = _dashActiveId;
  fetch('/signage/agents/virtual', {credentials:'same-origin'})
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      var agentId = '';
      var agents = (d && d.agents) || [];
      for (var i=0;i<agents.length;i++){ if (agents[i].dashboard_id === activeId){ agentId = agents[i].id; break; } }
      if (agentId){ window.open('/display?agent=' + encodeURIComponent(agentId), '_blank'); return; }
      dashboardsFullscreenOpenDialog(activeId);
    })
    .catch(function(){ dashboardsFullscreenOpenDialog(activeId); });
}

// ---- Fullscreen settings dialog (DVI-1321 P1) -------------------------------
// Live transport for updating an already-open fullscreen tab (D1-A): a
// same-browser BroadcastChannel the kiosk ?dash= page subscribes to, plus a
// per-dashboard localStorage key the kiosk reads on boot so the initial open
// already honors the settings. No server state on the render path.
var _dashFsDashId = '';
var _dashFsChanObj = null;
function _dashFsChannel(){
  if (_dashFsChanObj) return _dashFsChanObj;
  try { _dashFsChanObj = new BroadcastChannel('togen-fullscreen'); } catch(e){ _dashFsChanObj = null; }
  return _dashFsChanObj;
}
function _dashFsRemembered(dashId){
  var m = (_userSettings && _userSettings.fullscreen_remember) || null;
  return (m && typeof m === 'object') ? (m[dashId] || null) : null;
}
function _dashFsCollectLayout(){
  return {
    fit: document.getElementById('dashboards-fullscreen-fit').value || 'scale',
    orientation: document.getElementById('dashboards-fullscreen-orientation').value || 'auto',
    columns: document.getElementById('dashboards-fullscreen-columns').value || 'auto',
    zoom: parseInt(document.getElementById('dashboards-fullscreen-zoom').value, 10) || 100,
    flow: document.getElementById('dashboards-fullscreen-flow').value || 'row',
    rows: document.getElementById('dashboards-fullscreen-rows').value || 'auto'
  };
}
// Resolve the user's live interface prefs (D5) into the shape the kiosk
// applyInterface() consumes, so a Fullscreen view finally uses the user's own
// background image + dark/light mode instead of the corporate-only default.
function _dashFsIface(){
  var dark = document.documentElement.classList.contains('dark');
  var mode = effectiveBgMode();
  var iface = { theme: dark ? 'dark' : 'light', bg_mode: mode,
                bg_opacity: effectiveOpacity(),
                side_padding: (_userSettings && typeof _userSettings.tile_side_padding === 'number')
                              ? _userSettings.tile_side_padding : 0 };
  if (mode === 'custom' && _userSettings && _userSettings.custom_bg_ext){
    iface.custom_bg = true;
    // Session-authed, same-browser URL — the kiosk preview shares this session.
    iface.custom_bg_url = '/user-settings/custom-bg?v=' + _customBgBust;
  }
  return iface;
}
// Write the live blob (layout + optional resolved iface) to localStorage for the
// kiosk boot AND broadcast it so an already-open fullscreen tab updates instantly.
function _dashFsPersistLocal(){
  var useIface = document.getElementById('dashboards-fullscreen-use-interface').checked;
  var blob = { layout: _dashFsCollectLayout(), use_interface: useIface,
               iface: useIface ? _dashFsIface() : null };
  try { localStorage.setItem('togen-fs:' + _dashFsDashId, JSON.stringify(blob)); } catch(e){}
  var ch = _dashFsChannel();
  if (ch){ try { ch.postMessage({ dash: _dashFsDashId, settings: blob }); } catch(e){} }
  return blob;
}
// Durable "Remember these settings" — per-dashboard, across the user's browsers,
// so the next Fullscreen opens directly + the dialog prefills.
function _dashFsSaveRemember(){
  var on = document.getElementById('dashboards-fullscreen-remember').checked;
  var payload = { dashboard_id: _dashFsDashId, remember: on,
                  layout: _dashFsCollectLayout(),
                  use_interface: document.getElementById('dashboards-fullscreen-use-interface').checked };
  fetch('/dashboards/fullscreen-remember', {
    method:'POST', headers:{'Content-Type':'application/json'},
    credentials:'same-origin', body: JSON.stringify(payload)
  }).then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      if (!_userSettings) _userSettings = {};
      if (!_userSettings.fullscreen_remember || typeof _userSettings.fullscreen_remember !== 'object')
        _userSettings.fullscreen_remember = {};
      if (on) _userSettings.fullscreen_remember[_dashFsDashId] = d.remembered;
      else delete _userSettings.fullscreen_remember[_dashFsDashId];
    }).catch(function(){});
}
// DVI-1321 P2/P3: the dialog's prefill when this dashboard has no remembered
// setting — the user's own default display settings, else the org default, else
// the system neutral. Mirrors the server's _resolve_display_layout precedence so
// the dialog shows exactly what the Fullscreen view would open with.
function _dashFsDefaultLayout(){
  var neutral = { fit:'scale', orientation:'auto', columns:'auto', zoom:100, flow:'row', rows:'auto' };
  var ud = (_userSettings && _userSettings.display_settings) || null;
  if (ud && ud.layout){ var l = {}; for (var k in neutral) l[k] = neutral[k];
    for (var k2 in ud.layout) if (ud.layout[k2] != null) l[k2] = ud.layout[k2]; return l; }
  if (_dashDisplayDefaults && _dashDisplayDefaults.layout){
    var l2 = {}; for (var k3 in neutral) l2[k3] = neutral[k3];
    for (var k4 in _dashDisplayDefaults.layout) if (_dashDisplayDefaults.layout[k4] != null) l2[k4] = _dashDisplayDefaults.layout[k4];
    return l2; }
  return neutral;
}
function _dashFsDefaultUseInterface(){
  var ud = (_userSettings && _userSettings.display_settings) || null;
  if (ud) return !!ud.use_interface;
  return !!(_dashDisplayDefaults && _dashDisplayDefaults.use_interface_default);
}
function dashboardsFullscreenOpenDialog(dashId){
  _dashFsDashId = dashId;
  var active = dashboardsActive();
  document.getElementById('dashboards-fullscreen-name').textContent = (active && active.name) || 'Dashboard';
  var rem = _dashFsRemembered(dashId);
  var lay = (rem && rem.layout) || _dashFsDefaultLayout();
  document.getElementById('dashboards-fullscreen-fit').value = lay.fit || 'scale';
  document.getElementById('dashboards-fullscreen-orientation').value = lay.orientation || 'auto';
  _dashColsFill('dashboards-fullscreen-columns', lay.columns);
  document.getElementById('dashboards-fullscreen-zoom').value = String(lay.zoom || 100);
  document.getElementById('dashboards-fullscreen-flow').value = lay.flow || 'row';
  _dashRowsFill('dashboards-fullscreen-rows', lay.rows);
  document.getElementById('dashboards-fullscreen-use-interface').checked = rem ? !!rem.use_interface : _dashFsDefaultUseInterface();
  document.getElementById('dashboards-fullscreen-remember').checked = !!rem;
  document.getElementById('dashboards-fullscreen-status').textContent = '';
  var openNote = document.getElementById('dashboards-fullscreen-open-note');
  var updateBtn = document.getElementById('dashboards-fullscreen-update-btn');
  _dashLiftOverlay('dashboards-fullscreen-overlay');
  document.getElementById('dashboards-fullscreen-overlay').classList.remove('hidden');
  if (rem){
    // Remembered → open directly now AND keep the dialog open so the user can
    // change settings + Update the open view (the issue's core interaction).
    _dashFsPersistLocal();
    window.open('/display?dash=' + encodeURIComponent(dashId), '_blank');
    openNote.textContent = 'Opened with your remembered settings. Change any option and press Update to apply it to the open view.';
    openNote.classList.remove('hidden');
    updateBtn.classList.remove('hidden');
  } else {
    openNote.classList.add('hidden');
    updateBtn.classList.add('hidden');
  }
}
function dashboardsFullscreenClose(){
  document.getElementById('dashboards-fullscreen-overlay').classList.add('hidden');
}
function dashboardsFullscreenOpen(){
  _dashFsPersistLocal();
  _dashFsSaveRemember();
  window.open('/display?dash=' + encodeURIComponent(_dashFsDashId), '_blank');
  document.getElementById('dashboards-fullscreen-update-btn').classList.remove('hidden');
  var note = document.getElementById('dashboards-fullscreen-open-note');
  note.textContent = 'Opened. Change any option and press Update to apply it to the open view.';
  note.classList.remove('hidden');
}
function dashboardsFullscreenUpdate(){
  _dashFsPersistLocal();
  _dashFsSaveRemember();   // keep the remembered copy in step when the box is checked
  var st = document.getElementById('dashboards-fullscreen-status');
  st.textContent = 'Updated the open Fullscreen view.';
  setTimeout(function(){ if (st) st.textContent = ''; }, 2500);
}

function dashboardsEnterEditAndAdd() {
  if (!dashboardsActive()) return;
  _dashEditMode = true;
  dashboardsRender();
  dashboardsAddTile();
}

// ---- Grid + tiles ----
function dashboardsRenderGrid() {
  dashboardsStopAutoRefresh();
  dashboardsStopTimeTick();
  dashboardsStopImgTimers();   // clear slideshow timers before the grid rebuilds
  dashOvResetFull();   // drop any maximized Oversight tile before the grid rebuilds
  var grid = document.getElementById('dashboards-grid');
  var gridEmpty = document.getElementById('dashboards-grid-empty');
  var active = dashboardsActive();
  grid.innerHTML = '';
  if (!active) { gridEmpty.classList.add('hidden'); return; }
  var tiles = (active.tiles || []).slice().sort(function(a,b){ return (a.order||0) - (b.order||0); });
  gridEmpty.classList.toggle('hidden', tiles.length > 0);
  tiles.forEach(function(tile, idx){
    var el = dashboardsBuildTile(tile, idx, tiles.length);
    // Attach BEFORE rendering the body: renderers (Oversight) resolve their
    // scaffold via document.getElementById, which never matches detached nodes —
    // rendering detached threw once tile data was cached and blanked the grid.
    grid.appendChild(el);
    try {
      dashboardsRenderTileBody(el.querySelector('.dash-tile-body'), tile);
    } catch (e) {
      var b = el.querySelector('.dash-tile-body');
      if (b) b.innerHTML = '<span class="text-xs text-red-500">Tile failed to render.</span>';
      if (window.console && console.error) console.error('dash tile render failed', tile && tile.id, e);
    }
  });
  if (!_dashEditMode) dashboardsStartAutoRefresh();
  dashboardsStartTimeTick();
}

function dashboardsBuildTile(tile, idx, total) {
  var sizeKey = DASH_SIZES[tile.size] ? tile.size : 'medium';
  var size = (sizeKey === 'custom') ? dashCustomSpan(tile.span) : DASH_SIZES[sizeKey];
  var el = document.createElement('div');
  el.className = 'dash-tile dash-size-' + sizeKey + ' togen-pane bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden';
  // Column span is CSS-driven from --dash-cols (see the tool's <style>) so mobile
  // breakpoints can remap it; row span stays inline (unaffected on mobile).
  el.style.setProperty('--dash-cols', String(size.cols));
  el.style.gridRow = 'span ' + size.rows;
  el.setAttribute('data-tile-id', tile.id);

  // Edit-mode header with controls; view mode shows an optional title only.
  var cfg = tile.config || {};
  var titleText = (cfg.title) ? cfg.title : '';
  if (_dashEditMode) {
    var hdr = document.createElement('div');
    hdr.className = 'flex items-center gap-1 px-2 py-1.5 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/60 text-xs';
    var typeLabel = (_dashTileTypes[tile.type] && _dashTileTypes[tile.type].label) || tile.type;
    var label = document.createElement('span');
    label.className = 'font-medium text-gray-500 dark:text-gray-400 truncate';
    label.textContent = typeLabel + (titleText ? (' • ' + titleText) : '');
    hdr.appendChild(label);
    var ctrls = document.createElement('div');
    ctrls.className = 'ml-auto flex items-center gap-1';
    ctrls.appendChild(dashTileCtrlBtn('↑', 'Move up', idx === 0, function(){ dashboardsMoveTile(tile.id, -1); }));
    ctrls.appendChild(dashTileCtrlBtn('↓', 'Move down', idx === total - 1, function(){ dashboardsMoveTile(tile.id, 1); }));
    ctrls.appendChild(dashTileCtrlBtn('✎', 'Edit tile', false, function(){ dashboardsEditTile(tile.id); }));
    ctrls.appendChild(dashTileCtrlBtn('×', 'Remove tile', false, function(){ dashboardsRemoveTile(tile.id); }));
    hdr.appendChild(ctrls);
    el.appendChild(hdr);
    // Drag to reorder (HTML5 DnD; up/down buttons are the dependency-free fallback).
    el.setAttribute('draggable', 'true');
    el.addEventListener('dragstart', function(e){ _dashDragTileId = tile.id; el.classList.add('dash-dragging'); e.dataTransfer.effectAllowed = 'move'; });
    el.addEventListener('dragend', function(){ _dashDragTileId = null; el.classList.remove('dash-dragging'); var g = document.getElementById('dashboards-grid'); Array.prototype.forEach.call(g.children, function(c){ c.classList.remove('dash-drag-over'); }); });
    el.addEventListener('dragover', function(e){ e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (_dashDragTileId && _dashDragTileId !== tile.id) el.classList.add('dash-drag-over'); });
    el.addEventListener('dragleave', function(){ el.classList.remove('dash-drag-over'); });
    el.addEventListener('drop', function(e){ e.preventDefault(); el.classList.remove('dash-drag-over'); dashboardsDropOn(tile.id); });
  } else if (titleText) {
    // A tile shows a header only when the optional title is set (DVI-1286) — no
    // default type-label heading, so every tile is consistent and stays clean.
    var vh = document.createElement('div');
    vh.className = 'px-3 pt-2.5 pb-1 text-sm font-semibold text-gray-800 dark:text-gray-100 truncate flex items-center justify-between';
    vh.innerHTML = '<span>' + esc(titleText) + '</span>';
    el.appendChild(vh);
  }

  var body = document.createElement('div');
  // Per-tile overflow (DVI-1220 P2 B1): clip hides anything past the tile
  // edge; scroll/cycle/default all scroll here (a browser viewer has input —
  // auto-cycle paging is the kiosk's behavior for the same tile).
  var ovfCls = (tile.overflow === 'clip') ? 'overflow-hidden' : 'overflow-auto';
  body.className = 'dash-tile-body flex-1 min-h-0 ' + ovfCls + ' p-3 text-sm text-gray-700 dark:text-gray-200';
  // DVI-1275 P2: per-tile zoom via CSS `zoom` on the tile body — composes
  // multiplicatively with the display-level zoom in the kiosk.
  var tzoom = Math.max(50, Math.min(200, (tile.zoom | 0) || 100));
  if (tzoom !== 100) body.style.zoom = tzoom / 100;
  // Body content is rendered by the caller AFTER the tile is attached to the
  // grid (see dashboardsRenderGrid) — never render into a detached tile.
  el.appendChild(body);
  return el;
}

function dashTileCtrlBtn(glyph, title, disabled, handler) {
  var b = document.createElement('button');
  b.type = 'button';
  b.title = title;
  b.textContent = glyph;
  b.className = 'w-6 h-6 flex items-center justify-center rounded border border-gray-300 dark:border-gray-600 leading-none ' +
    (disabled
      ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
      : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer');
  if (disabled) { b.disabled = true; } else { b.onclick = handler; }
  return b;
}

// Auto-refresh: fetch live data for every data-backed tile on the active dashboard.
var _dashRefreshTimer = null;
var _dashTileData = {};   // tile_id -> latest data payload

// DVI-1312 P1: whether a tile needs a server fetch. A Stat tile is client-only
// UNLESS a row binds to a live metric — a fully-manual Stat tile keeps its
// no-poll behavior (backward compatible), a bound one is refreshed like any
// data tile.
function dashStatHasBinding(cfg) {
  return !!(cfg && (cfg.rows || []).some(function(r){ return r && r.metric; }));
}
function dashTileNeedsData(tile) {
  var desc = _dashTileTypes[tile.type];
  if (!desc || !desc.renderer) return false;
  if (tile.type === 'stat') return dashStatHasBinding(tile.config || {});
  return !desc.client_only;
}
function dashboardsStartAutoRefresh() {
  dashboardsStopAutoRefresh();
  var active = dashboardsActive();
  if (!active || _dashEditMode) return;
  var dataTiles = (active.tiles || []).filter(dashTileNeedsData);
  if (!dataTiles.length) return;
  // Fetch immediately, then every 60 s.
  function fetchAll() {
    var active2 = dashboardsActive();
    if (!active2 || _dashEditMode) { dashboardsStopAutoRefresh(); return; }
    (active2.tiles || []).forEach(function(tile){
      if (!dashTileNeedsData(tile)) return;
      dashboardsFetchTileData(active2.id, tile.id);
    });
  }
  fetchAll();
  _dashRefreshTimer = setInterval(fetchAll, 60000);
}

function dashboardsStopAutoRefresh() {
  if (_dashRefreshTimer) { clearInterval(_dashRefreshTimer); _dashRefreshTimer = null; }
}

// Time tiles tick every second (in both view and edit mode). One shared timer
// re-renders every Time tile body in place — the clock stays live without a
// full grid rebuild, and auto-font re-fits once the tiles are laid out.
var _dashTimeTimer = null;
function dashboardsStartTimeTick() {
  dashboardsStopTimeTick();
  var active = dashboardsActive();
  if (!active) return;
  var hasTime = (active.tiles || []).some(function(t){ return t.type === 'time'; });
  if (!hasTime) return;
  dashboardsTickTime();   // immediate render now that tiles are in the DOM (auto-fit needs size)
  _dashTimeTimer = setInterval(dashboardsTickTime, 1000);
}
function dashboardsStopTimeTick() {
  if (_dashTimeTimer) { clearInterval(_dashTimeTimer); _dashTimeTimer = null; }
}
function dashboardsTickTime() {
  var active = dashboardsActive();
  if (!active) { dashboardsStopTimeTick(); return; }
  (active.tiles || []).forEach(function(tile){
    if (tile.type !== 'time') return;
    var el = document.querySelector('[data-tile-id="' + tile.id + '"]');
    if (!el) return;
    var body = el.querySelector('.dash-tile-body');
    if (body) dashTileRenderTime(body, tile.config || {});
  });
}

function dashboardsFetchTileData(dashId, tileId) {
  fetch('/dashboards/tile-data?dash_id=' + encodeURIComponent(dashId) + '&tile_id=' + encodeURIComponent(tileId))
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) return;
      _dashTileData[tileId] = d.data || {};
      // Re-render just this tile's body in-place.
      var el = document.querySelector('[data-tile-id="' + tileId + '"]');
      if (!el) return;
      var body = el.querySelector('.dash-tile-body');
      if (!body) return;
      var active = dashboardsActive();
      var tile = active && (active.tiles || []).filter(function(t){ return t.id === tileId; })[0];
      if (tile) dashboardsRenderTileBody(body, tile, _dashTileData[tileId]);
    })
    .catch(function(){});
}

// Tile body renderer dispatch (P2 data tiles register more renderers here).
function dashboardsRenderTileBody(body, tile, liveData) {
  if (tile.type === 'text') { dashTileRenderText(body, tile.config || {}); return; }
  if (tile.type === 'time') { dashTileRenderTime(body, tile.config || {}); return; }
  if (tile.type === 'stat') {
    var scfg = tile.config || {};
    var sdata = liveData || _dashTileData[tile.id];
    if (dashStatHasBinding(scfg) && !sdata) {
      body.innerHTML = '<span class="text-gray-400 dark:text-gray-500 text-xs italic">Loading…</span>';
      return;
    }
    dashTileRenderStat(body, scfg, sdata || null); return;
  }
  if (tile.type === 'logo') { dashTileRenderLogo(body, tile.config || {}); return; }
  if (tile.type === 'image') { dashTileRenderImage(body, tile.config || {}, tile); return; }
  // DVI-1305 P4 — Group / Panel container. Renders immediately from config (its
  // client-rendered children need no server data); data children fill in from
  // the group's fetched payload {children:{id:payload}} on the auto-refresh pass.
  if (tile.type === 'group') { dashTileRenderGroup(body, tile, liveData || _dashTileData[tile.id] || {}); return; }
  var data = liveData || _dashTileData[tile.id];
  if (!data) {
    body.innerHTML = '<span class="text-gray-400 dark:text-gray-500 text-xs italic">Loading…</span>';
    return;
  }
  if (data.access === false) {
    body.innerHTML = '<span class="text-amber-500 dark:text-amber-400 text-xs">No access: ' + esc(data.reason || 'Permission denied') + '</span>';
    return;
  }
  if (tile.type === 'work_order_view') { dashTileRenderWorkOrderView(body, tile.config || {}, data); return; }
  if (tile.type === 'parts') { dashTileRenderParts(body, tile.config || {}, data); return; }
  if (tile.type === 'scada_view') { dashTileRenderScadaView(body, tile.config || {}, data); return; }
  if (tile.type === 'batch_production') { dashTileRenderBatchProduction(body, tile.config || {}, data); return; }
  if (tile.type === 'calendar') { dashTileRenderCalendar(body, tile.config || {}, data); return; }
  if (tile.type === 'idencia') { dashTileRenderIdencia(body, tile.config || {}, data); return; }
  if (tile.type === 'weather') { dashTileRenderWeather(body, tile.config || {}, data); return; }
  if (tile.type === 'powerbi_data') { dashTileRenderPowerbiData(body, tile.config || {}, data); return; }
  if (tile.type === 'powerbi_report') { dashTileRenderPowerbiReport(body, tile.config || {}, data, tile); return; }
  if (tile.type === 'oversight') { dashTileRenderOversight(body, tile.config || {}, data, tile); return; }
  if (tile.type === 'chart') { dashTileRenderChart(body, tile.config || {}, data); return; }
  if (tile.type === 'report_table') { dashTileRenderReportTable(body, tile.config || {}, data); return; }
  body.innerHTML = '<span class="text-gray-400 dark:text-gray-500">Unknown tile type.</span>';
}

// DVI-1305 P4 — Group / Panel container renderer. Lays child tiles out in an
// inner CSS grid under an optional panel heading; each child body is rendered
// through the shared dashboardsRenderTileBody dispatch with its own payload
// (from the group's fetched {children:{id:payload}} map). wide/xlarge children
// span the full panel width; row span follows the child's size preset.
var DASH_GROUP_ROWSPAN = { small:1, medium:1, tall:2, large:2, wide:1, list:3, xlarge:3, custom:1 };
function _dashGroupCols(cfg, n) {
  var c = cfg.columns;
  if (c === '1' || c === '2' || c === '3' || c === '4') return parseInt(c, 10);
  return n <= 1 ? 1 : 2;   // auto: a compact panel
}
function dashTileRenderGroup(body, tile, data) {
  var cfg = tile.config || {};
  var children = cfg.children || [];
  var kids = (data && data.children) || {};
  body.innerHTML = '';
  if (!children.length) {
    body.innerHTML = '<span class="text-gray-400 dark:text-gray-500 text-xs italic">Empty panel — add tiles in edit mode.</span>';
    return;
  }
  var cols = _dashGroupCols(cfg, children.length);
  var grid = document.createElement('div');
  grid.className = 'grid gap-2 h-full content-start';
  grid.style.gridTemplateColumns = 'repeat(' + cols + ',minmax(0,1fr))';
  // Attach the grid to the (already-attached) tile body BEFORE rendering child
  // bodies — some renderers (Oversight) resolve their scaffold via getElementById
  // and never match a detached node.
  body.appendChild(grid);
  children.forEach(function(child) {
    var panel = document.createElement('div');
    panel.className = 'rounded-lg border border-gray-200 dark:border-gray-700 flex flex-col min-w-0 overflow-hidden';
    var full = (child.size === 'wide' || child.size === 'xlarge');
    panel.style.gridColumn = 'span ' + (full ? cols : 1);
    panel.style.gridRow = 'span ' + (DASH_GROUP_ROWSPAN[child.size] || 1);
    var ctitle = (child.config && child.config.title) ? child.config.title : '';
    if (ctitle) {
      var ch = document.createElement('div');
      ch.className = 'px-2 py-1 text-xs font-semibold text-gray-700 dark:text-gray-200 border-b border-gray-200 dark:border-gray-700 truncate';
      ch.textContent = ctitle;
      panel.appendChild(ch);
    }
    var cbody = document.createElement('div');
    var ovf = (child.overflow === 'clip') ? 'overflow-hidden' : 'overflow-auto';
    cbody.className = 'dash-tile-body flex-1 min-h-0 ' + ovf + ' p-2 text-xs text-gray-700 dark:text-gray-200';
    var cz = Math.max(50, Math.min(200, (child.zoom | 0) || 100));
    if (cz !== 100) cbody.style.zoom = cz / 100;
    panel.appendChild(cbody);
    grid.appendChild(panel);
    try { dashboardsRenderTileBody(cbody, child, kids[child.id]); }
    catch (e) { cbody.innerHTML = '<span class="text-xs text-red-500">Tile failed to render.</span>'; }
  });
}

var _WOR_STATUS_LABELS = { new: 'New', in_progress: 'In progress', completed: 'Completed', cancelled: 'Cancelled' };
var _WOR_TYPE_LABELS = { maintenance: 'Maintenance', mobilemachine: 'Mobile Machine', safety: 'Safety', preventive: 'Preventive' };

function _dashWovEquipHtml(eq) {
  if (!eq) return '';
  var chip = function(n, label, cls){
    return '<div class="flex-1 rounded-lg ' + cls + ' px-2 py-1.5 text-center">' +
      '<div class="text-lg font-semibold leading-none">' + (n || 0) + '</div>' +
      '<div class="text-[10px] uppercase tracking-wide mt-0.5">' + label + '</div></div>';
  };
  return '<div class="flex gap-2 mb-2">' +
    chip(eq.down, 'Equipment down', 'bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400') +
    chip(eq.offsite, 'Offsite', 'bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400') +
    '</div>';
}

function _dashWovItemHtml(wo) {
  var statusLabel = _WOR_STATUS_LABELS[wo.status] || wo.status;
  var typeLabel = _WOR_TYPE_LABELS[wo.wor_type] || wo.wor_type;
  var overdueCls = wo.is_overdue
    ? ' border-l-2 border-red-400 dark:border-red-500'
    : ' border-l-2 border-gray-200 dark:border-gray-700';
  var overdueTag = wo.is_overdue
    ? '<span class="ml-1 text-xs font-medium text-red-600 dark:text-red-400">Overdue</span>'
    : '';
  return '<div class="pl-2 py-1' + overdueCls + '">' +
    '<div class="text-xs font-medium text-gray-800 dark:text-gray-100 leading-snug">' + esc(wo.title) + overdueTag + '</div>' +
    '<div class="text-xs text-gray-500 dark:text-gray-400">' + esc(typeLabel) + ' &middot; ' + esc(statusLabel) +
      (wo.due_date ? ' &middot; Due ' + esc(wo.due_date) : '') + '</div>' +
    '</div>';
}

function dashTileRenderWorkOrderView(body, cfg, data) {
  var eqHtml = _dashWovEquipHtml(data.equipment);
  var emptyMsg = data.pm_today ? 'No preventive maintenance due today.' : 'No work orders match the current filters.';
  // DVI-1305 P3 (D4-B): multi-section view — labeled WO buckets as sub-lists.
  if (data.grouped) {
    var sections = data.sections || [];
    if (!sections.length) {
      body.innerHTML = eqHtml + '<p class="text-gray-400 dark:text-gray-500 text-xs italic text-center py-4">' + emptyMsg + '</p>';
      return;
    }
    var ghtml = '';
    sections.forEach(function(sec) {
      ghtml += '<div class="mb-2">' +
        '<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-0.5">' +
          esc(sec.label) + ' <span class="text-gray-400 dark:text-gray-500 font-normal">(' + (sec.total != null ? sec.total : sec.count) + ')</span></div>' +
        '<div class="space-y-1">';
      (sec.items || []).forEach(function(wo) { ghtml += _dashWovItemHtml(wo); });
      ghtml += '</div>';
      if (sec.total && sec.total > (sec.items || []).length) {
        ghtml += '<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">+' + (sec.total - (sec.items || []).length) + ' more</p>';
      }
      ghtml += '</div>';
    });
    body.innerHTML = eqHtml + ghtml;
    return;
  }
  var items = data.items || [];
  if (!items.length) {
    body.innerHTML = eqHtml + '<p class="text-gray-400 dark:text-gray-500 text-xs italic text-center py-4">' + emptyMsg + '</p>';
    return;
  }
  var html = '<div class="space-y-1">';
  items.forEach(function(wo) { html += _dashWovItemHtml(wo); });
  html += '</div>';
  if (data.total && data.total > items.length) {
    html += '<p class="text-xs text-gray-400 dark:text-gray-500 mt-1 text-center">+' + (data.total - items.length) + ' more</p>';
  } else if (items.length >= 50) {
    html += '<p class="text-xs text-gray-400 dark:text-gray-500 mt-1 text-center">Showing first 50 results</p>';
  }
  body.innerHTML = eqHtml + html;
}

function dashTileRenderParts(body, cfg, data) {
  var items = data.items || [];
  if (!items.length) {
    body.innerHTML = '<p class="text-gray-400 dark:text-gray-500 text-xs italic text-center py-4">No work orders match the current filters.</p>';
    return;
  }
  var html = '<div class="space-y-1">';
  items.forEach(function(wo) {
    var statusLabel = _WOR_STATUS_LABELS[wo.status] || wo.status;
    var typeLabel = _WOR_TYPE_LABELS[wo.wor_type] || wo.wor_type;
    var pct = wo.part_count > 0 ? Math.round(wo.stocked_count / wo.part_count * 100) : 0;
    var barCls = pct >= 100 ? 'bg-green-500' : (pct > 0 ? 'bg-amber-400' : 'bg-gray-300 dark:bg-gray-600');
    html += '<div class="border-l-2 border-gray-200 dark:border-gray-700 pl-2 py-1">' +
      '<div class="text-xs font-medium text-gray-800 dark:text-gray-100 leading-snug">' + esc(wo.title) + '</div>' +
      '<div class="text-xs text-gray-500 dark:text-gray-400">' + esc(typeLabel) + ' &middot; ' + esc(statusLabel) + ' &middot; ' +
        wo.stocked_count + '/' + wo.part_count + ' stocked' +
        (wo.unapproved_count > 0 ? ' &middot; ' + wo.unapproved_count + ' unapproved' : '') + '</div>' +
      '<div class="mt-0.5 h-1 rounded-full bg-gray-200 dark:bg-gray-700">' +
        '<div class="h-1 rounded-full ' + barCls + '" style="width:' + pct + '%"></div>' +
      '</div>' +
      '</div>';
  });
  html += '</div>';
  if (data.total && data.total > items.length) {
    html += '<p class="text-xs text-gray-400 dark:text-gray-500 mt-1 text-center">+' + (data.total - items.length) + ' more</p>';
  }
  body.innerHTML = html;
}

var _SCADA_EMAIL_STATUS_LABELS = {
  sent: 'Sent', sent_pending_approval: 'Sent (internal) + pending approval',
  pending_approval: 'Pending approval', skipped_notify_off: 'Skipped (notify off)',
  skipped_no_recipients: 'Skipped (no recipients)', manual_no_email: 'Manual run (no email)',
  skipped_oversized: 'Skipped (oversized)'
};

function dashTileRenderScadaView(body, cfg, data) {
  var html = '';
  var readings = data.readings || [];
  if (!readings.length) {
    html += '<p class="text-gray-400 dark:text-gray-500 text-xs italic text-center py-2">No sensor readings available.</p>';
  } else {
    html += '<div class="space-y-1">';
    readings.forEach(function(r) {
      var label = r.alias || r.label;
      var oor = r.oor;
      var oorCls = oor ? ' text-red-600 dark:text-red-400 font-semibold' : ' text-gray-800 dark:text-gray-100';
      var oorTag = oor ? '<span class="ml-1 text-xs text-red-500 dark:text-red-400">⚠</span>' : '';
      var unit = r.unit ? ' ' + r.unit : '';
      html += '<div class="flex items-baseline justify-between border-b border-gray-100 dark:border-gray-800 py-0.5">' +
        '<span class="text-xs text-gray-600 dark:text-gray-300 truncate max-w-[55%]">' + esc(label) + '</span>' +
        '<span class="text-xs' + oorCls + '">' + esc(String(r.value !== null && r.value !== undefined ? r.value : '—')) + esc(unit) + oorTag + '</span>' +
        '</div>';
    });
    html += '</div>';
    if (data.total && data.total > readings.length) {
      html += '<p class="text-xs text-gray-400 dark:text-gray-500 mt-1 text-center">+' + (data.total - readings.length) + ' more</p>';
    }
    if (data.oor_in_window && data.oor_in_window.length) {
      html += '<p class="text-xs text-red-500 dark:text-red-400 mt-1">Out of range in window (' + esc(data.oor_window) + '): ' + esc(data.oor_in_window.join(', ')) + '</p>';
    }
  }
  if (data.snap_time) {
    html += '<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">As of ' + esc(data.snap_time) + '</p>';
  }
  if (data.scheduler_status) {
    var s = data.scheduler_status;
    var esLabel = _SCADA_EMAIL_STATUS_LABELS[s.email_status] || s.email_status || '';
    html += '<div class="mt-2 pt-1 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-500 dark:text-gray-400">' +
      'Last report: ' + esc(s.run_date || '—') + (esLabel ? ' (' + esc(esLabel) + ')' : '') + '</div>';
  }
  body.innerHTML = html;
}

// DVI-1524 P5 — QC Batch Production daily-summary tile (sibling of scada_view).
function dashTileRenderBatchProduction(body, cfg, data) {
  if (data.available === false) {
    body.innerHTML = '<span class="text-amber-500 dark:text-amber-400 text-xs">' + esc(data.message || 'QC data unavailable.') + '</span>';
    return;
  }
  var html = '';
  html += '<div class="flex items-baseline justify-between mb-1">' +
    '<span class="text-2xl font-semibold text-gray-800 dark:text-gray-100">' + (data.batch_count || 0) + '</span>' +
    '<span class="text-xs text-gray-500 dark:text-gray-400">batch' + ((data.batch_count === 1) ? '' : 'es') + ' today</span></div>';
  var yd = data.total_yards || 0, oor = data.oor || 0;
  html += '<div class="flex items-baseline justify-between text-xs text-gray-600 dark:text-gray-300">' +
    '<span>' + esc(String(yd)) + ' yd³</span>' +
    '<span class="' + (oor ? 'text-red-600 dark:text-red-400 font-semibold' : '') + '">' + oor + ' out of range' + (oor ? ' ⚠' : '') + '</span></div>';
  var sections = data.sections || [];
  if (sections.length && !data.segment) {
    html += '<div class="mt-2 pt-1 border-t border-gray-100 dark:border-gray-800 space-y-0.5">';
    sections.forEach(function(s) {
      html += '<div class="flex items-baseline justify-between text-xs">' +
        '<span class="text-gray-600 dark:text-gray-300 truncate max-w-[55%]">' + esc(s.label) + '</span>' +
        '<span class="text-gray-700 dark:text-gray-200">' + (s.batch_count || 0) + ' · ' + esc(String(s.total_yards || 0)) + ' yd³' +
        (s.oor ? ' · <span class="text-red-500 dark:text-red-400">' + s.oor + ' OOR</span>' : '') + '</span></div>';
    });
    html += '</div>';
  }
  html += '<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">' + esc(data.date || '') + '</p>';
  body.innerHTML = html;
}

// DVI-1305 P1 — tile-level text styling (font size / alignment / heading accent).
// Each map is keyed only by the non-default values, so a tile with no styling
// (or an existing tile that predates these keys) emits no inline style and is
// rendered byte-for-byte as before.
var _DASH_TEXT_FONT = { small: '0.8rem', medium: '1rem', large: '1.25rem', xlarge: '1.6rem' };
var _DASH_TEXT_ACCENT = { teal: '#14b8a6', blue: '#3b82f6', green: '#22c55e', amber: '#f59e0b', red: '#ef4444', purple: '#a855f7', gray: '#64748b' };
function _dashTextAccentColor(a) { return _DASH_TEXT_ACCENT[a] || ''; }
function _dashTextStyleAttr(cfg) {
  var s = '';
  var fs = _DASH_TEXT_FONT[cfg.font_size]; if (fs) s += 'font-size:' + fs + ';';
  var al = cfg.align; if (al === 'center' || al === 'right') s += 'text-align:' + al + ';';
  return s;
}
function dashTileRenderText(body, cfg) {
  var txt = cfg.text || '';
  if (!txt.trim()) {
    body.innerHTML = '<span class="text-gray-400 dark:text-gray-500 italic">Empty text tile.</span>';
    return;
  }
  var style = _dashTextStyleAttr(cfg);
  if (cfg.markdown) {
    // renderNewsMarkdown escapes input first, then applies a fixed markdown
    // subset — HTML never passes through, so this is safe for user content.
    body.innerHTML = '<div class="dash-md"' + (style ? ' style="' + style + '"' : '') + '>' + renderNewsMarkdown(txt, { accent: cfg.accent }) + '</div>';
  } else {
    var p = document.createElement('div');
    p.className = 'whitespace-pre-wrap break-words';
    if (style) p.setAttribute('style', style);
    p.textContent = txt;   // plain text, escaped by the DOM
    body.innerHTML = '';
    body.appendChild(p);
  }
}

// ---- Time tile (DVI-1214) ----
// Fully client-rendered: the browser's wall clock + Intl timezone conversion.
// A shared 1 s ticker (dashboardsStartTimeTick) re-renders each Time tile in place.
var _DASH_TZ_FALLBACK = [
  'UTC','America/New_York','America/Chicago','America/Denver','America/Phoenix',
  'America/Los_Angeles','America/Anchorage','Pacific/Honolulu','America/Toronto',
  'America/Mexico_City','America/Sao_Paulo','Europe/London','Europe/Paris',
  'Europe/Berlin','Europe/Madrid','Europe/Moscow','Africa/Johannesburg',
  'Asia/Dubai','Asia/Kolkata','Asia/Bangkok','Asia/Shanghai','Asia/Singapore',
  'Asia/Tokyo','Asia/Seoul','Australia/Sydney','Pacific/Auckland'
];
function _dashTimeZoneOptions() {
  var list = null;
  try { if (typeof Intl.supportedValuesOf === 'function') list = Intl.supportedValuesOf('timeZone'); } catch (e) {}
  return (list && list.length) ? list : _DASH_TZ_FALLBACK;
}

// ===== Regional preferences (DVI-1364 P1) ===================================
// Shared helpers for the Togen Admin (org default) + User Settings (per-user
// override) regional panels. Time-zone lists reuse _dashTimeZoneOptions().
var _tgaRegLoc = null;   // admin: {label,latitude,longitude} currently selected
var _usRegLoc = null;    // user: the user's OVERRIDE location, or null (inherit)

function _regFillTzSelect(selId, includeDefault, current) {
  var sel = document.getElementById(selId);
  if (!sel) return;
  var opts = includeDefault ? '<option value="">Use company default</option>' : '';
  var zones = _dashTimeZoneOptions();
  for (var i = 0; i < zones.length; i++) {
    var z = zones[i];
    opts += '<option value="' + esc(z) + '">' + esc(z.replace(/_/g, ' ')) + '</option>';
  }
  sel.innerHTML = opts;
  // A stored zone not in the Intl list still needs to be selectable.
  if (current && !Array.prototype.some.call(sel.options, function(o){ return o.value === current; })) {
    var extra = document.createElement('option');
    extra.value = current; extra.textContent = current.replace(/_/g, ' ');
    sel.appendChild(extra);
  }
  sel.value = current || '';
}

function _regGeocode(query, resultsId, onPick) {
  var host = document.getElementById(resultsId);
  var q = (query || '').trim();
  if (!q) { if (host) host.innerHTML = ''; return; }
  if (host) host.innerHTML = '<p class="text-xs text-gray-400">Searching…</p>';
  fetch('/dashboards/weather/geocode?q=' + encodeURIComponent(q))
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!host) return;
      var results = (d && d.results) || [];
      if (!results.length) { host.innerHTML = '<p class="text-xs text-gray-400">No matches.</p>'; return; }
      host.innerHTML = '';
      results.forEach(function(r){
        var b = document.createElement('button');
        b.type = 'button';
        b.className = 'block w-full text-left px-3 py-1.5 text-sm rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-800 dark:text-gray-100';
        b.textContent = r.label;
        b.onclick = function(){ onPick({label: r.label, latitude: r.latitude, longitude: r.longitude}); host.innerHTML = ''; };
        host.appendChild(b);
      });
    })
    .catch(function(){ if (host) host.innerHTML = '<p class="text-xs text-red-500">Search failed.</p>'; });
}

// Browser geolocation (DVI-1364 P3, Q4 D1). Stamps lat/lon with the label
// "My location" — no reverse-geocode. Requires HTTPS (Togen is HTTPS) + the
// user's permission; calls onErr with a friendly message on denial/failure.
function _regGeolocate(onOk, onErr) {
  if (!navigator.geolocation) {
    if (onErr) onErr('This browser does not support location.');
    return;
  }
  navigator.geolocation.getCurrentPosition(function(pos) {
    var c = pos.coords || {};
    onOk({ label: 'My location',
           latitude: Math.round(c.latitude * 10000) / 10000,
           longitude: Math.round(c.longitude * 10000) / 10000 });
  }, function(err) {
    var msg = 'Could not get your location.';
    if (err && err.code === 1) msg = 'Location permission was denied.';
    else if (err && err.code === 2) msg = 'Your location is unavailable.';
    else if (err && err.code === 3) msg = 'The location request timed out.';
    if (onErr) onErr(msg);
  }, { enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 });
}

// ---- Togen Admin → General → Regional (org defaults) -----------------------
function togenadminRegionalLoad() {
  fetch('/togen-admin/regional')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      var res = d.resolved || {};
      var tf = document.getElementById('tga-reg-time-format');
      var df = document.getElementById('tga-reg-date-format');
      if (tf) tf.value = res.time_format || '12h';
      if (df) df.value = res.date_format || 'month_day_year';
      _regFillTzSelect('tga-reg-timezone', false, res.timezone || 'America/Chicago');
      _tgaRegLoc = res.location || null;
      togenadminRegionalRenderLoc();
    })
    .catch(function(){});
}

function togenadminRegionalRenderLoc() {
  var el = document.getElementById('tga-reg-loc-current');
  if (!el) return;
  el.textContent = _tgaRegLoc && _tgaRegLoc.label ? ('Current: ' + _tgaRegLoc.label) :
    (_tgaRegLoc ? ('Current: ' + _tgaRegLoc.latitude + ', ' + _tgaRegLoc.longitude) : 'No location set.');
}

function togenadminRegionalSearch() {
  var q = document.getElementById('tga-reg-loc-search');
  _regGeocode(q ? q.value : '', 'tga-reg-loc-results', function(loc){
    _tgaRegLoc = loc; togenadminRegionalRenderLoc();
    if (q) q.value = '';
  });
}

function togenadminRegionalSave() {
  var status = document.getElementById('tga-reg-status');
  var body = { regional: {
    time_format: document.getElementById('tga-reg-time-format').value,
    date_format: document.getElementById('tga-reg-date-format').value,
    timezone: document.getElementById('tga-reg-timezone').value,
    location: _tgaRegLoc
  }};
  if (status) status.textContent = 'Saving…';
  fetch('/togen-admin/regional', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) })
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (d && d.ok) { if (status) { status.textContent = 'Saved.'; status.className = 'text-xs text-green-600 dark:text-green-400'; } }
      else if (status) { status.textContent = 'Save failed.'; status.className = 'text-xs text-red-500'; }
    })
    .catch(function(){ if (status) { status.textContent = 'Save failed.'; status.className = 'text-xs text-red-500'; } });
}

// ---- User Settings → Account → Regional (per-user override) ----------------
function usRegionalInit() {
  var ov = (_userSettings && _userSettings.regional) || {};
  var tf = document.getElementById('us-reg-time-format');
  var df = document.getElementById('us-reg-date-format');
  if (tf) tf.value = ov.time_format || '';
  if (df) df.value = ov.date_format || '';
  _regFillTzSelect('us-reg-timezone', true, ov.timezone || '');
  _usRegLoc = ov.location || null;
  usRegionalRenderLoc();
}

function usRegionalRenderLoc() {
  var cur = document.getElementById('us-reg-loc-current');
  var clr = document.getElementById('us-reg-loc-clear');
  var deflt = (window._regional && window._regional.location && window._regional.location.label) || '';
  if (_usRegLoc && (_usRegLoc.label || _usRegLoc.latitude != null)) {
    if (cur) cur.textContent = 'Your location: ' + (_usRegLoc.label || (_usRegLoc.latitude + ', ' + _usRegLoc.longitude));
    if (clr) clr.classList.remove('hidden');
  } else {
    if (cur) cur.textContent = deflt ? ('Using company default: ' + deflt) : 'Using company default.';
    if (clr) clr.classList.add('hidden');
  }
}

function usRegionalSearch() {
  var q = document.getElementById('us-reg-loc-search');
  _regGeocode(q ? q.value : '', 'us-reg-loc-results', function(loc){
    _usRegLoc = loc; usRegionalRenderLoc();
    if (q) q.value = '';
  });
}

function usRegionalClearLoc() { _usRegLoc = null; usRegionalRenderLoc(); }

function usRegionalUseMyLocation() {
  var host = document.getElementById('us-reg-loc-results');
  if (host) host.innerHTML = '<p class="text-xs text-gray-400">Locating…</p>';
  _regGeolocate(function(loc) {
    _usRegLoc = loc; usRegionalRenderLoc();
    if (host) host.innerHTML = '';
  }, function(msg) {
    if (host) host.innerHTML = '<p class="text-xs text-red-500">' + esc(msg) + '</p>';
  });
}

function usRegionalSave() {
  var status = document.getElementById('us-reg-status');
  var body = { regional: {
    time_format: document.getElementById('us-reg-time-format').value,
    date_format: document.getElementById('us-reg-date-format').value,
    timezone: document.getElementById('us-reg-timezone').value,
    location: _usRegLoc
  }};
  if (status) status.textContent = 'Saving…';
  fetch('/user-settings/regional', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) })
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (d && d.ok) {
        _userSettings = _userSettings || {};
        if (d.regional) _userSettings.regional = d.regional; else delete _userSettings.regional;
        if (d.resolved) window._regional = d.resolved;
        usRegionalInit();
        if (status) { status.textContent = 'Saved.'; status.className = 'text-xs text-green-600 dark:text-green-400'; }
      } else if (status) { status.textContent = 'Save failed.'; status.className = 'text-xs text-red-500'; }
    })
    .catch(function(){ if (status) { status.textContent = 'Save failed.'; status.className = 'text-xs text-red-500'; } });
}

function usRegionalReset() {
  var status = document.getElementById('us-reg-status');
  if (status) status.textContent = 'Saving…';
  fetch('/user-settings/regional', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({clear: true}) })
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (d && d.ok) {
        _userSettings = _userSettings || {}; delete _userSettings.regional;
        if (d.resolved) window._regional = d.resolved;
        usRegionalInit();
        if (status) { status.textContent = 'Reset to company defaults.'; status.className = 'text-xs text-green-600 dark:text-green-400'; }
      } else if (status) { status.textContent = 'Reset failed.'; status.className = 'text-xs text-red-500'; }
    })
    .catch(function(){ if (status) { status.textContent = 'Reset failed.'; status.className = 'text-xs text-red-500'; } });
}

function _dashTimeParts(cfg) {
  var now = new Date();
  var tz = cfg.timezone || '';
  var timeOpts = { hour: 'numeric', minute: '2-digit', hour12: (cfg.time_format !== '24h') };
  if (cfg.show_seconds) timeOpts.second = '2-digit';
  if (tz) timeOpts.timeZone = tz;
  var timeStr;
  try { timeStr = now.toLocaleTimeString([], timeOpts); }
  catch (e) { delete timeOpts.timeZone; tz = ''; timeStr = now.toLocaleTimeString([], timeOpts); }
  var dateStr = '';
  if (cfg.show_date) {
    var df = cfg.date_format || 'full';
    try {
      if (df === 'iso') {
        dateStr = new Intl.DateTimeFormat('en-CA', { timeZone: tz || undefined, year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
      } else {
        var dOpts;
        if (df === 'long') dOpts = { year: 'numeric', month: 'long', day: 'numeric' };
        else if (df === 'medium') dOpts = { weekday: 'short', month: 'short', day: 'numeric' };
        else if (df === 'short') dOpts = { year: 'numeric', month: 'numeric', day: 'numeric' };
        else dOpts = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
        if (tz) dOpts.timeZone = tz;
        dateStr = now.toLocaleDateString([], dOpts);
      }
    } catch (e2) { dateStr = ''; }
  }
  var zoneStr = '';
  if (cfg.show_zone && tz) zoneStr = tz.split('/').pop().replace(/_/g, ' ');
  return { time: timeStr, date: dateStr, zone: zoneStr };
}
// Scale the clock so the largest line fills the tile ("auto"), or apply a fixed
// preset. One-pass ratio fit off a 100px probe — cheap and reflow-stable.
function _dashTimeApplyFont(body, wrap, timeEl, dateEl, zoneEl, mode) {
  function setSizes(fs) {
    timeEl.style.fontSize = fs + 'px';
    if (dateEl) dateEl.style.fontSize = Math.max(11, Math.round(fs * 0.34)) + 'px';
    if (zoneEl) zoneEl.style.fontSize = Math.max(10, Math.round(fs * 0.26)) + 'px';
  }
  var fixed = { small: 20, medium: 34, large: 52, xlarge: 80 };
  if (mode !== 'auto' && fixed[mode]) { setSizes(fixed[mode]); return; }
  var availW = body.clientWidth, availH = body.clientHeight;
  if (availW <= 0 || availH <= 0) { setSizes(34); return; }   // not laid out yet; ticker re-fits
  var probe = 100;
  setSizes(probe);
  var w = wrap.scrollWidth, h = wrap.scrollHeight;
  if (w <= 0 || h <= 0) { setSizes(34); return; }
  var scale = Math.min(availW / w, availH / h) * 0.9;
  setSizes(Math.max(12, Math.floor(probe * scale)));
}
function dashTileRenderTime(body, cfg) {
  var parts = _dashTimeParts(cfg);
  body.innerHTML = '';
  var wrap = document.createElement('div');
  wrap.className = 'w-full h-full flex flex-col items-center justify-center text-center overflow-hidden';
  var timeEl = document.createElement('div');
  timeEl.className = 'font-semibold tabular-nums leading-none whitespace-nowrap text-gray-900 dark:text-gray-100';
  timeEl.textContent = parts.time;
  wrap.appendChild(timeEl);
  var dateEl = null, zoneEl = null;
  if (parts.date) {
    dateEl = document.createElement('div');
    dateEl.className = 'mt-1 leading-tight whitespace-nowrap text-gray-500 dark:text-gray-400';
    dateEl.textContent = parts.date;
    wrap.appendChild(dateEl);
  }
  if (parts.zone) {
    zoneEl = document.createElement('div');
    zoneEl.className = 'mt-0.5 leading-tight whitespace-nowrap uppercase tracking-wide text-gray-400 dark:text-gray-500';
    zoneEl.textContent = parts.zone;
    wrap.appendChild(zoneEl);
  }
  body.appendChild(wrap);
  _dashTimeApplyFont(body, wrap, timeEl, dateEl, zoneEl, cfg.font_size || 'auto');
}

// ---- Stat / Metrics tile (DVI-1307, DVI-1305 P2) ----
// Manual KPI rows rendered as stat cards. Client-rendered from config (no
// provider), so it renders identically in the SPA, the signage kiosk, and a
// report snapshot / Print-to-PDF (those reuse this SPA render path).
var _DASH_STAT_VALFS = { small: '1.4rem', medium: '2.1rem', large: '3rem' };
function _dashStatGridStyle(cfg, n) {
  var cols = cfg.columns;
  if (cols === '1' || cols === '2' || cols === '3' || cols === '4') {
    return 'grid-template-columns:repeat(' + cols + ',minmax(0,1fr));';
  }
  // Auto: 1 card → single column, 2-4 → two-up, 5+ → three-up. CSS min sizing
  // keeps each card from overflowing a narrow tile.
  var c = (n <= 1) ? 1 : (n <= 4 ? 2 : 3);
  return 'grid-template-columns:repeat(' + c + ',minmax(0,1fr));';
}
// DVI-1312 P1 — evaluate a row's color rules against its resolved value.
// Numeric ops compare `number`; `match` compares the display string (case-
// insensitive). First matching rule wins → returns a palette color, else ''.
// Shared by the SPA renderer and the editor preview; the kiosk mirrors it.
function _dashStatColorFor(rules, number, valueStr) {
  if (!rules || !rules.length) return '';
  var hasNum = (number !== null && number !== undefined && !isNaN(number));
  var s = (valueStr == null ? '' : String(valueStr)).trim().toLowerCase();
  for (var i = 0; i < rules.length; i++) {
    var rl = rules[i]; if (!rl || !rl.op) continue;
    var col = _dashTextAccentColor(rl.color);
    if (!col) continue;
    if (rl.op === 'match') {
      if (s === String(rl.value == null ? '' : rl.value).trim().toLowerCase()) return col;
      continue;
    }
    // numeric comparison
    var v = parseFloat(rl.value);
    if (!hasNum || isNaN(v)) continue;
    var n = number;
    var ok = false;
    if (rl.op === 'lt') ok = n < v;
    else if (rl.op === 'le') ok = n <= v;
    else if (rl.op === 'gt') ok = n > v;
    else if (rl.op === 'ge') ok = n >= v;
    else if (rl.op === 'eq') ok = n === v;
    else if (rl.op === 'ne') ok = n !== v;
    else if (rl.op === 'between') {
      var v2 = parseFloat(rl.value2);
      if (!isNaN(v2)) { var lo = Math.min(v, v2), hi = Math.max(v, v2); ok = (n >= lo && n <= hi); }
    }
    if (ok) return col;
  }
  return '';
}
// Resolve a row's {display value, numeric value, color, redacted/error flags}
// from its manual config + (when bound) the server-resolved metric payload.
function _dashStatResolveRow(r, metric) {
  var out = { value: r.value || '', number: null, redacted: false, error: '', live: !!r.metric };
  if (r.metric) {
    if (!metric) { out.pending = true; return out; }   // bound but data not in yet
    if (metric.access === false) { out.redacted = true; return out; }
    if (metric.ok === false) { out.error = metric.message || 'Unavailable'; return out; }
    out.value = (metric.value_str != null) ? String(metric.value_str) : '';
    out.number = (metric.number != null) ? metric.number : parseFloat(out.value);
    out.as_of = metric.as_of || ''; out.cached = !!metric.cached;
  } else if (out.value !== '') {
    var n = parseFloat(out.value); if (!isNaN(n)) out.number = n;
  }
  return out;
}
function dashTileRenderStat(body, cfg, data) {
  var metrics = (data && data.metrics) || {};
  var allRows = cfg.rows || [];
  // Keep a row if it has any manual content OR a metric binding. Iterate the
  // original index so it lines up with the server's metrics map (keyed by the
  // row's position in the sanitized config).
  var shown = [];
  allRows.forEach(function(r, i){ if (r && (r.label || r.value || r.sub || r.metric)) shown.push({ r: r, i: i }); });
  if (!shown.length) {
    body.innerHTML = '<span class="text-gray-400 dark:text-gray-500 italic">No metrics yet.</span>';
    return;
  }
  var vfs = _DASH_STAT_VALFS[cfg.value_size] || _DASH_STAT_VALFS.medium;
  var anyCached = false;
  var html = '<div class="grid gap-2 content-start h-full overflow-auto" style="' + _dashStatGridStyle(cfg, shown.length) + '">';
  shown.forEach(function(item) {
    var r = item.r;
    var res = _dashStatResolveRow(r, metrics[String(item.i)]);
    if (res.cached) anyCached = true;
    var disp, ruleCol = '';
    if (res.redacted) disp = '<span class="text-amber-500 dark:text-amber-400" style="font-size:0.8em">No access</span>';
    else if (res.error) disp = '<span class="text-amber-500 dark:text-amber-400" style="font-size:0.8em" title="' + esc(res.error) + '">&mdash;</span>';
    else {
      ruleCol = _dashStatColorFor(r.color_rules, res.number, res.value);
      disp = res.value ? esc(res.value) : (res.pending ? '<span class="text-gray-400 dark:text-gray-500" style="font-size:0.7em">live</span>' : '&mdash;');
    }
    var col = ruleCol || _dashTextAccentColor(r.accent);
    var vstyle = 'font-size:' + vfs + ';line-height:1.05;' + (col ? 'color:' + col + ';' : '');
    var valCls = col ? '' : 'text-gray-900 dark:text-white';
    html += '<div class="rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2 flex flex-col items-center justify-center text-center min-w-0">' +
      '<div class="font-bold tabular-nums break-words ' + valCls + '" style="' + vstyle + '">' + disp + '</div>' +
      (r.label ? '<div class="text-xs text-gray-500 dark:text-gray-400 mt-1 break-words">' + esc(r.label) + '</div>' : '') +
      (r.sub ? '<div class="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5 break-words">' + esc(r.sub) + '</div>' : '') +
      '</div>';
  });
  html += '</div>';
  if (anyCached) html += '<div class="text-[10px] text-amber-500 dark:text-amber-400 mt-1">Cached data</div>';
  body.innerHTML = html;
}

// ---- Weather tile (DVI-1277) ----
// Dependency-free inline-SVG weather icons (mapped from the provider's WMO
// icon category) so they render identically in the SPA, the signage kiosk, and
// Print-to-PDF. Fixed colors (not currentColor) so they read the same in light
// and dark and don't wash out in print.
function _dashWeatherIcon(icon, isDay){
  var day = (isDay !== false);
  function cloud(){ return '<g fill="#94a3b8"><circle cx="24" cy="40" r="10"/><circle cx="38" cy="38" r="13"/><circle cx="46" cy="43" r="8"/><rect x="20" y="40" width="30" height="11" rx="5"/></g>'; }
  function rays(pts, w){ var r=''; pts.forEach(function(p){ r+='<line x1="'+p[0]+'" y1="'+p[1]+'" x2="'+p[2]+'" y2="'+p[3]+'"/>'; }); return '<g stroke="#f59e0b" stroke-width="'+w+'" stroke-linecap="round">'+r+'</g>'; }
  function sunBig(){ return '<circle cx="32" cy="30" r="12" fill="#f59e0b"/>'+rays([[32,6,32,13],[32,47,32,54],[6,30,13,30],[51,30,58,30],[14,12,19,17],[45,43,50,48],[50,12,45,17],[19,43,14,48]],3); }
  function moonBig(){ return '<path d="M42 44 A16 16 0 1 1 26 12 A13 13 0 0 0 42 44 Z" fill="#cbd5e1"/>'; }
  function sunSmall(){ return '<g transform="translate(2,2)"><circle cx="20" cy="20" r="8" fill="#f59e0b"/>'+rays([[20,2,20,7],[20,33,20,38],[2,20,7,20],[33,20,38,20],[7.5,7.5,11,11],[29,29,32.5,32.5],[32.5,7.5,29,11],[11,29,7.5,32.5]],2.5)+'</g>'; }
  function moonSmall(){ return '<path d="M30 30 A11 11 0 1 1 19 8 A9 9 0 0 0 30 30 Z" fill="#cbd5e1"/>'; }
  function drops(color){ return '<g stroke="'+color+'" stroke-width="3" stroke-linecap="round"><line x1="26" y1="54" x2="24" y2="60"/><line x1="34" y1="54" x2="32" y2="60"/><line x1="42" y1="54" x2="40" y2="60"/></g>'; }
  function flakes(){ return '<g fill="#e2e8f0"><circle cx="26" cy="57" r="2.5"/><circle cx="35" cy="59" r="2.5"/><circle cx="44" cy="57" r="2.5"/></g>'; }
  function bolt(){ return '<polygon points="34,52 27,62 33,62 30,70 41,57 34,57" fill="#f59e0b"/>'; }
  function fog(){ return '<g stroke="#cbd5e1" stroke-width="3" stroke-linecap="round"><line x1="20" y1="55" x2="46" y2="55"/><line x1="24" y1="61" x2="42" y2="61"/></g>'; }
  var svg = '<svg viewBox="0 0 64 72" width="100%" height="100%" preserveAspectRatio="xMidYMid meet" style="display:block">';
  if (icon === 'clear' || icon === 'mostly-clear'){ svg += day ? sunBig() : moonBig(); }
  else if (icon === 'partly-cloudy'){ svg += (day ? sunSmall() : moonSmall()) + cloud(); }
  else if (icon === 'fog'){ svg += cloud() + fog(); }
  else if (icon === 'drizzle'){ svg += cloud() + drops('#60a5fa'); }
  else if (icon === 'rain'){ svg += cloud() + drops('#3b82f6'); }
  else if (icon === 'snow'){ svg += cloud() + flakes(); }
  else if (icon === 'thunder'){ svg += cloud() + bolt(); }
  else { svg += cloud(); }   // cloudy / unknown
  return svg + '</svg>';
}
function _dashWeatherTemp(v){
  if (v === null || v === undefined || isNaN(v)) return '—';
  return Math.round(v) + '°';
}
function _dashWeatherDayName(dateStr){
  var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(dateStr || '');
  if (!m) return '';
  var d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
  if (isNaN(d.getTime())) return '';
  return d.toLocaleDateString([], { weekday: 'short' });
}
function dashTileRenderWeather(body, cfg, data){
  if (data.available === false){
    body.innerHTML = '<div class="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400 py-2">' +
      '<span>&#9925;</span><span>' + esc(data.message || 'Weather not available.') + '</span></div>';
    return;
  }
  var cur = data.current || {}, tu = data.temp_unit || '°F', su = data.speed_unit || 'mph';
  var mode = data.mode || cfg.mode || 'current';
  var loc = data.location || '';
  var html = '<div class="h-full flex flex-col">' + _dashIdeCachedBadge(data);
  if (mode === 'compact'){
    html += '<div class="flex-1 min-h-0 flex flex-col items-center justify-center text-center">' +
      '<div style="width:56px;height:60px">' + _dashWeatherIcon(cur.icon, cur.is_day) + '</div>' +
      '<div class="text-3xl font-bold tabular-nums text-gray-900 dark:text-white leading-none mt-1">' + _dashWeatherTemp(cur.temp) + '<span class="text-base align-top">' + esc(tu) + '</span></div>' +
      (loc ? '<div class="text-[11px] text-gray-500 dark:text-gray-400 truncate max-w-full mt-1" title="' + esc(loc) + '">' + esc(loc) + '</div>' : '') +
      '</div>';
    body.innerHTML = html + '</div>';
    return;
  }
  var today = data.today || {};
  html += '<div class="flex items-center gap-3">' +
    '<div class="shrink-0" style="width:60px;height:64px">' + _dashWeatherIcon(cur.icon, cur.is_day) + '</div>' +
    '<div class="min-w-0">' +
      '<div class="text-3xl font-bold tabular-nums text-gray-900 dark:text-white leading-none">' + _dashWeatherTemp(cur.temp) + '<span class="text-base align-top">' + esc(tu) + '</span></div>' +
      '<div class="text-sm text-gray-600 dark:text-gray-300 truncate">' + esc(cur.label || '') + '</div>' +
      (loc ? '<div class="text-[11px] text-gray-500 dark:text-gray-400 truncate" title="' + esc(loc) + '">' + esc(loc) + '</div>' : '') +
    '</div></div>';
  if (mode === 'current'){
    var det = [];
    if (cur.feels_like != null) det.push('Feels ' + _dashWeatherTemp(cur.feels_like) + esc(tu));
    if (today && today.high != null) det.push('H ' + _dashWeatherTemp(today.high) + ' / L ' + _dashWeatherTemp(today.low));
    if (cur.humidity != null) det.push('Humidity ' + Math.round(cur.humidity) + '%');
    if (cur.wind != null) det.push('Wind ' + Math.round(cur.wind) + ' ' + esc(su));
    html += '<div class="mt-2 grid grid-cols-2 gap-1 text-xs text-gray-600 dark:text-gray-300">' +
      det.map(function(d){ return '<div class="truncate">' + d + '</div>'; }).join('') + '</div>';
    body.innerHTML = html + '</div>';
    return;
  }
  // forecast strip
  var days = (data.days || []).slice(0, data.forecast_days || cfg.forecast_days || 5);
  html += '<div class="flex-1 min-h-0 overflow-auto mt-2"><div class="flex gap-1">';
  days.forEach(function(d, i){
    html += '<div class="flex-1 min-w-0 flex flex-col items-center text-center rounded-lg border border-gray-200 dark:border-gray-700 py-1 px-0.5">' +
      '<div class="text-[11px] font-medium text-gray-600 dark:text-gray-300 truncate w-full">' + (i === 0 ? 'Today' : esc(_dashWeatherDayName(d.date))) + '</div>' +
      '<div style="width:32px;height:36px">' + _dashWeatherIcon(d.icon, true) + '</div>' +
      '<div class="text-[11px] tabular-nums text-gray-900 dark:text-gray-100">' + _dashWeatherTemp(d.high) + '</div>' +
      '<div class="text-[10px] tabular-nums text-gray-400 dark:text-gray-500">' + _dashWeatherTemp(d.low) + '</div>' +
    '</div>';
  });
  html += '</div></div>';
  body.innerHTML = html + '</div>';
}
// Weather tile editor helpers (DVI-1277): place-name search resolves lat/lon
// at edit time (stored on the tile so render never geocodes).
function dashboardsWeatherSearch(){
  var q = (document.getElementById('dash-cfg-wx-search').value || '').trim();
  var out = document.getElementById('dash-cfg-wx-results');
  if (!q){ out.innerHTML = ''; return; }
  out.innerHTML = '<span class="text-xs text-gray-400">Searching…</span>';
  fetch('/dashboards/weather/geocode?q=' + encodeURIComponent(q))
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      var res = (d && d.results) || [];
      if (!res.length){ out.innerHTML = '<span class="text-xs text-amber-600 dark:text-amber-400">' + esc((d && d.error) || 'No places matched.') + '</span>'; return; }
      out.innerHTML = res.map(function(r){
        return '<button type="button" class="block w-full text-left px-2 py-1 text-xs rounded-lg border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700" ' +
          'onclick="dashboardsWeatherPick(' + r.latitude + ',' + r.longitude + ',\'' + esc(r.label).replace(/'/g, "\\'") + '\')">' + esc(r.label) + '</button>';
      }).join('');
    })
    .catch(function(){ out.innerHTML = '<span class="text-xs text-amber-600 dark:text-amber-400">Place search failed.</span>'; });
}
function dashboardsWeatherPick(lat, lon, label){
  document.getElementById('dash-cfg-wx-lat').value = lat;
  document.getElementById('dash-cfg-wx-lon').value = lon;
  document.getElementById('dash-cfg-wx-label').value = label || '';
  document.getElementById('dash-cfg-wx-results').innerHTML = '';
  document.getElementById('dash-cfg-wx-search').value = '';
  dashboardsWeatherShowCurrent();
}
function dashboardsWeatherShowCurrent(){
  var el = document.getElementById('dash-cfg-wx-current');
  var label = document.getElementById('dash-cfg-wx-label').value || '';
  var lat = document.getElementById('dash-cfg-wx-lat').value;
  if (label || lat){
    el.innerHTML = '<span class="inline-flex items-center gap-1 text-xs text-gray-700 dark:text-gray-200">&#128205; ' +
      esc(label || (lat + ', ' + document.getElementById('dash-cfg-wx-lon').value)) +
      ' <button type="button" class="text-red-500 hover:underline" onclick="dashboardsWeatherClear()">clear</button></span>';
  } else {
    el.innerHTML = '<span class="text-xs text-amber-600 dark:text-amber-400">No location set — search for a place above.</span>';
  }
}
function dashboardsWeatherClear(){
  document.getElementById('dash-cfg-wx-lat').value = '';
  document.getElementById('dash-cfg-wx-lon').value = '';
  document.getElementById('dash-cfg-wx-label').value = '';
  dashboardsWeatherShowCurrent();
}
function dashboardsWeatherModeChanged(){
  var m = document.getElementById('dash-cfg-wx-mode').value || 'current';
  var wrap = document.getElementById('dash-cfg-wx-fdays-wrap');
  if (wrap) wrap.classList.toggle('hidden', m !== 'forecast');
}
function dashboardsWeatherSourceChanged(){
  // DVI-1364 P3. The manual place-search block only applies when the source is
  // a specific place; my_location / org_default resolve from regional settings.
  var srcEl = document.getElementById('dash-cfg-wx-source');
  var src = srcEl ? srcEl.value : 'manual';
  var manual = document.getElementById('dash-cfg-wx-manual-wrap');
  var note = document.getElementById('dash-cfg-wx-source-note');
  if (manual) manual.classList.toggle('hidden', src !== 'manual');
  if (note){
    if (src === 'my_location'){
      note.textContent = 'Uses each viewer\'s personal location from their regional settings (falls back to the company location on a shared display).';
      note.classList.remove('hidden');
    } else if (src === 'org_default'){
      note.textContent = 'Uses the company default location from the organization\'s regional settings.';
      note.classList.remove('hidden');
    } else {
      note.classList.add('hidden');
    }
  }
}
function dashboardsWeatherUseMyLocation(){
  var out = document.getElementById('dash-cfg-wx-results');
  if (out) out.innerHTML = '<span class="text-xs text-gray-400">Locating…</span>';
  _regGeolocate(function(loc){
    dashboardsWeatherPick(loc.latitude, loc.longitude, loc.label);
  }, function(msg){
    if (out) out.innerHTML = '<span class="text-xs text-red-500">' + esc(msg) + '</span>';
  });
}

// ---- Calendar tile (DVI-1196 P3) ----
function _dashCalDate(s) {
  // s is a date-only string "YYYY-MM-DD".
  var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s || '');
  if (!m) return s || '';
  var d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
  if (isNaN(d.getTime())) return s || '';
  return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
}
function _dashCalWhen(ev) {
  var s = ev.start || '';
  if (ev.all_day) return _dashCalDate(s);
  // Graph returns UTC dateTimes (Prefer: outlook.timezone="UTC") with no offset
  // suffix — append 'Z' so the browser renders them in local time.
  var iso = (s.indexOf('Z') > -1 || /[+\-]\d\d:?\d\d$/.test(s)) ? s : s + 'Z';
  var d = new Date(iso);
  if (isNaN(d.getTime())) return s;
  return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
         d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
// ---- Logo tile (DVI-1280) — client-rendered mark, no server data. ----
// The Togen bridge mark is inlined (gradient id namespaced so it can't collide
// with the top-bar logo's gradient on the same page); the iCast mark and the
// admin corporate logo are <img>s off no-auth serve routes so the kiosk and a
// report snapshot render them too. Recolor is a CSS filter.
var _DASH_TOGEN_LOGO_INNER =
  '<defs><linearGradient id="dashLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">' +
  '<stop offset="0%" style="stop-color:#00B4D8"/><stop offset="100%" style="stop-color:#0077A8"/></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(#dashLogoGrad)" stroke-width="8" fill="none" stroke-linecap="round"/>' +
  '<rect x="28" y="114" width="90" height="8" rx="2" fill="url(#dashLogoGrad)"/>' +
  '<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" fill="#111827" letter-spacing="-1">togen</text>' +
  '<circle cx="154" cy="70" r="5" fill="#00B4D8"/>';
function _dashLogoFilter(color){
  if (color === 'white') return 'brightness(0) invert(1)';
  if (color === 'dark') return 'brightness(0)';
  if (color === 'auto') return document.documentElement.classList.contains('dark') ? 'brightness(0) invert(1)' : '';
  return '';
}
function _dashLogoDim(size){
  if (size === 'fill') return 'width:100%;height:100%;object-fit:contain';
  var heights = { small: '40px', medium: '72px', large: '120px' };
  return 'height:' + (heights[size] || '72px') + ';width:auto;max-width:100%;max-height:100%;object-fit:contain';
}
function dashTileRenderLogo(body, cfg){
  cfg = cfg || {};
  var which = cfg.logo || 'icast';
  var brand = window._brandingSettings || {};
  var isCorp = (brand.logo_mode === 'corporate');
  var filt = _dashLogoFilter(cfg.color || 'as_is');
  var dim = _dashLogoDim(cfg.size || 'medium') + (filt ? ';filter:' + filt : '');
  var inner;
  if (which === 'togen' || (which === 'branding' && !isCorp)){
    inner = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="20 25 360 135" preserveAspectRatio="xMidYMid meet" style="display:block;' + dim + '">' + _DASH_TOGEN_LOGO_INNER + '</svg>';
  } else {
    var src = (which === 'branding') ? '/togen-admin/branding/logo.svg' : '/dashboards/logo/icast.svg';
    inner = '<img alt="Logo" src="' + src + '" style="display:block;' + dim + '" />';
  }
  var align = cfg.align || 'center';
  var jc = align === 'left' ? 'flex-start' : (align === 'right' ? 'flex-end' : 'center');
  body.innerHTML = '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:' + jc + ';overflow:hidden">' + inner + '</div>';
}

// ---- Image tile (DVI-1280) — single / slideshow / gallery. Image bytes live
// on the server (referenced by id in config); rendered client-side. ----
var _dashImgTimers = {};   // tileId -> slideshow interval id
function dashboardsStopImgTimers(){
  for (var k in _dashImgTimers){ if (_dashImgTimers[k]) clearInterval(_dashImgTimers[k]); }
  _dashImgTimers = {};
}
function _dashImgUrl(id){ return '/dashboards/image/' + encodeURIComponent(id); }
function dashTileRenderImage(body, cfg, tile){
  cfg = cfg || {};
  var imgs = (cfg.images || []).filter(function(im){ return im && im.id; });
  var tid = tile && tile.id;
  if (tid && _dashImgTimers[tid]){ clearInterval(_dashImgTimers[tid]); _dashImgTimers[tid] = null; }
  if (!imgs.length){
    body.innerHTML = '<span class="text-gray-400 dark:text-gray-500 text-xs italic">No images yet.</span>';
    return;
  }
  var fit = (cfg.fit === 'cover') ? 'cover' : 'contain';
  var mode = cfg.mode || 'single';
  if (mode === 'gallery'){ _dashImgRenderGallery(body, imgs, fit); return; }
  if (mode === 'slideshow' && imgs.length > 1){ _dashImgRenderSlideshow(body, imgs, cfg, tid, fit); return; }
  body.innerHTML = '<img alt="' + esc(imgs[0].name || '') + '" src="' + _dashImgUrl(imgs[0].id) + '" style="width:100%;height:100%;object-fit:' + fit + ';display:block" />';
}
function _dashImgRenderSlideshow(body, imgs, cfg, tid, fit){
  var order = imgs.map(function(_, i){ return i; });
  if (cfg.shuffle){ for (var i = order.length - 1; i > 0; i--){ var j = Math.floor(Math.random() * (i + 1)); var t = order[i]; order[i] = order[j]; order[j] = t; } }
  var fade = !!cfg.fade;
  body.innerHTML = '<div style="position:relative;width:100%;height:100%;overflow:hidden">' +
    order.map(function(oi, idx){
      return '<img alt="' + esc(imgs[oi].name || '') + '" src="' + _dashImgUrl(imgs[oi].id) +
        '" style="position:absolute;inset:0;width:100%;height:100%;object-fit:' + fit +
        ';opacity:' + (idx === 0 ? '1' : '0') + (fade ? ';transition:opacity .8s ease-in-out' : '') + '" />';
    }).join('') + '</div>';
  var frames = body.querySelectorAll('img');
  if (frames.length < 2 || !tid) return;
  var cur = 0, ms = Math.max(5, Math.min(120, cfg.interval || 8)) * 1000;
  _dashImgTimers[tid] = setInterval(function(){
    frames[cur].style.opacity = '0';
    cur = (cur + 1) % frames.length;
    frames[cur].style.opacity = '1';
  }, ms);
}
function _dashImgRenderGallery(body, imgs, fit){
  var html = '<div style="width:100%;height:100%;overflow:auto"><div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(80px,1fr));gap:6px;padding:2px">';
  imgs.forEach(function(im){
    html += '<button type="button" onclick="dashImgLightbox(this)" data-dashimg="' + esc(im.id) + '" title="' + esc(im.name || '') +
      '" style="border:0;padding:0;background:none;cursor:pointer;aspect-ratio:1;overflow:hidden;border-radius:6px">' +
      '<img alt="' + esc(im.name || '') + '" src="' + _dashImgUrl(im.id) + '" style="width:100%;height:100%;object-fit:cover;display:block" /></button>';
  });
  html += '</div></div>';
  body.innerHTML = html;
}
// Shared lightbox for the gallery (built lazily; reused across galleries and the
// Report Viewer, which renders through this same code path).
var _dashLbList = [], _dashLbIdx = 0, _dashLbKeyBound = false;
function _dashLbEnsure(){
  var el = document.getElementById('dash-img-lightbox');
  if (el) return el;
  el = document.createElement('div');
  el.id = 'dash-img-lightbox';
  el.className = 'hidden fixed inset-0 z-[140] bg-black/80 flex items-center justify-center';
  el.innerHTML =
    '<button type="button" onclick="dashImgLbClose()" class="absolute top-4 right-4 text-white text-3xl leading-none px-2 cursor-pointer">&times;</button>' +
    '<button type="button" onclick="dashImgLbNav(-1)" class="absolute left-4 text-white text-4xl leading-none px-3 cursor-pointer">&#8249;</button>' +
    '<img id="dash-img-lb-img" alt="" style="max-width:92vw;max-height:88vh;object-fit:contain" />' +
    '<button type="button" onclick="dashImgLbNav(1)" class="absolute right-4 text-white text-4xl leading-none px-3 cursor-pointer">&#8250;</button>';
  el.onclick = function(e){ if (e.target === el) dashImgLbClose(); };
  document.body.appendChild(el);
  if (!_dashLbKeyBound){
    document.addEventListener('keydown', function(e){
      var lb = document.getElementById('dash-img-lightbox');
      if (!lb || lb.classList.contains('hidden')) return;
      if (e.key === 'Escape') dashImgLbClose();
      else if (e.key === 'ArrowLeft') dashImgLbNav(-1);
      else if (e.key === 'ArrowRight') dashImgLbNav(1);
    });
    _dashLbKeyBound = true;
  }
  return el;
}
function dashImgLightbox(btn){
  var grid = btn.parentNode;
  var btns = grid.querySelectorAll('[data-dashimg]');
  _dashLbList = []; _dashLbIdx = 0;
  for (var i = 0; i < btns.length; i++){
    _dashLbList.push(btns[i].getAttribute('data-dashimg'));
    if (btns[i] === btn) _dashLbIdx = i;
  }
  _dashLbShow();
}
function _dashLbShow(){
  var el = _dashLbEnsure();
  var img = document.getElementById('dash-img-lb-img');
  if (img) img.src = _dashImgUrl(_dashLbList[_dashLbIdx]);
  el.classList.remove('hidden');
}
function dashImgLbNav(d){
  if (!_dashLbList.length) return;
  _dashLbIdx = (_dashLbIdx + d + _dashLbList.length) % _dashLbList.length;
  _dashLbShow();
}
function dashImgLbClose(){ var el = document.getElementById('dash-img-lightbox'); if (el) el.classList.add('hidden'); }

function dashTileRenderCalendar(body, cfg, data) {
  // Soft-degrade: Graph 403 / missing config → an amber chip, never an error.
  if (data.available === false) {
    var msg = data.message || 'Calendar not available for this mailbox.';
    body.innerHTML = '<div class="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400 py-2">' +
      '<span>&#128197;</span><span>' + esc(msg) + '</span></div>';
    return;
  }
  var events = data.events || [];
  if (!events.length) {
    body.innerHTML = '<p class="text-gray-400 dark:text-gray-500 text-xs italic text-center py-4">No events in this range.</p>';
    return;
  }
  var html = '<div class="space-y-1">';
  events.forEach(function(ev){
    var when = _dashCalWhen(ev);
    var loc = ev.location ? ' &middot; ' + esc(ev.location) : '';
    var typeTag = ev.wor_type_label ? ' &middot; ' + esc(ev.wor_type_label) : '';
    html += '<div class="pl-2 py-1 border-l-2 border-togen-teal/40">' +
      '<div class="text-xs font-medium text-gray-800 dark:text-gray-100 leading-snug">' + esc(ev.subject || '(no title)') + '</div>' +
      '<div class="text-xs text-gray-500 dark:text-gray-400">' + esc(when) + typeTag + loc + '</div>' +
      '</div>';
  });
  html += '</div>';
  if (data.total && data.total > events.length) {
    html += '<p class="text-xs text-gray-400 dark:text-gray-500 mt-1 text-center">+' + (data.total - events.length) + ' more</p>';
  }
  body.innerHTML = html;
}

// ---- Idencia tile (DVI-1226 P2) ----
// Dependency-free charts: the pie is inline SVG (stroke-dasharray donut), bars
// are plain flex divs — no chart library on either the SPA or the kiosk.
var _DASH_IDE_COLORS = ['#00B4D8','#6366F1','#F59E0B','#10B981','#EF4444','#8B5CF6','#EC4899','#84CC16','#F97316','#64748B'];
function _dashIdeColor(i){ return _DASH_IDE_COLORS[i % _DASH_IDE_COLORS.length]; }
function _dashIdeSliceLabel(s){ return s.label + (s.more ? ' (+' + s.more + ' more)' : ''); }
// "Cached — as of HH:MM" badge (D2): shown whenever the server fell back to the
// last-good cache; the date is prefixed when the snapshot isn't from today.
function _dashIdeCachedBadge(data){
  if (!data.cached) return '';
  var t = String(data.as_of || '');
  var when = t.slice(11, 16);
  var d = new Date();
  var todayStr = d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2);
  if (t.slice(0, 10) && t.slice(0, 10) !== todayStr) when = t.slice(0, 10) + ' ' + when;
  return '<div class="shrink-0"><span class="inline-flex items-center gap-1 rounded-full bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 text-[10px] font-medium px-2 py-0.5 mb-1">Cached &mdash; as of ' + esc(when || 'unknown') + '</span></div>';
}
function _dashIdePieSvg(slices, total){
  var svg = '<svg viewBox="0 0 42 42" class="h-full max-h-full" style="max-width:100%">';
  svg += '<circle cx="21" cy="21" r="15.915" fill="none" stroke="rgba(148,163,184,0.25)" stroke-width="6"></circle>';
  var off = 25;   // 12 o'clock start
  slices.forEach(function(s, i){
    var pct = total > 0 ? (s.value / total) * 100 : 0;
    if (pct <= 0) return;
    svg += '<circle cx="21" cy="21" r="15.915" fill="none" stroke="' + _dashIdeColor(i) + '" stroke-width="6" stroke-dasharray="' + pct + ' ' + (100 - pct) + '" stroke-dashoffset="' + off + '"></circle>';
    off -= pct;
  });
  svg += '<text x="21" y="21.5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" font-size="8" font-weight="600">' + total + '</text>';
  return svg + '</svg>';
}
function _dashIdeBarsHtml(slices){
  var max = 0;
  slices.forEach(function(s){ if (s.value > max) max = s.value; });
  var html = '<div class="flex-1 min-h-0 flex items-end justify-around gap-1 pt-3" style="min-height:70px">';
  slices.forEach(function(s, i){
    var pct = max > 0 ? Math.max(2, Math.round(s.value / max * 100)) : 2;
    html += '<div class="flex flex-col items-center justify-end flex-1 min-w-0 h-full">' +
      '<div class="text-[10px] font-medium text-gray-600 dark:text-gray-300">' + s.value + '</div>' +
      '<div class="w-full max-w-[42px] rounded-t" style="height:' + pct + '%;background:' + _dashIdeColor(i) + '"></div>' +
      '<div class="text-[10px] text-gray-500 dark:text-gray-400 truncate w-full text-center mt-0.5" title="' + esc(s.label) + '">' + esc(_dashIdeSliceLabel(s)) + '</div>' +
      '</div>';
  });
  return html + '</div>';
}
function _dashIdeLegendHtml(slices){
  return '<div class="shrink-0 mt-1.5 space-y-0.5 overflow-auto max-h-24">' + slices.map(function(s, i){
    return '<div class="flex items-center gap-1.5 text-xs">' +
      '<span class="inline-block w-2.5 h-2.5 rounded-sm shrink-0" style="background:' + _dashIdeColor(i) + '"></span>' +
      '<span class="truncate text-gray-700 dark:text-gray-200">' + esc(_dashIdeSliceLabel(s)) + '</span>' +
      '<span class="ml-auto text-gray-500 dark:text-gray-400 tabular-nums">' + s.value + '</span></div>';
  }).join('') + '</div>';
}
// Status bars: per-project produced/required progress for the projects
// dataset, share-of-total bars for grouped items/shipments.
function _dashIdeStatusBarsHtml(data){
  var html = '<div class="flex-1 min-h-0 overflow-auto space-y-1.5 pt-1">';
  if (data.progress && data.progress.length){
    data.progress.forEach(function(p){
      var req = p.required || 0, prod = p.produced || 0;
      var pct = req > 0 ? Math.min(100, Math.round(prod / req * 100)) : (prod > 0 ? 100 : 0);
      var barCls = (req > 0 && prod >= req) ? 'bg-green-500' : 'bg-togen-teal';
      html += '<div>' +
        '<div class="flex items-baseline justify-between gap-2 text-xs">' +
          '<span class="truncate font-medium text-gray-800 dark:text-gray-100">' + esc(p.name) + '</span>' +
          '<span class="text-gray-500 dark:text-gray-400 tabular-nums shrink-0">' + prod + (req ? ' / ' + req : '') + '</span></div>' +
        '<div class="mt-0.5 h-1.5 rounded-full bg-gray-200 dark:bg-gray-700"><div class="h-1.5 rounded-full ' + barCls + '" style="width:' + pct + '%"></div></div>' +
        '</div>';
    });
    if (data.truncated) html += '<p class="text-xs text-gray-400 dark:text-gray-500 text-center">+' + data.truncated + ' more</p>';
  } else {
    var slices = data.slices || [], max = 0;
    slices.forEach(function(s){ if (s.value > max) max = s.value; });
    slices.forEach(function(s, i){
      var pct = max > 0 ? Math.max(2, Math.round(s.value / max * 100)) : 0;
      html += '<div>' +
        '<div class="flex items-baseline justify-between gap-2 text-xs">' +
          '<span class="truncate font-medium text-gray-800 dark:text-gray-100">' + esc(_dashIdeSliceLabel(s)) + '</span>' +
          '<span class="text-gray-500 dark:text-gray-400 tabular-nums shrink-0">' + s.value + '</span></div>' +
        '<div class="mt-0.5 h-1.5 rounded-full bg-gray-200 dark:bg-gray-700"><div class="h-1.5 rounded-full" style="width:' + pct + '%;background:' + _dashIdeColor(i) + '"></div></div>' +
        '</div>';
    });
  }
  return html + '</div>';
}
function _dashIdeKpiHtml(data){
  var chips = [];
  if (data.totals){
    chips = [
      {label: 'Projects', value: (data.progress || []).length},
      {label: 'Required', value: data.totals.required},
      {label: 'Produced', value: data.totals.produced},
      {label: 'Remaining', value: data.totals.remaining}
    ];
  } else {
    chips = [{label: 'Total', value: data.total || 0}];
    (data.slices || []).slice(0, 5).forEach(function(s){
      chips.push({label: _dashIdeSliceLabel(s), value: s.value});
    });
  }
  var html = '<div class="flex-1 min-h-0 overflow-auto grid grid-cols-2 gap-2 content-start pt-1">';
  chips.forEach(function(c){
    html += '<div class="rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2 text-center">' +
      '<div class="text-2xl font-bold text-gray-900 dark:text-white tabular-nums leading-tight">' + esc(String(c.value)) + '</div>' +
      '<div class="text-[11px] text-gray-500 dark:text-gray-400 truncate" title="' + esc(c.label) + '">' + esc(c.label) + '</div></div>';
  });
  return html + '</div>';
}
function dashTileRenderIdencia(body, cfg, data){
  // Soft-degrade: not configured / live+cache both failed → amber chip.
  if (data.available === false){
    body.innerHTML = '<div class="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400 py-2">' +
      '<span>&#128202;</span><span>' + esc(data.message || 'Idencia data not available.') + '</span></div>';
    return;
  }
  var display = data.display || cfg.display || 'status_bars';
  var slices = data.slices || [];
  var hasRows = slices.length || (data.progress || []).length;
  var html = '<div class="h-full flex flex-col">' + _dashIdeCachedBadge(data);
  if (!hasRows){
    html += '<p class="text-gray-400 dark:text-gray-500 text-xs italic text-center py-4">No Idencia records match the current filters.</p></div>';
    body.innerHTML = html;
    return;
  }
  if (display === 'pie'){
    html += '<div class="flex-1 min-h-0 flex items-center justify-center text-gray-800 dark:text-gray-100" style="min-height:70px">' +
      _dashIdePieSvg(slices, data.total || 0) + '</div>';
    if (cfg.show_legend !== false) html += _dashIdeLegendHtml(slices);
  } else if (display === 'bar'){
    html += _dashIdeBarsHtml(slices);
  } else if (display === 'kpi'){
    html += _dashIdeKpiHtml(data);
  } else {
    html += _dashIdeStatusBarsHtml(data);
  }
  body.innerHTML = html + '</div>';
}

// ---- Power BI tiles (DVI-1243 P2) ----
// Data tile: the server already ran the DAX and returned render-ready shapes;
// draw them with the same dependency-free chart helpers as the Idencia tile.
function dashTileRenderPowerbiData(body, cfg, data){
  if (data.available === false){
    body.innerHTML = '<div class="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400 py-2">' +
      '<span>&#128200;</span><span>' + esc(data.message || 'Power BI data not available.') + '</span></div>';
    return;
  }
  var display = data.display || cfg.display || 'table';
  var slices = data.slices || [];
  var html = '<div class="h-full flex flex-col">' + _dashIdeCachedBadge(data);
  if (!(data.rows || []).length && !slices.length && !(data.kpis || []).length){
    html += '<p class="text-gray-400 dark:text-gray-500 text-xs italic text-center py-4">The query returned no rows.</p></div>';
    body.innerHTML = html;
    return;
  }
  if (display === 'pie' || display === 'bar'){
    if (!slices.length){
      html += '<p class="text-gray-400 dark:text-gray-500 text-xs italic text-center py-4">No numeric column to chart &mdash; switch the tile to Table display.</p>';
    } else if (display === 'pie'){
      var tot = 0; slices.forEach(function(s){ tot += (s.value || 0); });
      html += '<div class="flex-1 min-h-0 flex items-center justify-center text-gray-800 dark:text-gray-100" style="min-height:70px">' +
        _dashIdePieSvg(slices, Math.round(tot * 100) / 100) + '</div>';
      if (cfg.show_legend !== false) html += _dashIdeLegendHtml(slices);
    } else {
      html += _dashIdeBarsHtml(slices);
    }
  } else if (display === 'kpi'){
    html += _dashPbiKpiHtml(data);
  } else {
    html += _dashPbiTableHtml(data);
  }
  body.innerHTML = html + '</div>';
}
function _dashPbiKpiHtml(data){
  var chips = [];
  if ((data.kpis || []).length){
    chips = data.kpis.slice(0, 8);
  } else {
    chips = [{label: 'Rows', value: data.total || 0}];
    (data.slices || []).slice(0, 5).forEach(function(s){
      chips.push({label: _dashIdeSliceLabel(s), value: s.value});
    });
  }
  var html = '<div class="flex-1 min-h-0 overflow-auto grid grid-cols-2 gap-2 content-start pt-1">';
  chips.forEach(function(c){
    var v = (c.value == null) ? '—' : String(c.value);
    html += '<div class="rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2 text-center">' +
      '<div class="text-2xl font-bold text-gray-900 dark:text-white tabular-nums leading-tight">' + esc(v) + '</div>' +
      '<div class="text-[11px] text-gray-500 dark:text-gray-400 truncate" title="' + esc(c.label) + '">' + esc(c.label) + '</div></div>';
  });
  return html + '</div>';
}
function _dashPbiTableHtml(data){
  var cols = data.columns || [], rows = data.rows || [];
  var html = '<div class="flex-1 min-h-0 overflow-auto"><table class="w-full text-xs">' +
    '<thead><tr>' + cols.map(function(c){
      return '<th class="text-left font-medium text-gray-500 dark:text-gray-400 px-1.5 py-1 sticky top-0 bg-white dark:bg-gray-800">' + esc(c) + '</th>';
    }).join('') + '</tr></thead><tbody>';
  rows.forEach(function(r){
    html += '<tr class="border-t border-gray-100 dark:border-gray-700/60">' + r.map(function(v){
      var num = (typeof v === 'number');
      return '<td class="px-1.5 py-1 text-gray-800 dark:text-gray-100' + (num ? ' tabular-nums text-right' : '') + '">' +
        esc(v == null ? '' : String(v)) + '</td>';
    }).join('') + '</tr>';
  });
  html += '</tbody></table>';
  if (data.total && data.total > rows.length){
    html += '<p class="text-xs text-gray-400 dark:text-gray-500 mt-1 text-center">+' + (data.total - rows.length) + ' more</p>';
  }
  return html + '</div>';
}

// Report tile: app-owns-data embed via the powerbi-client SDK (lazy CDN load,
// same pattern as pdf.js). The server mints + rotates the embed token; each
// 60 s tile poll hands it back here — same signature just updates the token
// in place (setAccessToken), so the embed never flickers on a poll.
var _dashPbi = {};             // tileId -> {sig, token, embed}
var _dashPbiSdkPromise = null;
function _dashPbiEnsureSdk(){
  if (window.powerbi) return Promise.resolve(window.powerbi);
  if (_dashPbiSdkPromise) return _dashPbiSdkPromise;
  _dashPbiSdkPromise = new Promise(function(resolve, reject){
    var s = document.createElement('script');
    s.src = 'https://cdn.jsdelivr.net/npm/powerbi-client@2.23.1/dist/powerbi.min.js';
    s.onload = function(){
      if (window.powerbi) resolve(window.powerbi);
      else { _dashPbiSdkPromise = null; reject(new Error('powerbi-client failed to load')); }
    };
    s.onerror = function(){ _dashPbiSdkPromise = null; reject(new Error('powerbi-client failed to load')); };
    document.head.appendChild(s);
  });
  return _dashPbiSdkPromise;
}
function dashTileRenderPowerbiReport(body, cfg, data, tile){
  var tid = tile.id;
  if (data.available === false){
    delete _dashPbi[tid];
    body.innerHTML = '<div class="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400 py-2">' +
      '<span>&#128202;</span><span>' + esc(data.message || 'Power BI report not available.') + '</span></div>';
    return;
  }
  var rep = data.report || {};
  var sig = rep.id + '|' + (data.page || '') + '|' + !!data.show_filter_pane +
    '|' + !!data.show_page_nav + '|' + (data.interactive !== false);
  var st = _dashPbi[tid];
  var host = body.querySelector('.dashpbi-embed');
  if (st && st.sig === sig && host){
    if (st.token !== data.token && st.embed){
      st.token = data.token;
      try { st.embed.setAccessToken(data.token); }
      catch(e){ delete _dashPbi[tid]; dashTileRenderPowerbiReport(body, cfg, data, tile); }
    }
    return;
  }
  body.className = 'dash-tile-body flex-1 min-h-0 relative overflow-hidden p-0';
  body.innerHTML = '<div class="dashpbi-embed absolute inset-0"' +
    (data.interactive === false ? ' style="pointer-events:none"' : '') + '></div>';
  host = body.querySelector('.dashpbi-embed');
  _dashPbi[tid] = st = { sig: sig, token: data.token, embed: null };
  _dashPbiEnsureSdk().then(function(pbi){
    if (_dashPbi[tid] !== st || !document.body.contains(host)) return;  // superseded / tile gone
    var conf = {
      type: 'report',
      id: rep.id,
      embedUrl: rep.embed_url || ('https://app.powerbi.com/reportEmbed?reportId=' +
        encodeURIComponent(rep.id) + '&groupId=' + encodeURIComponent(rep.workspace_id || '')),
      accessToken: data.token,
      tokenType: 1,        // models.TokenType.Embed (app-owns-data)
      permissions: 0,      // View
      settings: { filterPaneEnabled: !!data.show_filter_pane,
                  navContentPaneEnabled: !!data.show_page_nav }
    };
    if (data.page) conf.pageName = data.page;
    try { pbi.reset(host); } catch(e){}
    try { st.embed = pbi.embed(host, conf); }
    catch(e2){
      host.innerHTML = '<div class="p-3 text-xs text-amber-600 dark:text-amber-400">Could not embed the report: ' +
        esc(String((e2 && e2.message) || e2)) + '</div>';
    }
  }).catch(function(){
    if (document.body.contains(host)){
      host.innerHTML = '<div class="p-3 text-xs text-amber-600 dark:text-amber-400">Could not load the Power BI viewer (script blocked or offline).</div>';
    }
  });
}

// ---- Chart + Report Table tiles (DVI-1254 / DVI-1252 P1) ----
// Dependency-free inline-SVG visualization foundation. Every chart renders as
// static inline SVG (no JS/canvas), so the identical output works in the SPA,
// the signage kiosk (mirrored as chart*/renderChart below — no build step), and
// browser Print-to-PDF (P4). The server hands back a normalized chart model
// {chart_type, categories:[], series:[{name,values:[]}], gauge?, truncated}.
function _dashChartColor(i){ return _DASH_IDE_COLORS[i % _DASH_IDE_COLORS.length]; }
function _dashChartFmt(v){
  if (v == null || isNaN(v)) return '';
  var a = Math.abs(v);
  if (a >= 1000000) return (v / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
  if (a >= 1000) return (v / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
  return (Math.round(v * 100) / 100).toString();
}
function _dashChartNiceMax(m){
  if (!(m > 0)) return 1;
  var exp = Math.floor(Math.log(m) / Math.LN10), base = Math.pow(10, exp), f = m / base, nf;
  if (f <= 1) nf = 1; else if (f <= 2) nf = 2; else if (f <= 2.5) nf = 2.5;
  else if (f <= 5) nf = 5; else nf = 10;
  return nf * base;
}
function _dashChartSegments(pts){
  var segs = [], cur = [];
  pts.forEach(function(p){ if (p) { cur.push(p); } else if (cur.length) { segs.push(cur); cur = []; } });
  if (cur.length) segs.push(cur);
  return segs;
}
// X/Y-axis chart (bar grouped, bar stacked, line, area) — one code path so the
// axes, gridlines, and multi-series colouring stay identical across types.
function _dashChartAxisSvg(model){
  var W = 320, H = 180, padL = 34, padR = 10, padT = 10, padB = 28;
  var cats = model.categories || [], series = model.series || [], type = model.chart_type;
  var stacked = (type === 'bar_stacked');
  var plotW = W - padL - padR, plotH = H - padT - padB;
  var maxV = 0;
  if (stacked){
    cats.forEach(function(_, ci){ var s = 0; series.forEach(function(se){ var v = se.values[ci]; if (typeof v === 'number' && v > 0) s += v; }); if (s > maxV) maxV = s; });
  } else {
    series.forEach(function(se){ (se.values || []).forEach(function(v){ if (typeof v === 'number' && v > maxV) maxV = v; }); });
  }
  var niceMax = _dashChartNiceMax(maxV);
  var yFor = function(v){ return padT + plotH * (1 - (v / niceMax)); };
  var svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" class="w-full h-full" preserveAspectRatio="xMidYMid meet">';
  var steps = 4;
  for (var t = 0; t <= steps; t++){
    var val = niceMax * t / steps, y = yFor(val);
    svg += '<line x1="' + padL + '" y1="' + y + '" x2="' + (W - padR) + '" y2="' + y + '" stroke="currentColor" stroke-opacity="0.12" stroke-width="0.5"></line>';
    svg += '<text x="' + (padL - 3) + '" y="' + (y + 2.2) + '" text-anchor="end" font-size="6" fill="currentColor" fill-opacity="0.55">' + esc(_dashChartFmt(val)) + '</text>';
  }
  var y0 = yFor(0);
  svg += '<line x1="' + padL + '" y1="' + y0 + '" x2="' + (W - padR) + '" y2="' + y0 + '" stroke="currentColor" stroke-opacity="0.35" stroke-width="0.7"></line>';
  var band = cats.length ? plotW / cats.length : plotW;
  var every = cats.length > 12 ? Math.ceil(cats.length / 12) : 1;
  cats.forEach(function(c, ci){
    if (ci % every !== 0) return;
    var cx = padL + band * (ci + 0.5), lbl = String(c);
    if (lbl.length > 10) lbl = lbl.slice(0, 9) + '…';
    svg += '<text x="' + cx + '" y="' + (H - padB + 9) + '" text-anchor="middle" font-size="6" fill="currentColor" fill-opacity="0.6">' + esc(lbl) + '</text>';
  });
  if (type === 'bar' || type === 'bar_stacked'){
    var innerPad = band * 0.18, groupW = band - innerPad * 2;
    cats.forEach(function(c, ci){
      var gx = padL + band * ci + innerPad;
      if (stacked){
        var acc = 0;
        series.forEach(function(se, si){
          var v = se.values[ci]; if (typeof v !== 'number' || v <= 0) return;
          var h = plotH * (v / niceMax), yTop = yFor(acc + v); acc += v;
          svg += '<rect x="' + gx + '" y="' + yTop + '" width="' + groupW + '" height="' + Math.max(0.5, h) + '" fill="' + _dashChartColor(si) + '"><title>' + esc(se.name + ': ' + v) + '</title></rect>';
        });
      } else {
        var bw = groupW / Math.max(1, series.length);
        series.forEach(function(se, si){
          var v = se.values[ci]; if (typeof v !== 'number') return;
          var h = plotH * (Math.max(0, v) / niceMax), yTop = yFor(Math.max(0, v));
          svg += '<rect x="' + (gx + bw * si) + '" y="' + yTop + '" width="' + Math.max(0.5, bw * 0.9) + '" height="' + Math.max(0.5, h) + '" fill="' + _dashChartColor(si) + '"><title>' + esc(se.name + ': ' + v) + '</title></rect>';
        });
      }
    });
  } else {
    series.forEach(function(se, si){
      var pts = (se.values || []).map(function(v, ci){ return (typeof v === 'number') ? [padL + band * (ci + 0.5), yFor(v)] : null; });
      var col = _dashChartColor(si), segs = _dashChartSegments(pts);
      if (type === 'area'){
        segs.forEach(function(seg){
          if (seg.length < 2) return;
          var d = 'M' + seg[0][0] + ',' + y0 + ' L' + seg.map(function(p){ return p[0] + ',' + p[1]; }).join(' L') + ' L' + seg[seg.length - 1][0] + ',' + y0 + ' Z';
          svg += '<path d="' + d + '" fill="' + col + '" fill-opacity="0.18"></path>';
        });
      }
      segs.forEach(function(seg){
        if (seg.length < 2) return;
        svg += '<polyline points="' + seg.map(function(p){ return p[0] + ',' + p[1]; }).join(' ') + '" fill="none" stroke="' + col + '" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"></polyline>';
      });
      pts.forEach(function(p){ if (p) svg += '<circle cx="' + p[0] + '" cy="' + p[1] + '" r="1.6" fill="' + col + '"></circle>'; });
    });
  }
  return svg + '</svg>';
}
function _dashChartToSlices(model){
  var s0 = (model.series && model.series[0]) || {values: []}, slices = [];
  (model.categories || []).forEach(function(c, ci){ var v = s0.values[ci]; if (typeof v === 'number' && v > 0) slices.push({label: String(c), value: v}); });
  return slices;
}
function _dashChartPieSvg(slices, donut){
  var total = 0; slices.forEach(function(s){ total += s.value; });
  var sw = donut ? 6 : 16, r = 15.915;
  var svg = '<svg viewBox="0 0 42 42" class="h-full max-h-full" style="max-width:100%">';
  svg += '<circle cx="21" cy="21" r="' + r + '" fill="none" stroke="rgba(148,163,184,0.2)" stroke-width="' + sw + '"></circle>';
  var off = 25;
  slices.forEach(function(s, i){
    var pct = total > 0 ? (s.value / total) * 100 : 0; if (pct <= 0) return;
    svg += '<circle cx="21" cy="21" r="' + r + '" fill="none" stroke="' + _dashChartColor(i) + '" stroke-width="' + sw + '" stroke-dasharray="' + pct + ' ' + (100 - pct) + '" stroke-dashoffset="' + off + '"><title>' + esc(s.label + ': ' + s.value) + '</title></circle>';
    off -= pct;
  });
  if (donut) svg += '<text x="21" y="21.5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" font-size="7" font-weight="600">' + esc(_dashChartFmt(total)) + '</text>';
  return svg + '</svg>';
}
function _dashChartLegendItems(items){
  return '<div class="shrink-0 mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 overflow-auto max-h-20">' + items.map(function(it, i){
    return '<div class="flex items-center gap-1.5 text-xs">' +
      '<span class="inline-block w-2.5 h-2.5 rounded-sm shrink-0" style="background:' + _dashChartColor(i) + '"></span>' +
      '<span class="truncate text-gray-700 dark:text-gray-200" title="' + esc(it.label) + '">' + esc(it.label) + '</span>' +
      (it.value != null ? '<span class="text-gray-500 dark:text-gray-400 tabular-nums">' + esc(_dashChartFmt(it.value)) + '</span>' : '') +
      '</div>';
  }).join('') + '</div>';
}
// Semicircular gauge: value vs. target (or its own max when no target set).
function _dashChartGaugeSvg(g){
  var val = g.value || 0, tgt = (g.target > 0 ? g.target : (val || 1));
  var ratio = tgt > 0 ? Math.max(0, Math.min(1, val / tgt)) : 0;
  var W = 120, H = 72, cx = 60, cy = 62, r = 48;
  var pt = function(a){ return [cx + r * Math.cos(Math.PI * (1 - a)), cy - r * Math.sin(Math.PI * (1 - a))]; };
  var st = pt(0), bg = pt(1), end = pt(ratio);
  var col = ratio >= 1 ? '#10B981' : (ratio >= 0.5 ? '#00B4D8' : '#F59E0B');
  var svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" class="w-full" style="max-height:100%">';
  svg += '<path d="M' + st[0] + ',' + st[1] + ' A' + r + ',' + r + ' 0 0 1 ' + bg[0] + ',' + bg[1] + '" fill="none" stroke="currentColor" stroke-opacity="0.15" stroke-width="9" stroke-linecap="round"></path>';
  svg += '<path d="M' + st[0] + ',' + st[1] + ' A' + r + ',' + r + ' 0 0 1 ' + end[0] + ',' + end[1] + '" fill="none" stroke="' + col + '" stroke-width="9" stroke-linecap="round"></path>';
  svg += '<text x="' + cx + '" y="' + (cy - 8) + '" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">' + esc(_dashChartFmt(val)) + '</text>';
  if (g.has_target) svg += '<text x="' + cx + '" y="' + (cy + 3) + '" text-anchor="middle" font-size="7" fill="currentColor" fill-opacity="0.6">of ' + esc(_dashChartFmt(tgt)) + ' (' + Math.round(ratio * 100) + '%)</text>';
  return svg + '</svg>';
}
function _dashChartProgressHtml(g){
  var val = g.value || 0, tgt = (g.target > 0 ? g.target : 0);
  var pct = tgt > 0 ? Math.min(100, Math.round(val / tgt * 100)) : (val > 0 ? 100 : 0);
  var barCls = (tgt > 0 && val >= tgt) ? 'bg-green-500' : 'bg-togen-teal';
  return '<div class="flex-1 min-h-0 flex flex-col justify-center gap-2 px-1">' +
    '<div class="flex items-baseline justify-between gap-2">' +
      '<span class="text-sm font-medium text-gray-700 dark:text-gray-200 truncate">' + esc(g.label || '') + '</span>' +
      '<span class="text-lg font-bold text-gray-900 dark:text-white tabular-nums">' + esc(_dashChartFmt(val)) +
        (tgt > 0 ? '<span class="text-xs font-normal text-gray-500 dark:text-gray-400"> / ' + esc(_dashChartFmt(tgt)) + '</span>' : '') + '</span></div>' +
    '<div class="h-3 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden"><div class="h-3 rounded-full ' + barCls + '" style="width:' + pct + '%"></div></div>' +
    (tgt > 0 ? '<div class="text-xs text-gray-500 dark:text-gray-400 text-right">' + pct + '%</div>' : '') + '</div>';
}
function _dashChartKpiHtml(model){
  var chips = [];
  (model.series || []).forEach(function(se){
    var sum = 0, any = false;
    (se.values || []).forEach(function(v){ if (typeof v === 'number'){ sum += v; any = true; } });
    chips.push({label: se.name, value: any ? sum : null});
  });
  if (!chips.length) chips = [{label: model.x_label || 'Value', value: null}];
  var html = '<div class="flex-1 min-h-0 overflow-auto grid grid-cols-2 gap-2 content-start pt-1">';
  chips.slice(0, 8).forEach(function(c){
    var v = (c.value == null) ? '—' : _dashChartFmt(c.value);
    html += '<div class="rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2 text-center">' +
      '<div class="text-2xl font-bold text-gray-900 dark:text-white tabular-nums leading-tight">' + esc(v) + '</div>' +
      '<div class="text-[11px] text-gray-500 dark:text-gray-400 truncate" title="' + esc(c.label) + '">' + esc(c.label) + '</div></div>';
  });
  return html + '</div>';
}
function dashTileRenderChart(body, cfg, data){
  if (data.available === false){
    body.innerHTML = '<div class="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400 py-2"><span>&#128200;</span><span>' + esc(data.message || 'Chart data not available.') + '</span></div>';
    return;
  }
  var type = data.chart_type || cfg.chart_type || 'bar';
  var series = data.series || [], cats = data.categories || [];
  var hasData = (type === 'gauge' || type === 'progress') ? !!data.gauge : (cats.length && series.length);
  var html = '<div class="h-full flex flex-col text-gray-800 dark:text-gray-100">' + _dashIdeCachedBadge(data);
  if (!hasData){ body.innerHTML = html + '<p class="text-gray-400 dark:text-gray-500 text-xs italic text-center py-4">No data to chart.</p></div>'; return; }
  if (type === 'pie' || type === 'donut'){
    var slices = _dashChartToSlices(data);
    if (!slices.length){ html += '<p class="text-gray-400 dark:text-gray-500 text-xs italic text-center py-4">No numeric values to chart.</p>'; }
    else {
      html += '<div class="flex-1 min-h-0 flex items-center justify-center" style="min-height:70px">' + _dashChartPieSvg(slices, type === 'donut') + '</div>';
      if (cfg.show_legend !== false) html += _dashChartLegendItems(slices);
    }
  } else if (type === 'kpi'){ html += _dashChartKpiHtml(data); }
  else if (type === 'progress'){ html += _dashChartProgressHtml(data.gauge || {}); }
  else if (type === 'gauge'){
    html += '<div class="flex-1 min-h-0 flex items-center justify-center">' + _dashChartGaugeSvg(data.gauge || {}) + '</div>';
    if (data.gauge && data.gauge.label) html += '<div class="shrink-0 text-center text-xs text-gray-500 dark:text-gray-400 truncate">' + esc(data.gauge.label) + '</div>';
  } else {
    html += '<div class="flex-1 min-h-0 flex items-stretch" style="min-height:80px">' + _dashChartAxisSvg(data) + '</div>';
    if (cfg.show_legend !== false && series.length > 1) html += _dashChartLegendItems(series.map(function(s){ return {label: s.name}; }));
  }
  if (data.truncated) html += '<p class="text-[11px] text-gray-400 dark:text-gray-500 text-center shrink-0">+' + data.truncated + ' more categories</p>';
  body.innerHTML = html + '</div>';
}
function dashTileRenderReportTable(body, cfg, data){
  if (data.available === false){
    body.innerHTML = '<div class="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400 py-2"><span>&#128203;</span><span>' + esc(data.message || 'Report not available.') + '</span></div>';
    return;
  }
  var html = '<div class="h-full flex flex-col">' + _dashIdeCachedBadge(data);
  if (!(data.rows || []).length){ body.innerHTML = html + '<p class="text-gray-400 dark:text-gray-500 text-xs italic text-center py-4">This report returned no rows.</p></div>'; return; }
  body.innerHTML = html + _dashPbiTableHtml(data) + '</div>';
}

// ---- Oversight tile (DVI-1213) ----
// A self-contained, per-tile embedded Oversight floor-plan viewer. Mirrors the
// emov/scadaov embeds but keyed by tile id so a dashboard can hold several
// Oversight tiles at once, each with its own plan/layers/zoom/pan. On the 60 s
// auto-refresh we re-render only the markers (zoom/pan preserved) unless the
// plan/style/theme changed, in which case the scaffold rebuilds.
var _dashOv = {};            // tileId -> {zoom, panX, panY, full, sig, data, cfg, built, bound}
var _dashOvFullTile = null;  // tileId currently maximized (one at a time)
var _dashOvKeyBound = false;
var DASHOV_BTN = 'w-6 h-6 flex items-center justify-center rounded bg-white/80 dark:bg-gray-800/80 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600 text-sm leading-none shadow hover:bg-white dark:hover:bg-gray-700 cursor-pointer';

function dashTileRenderOversight(body, cfg, data, tile) {
  var tid = tile.id;
  if (data.available === false) {
    dashOvTeardown(tid);
    body.className = 'dash-tile-body flex-1 min-h-0 overflow-auto p-3 text-sm text-gray-700 dark:text-gray-200';
    body.innerHTML = '<div class="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400 py-2"><span>&#128506;</span><span>' + esc(data.message || 'Oversight view not available.') + '</span></div>';
    return;
  }
  var st = _dashOv[tid];
  var sig = (data.plan && data.plan.id) + '|' + (data.style || '') + '|' + document.documentElement.classList.contains('dark');
  if (st && st.built && st.sig === sig && body.querySelector('.dashov-wrap')) {
    // Same plan/style/theme — just refresh markers + legend in place.
    st.data = data; st.cfg = cfg;
    dashOvRenderMarkers(tid);
    dashOvSizeBox(body, tid, st);
    var lg = document.getElementById('ovlg_' + tid);
    if (lg) lg.innerHTML = (cfg.show_legend !== false) ? dashOvLegendHtml(st) : '';
    return;
  }
  _dashOv[tid] = st = { zoom: 1, panX: 0, panY: 0, full: false, sig: sig, data: data, cfg: cfg, tid: tid, built: false, bound: false };
  dashOvBuildScaffold(body, tid, st);
}

// DVI-1220 P2 (B3): lock the tile's row span to the plan image's aspect ratio
// so the tile hugs the floorplan instead of letterboxing. Uses the server-stored
// image dims (no need to wait for the image to load) and the grid's 120px row
// basis + 12px gap; the derived span is clamped to the editor's 1–6 row bounds.
function dashOvApplyAspect(body, tid, st) {
  if (!st.cfg || !st.cfg.lock_aspect) return;
  var tileEl = body.closest ? body.closest('.dash-tile') : null;
  if (!tileEl || !tileEl.clientWidth) return;
  var sid = dashOvResolveStyle(st);
  var dims = sid ? (((st.data.plan || {}).images || {})[sid] || {}) : {};
  if (!dims.w || !dims.h) return;
  var hdr = (tileEl.firstElementChild && tileEl.firstElementChild !== body)
    ? tileEl.firstElementChild.offsetHeight : 0;
  var want = tileEl.clientWidth * dims.h / dims.w + hdr;
  var rowH = 120, gap = 12;   // #dashboards-grid auto-rows 120px, gap 0.75rem
  var rows = Math.max(1, Math.min(6, Math.round((want + gap) / (rowH + gap))));
  var span = 'span ' + rows;
  if (tileEl.style.gridRow !== span) {
    tileEl.style.gridRow = span;
    // The body height changed with the span — refit the plan to the new box.
    // Only fires when the span actually changes (typically once, on first data).
    requestAnimationFrame(function(){ dashOvFit(tid); });
  }
}

// DVI-1501 prod-verify fix #2: the Oversight viewer's wrap is position:absolute
// (inset:0), so it only fills a parent that has a definite height. Inside the
// Dashboards TOOL grid (#dashboards-grid, rows minmax(120px,auto)) the tile card
// is a `.dash-tile` cell that always has height, so aspect-lock (row span) is
// enough. But on the home/Equipment tile surface (_buildDashTile → `togen-pane`
// card) and inside an embedded dashboard (_buildDashRefBody), the card grid has
// content-height rows and the card is NOT a `.dash-tile`, so the card height is
// content-based (no free space to distribute).
//
// The tile body carries Tailwind `flex-1` = `flex:1 1 0%`. That `flex-basis:0%`
// OVERRIDES an inline `height` for flex-basis, and with no free space in a
// content-sized column the body resolves to 0 tall — so fix #1's `style.height`
// was silently ignored and the floor plan stayed invisible (confirmed via a
// headless-Chrome layout repro: `flex-1`+`height:288px` in a content-sized flex
// column measures 0; `flex-1`+`min-height:288px` measures 288). The Dashboards
// tool worked only because its `.dash-tile` cell has a definite height, giving
// the flex column free space to grow the body into.
//
// Fix: set `min-height` (which a flex item honors as a floor regardless of
// `flex-basis:0%`) instead of `height`, derived from the plan aspect ratio
// (bounded), with a fixed fallback when width/dims aren't measurable yet. A stale
// `height` from fix #1's build is cleared so it can't re-collapse the body.
function dashOvSizeBox(body, tid, st) {
  var tileEl = body.closest ? body.closest('.dash-tile') : null;
  if (tileEl) { dashOvApplyAspect(body, tid, st); return; }   // Dashboards-tool grid cell
  if (body.style.height) body.style.height = '';   // drop fix #1's ignored height
  var w = (body.clientWidth) || 0;
  var sid = dashOvResolveStyle(st);
  var dims = sid ? (((st.data.plan || {}).images || {})[sid] || {}) : {};
  var h = (w && dims.w && dims.h) ? (w * dims.h / dims.w) : 288;   // 18rem fallback
  h = Math.max(176, Math.min(384, h));   // clamp 11rem..24rem
  var px = Math.round(h) + 'px';
  if (body.style.minHeight !== px) {
    body.style.minHeight = px;
    requestAnimationFrame(function(){ dashOvFit(tid); });
  } else {
    dashOvFit(tid);
  }
}

function dashOvTeardown(tid) {
  if (_dashOvFullTile === tid) { _dashOvFullTile = null; dashOvBackdrop(false); }
  delete _dashOv[tid];
}

function dashOvResolveStyle(st) {
  var imgs = (st.data.plan && st.data.plan.images) || {};
  var imaged = Object.keys(imgs);
  if (!imaged.length) return null;
  var cfgStyle = st.data.style || '';
  if (cfgStyle && imgs[cfgStyle]) return cfgStyle;
  var isDark = document.documentElement.classList.contains('dark');
  var want = isDark ? st.data.style_default_dark : st.data.style_default_light;
  if (want && imgs[want]) return want;
  var ordered = (st.data.styles || []).filter(function(sid){ return imgs[sid]; });
  return ordered.length ? ordered[0] : imaged[0];
}

function dashOvBuildScaffold(body, tid, st) {
  // Recover from a stale maximize if the previously-full tile's DOM is gone.
  if (_dashOvFullTile && !document.getElementById('ovwrap_' + _dashOvFullTile)) {
    _dashOvFullTile = null; dashOvBackdrop(false);
  }
  var sid = dashOvResolveStyle(st);
  body.className = 'dash-tile-body flex-1 min-h-0 relative overflow-hidden p-0';
  if (!sid) {
    body.innerHTML = '<div class="p-3 text-xs text-gray-500 dark:text-gray-400">This plan has no image uploaded yet.</div>';
    return;
  }
  var interactive = st.cfg.interactive !== false;
  var wrapId = 'ovwrap_' + tid, stageId = 'ovstage_' + tid, panId = 'ovpan_' + tid, imgId = 'ovimg_' + tid, mkId = 'ovmk_' + tid;
  var ctrls = '<div class="absolute top-1 right-1 flex gap-1 z-10">';
  if (interactive) {
    ctrls += '<button onclick="dashOvZoomBy(\'' + tid + '\',1.2)" title="Zoom in" class="' + DASHOV_BTN + '">+</button>' +
             '<button onclick="dashOvZoomBy(\'' + tid + '\',0.8)" title="Zoom out" class="' + DASHOV_BTN + '">&minus;</button>' +
             '<button onclick="dashOvFit(\'' + tid + '\')" title="Fit to view" class="' + DASHOV_BTN + '">&#9711;</button>';
  }
  ctrls += '<button id="ovfullbtn_' + tid + '" onclick="dashOvToggleFull(\'' + tid + '\')" title="Full view" class="' + DASHOV_BTN + '">' +
             '<svg id="ovfull_' + tid + '" class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"></svg>' +
           '</button></div>';
  body.innerHTML =
    '<div id="' + wrapId + '" class="dashov-wrap absolute inset-0 overflow-hidden bg-gray-50 dark:bg-gray-900">' +
      '<div id="' + stageId + '" class="absolute inset-0 overflow-hidden select-none" style="touch-action:none;' + (interactive ? 'cursor:grab;' : '') + '">' +
        '<div id="' + panId + '" style="position:absolute;left:0;top:0;transform-origin:0 0;">' +
          '<img id="' + imgId + '" alt="Floor plan" style="display:block;max-width:none;pointer-events:none;" />' +
          '<div id="' + mkId + '" style="position:absolute;left:0;top:0;right:0;bottom:0;"></div>' +
        '</div>' +
      '</div>' + ctrls +
      '<div id="ovlg_' + tid + '" class="absolute bottom-1 left-1 z-10 pointer-events-none"></div>' +
    '</div>';
  // Resolve within `body` (not document.getElementById): _buildDashTile (home /
  // Equipment tile surface) and _buildDashRefBody (embedded dashboard) render the
  // tile body while the card is still DETACHED, so a document lookup returns null
  // and the <img> would never be wired/sourced (= floor plan never loads). The
  // img.onload → dashOvFit + dashOvSizeBox's rAF both run post-attach, so those
  // may keep using ids. (DVI-1501 prod-verify fix.)
  _ovSetFullIcon(body.querySelector('#ovfull_' + tid), false);
  var lg = body.querySelector('#ovlg_' + tid);
  if (lg) lg.innerHTML = (st.cfg.show_legend !== false) ? dashOvLegendHtml(st) : '';
  var img = body.querySelector('#' + imgId);
  var plan = st.data.plan;
  var dims = (plan.images && plan.images[sid]) || {};
  img.onload = function(){
    dashOvFit(tid); dashOvRenderMarkers(tid);
    // Bind pan/zoom now the node is attached — a detached build (tile surface /
    // embedded dashboard) couldn't resolve the stage at scaffold time and the
    // st.bound guard makes this idempotent (DVI-1501 prod-verify fix).
    if (interactive) dashOvBindStage(tid);
  };
  img.src = '/oversight/plan-image/' + encodeURIComponent(plan.id) +
    '?style=' + encodeURIComponent(sid) + '&v=' + (dims.w || 0) + 'x' + (dims.h || 0);
  if (interactive) dashOvBindStage(tid);
  st.built = true;
  dashOvSizeBox(body, tid, st);   // B3 aspect-lock (grid) / explicit height (tile surface + embed)
  dashOvBindKeys();
}

function dashOvRenderMarkers(tid) {
  var st = _dashOv[tid]; if (!st) return;
  var host = document.getElementById('ovmk_' + tid); if (!host) return;
  var placements = oversightSortLocationFirst((st.data && st.data.placements) || []);
  var interactive = st.cfg.interactive !== false;
  host.innerHTML = '';
  placements.forEach(function(pl){
    var el = document.createElement('div');
    el.className = 'dashov-marker';
    el.style.position = 'absolute';
    el.style.left = (pl.x * 100) + '%';
    el.style.top = (pl.y * 100) + '%';
    el.style.transform = 'translate(-50%,-50%) scale(' + (1 / st.zoom) + ')';
    el.style.transformOrigin = 'center center';
    el.appendChild(oversightIsLocation(pl) ? oversightLocationChip(pl) : dashOvMarkerChip(tid, pl, interactive));
    host.appendChild(el);
  });
}

function dashOvMarkerColor(pl) {
  var d = pl.data;
  if (!pl.known || !d) return 'bg-gray-500 text-white';
  if (pl.layer === 'equipment') {
    if (d.state === 'red') return 'bg-red-600 text-white';
    if (d.state === 'yellow') return 'bg-amber-500 text-white';
    return 'bg-emerald-600 text-white';
  }
  if (d.stale) return 'bg-amber-500 text-white';
  var v = parseFloat(d.value);
  if (!isNaN(v)) {
    if (d.low != null && v < d.low) return 'bg-blue-600 text-white';
    if (d.high != null && v > d.high) return 'bg-red-600 text-white';
    return 'bg-emerald-600 text-white';
  }
  return 'bg-slate-600 text-white';
}

function dashOvMarkerChip(tid, pl, interactive) {
  var d = pl.data;
  var chip = document.createElement('div');
  chip.className = 'relative inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold shadow-md border border-white/40 whitespace-nowrap ' + dashOvMarkerColor(pl);
  var txt = oversightEsc(pl.label || (d && d.label) || pl.ref);
  chip.innerHTML = '<span>' + txt + '</span>' +
    (!pl.known ? '<span class="text-[9px] font-bold uppercase bg-black/20 rounded px-1">unknown</span>' : '');
  if (interactive) { chip.style.cursor = 'pointer'; chip.onclick = function(e){ dashOvShowDetail(tid, pl, e); }; }
  return chip;
}

function dashOvDetailBox() {
  var box = document.getElementById('dashov-detail');
  if (!box) {
    box = document.createElement('div');
    box.id = 'dashov-detail';
    box.className = 'hidden fixed z-[125] w-64 rounded-xl bg-white dark:bg-gray-800 shadow-xl border border-gray-200 dark:border-gray-700 p-3 text-sm';
    document.body.appendChild(box);
  }
  return box;
}
function dashOvCloseDetail() { var b = document.getElementById('dashov-detail'); if (b) b.classList.add('hidden'); }

function dashOvShowDetail(tid, pl, evt) {
  var d = pl.data;
  var box = dashOvDetailBox();
  var rows = '';
  function row(k, v){ return '<div class="flex justify-between gap-3"><span class="text-gray-500 dark:text-gray-400">' + k + '</span><span class="font-medium text-gray-800 dark:text-gray-100 text-right">' + v + '</span></div>'; }
  var title = oversightEsc((d && d.label) || pl.label || pl.ref);
  if (!pl.known) {
    rows = '<p class="text-xs text-amber-600 dark:text-amber-400">' +
      (pl.layer === 'equipment'
        ? 'This location references equipment that is no longer included in Oversight.'
        : 'This location references a source that is no longer reporting.') + '</p>';
  } else if (pl.layer === 'equipment' && d) {
    var eqL = {green: 'In service — no open work orders', yellow: 'Open work order(s)', red: 'Out of service'}[d.state] || d.state;
    var eqC = {green: 'bg-emerald-600', yellow: 'bg-amber-500', red: 'bg-red-600'}[d.state] || 'bg-gray-500';
    rows += row('Status', '<span class="inline-flex items-center gap-1"><span class="inline-block h-2.5 w-2.5 rounded-full ' + eqC + '"></span>' + oversightEsc(eqL) + '</span>');
    if (d.open_count != null) rows += row('Open work orders', oversightEsc(String(d.open_count)));
    if (d.in_progress_count) rows += row('In progress', oversightEsc(String(d.in_progress_count)));
  } else if (d) {
    var unit = d.unit ? ' ' + oversightEsc(d.unit) : '';
    rows += row('Value', '<span class="text-base">' + oversightEsc(String(d.value)) + unit + '</span>');
    if (d.type) rows += row('Type', oversightEsc(d.type));
    if (d.location) rows += row('Location', oversightEsc(d.location));
    if (d.low != null || d.high != null) rows += row('Range', (d.low != null ? d.low : '—') + ' – ' + (d.high != null ? d.high : '—'));
    if (d.ts) rows += row('Reading', oversightEsc(d.ts));
    if (d.stale) rows += '<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">Reading is stale.</p>';
  } else {
    rows = '<p class="text-xs text-gray-500">No current data.</p>';
  }
  var link = '';
  if (pl.layer === 'scada' && typeof oversightOpenInScada === 'function') {
    link = '<button onclick="dashOvCloseDetail();oversightOpenInScada(\'' + encodeURIComponent(pl.ref) + '\')" class="mt-2 w-full px-2 py-1 text-xs rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 cursor-pointer">Open in Kiln Metrics</button>';
  } else if (pl.layer === 'equipment' && typeof oversightOpenInEquipment === 'function') {
    link = '<button onclick="dashOvCloseDetail();oversightOpenInEquipment(\'' + encodeURIComponent(pl.ref) + '\')" class="mt-2 w-full px-2 py-1 text-xs rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 cursor-pointer">Open in Equipment</button>';
  }
  box.innerHTML = '<div class="flex items-center justify-between mb-2"><span class="font-semibold text-gray-900 dark:text-gray-100">' + title + '</span>' +
    '<button onclick="dashOvCloseDetail()" class="text-gray-400 hover:text-gray-600 cursor-pointer text-lg leading-none">&times;</button></div>' +
    '<div class="space-y-1">' + rows + '</div>' + link;
  box.classList.remove('hidden');
  var x = Math.min(evt.clientX + 12, window.innerWidth - 270);
  var y = Math.min(evt.clientY + 12, window.innerHeight - 200);
  box.style.left = Math.max(8, x) + 'px';
  box.style.top = Math.max(8, y) + 'px';
}

function dashOvLegendHtml(st) {
  var layers = (st.data.layers || []).map(function(l){ return l.id; });
  function dot(c, t){ return '<span class="inline-flex items-center gap-1"><span class="inline-block h-2 w-2 rounded-full ' + c + '"></span>' + t + '</span>'; }
  var parts = [];
  if (layers.indexOf('equipment') > -1 || layers.indexOf('scada') > -1 || layers.indexOf('lorawan_mobile') > -1) {
    parts.push(dot('bg-emerald-600', 'OK'));
    parts.push(dot('bg-amber-500', 'Attention'));
    parts.push(dot('bg-red-600', 'Alert'));
  }
  if (!parts.length) return '';
  return '<span class="inline-flex items-center gap-2 text-[10px] px-1.5 py-0.5 rounded bg-white/85 dark:bg-gray-800/85 text-gray-600 dark:text-gray-300 shadow">' + parts.join('') + '</span>';
}

// ---- pan / zoom / fullscreen (per-tile; reuses the shared _ov* helpers) ----
function dashOvApplyTransform(tid) {
  var st = _dashOv[tid]; if (!st) return;
  var pan = document.getElementById('ovpan_' + tid);
  if (pan) pan.style.transform = 'translate(' + st.panX + 'px,' + st.panY + 'px) scale(' + st.zoom + ')';
  var inv = 1 / st.zoom;
  var ms = document.querySelectorAll('#ovmk_' + tid + ' .dashov-marker');
  for (var i = 0; i < ms.length; i++) ms[i].style.transform = 'translate(-50%,-50%) scale(' + inv + ')';
}
function dashOvFit(tid) {
  var st = _dashOv[tid]; if (!st) return;
  var stage = document.getElementById('ovstage_' + tid);
  var img = document.getElementById('ovimg_' + tid);
  if (!stage || !img) return;
  var iw = img.naturalWidth || img.width, ih = img.naturalHeight || img.height;
  var sw = stage.clientWidth, sh = stage.clientHeight;
  if (!iw || !ih || !sw || !sh) { st.zoom = 1; st.panX = 0; st.panY = 0; dashOvApplyTransform(tid); return; }
  // B3 fit modes (DVI-1220 P2): contain letterboxes the whole plan (default);
  // cover fills the tile and crops the plan's long edges.
  if (st.cfg && st.cfg.fit === 'cover') {
    st.zoom = Math.max(sw / iw, sh / ih);
  } else {
    st.zoom = Math.min((sw - 4) / iw, (sh - 4) / ih);
  }
  if (!(st.zoom > 0)) st.zoom = Math.min(sw / iw, sh / ih);
  st.panX = (sw - iw * st.zoom) / 2;
  st.panY = (sh - ih * st.zoom) / 2;
  dashOvApplyTransform(tid);
}
function dashOvZoomAt(tid, sx, sy, f) {
  var st = _dashOv[tid]; if (!st) return;
  var z2 = Math.max(0.05, Math.min(20, st.zoom * f));
  var k = z2 / st.zoom;
  st.panX = sx - (sx - st.panX) * k;
  st.panY = sy - (sy - st.panY) * k;
  st.zoom = z2;
  dashOvApplyTransform(tid);
}
function dashOvZoomBy(tid, f) {
  var stage = document.getElementById('ovstage_' + tid); if (!stage) return;
  dashOvZoomAt(tid, stage.clientWidth / 2, stage.clientHeight / 2, f);
}
function dashOvBindStage(tid) {
  var st = _dashOv[tid]; if (!st || st.bound) return;
  var stage = document.getElementById('ovstage_' + tid); if (!stage) return;
  st.bound = true;
  var panning = false, lastX = 0, lastY = 0;
  stage.addEventListener('pointerdown', function(e){
    if (e.target.closest('.dashov-marker')) return;
    panning = true; lastX = e.clientX; lastY = e.clientY;
    try { stage.setPointerCapture(e.pointerId); } catch(_){}
    dashOvCloseDetail();
  });
  stage.addEventListener('pointermove', function(e){
    if (!panning) return;
    st.panX += (e.clientX - lastX); st.panY += (e.clientY - lastY);
    lastX = e.clientX; lastY = e.clientY;
    dashOvApplyTransform(tid);
  });
  function endPan(e){ if (panning) { panning = false; try { stage.releasePointerCapture(e.pointerId); } catch(_){} } }
  stage.addEventListener('pointerup', endPan);
  stage.addEventListener('pointercancel', endPan);
  stage.addEventListener('wheel', function(e){
    e.preventDefault();
    var rect = stage.getBoundingClientRect();
    dashOvZoomAt(tid, e.clientX - rect.left, e.clientY - rect.top, e.deltaY < 0 ? 1.1 : 0.9);
  }, { passive: false });
}

function dashOvBackdrop(show) {
  var bd = document.getElementById('dashov-backdrop');
  if (!bd) {
    bd = document.createElement('div');
    bd.id = 'dashov-backdrop';
    bd.className = 'hidden fixed inset-0 bg-black/70 z-[88]';
    bd.onclick = function(){ if (_dashOvFullTile) dashOvToggleFull(_dashOvFullTile); };
    document.body.appendChild(bd);
  }
  bd.classList.toggle('hidden', !show);
}
function dashOvResetFull() { if (_dashOvFullTile) { _dashOvFullTile = null; dashOvBackdrop(false); } }
function dashOvToggleFull(tid) {
  var st = _dashOv[tid]; if (!st) return;
  var wrapId = 'ovwrap_' + tid, stageId = 'ovstage_' + tid;
  st.full = !st.full;
  var wrap = document.getElementById(wrapId);
  _ovApplyFull(st.full, wrapId);
  if (st.full) {
    _dashOvFullTile = tid;
    dashOvBackdrop(true);
    _ovSizeStage(stageId, wrapId, true);
  } else {
    _dashOvFullTile = null;
    dashOvBackdrop(false);
    if (wrap) { wrap.style.width = ''; wrap.style.height = ''; wrap.style.left = ''; wrap.style.top = ''; wrap.style.right = ''; wrap.style.bottom = ''; wrap.style.marginLeft = ''; wrap.style.marginRight = ''; }
  }
  _ovSetFullIcon('ovfull_' + tid, st.full);
  var fb = document.getElementById('ovfullbtn_' + tid); if (fb) fb.title = st.full ? 'Exit full view' : 'Full view';
  dashOvFit(tid);
}
function dashOvBindKeys() {
  if (_dashOvKeyBound) return;
  _dashOvKeyBound = true;
  document.addEventListener('keydown', function(e){
    if (e.key !== 'Escape' || _activeTool !== 'dashboards') return;
    if (_dashOvFullTile) { dashOvToggleFull(_dashOvFullTile); return; }
    dashOvCloseDetail();
  });
  window.addEventListener('resize', function(){
    if (_dashOvFullTile) { _ovSizeStage('ovstage_' + _dashOvFullTile, 'ovwrap_' + _dashOvFullTile, true); dashOvFit(_dashOvFullTile); }
  });
}

// ---- Reorder ----
function dashboardsCurrentOrder() {
  var active = dashboardsActive();
  if (!active) return [];
  return (active.tiles || []).slice().sort(function(a,b){ return (a.order||0) - (b.order||0); }).map(function(t){ return t.id; });
}

function dashboardsMoveTile(tileId, dir) {
  var order = dashboardsCurrentOrder();
  var i = order.indexOf(tileId);
  var j = i + dir;
  if (i === -1 || j < 0 || j >= order.length) return;
  order.splice(i, 1);
  order.splice(j, 0, tileId);
  dashboardsPersistOrder(order);
}

function dashboardsDropOn(targetId) {
  if (!_dashDragTileId || _dashDragTileId === targetId) return;
  var order = dashboardsCurrentOrder();
  var from = order.indexOf(_dashDragTileId);
  var to = order.indexOf(targetId);
  if (from === -1 || to === -1) return;
  order.splice(from, 1);
  order.splice(to, 0, _dashDragTileId);
  dashboardsPersistOrder(order);
}

function dashboardsPersistOrder(order) {
  var active = dashboardsActive();
  if (!active) return;
  fetch('/dashboards/' + encodeURIComponent(active.id) + '/reorder', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ order: order })
  }).then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { alert(d.error); return; }
      dashboardsApplyUpdated(d.dashboard);
    });
}

// ---- Add / edit tiles ----
// DVI-1255: both modals below are authored inside #tool-dashboards, a
// .tool-content pane that is display:none whenever another tool is active. A
// position:fixed descendant of a display:none ancestor does not render, so
// opening the picker from the Auditor Report Builder fired the handler but
// showed nothing (a dead button). Reparent the overlay to <body> on open so it
// escapes any hidden tool pane. This is a no-op move for the normal Dashboards
// flow (its pane is visible) and keeps the modal viewport-fixed either way.
function _dashLiftOverlay(id) {
  var el = document.getElementById(id);
  if (el && el.parentNode !== document.body) document.body.appendChild(el);
  return el;
}

function dashboardsAddTile() {
  var body = document.getElementById('dashboards-typepicker-body');
  body.innerHTML = '';
  Object.keys(_dashTileTypes).forEach(function(type){
    // DVI-1305 P4: the Group / Panel tile is only offerable at the top level of
    // a Dashboard — never inside another Group (no nested panels) and never in
    // the Report Builder's picker (both arm a save hook). Its own child picker
    // lists the non-group types directly.
    if (type === 'group' && _dashTileSaveHook) return;
    var t = _dashTileTypes[type];
    var btn = document.createElement('button');
    btn.className = 'w-full text-left px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-togen-teal hover:bg-togen-teal/5 transition-colors cursor-pointer';
    btn.innerHTML = '<span class="font-medium text-gray-800 dark:text-gray-100">' + esc(t.label || type) + '</span>';
    btn.onclick = function(){ dashboardsPickType(type); };
    body.appendChild(btn);
  });
  _dashLiftOverlay('dashboards-typepicker-overlay').classList.remove('hidden');
}

function dashboardsCloseTypePicker() {
  document.getElementById('dashboards-typepicker-overlay').classList.add('hidden');
}

function dashboardsPickType(type) {
  dashboardsCloseTypePicker();
  _dashEditingTileId = null;
  _dashNewTileType = type;
  // DVI-1305 P4: the Group / Panel tile has its own child-manager overlay
  // (title + columns + a child-tile list) instead of the standard config modal.
  if (type === 'group') { dashboardsGroupOpen(null); return; }
  dashboardsOpenTileEditor(type, 'medium', {}, null);
}

function dashboardsEditTile(tileId) {
  var active = dashboardsActive();
  if (!active) return;
  var tile = (active.tiles || []).filter(function(t){ return t.id === tileId; })[0];
  if (!tile) return;
  _dashEditingTileId = tileId;
  _dashNewTileType = null;
  if (tile.type === 'group') { dashboardsGroupOpen(tile); return; }   // DVI-1305 P4
  dashboardsOpenTileEditor(tile.type, tile.size, tile.config || {}, tile);
}

function dashboardsOpenTileEditor(type, size, cfg, tile) {
  var t = _dashTileTypes[type] || {};
  document.getElementById('dashboards-tile-title').textContent =
    (_dashEditingTileId ? 'Edit ' : 'Add ') + (t.label || type) + ' tile';
  var sizeKey = DASH_SIZES[size] ? size : 'medium';
  var sizeSel = document.getElementById('dashboards-tile-size');
  sizeSel.value = sizeKey;
  sizeSel.setAttribute('data-prev', sizeKey);   // don't seed over a stored span on open
  var span = dashCustomSpan(tile && tile.span);
  document.getElementById('dashboards-tile-span-cols').value = span.cols;
  document.getElementById('dashboards-tile-span-rows').value = span.rows;
  document.getElementById('dashboards-tile-overflow').value = (tile && tile.overflow) || '';
  document.getElementById('dashboards-tile-zoom').value = String((tile && tile.zoom) || 100);
  dashboardsSizeChanged();
  document.getElementById('dashboards-tile-status').textContent = '';
  dashboardsRenderTileConfig(type, cfg);
  _dashLiftOverlay('dashboards-tile-overlay').classList.remove('hidden');
}

// Show the width/height span inputs only for the Custom size; when switching
// TO custom, seed them from the previously selected preset so the tile keeps
// its shape as a starting point.
function dashboardsSizeChanged() {
  var sel = document.getElementById('dashboards-tile-size');
  var wrap = document.getElementById('dashboards-tile-span-wrap');
  var isCustom = sel.value === 'custom';
  wrap.classList.toggle('hidden', !isCustom);
  if (isCustom && sel.getAttribute('data-prev') && sel.getAttribute('data-prev') !== 'custom') {
    var prev = DASH_SIZES[sel.getAttribute('data-prev')];
    if (prev) {
      document.getElementById('dashboards-tile-span-cols').value = prev.cols;
      document.getElementById('dashboards-tile-span-rows').value = prev.rows;
    }
  }
  sel.setAttribute('data-prev', sel.value);
}

function dashboardsCloseTileEditor() {
  document.getElementById('dashboards-tile-overlay').classList.add('hidden');
  _dashTileSaveHook = null;   // never leave a foreign save hook armed (DVI-1255)
}

// DVI-1255: when set, the shared tile-editor modal returns the collected tile
// to this callback ({type,size,config,overflow,span}) instead of POSTing to a
// dashboard. The Report Builder reuses the identical tile picker + config UI.
var _dashTileSaveHook = null;

// ---- DVI-1305 P4: Group / Panel container manager ----
// A group is edited in its own overlay (title + panel size + inner columns + a
// child-tile list). Adding/editing a child reuses the SAME tile picker + config
// modal via _dashTileSaveHook (the DVI-1255 Report-Builder pattern), so every
// tile type is authored identically; the hook returns the collected child tile
// into the working list instead of POSTing to a dashboard.
var _dashGroupChildren = [];      // working copy of the panel's child tiles
var _dashGroupEditingId = null;   // group tile id when editing (null = add new)
var _dashGroupChildIndex = -1;    // child slot the child editor writes (-1 = append)

function dashboardsGroupOpen(tile) {
  var cfg = (tile && tile.config) || {};
  _dashGroupEditingId = tile ? tile.id : null;
  _dashGroupChildren = (cfg.children || []).map(function(c){ return JSON.parse(JSON.stringify(c)); });
  document.getElementById('dashboards-group-title').textContent = (tile ? 'Edit ' : 'Add ') + 'Group / Panel';
  document.getElementById('dashboards-group-name').value = cfg.title || '';
  var sizeSel = document.getElementById('dashboards-group-size');
  sizeSel.value = (tile && DASH_SIZES[tile.size]) ? tile.size : 'large';
  document.getElementById('dashboards-group-columns').value = cfg.columns || 'auto';
  document.getElementById('dashboards-group-status').textContent = '';
  dashboardsGroupRenderChildren();
  _dashLiftOverlay('dashboards-group-overlay').classList.remove('hidden');
}
function dashboardsGroupClose() {
  document.getElementById('dashboards-group-overlay').classList.add('hidden');
}
function dashboardsGroupRenderChildren() {
  var host = document.getElementById('dashboards-group-children');
  host.innerHTML = '';
  if (!_dashGroupChildren.length) {
    host.innerHTML = '<p class="text-xs text-gray-400 dark:text-gray-500 italic">No tiles yet. Click &ldquo;+ Add tile&rdquo;.</p>';
    return;
  }
  _dashGroupChildren.forEach(function(c, i){
    var t = _dashTileTypes[c.type] || {};
    var label = (t.label || c.type) + ((c.config && c.config.title) ? (' • ' + c.config.title) : '');
    var row = document.createElement('div');
    row.className = 'flex items-center gap-1 rounded-lg border border-gray-200 dark:border-gray-700 px-2 py-1.5';
    var name = document.createElement('span');
    name.className = 'flex-1 min-w-0 truncate text-xs text-gray-700 dark:text-gray-200';
    name.textContent = label;
    row.appendChild(name);
    row.appendChild(dashTileCtrlBtn('↑', 'Move up', i === 0, function(){ dashboardsGroupMoveChild(i, -1); }));
    row.appendChild(dashTileCtrlBtn('↓', 'Move down', i === _dashGroupChildren.length - 1, function(){ dashboardsGroupMoveChild(i, 1); }));
    row.appendChild(dashTileCtrlBtn('✎', 'Edit', false, function(){ dashboardsGroupEditChild(i); }));
    row.appendChild(dashTileCtrlBtn('×', 'Remove', false, function(){ dashboardsGroupRemoveChild(i); }));
    host.appendChild(row);
  });
}
function dashboardsGroupMoveChild(i, delta) {
  var j = i + delta;
  if (j < 0 || j >= _dashGroupChildren.length) return;
  var tmp = _dashGroupChildren[i]; _dashGroupChildren[i] = _dashGroupChildren[j]; _dashGroupChildren[j] = tmp;
  dashboardsGroupRenderChildren();
}
function dashboardsGroupRemoveChild(i) {
  _dashGroupChildren.splice(i, 1);
  dashboardsGroupRenderChildren();
}
function dashboardsGroupAddChild() {
  // Reuse the type-picker overlay but exclude 'group' (no nested panels).
  var body = document.getElementById('dashboards-typepicker-body');
  body.innerHTML = '';
  Object.keys(_dashTileTypes).forEach(function(type){
    if (type === 'group') return;
    var t = _dashTileTypes[type];
    var btn = document.createElement('button');
    btn.className = 'w-full text-left px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-togen-teal hover:bg-togen-teal/5 transition-colors cursor-pointer';
    btn.innerHTML = '<span class="font-medium text-gray-800 dark:text-gray-100">' + esc(t.label || type) + '</span>';
    btn.onclick = function(){ dashboardsCloseTypePicker(); dashboardsGroupChildEditor(type, 'medium', {}, null, -1); };
    body.appendChild(btn);
  });
  _dashLiftOverlay('dashboards-typepicker-overlay').classList.remove('hidden');
}
function dashboardsGroupEditChild(i) {
  var c = _dashGroupChildren[i];
  if (!c) return;
  dashboardsGroupChildEditor(c.type, c.size || 'medium', c.config || {}, c, i);
}
function dashboardsGroupChildEditor(type, size, cfg, childTile, index) {
  _dashGroupChildIndex = index;
  _dashEditingTileId = null;      // hook mode collects by _dashNewTileType, never POSTs
  _dashNewTileType = type;
  _dashTileSaveHook = function(out){
    var child = {
      type: out.type, size: out.size, config: out.config,
      overflow: out.overflow || '', zoom: out.zoom || 100
    };
    if (out.span) child.span = out.span;
    if (childTile && childTile.id) child.id = childTile.id;   // keep a stable id on edit
    if (_dashGroupChildIndex >= 0) _dashGroupChildren[_dashGroupChildIndex] = child;
    else _dashGroupChildren.push(child);
    _dashGroupChildIndex = -1;
    dashboardsGroupRenderChildren();
  };
  dashboardsOpenTileEditor(type, size, cfg, childTile);
}
function dashboardsGroupSave() {
  var active = dashboardsActive();
  if (!active) return;
  var status = document.getElementById('dashboards-group-status');
  var config = {
    title: (document.getElementById('dashboards-group-name').value || '').slice(0, 200),
    columns: document.getElementById('dashboards-group-columns').value || 'auto',
    children: _dashGroupChildren
  };
  var size = document.getElementById('dashboards-group-size').value || 'large';
  status.textContent = 'Saving…';
  var url, payload;
  if (_dashGroupEditingId) {
    url = '/dashboards/' + encodeURIComponent(active.id) + '/tiles/' + encodeURIComponent(_dashGroupEditingId);
    payload = { size: size, config: config };
  } else {
    url = '/dashboards/' + encodeURIComponent(active.id) + '/tiles';
    payload = { type: 'group', size: size, config: config };
  }
  fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { status.textContent = d.error; return; }
      dashboardsApplyUpdated(d.dashboard);
      dashboardsGroupClose();
    });
}

// Config editor. P1 hand-builds the Text editor; P2 adds WO/Parts/SCADA editors.
function _dashCfgInput(id, label, maxlen) {
  return '<div><label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">' + label + '</label>' +
    '<input id="' + id + '" type="text" maxlength="' + (maxlen || 200) + '" 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" /></div>';
}
function _dashCfgNumber(id, label, min, max) {
  return '<div><label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">' + label + '</label>' +
    '<input id="' + id + '" type="number" min="' + min + '" max="' + max + '" step="1" 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" /></div>';
}
function _dashCfgMaxItems(v) {
  var n = parseInt(v, 10);
  if (isNaN(n)) n = 0;
  return Math.max(0, Math.min(50, n));
}
function _dashCfgCheck(id, label) {
  return '<div class="flex items-center gap-2"><input id="' + id + '" type="checkbox" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal" />' +
    '<label for="' + id + '" class="text-sm text-gray-700 dark:text-gray-200 cursor-pointer">' + label + '</label></div>';
}
function _dashCfgSelect(id, label, opts) {
  var optsHtml = opts.map(function(o){ return '<option value="' + esc(o.value) + '">' + esc(o.label) + '</option>'; }).join('');
  return _dashCfgSelectRaw(id, label, optsHtml);
}
// Same wrapper, but caller supplies the <option> HTML (used by the async
// Oversight editor where plan/style options come from /oversight/config).
function _dashCfgSelectRaw(id, label, optsHtml) {
  return '<div><label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">' + label + '</label>' +
    '<select id="' + id + '" 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">' + optsHtml + '</select></div>';
}
// DVI-1305 P1 — Text Tile formatting toolbar + live preview.
function _dashTextTbBtn(kind, label, title) {
  return '<button type="button" title="' + esc(title) + '" onclick="dashTextTbInsert(\'' + kind + '\')" ' +
    'class="px-2 py-1 rounded border border-gray-300 dark:border-gray-600 text-xs text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">' + label + '</button>';
}
// Insert a Markdown snippet at the cursor in the text config textarea. Block
// marks (heading / list) go at the start of the current line; the rest wrap the
// current selection (or a placeholder).
function dashTextTbInsert(kind) {
  var ta = document.getElementById('dash-cfg-text');
  if (!ta) return;
  var s = ta.selectionStart, e = ta.selectionEnd, v = ta.value, sel = v.slice(s, e);
  if (kind === 'heading' || kind === 'list') {
    var ls = v.lastIndexOf('\n', s - 1) + 1;
    var prefix = kind === 'heading' ? '## ' : '- ';
    ta.value = v.slice(0, ls) + prefix + v.slice(ls);
    var pos2 = e + prefix.length;
    ta.focus(); ta.setSelectionRange(pos2, pos2);
    dashTextPreviewUpdate();
    return;
  }
  var ins;
  if (kind === 'bold') ins = '**' + (sel || 'bold') + '**';
  else if (kind === 'italic') ins = '*' + (sel || 'italic') + '*';
  else if (kind === 'link') ins = '[' + (sel || 'link text') + '](https://)';
  else if (kind === 'table') ins = (s > 0 && v.charAt(s - 1) !== '\n' ? '\n' : '') +
    '| Column A | Column B |\n| --- | --- |\n| value | value |\n| value | value |\n';
  else return;
  ta.value = v.slice(0, s) + ins + v.slice(e);
  var pos = s + ins.length;
  ta.focus(); ta.setSelectionRange(pos, pos);
  dashTextPreviewUpdate();
}
// Re-render the live Markdown preview (shown only in Markdown mode), applying
// the tile's chosen font size / alignment / heading accent.
function dashTextPreviewUpdate() {
  var wrap = document.getElementById('dash-cfg-text-preview-wrap');
  var prev = document.getElementById('dash-cfg-text-preview');
  var mdEl = document.getElementById('dash-cfg-markdown');
  if (!wrap || !prev) return;
  var md = mdEl && mdEl.checked;
  wrap.classList.toggle('hidden', !md);
  if (!md) return;
  var txt = (document.getElementById('dash-cfg-text') || {}).value || '';
  var cfg = {
    font_size: (document.getElementById('dash-cfg-text-size') || {}).value || 'default',
    align: (document.getElementById('dash-cfg-text-align') || {}).value || 'left',
    accent: (document.getElementById('dash-cfg-text-accent') || {}).value || 'default'
  };
  prev.setAttribute('style', _dashTextStyleAttr(cfg));
  prev.innerHTML = txt.trim()
    ? renderNewsMarkdown(txt, { accent: cfg.accent })
    : '<span class="text-gray-400 dark:text-gray-500 italic">Nothing to preview.</span>';
}
var _dashOvEditCfg = null;   // cached /oversight/config for the tile editor
var _dashIdeVocab = null;    // cached /idencia/vocab for the Idencia tile editor
var _dashIdeTextMode = false; // Idencia filter entry: false = guided pickers, true = edit-as-text

// --- Chart / Report Table editor helpers (DVI-1254 P1). Both tiles bind to a
// registered Auditor report source (/auditor/reports); the source + its
// declared params are populated dynamically at edit time.
var _dashReportsList = null;      // cached [{id,label,source,params}]
function _dashLoadReports(cb){
  if (_dashReportsList){ cb(_dashReportsList); return; }
  fetch('/auditor/reports').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){ _dashReportsList = (d && d.reports) || []; cb(_dashReportsList); })
    .catch(function(){ _dashReportsList = []; cb(_dashReportsList); });
}
function _dashReportById(rid){ return (_dashReportsList || []).filter(function(r){ return r.id === rid; })[0] || null; }
function _dashReportSourceOptions(sel){
  var opts = '<option value="">— Select a data source —</option>';
  (_dashReportsList || []).forEach(function(r){ opts += '<option value="' + esc(r.id) + '"' + (r.id === sel ? ' selected' : '') + '>' + esc(r.label) + '</option>'; });
  return opts;
}
function _dashReportParamsHtml(rid, stored){
  var rep = _dashReportById(rid); if (!rep || !(rep.params || []).length) return '';
  stored = stored || {};
  var html = '<div class="togen-inset mt-1 space-y-2 rounded-lg border border-gray-200 dark:border-gray-700 p-2">' +
    '<div class="text-[11px] font-medium text-gray-500 dark:text-gray-400">Report parameters</div>';
  rep.params.forEach(function(p){
    var id = 'dash-cfg-rpt-p-' + p.key;
    var cur = stored[p.key] != null ? String(stored[p.key]) : (p.default != null ? String(p.default) : '');
    if (p.type === 'select' && (p.options || []).length){
      html += '<div><label class="block text-[11px] text-gray-600 dark:text-gray-300 mb-0.5">' + esc(p.label || p.key) + '</label>' +
        '<select id="' + id + '" data-rptp="' + esc(p.key) + '" class="w-full 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">' +
        p.options.map(function(o){ var v = (o[0] != null ? o[0] : o.value), l = (o[1] != null ? o[1] : o.label); return '<option value="' + esc(v) + '"' + (String(v) === cur ? ' selected' : '') + '>' + esc(l) + '</option>'; }).join('') +
        '</select></div>';
    } else {
      var _fld = '<input id="' + id + '" data-rptp="' + esc(p.key) + '" type="text" value="' + esc(cur) + '" placeholder="' + esc(p.placeholder || '') + '" class="w-full 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" />';
      // DVI-1382 reject follow-up: a param may declare a picker (e.g. Sources) —
      // show the same ☰ checkbox picker as the ad-hoc Run reports param field so
      // the Report Table tile can populate the field from a list too.
      if (p.picker){
        _fld = '<div class="flex items-center gap-1">' + _fld +
          '<button type="button" onclick="auditorSrcPickerOpen(\'' + id + '\', \'' + esc(p.picker) + '\')" title="Choose sources from a list" class="togen-control shrink-0 px-2 py-1.5 text-xs rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer">&#9776;</button>' +
          '</div>';
      }
      html += '<div><label class="block text-[11px] text-gray-600 dark:text-gray-300 mb-0.5">' + esc(p.label || p.key) + '</label>' + _fld + '</div>';
    }
  });
  return html + '</div>';
}
function _dashCollectReportParams(){
  var out = {};
  document.querySelectorAll('#dashboards-tile-config [data-rptp]').forEach(function(el){
    var k = el.getAttribute('data-rptp'); if (k) out[k] = (el.value || '').slice(0, 500);
  });
  return out;
}
function dashChartSourceChanged(){
  var rid = (document.getElementById('dash-cfg-chart-source') || {}).value || '';
  var wrap = document.getElementById('dash-cfg-chart-params'); if (wrap) wrap.innerHTML = _dashReportParamsHtml(rid, {});
}
function dashReportTableSourceChanged(){
  var rid = (document.getElementById('dash-cfg-rt-source') || {}).value || '';
  var wrap = document.getElementById('dash-cfg-rt-params'); if (wrap) wrap.innerHTML = _dashReportParamsHtml(rid, {});
}
function dashChartTypeChanged(){
  var ct = (document.getElementById('dash-cfg-chart-type') || {}).value || 'bar';
  var tgt = document.getElementById('dash-cfg-chart-target-wrap');
  if (tgt) tgt.style.display = (ct === 'gauge' || ct === 'progress') ? '' : 'none';
}

function dashboardsRenderTileConfig(type, cfg) {
  var host = document.getElementById('dashboards-tile-config');
  host.setAttribute('data-tile-type', type);
  if (type === 'text') {
    host.innerHTML =
      '<div>' +
        '<label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Tile title (optional)</label>' +
        '<input id="dash-cfg-title" type="text" maxlength="200" 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" />' +
      '</div>' +
      '<div>' +
        '<label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Text</label>' +
        // DVI-1305 P1 formatting toolbar — buttons insert Markdown at the cursor.
        '<div id="dash-cfg-text-toolbar" class="flex flex-wrap gap-1 mb-1">' +
          _dashTextTbBtn('bold', '<span class="font-bold">B</span>', 'Bold') +
          _dashTextTbBtn('italic', '<span class="italic">I</span>', 'Italic') +
          _dashTextTbBtn('heading', 'H', 'Heading') +
          _dashTextTbBtn('list', '&bull; List', 'Bulleted list') +
          _dashTextTbBtn('link', '&#128279; Link', 'Link') +
          _dashTextTbBtn('table', '&#9638; Table', 'Table') +
        '</div>' +
        '<textarea id="dash-cfg-text" rows="6" 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 font-mono text-xs"></textarea>' +
      '</div>' +
      '<div class="grid grid-cols-3 gap-2">' +
        _dashCfgSelect('dash-cfg-text-size', 'Font size', [
          {value:'default', label:'Default'}, {value:'small', label:'Small'}, {value:'medium', label:'Medium'},
          {value:'large', label:'Large'}, {value:'xlarge', label:'X-Large'}
        ]) +
        _dashCfgSelect('dash-cfg-text-align', 'Alignment', [
          {value:'left', label:'Left'}, {value:'center', label:'Center'}, {value:'right', label:'Right'}
        ]) +
        _dashCfgSelect('dash-cfg-text-accent', 'Heading accent', [
          {value:'default', label:'Theme default'}, {value:'teal', label:'Teal'}, {value:'blue', label:'Blue'},
          {value:'green', label:'Green'}, {value:'amber', label:'Amber'}, {value:'red', label:'Red'},
          {value:'purple', label:'Purple'}, {value:'gray', label:'Gray'}
        ]) +
      '</div>' +
      '<div class="flex items-center gap-2">' +
        '<input id="dash-cfg-markdown" type="checkbox" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal" />' +
        '<label for="dash-cfg-markdown" class="text-sm text-gray-700 dark:text-gray-200 cursor-pointer">Enable Markdown</label>' +
        '<button type="button" onclick="dashboardsShowMdHelp()" title="Markdown reference" class="w-5 h-5 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">?</button>' +
      '</div>' +
      '<div id="dash-cfg-text-preview-wrap" class="hidden">' +
        '<label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Preview</label>' +
        '<div id="dash-cfg-text-preview" class="dash-md border border-gray-200 dark:border-gray-700 rounded-lg p-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 max-h-48 overflow-auto"></div>' +
      '</div>';
    document.getElementById('dash-cfg-title').value = cfg.title || '';
    document.getElementById('dash-cfg-text').value = cfg.text || '';
    document.getElementById('dash-cfg-markdown').checked = !!cfg.markdown;
    document.getElementById('dash-cfg-text-size').value = cfg.font_size || 'default';
    document.getElementById('dash-cfg-text-align').value = cfg.align || 'left';
    document.getElementById('dash-cfg-text-accent').value = cfg.accent || 'default';
    ['dash-cfg-text', 'dash-cfg-markdown', 'dash-cfg-text-size', 'dash-cfg-text-align', 'dash-cfg-text-accent'].forEach(function(id){
      var el = document.getElementById(id); if (!el) return;
      el.addEventListener(el.tagName === 'TEXTAREA' ? 'input' : 'change', dashTextPreviewUpdate);
    });
    dashTextPreviewUpdate();
  } else if (type === 'work_order_view') {
    host.innerHTML =
      _dashCfgInput('dash-cfg-title', 'Tile title (optional)') +
      _dashCfgCheck('dash-cfg-wov-pmtoday', 'PM today only (preventive maintenance scheduled/due today)') +
      '<div id="dash-cfg-wov-standard">' +
        '<div class="text-xs font-medium text-gray-600 dark:text-gray-300 mt-1 mb-0.5">Show statuses</div>' +
        _dashCfgCheck('dash-cfg-wov-new', 'New') +
        _dashCfgCheck('dash-cfg-wov-inprog', 'In progress') +
        _dashCfgCheck('dash-cfg-wov-due', 'Due for completion') +
        _dashCfgCheck('dash-cfg-wov-pms', 'Scheduled PMs') +
        _dashCfgSelect('dash-cfg-wov-range', 'Due date range', [
          {value:'all', label:'All'}, {value:'today', label:'Today'}, {value:'this_week', label:'This week'}
        ]) +
        _dashCfgCheck('dash-cfg-wov-overdue', 'Only overdue WOs') +
      '</div>' +
      _dashCfgSelect('dash-cfg-wov-group', 'Group into sections', [
        {value:'none', label:'Single list'},
        {value:'status', label:'By status (New / In progress / …)'},
        {value:'type', label:'By work order type (Maintenance / Safety / …)'}
      ]) +
      _dashCfgCheck('dash-cfg-wov-equip', 'Show equipment down / offsite counts') +
      _dashCfgNumber('dash-cfg-wov-max', 'Max items shown (0 = no cap)', 0, 50);
    document.getElementById('dash-cfg-wov-max').value = cfg.max_items || 0;
    document.getElementById('dash-cfg-title').value = cfg.title || '';
    document.getElementById('dash-cfg-wov-group').value = cfg.group_by || 'none';
    document.getElementById('dash-cfg-wov-pmtoday').checked = !!cfg.pm_today;
    document.getElementById('dash-cfg-wov-equip').checked = !!cfg.show_equipment_status;
    document.getElementById('dash-cfg-wov-new').checked = cfg.status_new !== false;
    document.getElementById('dash-cfg-wov-inprog').checked = cfg.status_in_progress !== false;
    document.getElementById('dash-cfg-wov-due').checked = !!cfg.status_due;
    document.getElementById('dash-cfg-wov-pms').checked = !!cfg.status_scheduled_pms;
    document.getElementById('dash-cfg-wov-range').value = cfg.range || 'all';
    document.getElementById('dash-cfg-wov-overdue').checked = !!cfg.overdue_only;
    (function(){
      var pm = document.getElementById('dash-cfg-wov-pmtoday');
      var std = document.getElementById('dash-cfg-wov-standard');
      var sync = function(){ std.classList.toggle('hidden', pm.checked); };
      pm.addEventListener('change', sync); sync();
    })();
  } else if (type === 'parts') {
    host.innerHTML =
      _dashCfgInput('dash-cfg-title', 'Tile title (optional)') +
      '<div class="text-xs font-medium text-gray-600 dark:text-gray-300 mt-1 mb-0.5">Show WOs where</div>' +
      _dashCfgCheck('dash-cfg-pts-awaiting', 'Parts not yet stocked') +
      _dashCfgCheck('dash-cfg-pts-approval', 'Parts awaiting approval') +
      _dashCfgCheck('dash-cfg-pts-delivered', 'Delivered — WO not yet complete') +
      _dashCfgNumber('dash-cfg-pts-max', 'Max items shown (0 = no cap)', 0, 50);
    document.getElementById('dash-cfg-pts-max').value = cfg.max_items || 0;
    document.getElementById('dash-cfg-title').value = cfg.title || '';
    document.getElementById('dash-cfg-pts-awaiting').checked = cfg.awaiting_parts !== false;
    document.getElementById('dash-cfg-pts-approval').checked = cfg.awaiting_approvals !== false;
    document.getElementById('dash-cfg-pts-delivered').checked = !!cfg.delivered_awaiting_wo;
  } else if (type === 'scada_view') {
    host.innerHTML =
      _dashCfgInput('dash-cfg-title', 'Tile title (optional)') +
      _dashCfgInput('dash-cfg-scada-sensors', 'Sensors (comma-separated; blank = all)') +
      _dashCfgCheck('dash-cfg-scada-oor', 'Highlight out-of-range readings') +
      _dashCfgSelect('dash-cfg-scada-oor-window', 'Out-of-range window', [
        {value:'24h', label:'Last 24 hours'}, {value:'48h', label:'Last 48 hours'}, {value:'last_week', label:'Last week'}
      ]) +
      _dashCfgCheck('dash-cfg-scada-sched', 'Show latest scheduled report status') +
      _dashCfgNumber('dash-cfg-scada-max', 'Max sensors shown (0 = no cap)', 0, 50);
    document.getElementById('dash-cfg-scada-max').value = cfg.max_items || 0;
    document.getElementById('dash-cfg-title').value = cfg.title || '';
    document.getElementById('dash-cfg-scada-sensors').value = (cfg.sensors || []).join(', ');
    document.getElementById('dash-cfg-scada-oor').checked = cfg.show_oor !== false;
    document.getElementById('dash-cfg-scada-oor-window').value = cfg.oor_window || '24h';
  } else if (type === 'batch_production') {
    host.innerHTML =
      _dashCfgInput('dash-cfg-title', 'Tile title (optional)') +
      _dashCfgInput('dash-cfg-bp-segment', 'Production segment id (blank = all plants)');
    document.getElementById('dash-cfg-title').value = cfg.title || '';
    document.getElementById('dash-cfg-bp-segment').value = cfg.segment || '';
    document.getElementById('dash-cfg-scada-sched').checked = cfg.show_scheduler !== false;
  } else if (type === 'calendar') {
    host.innerHTML =
      _dashCfgInput('dash-cfg-title', 'Tile title (optional)') +
      _dashCfgSelect('dash-cfg-cal-source', 'Source', [
        {value:'wo', label:'Work Order calendar'},
        {value:'my', label:'My calendar'},
        {value:'shared', label:'Shared calendar (mailbox)'}
      ]) +
      '<div id="dash-cfg-cal-mailbox-wrap">' +
        _dashCfgInput('dash-cfg-cal-mailbox', 'Shared mailbox address') +
      '</div>' +
      _dashCfgSelect('dash-cfg-cal-range', 'Range', [
        {value:'today', label:'Today'},
        {value:'this_week', label:'This week'},
        {value:'this_month', label:'This month'}
      ]) +
      _dashCfgNumber('dash-cfg-cal-max', 'Max events shown (0 = no cap)', 0, 50);
    document.getElementById('dash-cfg-cal-max').value = cfg.max_items || 0;
    document.getElementById('dash-cfg-title').value = cfg.title || '';
    document.getElementById('dash-cfg-cal-source').value = cfg.source || 'wo';
    document.getElementById('dash-cfg-cal-mailbox').value = cfg.mailbox || '';
    document.getElementById('dash-cfg-cal-range').value = cfg.range || 'this_week';
    document.getElementById('dash-cfg-cal-source').onchange = dashboardsCalendarSourceChanged;
    dashboardsCalendarSourceChanged();
  } else if (type === 'time') {
    var tzOpts = [{value:'', label:'Local time (this device)'}].concat(
      _dashTimeZoneOptions().map(function(z){ return {value:z, label:z.replace(/_/g,' ')}; }));
    host.innerHTML =
      _dashCfgInput('dash-cfg-title', 'Tile title (optional)') +
      _dashCfgSelect('dash-cfg-time-tz', 'Timezone', tzOpts) +
      _dashCfgSelect('dash-cfg-time-format', 'Time format', [
        {value:'12h', label:'12-hour (1:05 PM)'}, {value:'24h', label:'24-hour (13:05)'}
      ]) +
      _dashCfgCheck('dash-cfg-time-seconds', 'Show seconds') +
      _dashCfgCheck('dash-cfg-time-zone', 'Show timezone label') +
      _dashCfgCheck('dash-cfg-time-showdate', 'Show the date') +
      '<div id="dash-cfg-time-datefmt-wrap">' +
        _dashCfgSelect('dash-cfg-time-datefmt', 'Date format', [
          {value:'full', label:'Monday, January 5, 2026'},
          {value:'long', label:'January 5, 2026'},
          {value:'medium', label:'Mon, Jan 5'},
          {value:'short', label:'1/5/2026'},
          {value:'iso', label:'2026-01-05'}
        ]) +
      '</div>' +
      _dashCfgSelect('dash-cfg-time-fontsize', 'Font size', [
        {value:'auto', label:'Auto (largest that fits the tile)'},
        {value:'small', label:'Small'}, {value:'medium', label:'Medium'},
        {value:'large', label:'Large'}, {value:'xlarge', label:'Extra large'}
      ]);
    document.getElementById('dash-cfg-title').value = cfg.title || '';
    // Preselect the stored timezone if it's in the list (older browsers fall back
    // to a curated list, so an unknown zone just shows Local — render still honors it).
    document.getElementById('dash-cfg-time-tz').value = cfg.timezone || '';
    document.getElementById('dash-cfg-time-format').value = (cfg.time_format === '24h') ? '24h' : '12h';
    document.getElementById('dash-cfg-time-seconds').checked = !!cfg.show_seconds;
    document.getElementById('dash-cfg-time-zone').checked = cfg.show_zone !== false;
    document.getElementById('dash-cfg-time-showdate').checked = cfg.show_date !== false;
    document.getElementById('dash-cfg-time-datefmt').value = cfg.date_format || 'full';
    document.getElementById('dash-cfg-time-fontsize').value = cfg.font_size || 'auto';
    document.getElementById('dash-cfg-time-showdate').onchange = dashboardsTimeDateToggle;
    dashboardsTimeDateToggle();
  } else if (type === 'oversight') {
    // Dynamic editor: plan/layer/style options come from /oversight/config
    // (cached after first fetch). Only layers the user can access are offered,
    // so a tile can never be pointed at a layer they aren't allowed to see.
    host.innerHTML = '<p class="text-xs text-gray-500 dark:text-gray-400">Loading Oversight plans…</p>';
    var applyOv = function(oc){
      _dashOvEditCfg = oc;
      var plans = oc.plans || [];
      var planOpts = '<option value="">— Select a plan —</option>' + plans.map(function(p){
        var fac = (oc.facilities || []).filter(function(f){ return f.id === p.facility; })[0];
        var lbl = (fac ? (fac.name + ' / ') : '') + (p.name || p.id);
        return '<option value="' + esc(p.id) + '">' + esc(lbl) + '</option>';
      }).join('');
      var styleOpts = '<option value="">Auto (match theme)</option>' + (oc.styles || []).map(function(s){
        return '<option value="' + esc(s.id) + '">' + esc(s.name || s.id) + '</option>';
      }).join('');
      var accLayers = (oc.layers || []).filter(function(l){ return l.access; });
      var layerRows = accLayers.map(function(l){
        var isPeople = (l.id === 'visitors' || l.id === 'personnel');
        return '<div class="flex items-center gap-2"><input id="dash-cfg-ov-layer-' + esc(l.id) + '" data-ovlayer="' + esc(l.id) + '" type="checkbox" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal" />' +
          '<label for="dash-cfg-ov-layer-' + esc(l.id) + '" class="text-sm text-gray-700 dark:text-gray-200 cursor-pointer">' + esc(l.label) +
          (isPeople ? ' <span class="text-[10px] uppercase font-semibold text-amber-600 dark:text-amber-400">people</span>' : '') + '</label></div>';
      }).join('');
      if (!accLayers.length) layerRows = '<p class="text-xs text-gray-500 dark:text-gray-400">No layers available for your account.</p>';
      var hasPeople = accLayers.some(function(l){ return l.id === 'visitors' || l.id === 'personnel'; });
      host.innerHTML =
        _dashCfgInput('dash-cfg-title', 'Tile title (optional)') +
        _dashCfgSelectRaw('dash-cfg-ov-plan', 'Floor plan', planOpts) +
        '<div><div class="text-xs font-medium text-gray-600 dark:text-gray-300 mt-1 mb-0.5">Layers</div>' + layerRows + '</div>' +
        _dashCfgSelectRaw('dash-cfg-ov-style', 'Style', styleOpts) +
        _dashCfgSelect('dash-cfg-ov-fit', 'Plan fit', [
          {value:'contain', label:'Fit whole plan (letterbox)'},
          {value:'cover', label:'Fill the tile (crop plan edges)'}
        ]) +
        _dashCfgCheck('dash-cfg-ov-lockaspect', 'Lock tile height to the plan’s aspect ratio') +
        _dashCfgCheck('dash-cfg-ov-legend', 'Show legend') +
        _dashCfgCheck('dash-cfg-ov-interactive', 'Allow pan / zoom / marker detail') +
        (hasPeople ? ('<div class="pt-2 mt-1 border-t border-gray-200 dark:border-gray-700">' +
          _dashCfgCheck('dash-cfg-ov-people', 'Allow people-tracking layers on shared / signage views') +
          '<p class="text-[11px] text-amber-600 dark:text-amber-400 mt-1">Off by default: visitor / personnel positions are hidden on shared and wall / signage dashboards unless this is enabled.</p></div>') : '');
      document.getElementById('dash-cfg-title').value = cfg.title || '';
      document.getElementById('dash-cfg-ov-plan').value = cfg.plan || '';
      var sel = cfg.layers || [];
      accLayers.forEach(function(l){ var el = document.getElementById('dash-cfg-ov-layer-' + l.id); if (el) el.checked = sel.indexOf(l.id) > -1; });
      document.getElementById('dash-cfg-ov-style').value = cfg.style || '';
      document.getElementById('dash-cfg-ov-fit').value = (cfg.fit === 'cover') ? 'cover' : 'contain';
      document.getElementById('dash-cfg-ov-lockaspect').checked = !!cfg.lock_aspect;
      document.getElementById('dash-cfg-ov-legend').checked = cfg.show_legend !== false;
      document.getElementById('dash-cfg-ov-interactive').checked = cfg.interactive !== false;
      var peopleEl = document.getElementById('dash-cfg-ov-people');
      if (peopleEl) peopleEl.checked = !!cfg.allow_people;
    };
    if (_dashOvEditCfg) { applyOv(_dashOvEditCfg); }
    else {
      fetch('/oversight/config').then(function(r){ return r.ok ? r.json() : null; })
        .then(function(oc){ if (oc) applyOv(oc); else host.innerHTML = '<p class="text-xs text-red-500">Could not load Oversight plans.</p>'; })
        .catch(function(){ host.innerHTML = '<p class="text-xs text-red-500">Could not load Oversight plans.</p>'; });
    }
  } else if (type === 'idencia') {
    // Guided filter pickers come from the org sync's cached vocabulary
    // (/idencia/vocab); until a sync has run they degrade to typing the
    // canonical filter string directly. The stored value is always the
    // canonical string either way (D3-B + edit-as-text).
    host.innerHTML = '<p class="text-xs text-gray-500 dark:text-gray-400">Loading Idencia options…</p>';
    var applyIde = function(voc){
      _dashIdeVocab = voc;
      var parsed = _dashIdeParseFilters(cfg.filters || '');
      var hasVocab = !!((voc.projects || []).length || (voc.item_types || []).length || (voc.statuses || []).length);
      // Start in text mode when there is no vocab yet, or the stored string
      // holds values the pickers can't represent (never silently drop them).
      var known = function(vals, list){
        var lower = (list || []).map(function(v){ return String(v).toLowerCase(); });
        return vals.every(function(v){ return lower.indexOf(v) > -1; });
      };
      _dashIdeTextMode = !hasVocab || !known(parsed.project, voc.projects)
        || !known(parsed.type, voc.item_types) || !known(parsed.status, voc.statuses);
      var pickerBox = function(kind, label, values, selected){
        if (!(values || []).length) return '';
        var rows = values.map(function(v, i){
          return '<label class="flex items-center gap-1.5 text-xs text-gray-700 dark:text-gray-200 cursor-pointer">' +
            '<input type="checkbox" data-idef="' + kind + '" value="' + esc(v) + '"' +
            (selected.indexOf(String(v).toLowerCase()) > -1 ? ' checked' : '') +
            ' class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal" onchange="dashIdeFiltersFromPickers()" />' +
            '<span class="truncate" title="' + esc(v) + '">' + esc(v) + '</span></label>';
        }).join('');
        return '<div class="mt-1" id="dash-ide-fgroup-' + kind + '">' +
          '<div class="text-[11px] font-medium text-gray-500 dark:text-gray-400 mb-0.5">' + label + '</div>' +
          '<div class="max-h-24 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700 px-2 py-1 space-y-0.5">' + rows + '</div></div>';
      };
      host.innerHTML =
        _dashCfgInput('dash-cfg-title', 'Tile title (optional)') +
        _dashCfgSelect('dash-cfg-ide-dataset', 'Data', [
          {value:'items', label:'Items'},
          {value:'projects', label:'Project progress'},
          {value:'shipments', label:'Shipments'}
        ]) +
        '<div id="dash-cfg-ide-gb-wrap">' +
          _dashCfgSelect('dash-cfg-ide-group', 'Group by', [
            {value:'status', label:'Status'},
            {value:'item_type', label:'Item type'},
            {value:'project', label:'Project'}
          ]) +
        '</div>' +
        _dashCfgSelect('dash-cfg-ide-display', 'Display', [
          {value:'status_bars', label:'Status bars'},
          {value:'bar', label:'Bar graph'},
          {value:'pie', label:'Pie chart'},
          {value:'kpi', label:'Big numbers (KPI)'}
        ]) +
        '<div id="dash-cfg-ide-range-wrap">' +
          _dashCfgSelect('dash-cfg-ide-range', 'Range', [
            {value:'all', label:'All'},
            {value:'last_7d', label:'Last 7 days'},
            {value:'last_30d', label:'Last 30 days'},
            {value:'last_90d', label:'Last 90 days'}
          ]) +
        '</div>' +
        '<div class="pt-1">' +
          '<div class="text-xs font-medium text-gray-600 dark:text-gray-300">Filters</div>' +
          '<div id="dash-cfg-ide-pickers">' +
            pickerBox('project', 'Projects', voc.projects, parsed.project) +
            pickerBox('type', 'Item types', voc.item_types, parsed.type) +
            pickerBox('status', 'Statuses', voc.statuses, parsed.status) +
          '</div>' +
          (hasVocab ? '' : '<p class="text-[11px] text-gray-500 dark:text-gray-400 mt-1">No Idencia vocabulary cached yet (an org sync has not completed) — type filters directly.</p>') +
          '<div class="flex items-center gap-1.5 mt-1.5">' +
            '<input id="dash-cfg-ide-filters" type="text" maxlength="500" spellcheck="false" placeholder="project:&quot;Bridge A&quot; status:Produced,Shipped" class="flex-1 min-w-0 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 font-mono text-xs" />' +
            '<button type="button" id="dash-cfg-ide-copy" onclick="dashIdeCopyFilters()" class="togen-control shrink-0 px-2.5 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-xs text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Copy</button>' +
          '</div>' +
          (hasVocab ?
            ('<div class="flex items-center gap-2 mt-1">' +
              '<input id="dash-cfg-ide-text" type="checkbox" onchange="dashIdeTextToggle()" class="rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal" />' +
              '<label for="dash-cfg-ide-text" class="text-xs text-gray-600 dark:text-gray-300 cursor-pointer">Edit as text</label>' +
            '</div>' +
            '<p class="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5">Format: <code>key:value</code> with <code>project</code>/<code>type</code>/<code>status</code>; quote values with spaces, comma-separate alternatives. Unrecognized values are kept only in text mode.</p>')
            : '') +
        '</div>' +
        _dashCfgNumber('dash-cfg-ide-max', 'Max slices shown (0 = no cap)', 0, 20) +
        _dashCfgCheck('dash-cfg-ide-legend', 'Show legend (pie chart)');
      document.getElementById('dash-cfg-title').value = cfg.title || '';
      document.getElementById('dash-cfg-ide-dataset').value = cfg.dataset || 'items';
      document.getElementById('dash-cfg-ide-group').value = cfg.group_by || 'status';
      document.getElementById('dash-cfg-ide-display').value = cfg.display || 'status_bars';
      document.getElementById('dash-cfg-ide-range').value = cfg.range || 'all';
      document.getElementById('dash-cfg-ide-filters').value = cfg.filters || '';
      document.getElementById('dash-cfg-ide-max').value = cfg.max_slices || 0;
      document.getElementById('dash-cfg-ide-legend').checked = cfg.show_legend !== false;
      var textChk = document.getElementById('dash-cfg-ide-text');
      if (textChk) textChk.checked = _dashIdeTextMode;
      document.getElementById('dash-cfg-ide-dataset').onchange = dashboardsIdenciaDatasetChanged;
      dashboardsIdenciaDatasetChanged();
      dashIdeApplyMode();
    };
    if (_dashIdeVocab) { applyIde(_dashIdeVocab); }
    else {
      fetch('/idencia/vocab').then(function(r){ return r.ok ? r.json() : null; })
        .then(function(voc){ applyIde(voc || {}); })
        .catch(function(){ applyIde({}); });
    }
  } else if (type === 'powerbi_data' || type === 'powerbi_report') {
    // Guided workspace/report/dataset pickers come from the org sync's cached
    // vocabulary (/powerbi/vocab); until a sync has run they degrade to typing
    // GUIDs directly. The stored value is always the GUID either way.
    host.innerHTML = '<p class="text-xs text-gray-500 dark:text-gray-400">Loading Power BI options…</p>';
    var applyPbi = function(voc){
      _dashPbiVocab = voc;
      var isReport = (type === 'powerbi_report');
      var wss = voc.workspaces || [];
      var hasVocab = !!wss.length;
      var storedItem = isReport ? (cfg.report_id || '') : (cfg.dataset_id || '');
      var wsField, itemField;
      if (hasVocab) {
        var wsOpts = '<option value="">— Select a workspace —</option>' + wss.map(function(w){
          return '<option value="' + esc(w.id) + '">' + esc(w.name || w.id) + '</option>';
        }).join('');
        if (cfg.workspace_id && !wss.some(function(w){ return w.id === cfg.workspace_id; })) {
          wsOpts += '<option value="' + esc(cfg.workspace_id) + '">' + esc(cfg.workspace_id) + ' (not in synced vocabulary)</option>';
        }
        wsField = _dashCfgSelectRaw('dash-cfg-pbi-ws', 'Workspace', wsOpts);
        itemField = _dashCfgSelectRaw('dash-cfg-pbi-item', isReport ? 'Report' : 'Dataset',
          '<option value="">— Select a workspace first —</option>');
      } else {
        wsField = _dashCfgInput('dash-cfg-pbi-ws', 'Workspace GUID', 80);
        itemField = _dashCfgInput('dash-cfg-pbi-item', (isReport ? 'Report' : 'Dataset') + ' GUID', 80);
      }
      var html = _dashCfgInput('dash-cfg-title', 'Tile title (optional)') + wsField + itemField +
        (hasVocab ? '' : '<p class="text-[11px] text-gray-500 dark:text-gray-400 mt-1">No Power BI vocabulary cached yet (an org sync has not completed) — enter GUIDs directly.</p>') +
        '<div id="dash-cfg-pbi-capnote" class="hidden text-[11px] text-amber-600 dark:text-amber-400"></div>';
      if (isReport) {
        html +=
          _dashCfgInput('dash-cfg-pbi-page', 'Page (section name; blank = the report default)', 100) +
          _dashCfgCheck('dash-cfg-pbi-filterpane', 'Show the filter pane') +
          _dashCfgCheck('dash-cfg-pbi-pagenav', 'Show page navigation') +
          _dashCfgCheck('dash-cfg-pbi-interactive', 'Allow interaction (slicers, tooltips)');
      } else {
        html +=
          '<div><label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">DAX query</label>' +
          '<textarea id="dash-cfg-pbi-dax" rows="5" maxlength="4000" spellcheck="false" placeholder="EVALUATE SUMMARIZECOLUMNS(&#39;Table&#39;[Category], &quot;Total&quot;, [Total Sales])" 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 font-mono text-xs"></textarea>' +
          '<p class="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5">The query result drives the tile: first text column = labels, first numeric column = values. A single-row result renders as KPI chips.</p></div>' +
          _dashCfgSelect('dash-cfg-pbi-display', 'Display', [
            {value:'table', label:'Table'},
            {value:'kpi', label:'Big numbers (KPI)'},
            {value:'bar', label:'Bar graph'},
            {value:'pie', label:'Pie chart'}
          ]) +
          '<div id="dash-cfg-pbi-rows-wrap">' + _dashCfgNumber('dash-cfg-pbi-rows', 'Max table rows shown (0 = 50)', 0, 50) + '</div>' +
          '<div id="dash-cfg-pbi-slices-wrap">' + _dashCfgNumber('dash-cfg-pbi-slices', 'Max slices shown (0 = no cap)', 0, 20) + '</div>' +
          '<div id="dash-cfg-pbi-legend-wrap">' + _dashCfgCheck('dash-cfg-pbi-legend', 'Show legend (pie chart)') + '</div>';
      }
      host.innerHTML = html;
      document.getElementById('dash-cfg-title').value = cfg.title || '';
      document.getElementById('dash-cfg-pbi-ws').value = cfg.workspace_id || '';
      if (hasVocab) {
        document.getElementById('dash-cfg-pbi-ws').onchange = function(){ dashboardsPowerbiWsChanged(''); };
        dashboardsPowerbiWsChanged(storedItem);
      } else {
        document.getElementById('dash-cfg-pbi-item').value = storedItem;
      }
      if (isReport) {
        document.getElementById('dash-cfg-pbi-page').value = cfg.page || '';
        document.getElementById('dash-cfg-pbi-filterpane').checked = !!cfg.show_filter_pane;
        document.getElementById('dash-cfg-pbi-pagenav').checked = !!cfg.show_page_nav;
        document.getElementById('dash-cfg-pbi-interactive').checked = cfg.interactive !== false;
      } else {
        document.getElementById('dash-cfg-pbi-dax').value = cfg.dax || '';
        document.getElementById('dash-cfg-pbi-display').value = cfg.display || 'table';
        document.getElementById('dash-cfg-pbi-rows').value = cfg.max_rows || 0;
        document.getElementById('dash-cfg-pbi-slices').value = cfg.max_slices || 0;
        document.getElementById('dash-cfg-pbi-legend').checked = cfg.show_legend !== false;
        document.getElementById('dash-cfg-pbi-display').onchange = dashboardsPowerbiDisplayChanged;
        dashboardsPowerbiDisplayChanged();
      }
    };
    if (_dashPbiVocab) { applyPbi(_dashPbiVocab); }
    else {
      fetch('/powerbi/vocab').then(function(r){ return r.ok ? r.json() : null; })
        .then(function(voc){ applyPbi(voc || {}); })
        .catch(function(){ applyPbi({}); });
    }
  } else if (type === 'chart') {
    host.innerHTML = '<p class="text-xs text-gray-500 dark:text-gray-400">Loading report sources…</p>';
    _dashLoadReports(function(list){
      if (!list.length){ host.innerHTML = '<p class="text-xs text-amber-600 dark:text-amber-400">No report data sources are available to you. Ask an admin for a Reporting access grant (Auditor → Settings → Access).</p>'; return; }
      host.innerHTML =
        _dashCfgInput('dash-cfg-title', 'Tile title (optional)') +
        _dashCfgSelectRaw('dash-cfg-chart-source', 'Data source', _dashReportSourceOptions(cfg.report || '')) +
        '<div id="dash-cfg-chart-params"></div>' +
        _dashCfgSelect('dash-cfg-chart-type', 'Chart type', [
          {value:'bar', label:'Bar (grouped)'}, {value:'bar_stacked', label:'Bar (stacked)'},
          {value:'line', label:'Line'}, {value:'area', label:'Area'},
          {value:'pie', label:'Pie'}, {value:'donut', label:'Donut'},
          {value:'progress', label:'Progress bar'}, {value:'gauge', label:'Gauge'},
          {value:'kpi', label:'KPI / stat'}
        ]) +
        _dashCfgInput('dash-cfg-chart-label', 'Label column (blank = first column)') +
        _dashCfgInput('dash-cfg-chart-values', 'Value columns, comma-separated (blank = auto numeric)') +
        '<div id="dash-cfg-chart-target-wrap">' + _dashCfgInput('dash-cfg-chart-target', 'Target value (gauge / progress)') + '</div>' +
        _dashCfgCheck('dash-cfg-chart-total', 'Include the report’s totals row') +
        _dashCfgNumber('dash-cfg-chart-max', 'Max categories shown (0 = no cap, max 50)', 0, 50) +
        _dashCfgCheck('dash-cfg-chart-legend', 'Show legend');
      document.getElementById('dash-cfg-title').value = cfg.title || '';
      document.getElementById('dash-cfg-chart-source').value = cfg.report || '';
      document.getElementById('dash-cfg-chart-params').innerHTML = _dashReportParamsHtml(cfg.report || '', cfg.params || {});
      document.getElementById('dash-cfg-chart-type').value = cfg.chart_type || 'bar';
      document.getElementById('dash-cfg-chart-label').value = cfg.label_col || '';
      document.getElementById('dash-cfg-chart-values').value = (cfg.value_cols || []).join(', ');
      document.getElementById('dash-cfg-chart-target').value = (cfg.target != null ? cfg.target : '');
      document.getElementById('dash-cfg-chart-total').checked = !!cfg.include_total_row;
      document.getElementById('dash-cfg-chart-max').value = cfg.max_slices || 0;
      document.getElementById('dash-cfg-chart-legend').checked = cfg.show_legend !== false;
      document.getElementById('dash-cfg-chart-source').onchange = dashChartSourceChanged;
      document.getElementById('dash-cfg-chart-type').onchange = dashChartTypeChanged;
      dashChartTypeChanged();
    });
  } else if (type === 'report_table') {
    host.innerHTML = '<p class="text-xs text-gray-500 dark:text-gray-400">Loading report sources…</p>';
    _dashLoadReports(function(list){
      if (!list.length){ host.innerHTML = '<p class="text-xs text-amber-600 dark:text-amber-400">No report data sources are available to you. Ask an admin for a Reporting access grant (Auditor → Settings → Access).</p>'; return; }
      host.innerHTML =
        _dashCfgInput('dash-cfg-title', 'Tile title (optional)') +
        _dashCfgSelectRaw('dash-cfg-rt-source', 'Report source', _dashReportSourceOptions(cfg.report || '')) +
        '<div id="dash-cfg-rt-params"></div>' +
        _dashCfgNumber('dash-cfg-rt-max', 'Max rows shown (0 = no cap)', 0, 500);
      document.getElementById('dash-cfg-title').value = cfg.title || '';
      document.getElementById('dash-cfg-rt-source').value = cfg.report || '';
      document.getElementById('dash-cfg-rt-params').innerHTML = _dashReportParamsHtml(cfg.report || '', cfg.params || {});
      document.getElementById('dash-cfg-rt-max').value = cfg.max_items || 0;
      document.getElementById('dash-cfg-rt-source').onchange = dashReportTableSourceChanged;
    });
  } else if (type === 'weather') {
    host.innerHTML =
      _dashCfgInput('dash-cfg-title', 'Tile title (optional)') +
      _dashCfgSelect('dash-cfg-wx-source', 'Location source', [
        {value:'manual', label:'A specific place (search below)'},
        {value:'my_location', label:'My personal location (from my regional settings)'},
        {value:'org_default', label:'Company default location'}
      ]) +
      '<div id="dash-cfg-wx-manual-wrap">' +
        '<label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Location</label>' +
        '<div class="flex gap-2">' +
          '<input id="dash-cfg-wx-search" type="text" maxlength="120" placeholder="Search a city, e.g. Cleveland, OH" 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" onkeydown="if(event.key===\'Enter\'){event.preventDefault();dashboardsWeatherSearch();}" />' +
          '<button type="button" onclick="dashboardsWeatherSearch()" class="px-3 py-2 rounded-lg bg-togen-teal text-white text-sm">Search</button>' +
          '<button type="button" onclick="dashboardsWeatherUseMyLocation()" title="Use this device\'s location" class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 text-sm hover:bg-gray-100 dark:hover:bg-gray-700">&#128205; My location</button>' +
        '</div>' +
        '<div id="dash-cfg-wx-results" class="mt-1 space-y-1"></div>' +
        '<div id="dash-cfg-wx-current" class="mt-1"></div>' +
        '<input type="hidden" id="dash-cfg-wx-lat" /><input type="hidden" id="dash-cfg-wx-lon" /><input type="hidden" id="dash-cfg-wx-label" />' +
      '</div>' +
      '<p id="dash-cfg-wx-source-note" class="text-xs text-gray-500 dark:text-gray-400 hidden"></p>' +
      _dashCfgSelect('dash-cfg-wx-units', 'Units', [
        {value:'us', label:'Fahrenheit / mph'}, {value:'metric', label:'Celsius / km-h'}
      ]) +
      _dashCfgSelect('dash-cfg-wx-mode', 'Show', [
        {value:'current', label:'Current conditions'},
        {value:'forecast', label:'Forecast strip'},
        {value:'compact', label:'Compact (temp + icon)'}
      ]) +
      '<div id="dash-cfg-wx-fdays-wrap">' +
        _dashCfgNumber('dash-cfg-wx-fdays', 'Forecast days (1-7)', 1, 7) +
      '</div>';
    document.getElementById('dash-cfg-title').value = cfg.title || '';
    var _wSrc = cfg.location_source || 'manual';
    if (['manual','my_location','org_default'].indexOf(_wSrc) < 0) _wSrc = 'manual';
    document.getElementById('dash-cfg-wx-source').value = _wSrc;
    document.getElementById('dash-cfg-wx-lat').value = (cfg.latitude != null) ? cfg.latitude : '';
    document.getElementById('dash-cfg-wx-lon').value = (cfg.longitude != null) ? cfg.longitude : '';
    document.getElementById('dash-cfg-wx-label').value = cfg.location_label || '';
    document.getElementById('dash-cfg-wx-units').value = (cfg.units === 'metric') ? 'metric' : 'us';
    document.getElementById('dash-cfg-wx-mode').value = cfg.mode || 'current';
    document.getElementById('dash-cfg-wx-fdays').value = cfg.forecast_days || 5;
    document.getElementById('dash-cfg-wx-mode').onchange = dashboardsWeatherModeChanged;
    document.getElementById('dash-cfg-wx-source').onchange = dashboardsWeatherSourceChanged;
    dashboardsWeatherModeChanged();
    dashboardsWeatherSourceChanged();
    dashboardsWeatherShowCurrent();
  } else if (type === 'stat') {
    _dashStatEditRows = (cfg.rows || []).filter(function(r){ return r; })
      .map(function(r){ return { label: r.label || '', value: r.value || '', sub: r.sub || '',
        accent: r.accent || 'default', metric: r.metric || '',
        params: (r.params && typeof r.params === 'object') ? r.params : {},
        color_rules: (r.color_rules || []).map(function(x){ return {
          op: x.op || 'lt', value: x.value || '', value2: x.value2 || '', color: x.color || 'red' }; }) }; });
    host.innerHTML =
      _dashCfgInput('dash-cfg-title', 'Tile title (optional)') +
      '<div class="grid grid-cols-2 gap-2">' +
        _dashCfgSelect('dash-cfg-stat-cols', 'Columns', [
          {value:'auto', label:'Auto'}, {value:'1', label:'1'}, {value:'2', label:'2'},
          {value:'3', label:'3'}, {value:'4', label:'4'}
        ]) +
        _dashCfgSelect('dash-cfg-stat-vsize', 'Value size', [
          {value:'small', label:'Small'}, {value:'medium', label:'Medium'}, {value:'large', label:'Large'}
        ]) +
      '</div>' +
      '<div>' +
        '<label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Metrics (<span id="dash-cfg-stat-count">0</span>/' + DASH_STAT_MAX + ')</label>' +
        '<div id="dash-cfg-stat-list" class="space-y-2 mb-2"></div>' +
        '<button type="button" onclick="dashStatAddRow()" class="togen-control px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">+ Add metric</button>' +
        '<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Enter each KPI as a big Value with an optional Label and sub-caption. Reorder with the arrows.</p>' +
      '</div>' +
      '<div id="dash-cfg-stat-preview-wrap">' +
        '<label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Preview</label>' +
        '<div id="dash-cfg-stat-preview" class="border border-gray-200 dark:border-gray-700 rounded-lg p-2 bg-white dark:bg-gray-800" style="min-height:90px"></div>' +
      '</div>';
    document.getElementById('dash-cfg-title').value = cfg.title || '';
    document.getElementById('dash-cfg-stat-cols').value = cfg.columns || 'auto';
    document.getElementById('dash-cfg-stat-vsize').value = cfg.value_size || 'medium';
    document.getElementById('dash-cfg-stat-cols').onchange = dashStatPreviewUpdate;
    document.getElementById('dash-cfg-stat-vsize').onchange = dashStatPreviewUpdate;
    dashStatRenderRows();
  } else if (type === 'logo') {
    host.innerHTML =
      _dashCfgInput('dash-cfg-title', 'Tile title (optional)') +
      _dashCfgSelect('dash-cfg-logo-which', 'Logo', [
        {value:'icast', label:'iCast / Infrastructure Precast'},
        {value:'togen', label:'Togen'},
        {value:'branding', label:'Follow interface branding'}
      ]) +
      _dashCfgSelect('dash-cfg-logo-color', 'Color', [
        {value:'as_is', label:'As-is (brand colors)'},
        {value:'white', label:'White (for dark backgrounds)'},
        {value:'dark', label:'Dark (for light backgrounds)'},
        {value:'auto', label:'Auto (follow theme)'}
      ]) +
      _dashCfgSelect('dash-cfg-logo-size', 'Size', [
        {value:'small', label:'Small'}, {value:'medium', label:'Medium'},
        {value:'large', label:'Large'}, {value:'fill', label:'Fill the tile'}
      ]) +
      _dashCfgSelect('dash-cfg-logo-align', 'Alignment', [
        {value:'left', label:'Left'}, {value:'center', label:'Center'}, {value:'right', label:'Right'}
      ]) +
      '<div id="dash-cfg-logo-preview" class="togen-inset mt-1 p-3 rounded-lg border border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center" style="min-height:80px;background:repeating-conic-gradient(#e5e7eb 0% 25%, transparent 0% 50%) 50%/16px 16px"></div>';
    document.getElementById('dash-cfg-title').value = cfg.title || '';
    document.getElementById('dash-cfg-logo-which').value = cfg.logo || 'icast';
    document.getElementById('dash-cfg-logo-color').value = cfg.color || 'as_is';
    document.getElementById('dash-cfg-logo-size').value = cfg.size || 'medium';
    document.getElementById('dash-cfg-logo-align').value = cfg.align || 'center';
    ['dash-cfg-logo-which','dash-cfg-logo-color','dash-cfg-logo-size','dash-cfg-logo-align'].forEach(function(id){
      document.getElementById(id).onchange = dashboardsLogoPreview;
    });
    dashboardsLogoPreview();
  } else if (type === 'image') {
    _dashImgEditList = (cfg.images || []).filter(function(im){ return im && im.id; })
      .map(function(im){ return { id: im.id, name: im.name || '' }; });
    host.innerHTML =
      _dashCfgInput('dash-cfg-title', 'Tile title (optional)') +
      _dashCfgSelect('dash-cfg-img-mode', 'Mode', [
        {value:'single', label:'Single image'},
        {value:'slideshow', label:'Slideshow (cycle images)'},
        {value:'gallery', label:'Gallery (thumbnail grid)'}
      ]) +
      _dashCfgSelect('dash-cfg-img-fit', 'Fit', [
        {value:'contain', label:'Fit whole image (letterbox)'},
        {value:'cover', label:'Fill the tile (crop edges)'}
      ]) +
      '<div id="dash-cfg-img-slideopts">' +
        _dashCfgNumber('dash-cfg-img-interval', 'Slideshow interval, seconds (5-120)', 5, 120) +
        _dashCfgCheck('dash-cfg-img-fade', 'Fade between images') +
        _dashCfgCheck('dash-cfg-img-shuffle', 'Shuffle order') +
      '</div>' +
      '<div>' +
        '<label class="block text-xs font-medium text-gray-600 dark:text-gray-300 mb-1">Images (<span id="dash-cfg-img-count">0</span>/' + DASH_IMG_MAX + ')</label>' +
        '<div id="dash-cfg-img-list" class="grid grid-cols-4 gap-2 mb-2"></div>' +
        '<input id="dash-cfg-img-file" type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" multiple class="hidden" onchange="dashboardsImageUpload(this)" />' +
        '<button type="button" onclick="document.getElementById(\'dash-cfg-img-file\').click()" class="togen-control px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Attach images</button>' +
        '<span id="dash-cfg-img-status" class="ml-2 text-xs text-gray-500 dark:text-gray-400"></span>' +
        '<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">PNG, JPEG, WebP or SVG · up to 5 MB each.</p>' +
      '</div>';
    document.getElementById('dash-cfg-title').value = cfg.title || '';
    document.getElementById('dash-cfg-img-mode').value = cfg.mode || 'single';
    document.getElementById('dash-cfg-img-fit').value = (cfg.fit === 'cover') ? 'cover' : 'contain';
    document.getElementById('dash-cfg-img-interval').value = cfg.interval || 8;
    document.getElementById('dash-cfg-img-fade').checked = !!cfg.fade;
    document.getElementById('dash-cfg-img-shuffle').checked = !!cfg.shuffle;
    document.getElementById('dash-cfg-img-mode').onchange = dashboardsImageModeChanged;
    dashboardsImageModeChanged();
    dashboardsImageRenderList();
  } else {
    host.innerHTML = '<p class="text-gray-500 dark:text-gray-400">No configuration for this tile type.</p>';
  }
}

// ---- Logo tile editor: live preview reusing the render path. ----
function dashboardsLogoPreview(){
  var host = document.getElementById('dash-cfg-logo-preview');
  if (!host) return;
  dashTileRenderLogo(host, {
    logo: document.getElementById('dash-cfg-logo-which').value || 'icast',
    color: document.getElementById('dash-cfg-logo-color').value || 'as_is',
    size: document.getElementById('dash-cfg-logo-size').value || 'medium',
    align: document.getElementById('dash-cfg-logo-align').value || 'center'
  });
}

// ---- Image tile editor: staged image list + uploads. ----
var DASH_IMG_MAX = 20;
var _dashImgEditList = [];   // [{id,name}] staged while editing
function dashboardsImageModeChanged(){
  var m = (document.getElementById('dash-cfg-img-mode') || {}).value || 'single';
  var opts = document.getElementById('dash-cfg-img-slideopts');
  if (opts) opts.classList.toggle('hidden', m !== 'slideshow');
}
function dashboardsImageRenderList(){
  var host = document.getElementById('dash-cfg-img-list');
  if (!host) return;
  host.innerHTML = _dashImgEditList.map(function(im, i){
    return '<div class="relative group rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700" style="aspect-ratio:1">' +
      '<img alt="' + esc(im.name || '') + '" src="/dashboards/image/' + encodeURIComponent(im.id) + '" style="width:100%;height:100%;object-fit:cover;display:block" />' +
      '<button type="button" title="Remove" onclick="dashboardsImageRemove(' + i + ')" class="absolute top-0.5 right-0.5 w-5 h-5 flex items-center justify-center rounded-full bg-black/60 text-white text-xs leading-none cursor-pointer">&times;</button>' +
      '</div>';
  }).join('');
  var c = document.getElementById('dash-cfg-img-count');
  if (c) c.textContent = String(_dashImgEditList.length);
}
function dashboardsImageUpload(input){
  var files = Array.prototype.slice.call(input.files || []);
  input.value = '';   // allow re-selecting the same file
  var status = document.getElementById('dash-cfg-img-status');
  if (!files.length) return;
  var room = DASH_IMG_MAX - _dashImgEditList.length;
  if (room <= 0){ if (status) status.textContent = 'Limit of ' + DASH_IMG_MAX + ' images reached.'; return; }
  var batch = files.slice(0, room);
  var pending = batch.length, failed = 0;
  if (status) status.textContent = 'Uploading…';
  batch.forEach(function(f){
    var fd = new FormData();
    fd.append('image', f);
    fetch('/dashboards/image-upload', { method: 'POST', body: fd })
      .then(function(r){ return r.json().then(function(j){ return { ok: r.ok, j: j }; }); })
      .then(function(res){
        if (res.ok && res.j && res.j.id){ _dashImgEditList.push({ id: res.j.id, name: res.j.name || f.name }); }
        else { failed++; }
      })
      .catch(function(){ failed++; })
      .then(function(){
        if (--pending === 0){
          dashboardsImageRenderList();
          if (status) status.textContent = failed ? (failed + ' upload(s) failed.') : '';
        }
      });
  });
}
function dashboardsImageRemove(i){
  var im = _dashImgEditList[i];
  if (!im) return;
  _dashImgEditList.splice(i, 1);
  dashboardsImageRenderList();
  // Best-effort disk cleanup of the now-unreferenced image.
  fetch('/dashboards/image-delete', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ id: im.id })
  }).catch(function(){});
}

// ---- Power BI tile editor helpers (DVI-1243 P2) ----
var _dashPbiVocab = null;   // cached /powerbi/vocab for the tile editors

// Rebuild the report/dataset select for the chosen workspace; a stored id the
// vocab doesn't know is kept as an explicit option (never silently dropped).
// Reports on a capacity-less workspace get the amber embed-licensing note.
function dashboardsPowerbiWsChanged(keepItem) {
  var host = document.getElementById('dashboards-tile-config');
  var isReport = host.getAttribute('data-tile-type') === 'powerbi_report';
  var wsEl = document.getElementById('dash-cfg-pbi-ws');
  var itemEl = document.getElementById('dash-cfg-pbi-item');
  if (!wsEl || !itemEl || itemEl.tagName !== 'SELECT') return;
  var wsId = wsEl.value || '';
  var pool = ((_dashPbiVocab || {})[isReport ? 'reports' : 'datasets'] || [])
    .filter(function(it){ return it.workspace_id === wsId; });
  var opts = '<option value="">— Select ' + (isReport ? 'a report' : 'a dataset') + ' —</option>' +
    pool.map(function(it){ return '<option value="' + esc(it.id) + '">' + esc(it.name || it.id) + '</option>'; }).join('');
  var want = keepItem || '';
  if (want && !pool.some(function(it){ return it.id === want; })) {
    opts += '<option value="' + esc(want) + '">' + esc(want) + ' (not in synced vocabulary)</option>';
  }
  itemEl.innerHTML = opts;
  itemEl.value = want;
  if (isReport) {
    itemEl.onchange = dashboardsPowerbiCapNote;
    dashboardsPowerbiCapNote();
  }
}
function dashboardsPowerbiCapNote() {
  var note = document.getElementById('dash-cfg-pbi-capnote');
  var itemEl = document.getElementById('dash-cfg-pbi-item');
  if (!note || !itemEl) return;
  var rep = ((_dashPbiVocab || {}).reports || []).filter(function(r){ return r.id === itemEl.value; })[0];
  var show = !!(rep && rep.on_capacity === false);
  note.classList.toggle('hidden', !show);
  note.textContent = show
    ? 'This report’s workspace is not on a Fabric capacity — the embed will show guidance instead of the report until one is assigned.'
    : '';
}
// Display drives which knobs make sense: rows for Table, slices/legend for charts.
function dashboardsPowerbiDisplayChanged() {
  var dispEl = document.getElementById('dash-cfg-pbi-display');
  if (!dispEl) return;
  var d = dispEl.value || 'table';
  var rowsWrap = document.getElementById('dash-cfg-pbi-rows-wrap');
  if (rowsWrap) rowsWrap.classList.toggle('hidden', d !== 'table');
  var slicesWrap = document.getElementById('dash-cfg-pbi-slices-wrap');
  if (slicesWrap) slicesWrap.classList.toggle('hidden', d === 'table');
  var legendWrap = document.getElementById('dash-cfg-pbi-legend-wrap');
  if (legendWrap) legendWrap.classList.toggle('hidden', d !== 'pie');
}

// Show the date-format picker only when "Show the date" is enabled.
function dashboardsTimeDateToggle() {
  var chk = document.getElementById('dash-cfg-time-showdate');
  var wrap = document.getElementById('dash-cfg-time-datefmt-wrap');
  if (!chk || !wrap) return;
  wrap.classList.toggle('hidden', !chk.checked);
}

// Show the shared-mailbox field only when the Calendar source is "shared".
function dashboardsCalendarSourceChanged() {
  var srcEl = document.getElementById('dash-cfg-cal-source');
  var wrap = document.getElementById('dash-cfg-cal-mailbox-wrap');
  if (!srcEl || !wrap) return;
  wrap.classList.toggle('hidden', srcEl.value !== 'shared');
}

// ---- Idencia tile editor helpers (DVI-1226 P2) ----
// JS mirror of the server's _idencia_parse_filters: key ∈ project/type/status,
// values quoted or plain, comma-separated; unknown text between keys ignored.
function _dashIdeParseFilters(text) {
  var out = { project: [], type: [], status: [] };
  text = String(text || '');
  var re = /(project|type|status)\s*:/gi, m, keys = [];
  while ((m = re.exec(text))) keys.push({ key: m[1].toLowerCase(), start: m.index, end: re.lastIndex });
  keys.forEach(function(k, i){
    var seg = text.slice(k.end, i + 1 < keys.length ? keys[i + 1].start : text.length);
    var vre = /\s*"([^"]*)"|\s*'([^']*)'|([^,;]+)/g, vm;
    while ((vm = vre.exec(seg))) {
      var v = (vm[1] || vm[2] || vm[3] || '').trim();
      if (v) out[k.key].push(v.toLowerCase());
    }
  });
  return out;
}
// Rebuild the canonical filter string from the checked pickers (guided mode).
function dashIdeFiltersFromPickers() {
  var inp = document.getElementById('dash-cfg-ide-filters');
  if (!inp || _dashIdeTextMode) return;
  var quote = function(v){ return /[\s,;:'"]/.test(v) ? '"' + v.replace(/"/g, "'") + '"' : v; };
  var parts = [];
  ['project', 'type', 'status'].forEach(function(kind){
    var boxes = document.querySelectorAll('#dashboards-tile-config [data-idef="' + kind + '"]');
    var vals = [];
    for (var i = 0; i < boxes.length; i++) { if (boxes[i].checked) vals.push(boxes[i].value); }
    if (vals.length) parts.push(kind + ':' + vals.map(quote).join(','));
  });
  inp.value = parts.join(' ');
}
// Re-seed the pickers from the current filter string (text → guided switch).
function dashIdeSeedPickers(text) {
  var parsed = _dashIdeParseFilters(text);
  ['project', 'type', 'status'].forEach(function(kind){
    var boxes = document.querySelectorAll('#dashboards-tile-config [data-idef="' + kind + '"]');
    for (var i = 0; i < boxes.length; i++) {
      boxes[i].checked = parsed[kind].indexOf(boxes[i].value.toLowerCase()) > -1;
    }
  });
}
function dashIdeApplyMode() {
  var pick = document.getElementById('dash-cfg-ide-pickers');
  var inp = document.getElementById('dash-cfg-ide-filters');
  var hasPickers = !!(pick && pick.innerHTML.replace(/\s/g, ''));
  if (pick) pick.classList.toggle('hidden', _dashIdeTextMode || !hasPickers);
  if (inp) inp.readOnly = hasPickers && !_dashIdeTextMode;
}
function dashIdeTextToggle() {
  var chk = document.getElementById('dash-cfg-ide-text');
  _dashIdeTextMode = !!(chk && chk.checked);
  if (!_dashIdeTextMode) {
    // Back to guided: pickers absorb what they can, then canonicalize (values
    // the vocab doesn't know are dropped — the hint text warns about this).
    var inp = document.getElementById('dash-cfg-ide-filters');
    dashIdeSeedPickers(inp ? inp.value : '');
    dashIdeApplyMode();
    dashIdeFiltersFromPickers();
    return;
  }
  dashIdeApplyMode();
}
function dashIdeCopyFilters() {
  var inp = document.getElementById('dash-cfg-ide-filters');
  if (!inp) return;
  var done = function(ok){
    var b = document.getElementById('dash-cfg-ide-copy');
    if (b) { b.textContent = ok ? 'Copied' : 'Copy failed'; setTimeout(function(){ b.textContent = 'Copy'; }, 1500); }
  };
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(inp.value || '').then(function(){ done(true); }, function(){ done(false); });
  } else {
    try { inp.select(); document.execCommand('copy'); done(true); } catch (e) { done(false); }
  }
}
// Dataset drives which knobs make sense: Project progress has no grouping or
// date range; "Item type" grouping and the item-type picker apply to Items only.
function dashboardsIdenciaDatasetChanged() {
  var dsEl = document.getElementById('dash-cfg-ide-dataset');
  if (!dsEl) return;
  var ds = dsEl.value || 'items';
  var gbWrap = document.getElementById('dash-cfg-ide-gb-wrap');
  if (gbWrap) gbWrap.classList.toggle('hidden', ds === 'projects');
  var gb = document.getElementById('dash-cfg-ide-group');
  if (gb) {
    var itOpt = gb.querySelector('option[value="item_type"]');
    if (itOpt) itOpt.disabled = (ds !== 'items');
    if (ds !== 'items' && gb.value === 'item_type') gb.value = 'status';
  }
  var rngWrap = document.getElementById('dash-cfg-ide-range-wrap');
  if (rngWrap) rngWrap.classList.toggle('hidden', ds === 'projects');
  var typeGroup = document.getElementById('dash-ide-fgroup-type');
  if (typeGroup) typeGroup.classList.toggle('hidden', ds !== 'items');
}

// ---- Stat / Metrics tile editor (DVI-1307) — hand-built dynamic KPI-row list. ----
var DASH_STAT_MAX = 24;
var _dashStatEditRows = [];
var _DASH_STAT_ACCENTS = [
  {value:'default', label:'Default'}, {value:'teal', label:'Teal'}, {value:'blue', label:'Blue'},
  {value:'green', label:'Green'}, {value:'amber', label:'Amber'}, {value:'red', label:'Red'},
  {value:'purple', label:'Purple'}, {value:'gray', label:'Gray'}
];
function _dashStatAccentOpts(){
  return _DASH_STAT_ACCENTS.map(function(o){ return '<option value="' + o.value + '">' + o.label + '</option>'; }).join('');
}
// DVI-1312 P1 — color-rule ops + a source (metric) picker + a per-row rules
// mini-editor. The metric catalog (_dashMetricTypes) comes from /dashboards.
var _DASH_STAT_RULE_OPS = [
  {value:'lt', label:'< less than'}, {value:'le', label:'≤ at most'},
  {value:'gt', label:'> greater than'}, {value:'ge', label:'≥ at least'},
  {value:'eq', label:'= equals'}, {value:'ne', label:'≠ not equal'},
  {value:'between', label:'between'}, {value:'match', label:'text is'}
];
function _dashStatMetricDesc(mid){ return (mid && _dashMetricTypes[mid]) || null; }
function _dashStatMetricOptions(sel){
  var groups = {};
  Object.keys(_dashMetricTypes).forEach(function(mid){
    var m = _dashMetricTypes[mid]; var g = m.group || 'Other';
    (groups[g] = groups[g] || []).push({ id: mid, label: m.label || mid });
  });
  var html = '<option value="">Manual (typed value)</option>';
  Object.keys(groups).forEach(function(g){
    html += '<optgroup label="' + esc(g) + '">';
    groups[g].forEach(function(o){
      html += '<option value="' + esc(o.id) + '"' + (o.id === sel ? ' selected' : '') + '>' + esc(o.label) + '</option>';
    });
    html += '</optgroup>';
  });
  return html;
}
function _dashStatParamsHtml(mid){
  var m = _dashStatMetricDesc(mid); if (!m || !(m.params || []).length) return '';
  var inputCls = 'px-2 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm';
  var html = '<div class="flex flex-wrap gap-2 mt-1">';
  (m.params || []).forEach(function(p){
    html += '<label class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">' + esc(p.label || p.key) + ' ';
    if (p.type === 'select'){
      html += '<select data-statparam="' + esc(p.key) + '" class="' + inputCls + '">' +
        (p.options || []).map(function(o){ return '<option value="' + esc(o.value) + '">' + esc(o.label) + '</option>'; }).join('') + '</select>';
    } else {
      html += '<input data-statparam="' + esc(p.key) + '" maxlength="120" class="' + inputCls + '" />';
    }
    html += '</label>';
  });
  return html + '</div>';
}
function _dashStatRulesHtml(i, rules){
  var inputCls = 'px-1.5 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm';
  var html = '<div class="mt-1"><div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Color rules <span class="text-gray-400 dark:text-gray-500">(first match wins)</span></div>';
  (rules || []).forEach(function(rl, j){
    var isBetween = (rl.op === 'between');
    html += '<div class="flex gap-1 items-center mb-1" data-statrule="' + j + '">' +
      '<select data-rulefield="op" class="' + inputCls + '">' +
        _DASH_STAT_RULE_OPS.map(function(o){ return '<option value="' + o.value + '">' + o.label + '</option>'; }).join('') + '</select>' +
      '<input data-rulefield="value" maxlength="60" placeholder="value" class="w-16 ' + inputCls + '" />' +
      '<input data-rulefield="value2" maxlength="60" placeholder="and" class="w-16 ' + inputCls + '"' + (isBetween ? '' : ' style="display:none"') + ' />' +
      '<span class="text-xs text-gray-400">→</span>' +
      '<select data-rulefield="color" class="' + inputCls + '">' +
        [['green','Green'],['amber','Amber'],['red','Red'],['teal','Teal'],['blue','Blue'],['purple','Purple'],['gray','Gray']].map(function(o){ return '<option value="' + o[0] + '">' + o[1] + '</option>'; }).join('') + '</select>' +
      '<button type="button" title="Remove rule" onclick="dashStatRuleRemove(' + i + ',' + j + ')" class="px-1 text-red-500 hover:text-red-700 cursor-pointer">&times;</button>' +
    '</div>';
  });
  html += '<button type="button" onclick="dashStatRuleAdd(' + i + ')" class="text-xs text-togen-teal hover:underline cursor-pointer">+ color rule</button></div>';
  return html;
}
function dashStatSyncRows(){
  var list = document.getElementById('dash-cfg-stat-list'); if (!list) return;
  var rows = [];
  list.querySelectorAll('[data-statrow]').forEach(function(el){
    function fv(f){ var x = el.querySelector('[data-statfield="' + f + '"]'); return x ? x.value : ''; }
    var params = {};
    el.querySelectorAll('[data-statparam]').forEach(function(p){ params[p.getAttribute('data-statparam')] = p.value; });
    var rules = [];
    el.querySelectorAll('[data-statrule]').forEach(function(rl){
      function rf(f){ var x = rl.querySelector('[data-rulefield="' + f + '"]'); return x ? x.value : ''; }
      rules.push({ op: rf('op') || 'lt', value: rf('value'), value2: rf('value2'), color: rf('color') || 'red' });
    });
    rows.push({ label: fv('label'), value: fv('value'), sub: fv('sub'), accent: fv('accent') || 'default',
      metric: fv('metric') || '', params: params, color_rules: rules });
  });
  _dashStatEditRows = rows;
}
function dashStatRenderRows(){
  var list = document.getElementById('dash-cfg-stat-list'); if (!list) return;
  var inputCls = 'px-2 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm';
  var html = '';
  _dashStatEditRows.forEach(function(r, i){
    var bound = !!r.metric;
    html += '<div class="togen-inset rounded-lg border border-gray-200 dark:border-gray-700 p-2" data-statrow="' + i + '">' +
      '<div class="flex gap-2 items-center mb-1">' +
        '<input data-statfield="value" maxlength="60" placeholder="' + (bound ? 'Fallback' : 'Value') + '" class="w-24 ' + inputCls + '"' + (bound ? ' title="Used if the source is unavailable"' : '') + ' />' +
        '<input data-statfield="label" maxlength="120" placeholder="Label" class="flex-1 min-w-0 ' + inputCls + '" />' +
        '<button type="button" title="Move up" onclick="dashStatMoveRow(' + i + ',-1)" class="px-1.5 text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 cursor-pointer">&uarr;</button>' +
        '<button type="button" title="Move down" onclick="dashStatMoveRow(' + i + ',1)" class="px-1.5 text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 cursor-pointer">&darr;</button>' +
        '<button type="button" title="Remove" onclick="dashStatRemoveRow(' + i + ')" class="px-1.5 text-red-500 hover:text-red-700 cursor-pointer">&times;</button>' +
      '</div>' +
      '<div class="flex gap-2 items-center mb-1">' +
        '<input data-statfield="sub" maxlength="120" placeholder="Sub-caption (optional)" class="flex-1 min-w-0 ' + inputCls + '" />' +
        '<select data-statfield="accent" class="' + inputCls + '">' + _dashStatAccentOpts() + '</select>' +
      '</div>' +
      '<div class="flex items-center gap-1 mb-1">' +
        '<span class="text-xs text-gray-500 dark:text-gray-400">Source</span>' +
        '<select data-statfield="metric" onchange="dashStatMetricChanged(' + i + ')" class="flex-1 min-w-0 ' + inputCls + '">' + _dashStatMetricOptions(r.metric) + '</select>' +
      '</div>' +
      _dashStatParamsHtml(r.metric) +
      _dashStatRulesHtml(i, r.color_rules) +
    '</div>';
  });
  list.innerHTML = html;
  // Set field values via property (avoids attribute-escaping bugs) + wire live preview.
  list.querySelectorAll('[data-statrow]').forEach(function(el, i){
    var r = _dashStatEditRows[i] || {};
    ['value','label','sub','accent','metric'].forEach(function(f){
      var x = el.querySelector('[data-statfield="' + f + '"]'); if (!x) return;
      x.value = r[f] || (f === 'accent' ? 'default' : '');
      if (f !== 'metric') x.addEventListener(x.tagName === 'SELECT' ? 'change' : 'input', dashStatPreviewUpdate);
    });
    el.querySelectorAll('[data-statparam]').forEach(function(p){
      var key = p.getAttribute('data-statparam');
      p.value = (r.params && r.params[key] != null) ? r.params[key] : '';
      p.addEventListener('change', dashStatPreviewUpdate);
    });
    el.querySelectorAll('[data-statrule]').forEach(function(rl, j){
      var rule = (r.color_rules || [])[j] || {};
      ['op','value','value2','color'].forEach(function(f){
        var x = rl.querySelector('[data-rulefield="' + f + '"]'); if (!x) return;
        x.value = rule[f] || (f === 'op' ? 'lt' : (f === 'color' ? 'red' : ''));
        if (f === 'op') x.addEventListener('change', function(){ dashStatSyncRows(); dashStatRenderRows(); });
        else x.addEventListener(x.tagName === 'SELECT' ? 'change' : 'input', dashStatPreviewUpdate);
      });
    });
  });
  var cnt = document.getElementById('dash-cfg-stat-count'); if (cnt) cnt.textContent = _dashStatEditRows.length;
  dashStatPreviewUpdate();
}
function dashStatMetricChanged(i){
  dashStatSyncRows();
  var r = _dashStatEditRows[i]; if (!r) return;
  r.params = {};   // a new source has its own params
  dashStatRenderRows();
}
function dashStatRuleAdd(i){
  dashStatSyncRows();
  var r = _dashStatEditRows[i]; if (!r) return;
  r.color_rules = r.color_rules || [];
  if (r.color_rules.length >= 12) return;
  r.color_rules.push({ op: 'lt', value: '', value2: '', color: 'red' });
  dashStatRenderRows();
}
function dashStatRuleRemove(i, j){
  dashStatSyncRows();
  var r = _dashStatEditRows[i]; if (!r || !r.color_rules) return;
  r.color_rules.splice(j, 1);
  dashStatRenderRows();
}
function dashStatAddRow(){
  dashStatSyncRows();
  if (_dashStatEditRows.length >= DASH_STAT_MAX) return;
  _dashStatEditRows.push({ label: '', value: '', sub: '', accent: 'default', metric: '', params: {}, color_rules: [] });
  dashStatRenderRows();
}
function dashStatRemoveRow(i){
  dashStatSyncRows();
  _dashStatEditRows.splice(i, 1);
  dashStatRenderRows();
}
function dashStatMoveRow(i, dir){
  dashStatSyncRows();
  var j = i + dir;
  if (j < 0 || j >= _dashStatEditRows.length) return;
  var tmp = _dashStatEditRows[i]; _dashStatEditRows[i] = _dashStatEditRows[j]; _dashStatEditRows[j] = tmp;
  dashStatRenderRows();
}
function dashStatPreviewUpdate(){
  dashStatSyncRows();
  var host = document.getElementById('dash-cfg-stat-preview'); if (!host) return;
  dashTileRenderStat(host, {
    rows: _dashStatEditRows,
    columns: (document.getElementById('dash-cfg-stat-cols') || {}).value || 'auto',
    value_size: (document.getElementById('dash-cfg-stat-vsize') || {}).value || 'medium'
  }, null);
}

function dashboardsCollectConfig(type) {
  if (type === 'text') {
    return {
      title: (document.getElementById('dash-cfg-title').value || '').slice(0, 200),
      text: (document.getElementById('dash-cfg-text').value || '').slice(0, 20000),
      markdown: !!document.getElementById('dash-cfg-markdown').checked,
      font_size: (document.getElementById('dash-cfg-text-size') || {}).value || 'default',
      align: (document.getElementById('dash-cfg-text-align') || {}).value || 'left',
      accent: (document.getElementById('dash-cfg-text-accent') || {}).value || 'default'
    };
  }
  if (type === 'stat') {
    dashStatSyncRows();
    return {
      title: (document.getElementById('dash-cfg-title').value || '').slice(0, 200),
      columns: (document.getElementById('dash-cfg-stat-cols') || {}).value || 'auto',
      value_size: (document.getElementById('dash-cfg-stat-vsize') || {}).value || 'medium',
      rows: _dashStatEditRows
        .filter(function(r){ return r && (r.label || r.value || r.sub || r.metric); })
        .slice(0, DASH_STAT_MAX)
        .map(function(r){
          var out = {
            label: (r.label || '').slice(0, 120),
            value: (r.value || '').slice(0, 60),
            sub: (r.sub || '').slice(0, 120),
            accent: r.accent || 'default'
          };
          if (r.metric) { out.metric = r.metric; out.params = r.params || {}; }
          if (r.color_rules && r.color_rules.length) {
            out.color_rules = r.color_rules.map(function(x){ return {
              op: x.op || 'lt', value: (x.value || '').slice(0, 60),
              value2: (x.value2 || '').slice(0, 60), color: x.color || 'red' }; });
          }
          return out;
        })
    };
  }
  if (type === 'work_order_view') {
    return {
      title: (document.getElementById('dash-cfg-title').value || '').slice(0, 200),
      pm_today: !!document.getElementById('dash-cfg-wov-pmtoday').checked,
      show_equipment_status: !!document.getElementById('dash-cfg-wov-equip').checked,
      status_new: !!document.getElementById('dash-cfg-wov-new').checked,
      status_in_progress: !!document.getElementById('dash-cfg-wov-inprog').checked,
      status_due: !!document.getElementById('dash-cfg-wov-due').checked,
      status_scheduled_pms: !!document.getElementById('dash-cfg-wov-pms').checked,
      range: document.getElementById('dash-cfg-wov-range').value || 'all',
      overdue_only: !!document.getElementById('dash-cfg-wov-overdue').checked,
      group_by: (document.getElementById('dash-cfg-wov-group') || {}).value || 'none',
      max_items: _dashCfgMaxItems(document.getElementById('dash-cfg-wov-max').value)
    };
  }
  if (type === 'parts') {
    return {
      title: (document.getElementById('dash-cfg-title').value || '').slice(0, 200),
      awaiting_parts: !!document.getElementById('dash-cfg-pts-awaiting').checked,
      awaiting_approvals: !!document.getElementById('dash-cfg-pts-approval').checked,
      delivered_awaiting_wo: !!document.getElementById('dash-cfg-pts-delivered').checked,
      max_items: _dashCfgMaxItems(document.getElementById('dash-cfg-pts-max').value)
    };
  }
  if (type === 'scada_view') {
    var rawSensors = document.getElementById('dash-cfg-scada-sensors').value || '';
    var sensors = rawSensors.split(',').map(function(s){ return s.trim(); }).filter(Boolean);
    return {
      title: (document.getElementById('dash-cfg-title').value || '').slice(0, 200),
      sensors: sensors,
      show_oor: !!document.getElementById('dash-cfg-scada-oor').checked,
      oor_window: document.getElementById('dash-cfg-scada-oor-window').value || '24h',
      show_scheduler: !!document.getElementById('dash-cfg-scada-sched').checked,
      max_items: _dashCfgMaxItems(document.getElementById('dash-cfg-scada-max').value)
    };
  }
  if (type === 'batch_production') {
    return {
      title: (document.getElementById('dash-cfg-title').value || '').slice(0, 200),
      segment: (document.getElementById('dash-cfg-bp-segment').value || '').trim().slice(0, 64)
    };
  }
  if (type === 'calendar') {
    return {
      title: (document.getElementById('dash-cfg-title').value || '').slice(0, 200),
      source: document.getElementById('dash-cfg-cal-source').value || 'wo',
      mailbox: (document.getElementById('dash-cfg-cal-mailbox').value || '').trim().slice(0, 200),
      range: document.getElementById('dash-cfg-cal-range').value || 'this_week',
      max_items: _dashCfgMaxItems(document.getElementById('dash-cfg-cal-max').value)
    };
  }
  if (type === 'time') {
    return {
      title: (document.getElementById('dash-cfg-title').value || '').slice(0, 200),
      timezone: (document.getElementById('dash-cfg-time-tz').value || '').slice(0, 64),
      time_format: (document.getElementById('dash-cfg-time-format').value === '24h') ? '24h' : '12h',
      show_seconds: !!document.getElementById('dash-cfg-time-seconds').checked,
      show_zone: !!document.getElementById('dash-cfg-time-zone').checked,
      show_date: !!document.getElementById('dash-cfg-time-showdate').checked,
      date_format: document.getElementById('dash-cfg-time-datefmt').value || 'full',
      font_size: document.getElementById('dash-cfg-time-fontsize').value || 'auto'
    };
  }
  if (type === 'idencia') {
    var ideMax = parseInt(document.getElementById('dash-cfg-ide-max').value, 10);
    if (isNaN(ideMax)) ideMax = 0;
    return {
      title: (document.getElementById('dash-cfg-title').value || '').slice(0, 200),
      dataset: document.getElementById('dash-cfg-ide-dataset').value || 'items',
      group_by: document.getElementById('dash-cfg-ide-group').value || 'status',
      display: document.getElementById('dash-cfg-ide-display').value || 'status_bars',
      range: document.getElementById('dash-cfg-ide-range').value || 'all',
      filters: (document.getElementById('dash-cfg-ide-filters').value || '').trim().slice(0, 500),
      max_slices: Math.max(0, Math.min(20, ideMax)),
      show_legend: !!document.getElementById('dash-cfg-ide-legend').checked
    };
  }
  if (type === 'powerbi_data' || type === 'powerbi_report') {
    var pbiWs = (document.getElementById('dash-cfg-pbi-ws') || {}).value || '';
    var pbiItem = (document.getElementById('dash-cfg-pbi-item') || {}).value || '';
    var pbiTitle = (document.getElementById('dash-cfg-title').value || '').slice(0, 200);
    if (type === 'powerbi_report') {
      return {
        title: pbiTitle,
        workspace_id: pbiWs.trim(),
        report_id: pbiItem.trim(),
        page: ((document.getElementById('dash-cfg-pbi-page') || {}).value || '').trim().slice(0, 100),
        show_filter_pane: !!(document.getElementById('dash-cfg-pbi-filterpane') || {}).checked,
        show_page_nav: !!(document.getElementById('dash-cfg-pbi-pagenav') || {}).checked,
        interactive: !!(document.getElementById('dash-cfg-pbi-interactive') || {}).checked
      };
    }
    var pbiRows = parseInt((document.getElementById('dash-cfg-pbi-rows') || {}).value, 10);
    if (isNaN(pbiRows)) pbiRows = 0;
    var pbiSlices = parseInt((document.getElementById('dash-cfg-pbi-slices') || {}).value, 10);
    if (isNaN(pbiSlices)) pbiSlices = 0;
    return {
      title: pbiTitle,
      workspace_id: pbiWs.trim(),
      dataset_id: pbiItem.trim(),
      dax: ((document.getElementById('dash-cfg-pbi-dax') || {}).value || '').trim().slice(0, 4000),
      display: (document.getElementById('dash-cfg-pbi-display') || {}).value || 'table',
      max_rows: Math.max(0, Math.min(50, pbiRows)),
      max_slices: Math.max(0, Math.min(20, pbiSlices)),
      show_legend: !!(document.getElementById('dash-cfg-pbi-legend') || {}).checked
    };
  }
  if (type === 'oversight') {
    var layers = [];
    var boxes = document.querySelectorAll('#dashboards-tile-config [data-ovlayer]');
    for (var i = 0; i < boxes.length; i++) { if (boxes[i].checked) layers.push(boxes[i].getAttribute('data-ovlayer')); }
    var peopleEl = document.getElementById('dash-cfg-ov-people');
    return {
      title: (document.getElementById('dash-cfg-title').value || '').slice(0, 200),
      plan: document.getElementById('dash-cfg-ov-plan').value || '',
      layers: layers,
      style: document.getElementById('dash-cfg-ov-style').value || '',
      fit: document.getElementById('dash-cfg-ov-fit').value === 'cover' ? 'cover' : 'contain',
      lock_aspect: !!document.getElementById('dash-cfg-ov-lockaspect').checked,
      show_legend: !!document.getElementById('dash-cfg-ov-legend').checked,
      interactive: !!document.getElementById('dash-cfg-ov-interactive').checked,
      allow_people: !!(peopleEl && peopleEl.checked)
    };
  }
  if (type === 'chart') {
    var cMax = parseInt(document.getElementById('dash-cfg-chart-max').value, 10); if (isNaN(cMax)) cMax = 0;
    var vraw = document.getElementById('dash-cfg-chart-values').value || '';
    return {
      title: (document.getElementById('dash-cfg-title').value || '').slice(0, 200),
      report: (document.getElementById('dash-cfg-chart-source').value || '').slice(0, 100),
      params: _dashCollectReportParams(),
      chart_type: document.getElementById('dash-cfg-chart-type').value || 'bar',
      label_col: (document.getElementById('dash-cfg-chart-label').value || '').trim().slice(0, 100),
      value_cols: vraw.split(',').map(function(s){ return s.trim(); }).filter(Boolean).slice(0, 12),
      target: (document.getElementById('dash-cfg-chart-target').value || '').trim(),
      include_total_row: !!document.getElementById('dash-cfg-chart-total').checked,
      max_slices: Math.max(0, Math.min(50, cMax)),
      show_legend: !!document.getElementById('dash-cfg-chart-legend').checked
    };
  }
  if (type === 'report_table') {
    var rMax = parseInt(document.getElementById('dash-cfg-rt-max').value, 10); if (isNaN(rMax)) rMax = 0;
    return {
      title: (document.getElementById('dash-cfg-title').value || '').slice(0, 200),
      report: (document.getElementById('dash-cfg-rt-source').value || '').slice(0, 100),
      params: _dashCollectReportParams(),
      max_items: Math.max(0, Math.min(500, rMax))
    };
  }
  if (type === 'weather') {
    var wLat = parseFloat(document.getElementById('dash-cfg-wx-lat').value);
    var wLon = parseFloat(document.getElementById('dash-cfg-wx-lon').value);
    var wFd = parseInt(document.getElementById('dash-cfg-wx-fdays').value, 10);
    if (isNaN(wFd)) wFd = 5;
    var wSrcEl = document.getElementById('dash-cfg-wx-source');
    var wSrc = wSrcEl ? wSrcEl.value : 'manual';
    if (['manual','my_location','org_default'].indexOf(wSrc) < 0) wSrc = 'manual';
    return {
      title: (document.getElementById('dash-cfg-title').value || '').slice(0, 200),
      location_source: wSrc,
      location_label: (document.getElementById('dash-cfg-wx-label').value || '').trim().slice(0, 120),
      latitude: isNaN(wLat) ? null : wLat,
      longitude: isNaN(wLon) ? null : wLon,
      units: (document.getElementById('dash-cfg-wx-units').value === 'metric') ? 'metric' : 'us',
      mode: document.getElementById('dash-cfg-wx-mode').value || 'current',
      forecast_days: Math.max(1, Math.min(7, wFd))
    };
  }
  if (type === 'logo') {
    return {
      title: (document.getElementById('dash-cfg-title').value || '').slice(0, 200),
      logo: document.getElementById('dash-cfg-logo-which').value || 'icast',
      color: document.getElementById('dash-cfg-logo-color').value || 'as_is',
      size: document.getElementById('dash-cfg-logo-size').value || 'medium',
      align: document.getElementById('dash-cfg-logo-align').value || 'center'
    };
  }
  if (type === 'image') {
    var iInt = parseInt(document.getElementById('dash-cfg-img-interval').value, 10);
    if (isNaN(iInt)) iInt = 8;
    return {
      title: (document.getElementById('dash-cfg-title').value || '').slice(0, 200),
      images: _dashImgEditList.slice(0, DASH_IMG_MAX).map(function(im){ return { id: im.id, name: im.name || '' }; }),
      mode: document.getElementById('dash-cfg-img-mode').value || 'single',
      interval: Math.max(5, Math.min(120, iInt)),
      fade: !!document.getElementById('dash-cfg-img-fade').checked,
      shuffle: !!document.getElementById('dash-cfg-img-shuffle').checked,
      fit: (document.getElementById('dash-cfg-img-fit').value === 'cover') ? 'cover' : 'contain'
    };
  }
  return {};
}

function dashboardsSaveTile() {
  // DVI-1255: Report Builder path — the modal was opened with a save hook, so
  // return the collected tile to the caller instead of POSTing to a dashboard.
  // _dashNewTileType always carries the editing type in hook mode.
  if (_dashTileSaveHook) {
    var hSize = document.getElementById('dashboards-tile-size').value;
    var hType = _dashNewTileType;
    if (!hType) { dashboardsCloseTileEditor(); return; }
    var hOut = {
      type: hType,
      size: hSize,
      config: dashboardsCollectConfig(hType),
      overflow: document.getElementById('dashboards-tile-overflow').value || '',
      zoom: parseInt(document.getElementById('dashboards-tile-zoom').value, 10) || 100
    };
    if (hSize === 'custom') {
      hOut.span = dashCustomSpan({
        cols: document.getElementById('dashboards-tile-span-cols').value,
        rows: document.getElementById('dashboards-tile-span-rows').value
      });
    }
    var hook = _dashTileSaveHook;
    dashboardsCloseTileEditor();   // clears the hook
    hook(hOut);
    return;
  }
  var active = dashboardsActive();
  if (!active) return;
  var size = document.getElementById('dashboards-tile-size').value;
  var type = _dashEditingTileId
    ? (active.tiles.filter(function(t){ return t.id === _dashEditingTileId; })[0] || {}).type
    : _dashNewTileType;
  if (!type) return;
  var config = dashboardsCollectConfig(type);
  var status = document.getElementById('dashboards-tile-status');
  status.textContent = 'Saving…';
  var url, method;
  if (_dashEditingTileId) {
    url = '/dashboards/' + encodeURIComponent(active.id) + '/tiles/' + encodeURIComponent(_dashEditingTileId);
    method = 'POST';
  } else {
    url = '/dashboards/' + encodeURIComponent(active.id) + '/tiles';
    method = 'POST';
  }
  var payload = _dashEditingTileId ? { size: size, config: config } : { type: type, size: size, config: config };
  payload.overflow = document.getElementById('dashboards-tile-overflow').value || '';
  payload.zoom = parseInt(document.getElementById('dashboards-tile-zoom').value, 10) || 100;
  if (size === 'custom') {
    payload.span = dashCustomSpan({
      cols: document.getElementById('dashboards-tile-span-cols').value,
      rows: document.getElementById('dashboards-tile-span-rows').value
    });
  }
  fetch(url, {
    method: method,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload)
  }).then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { status.textContent = d.error; return; }
      dashboardsApplyUpdated(d.dashboard);
      dashboardsCloseTileEditor();
    });
}

function dashboardsRemoveTile(tileId) {
  var active = dashboardsActive();
  if (!active) return;
  if (!window.confirm('Remove this tile?')) return;
  fetch('/dashboards/' + encodeURIComponent(active.id) + '/tiles/' + encodeURIComponent(tileId), { method: 'DELETE' })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { alert(d.error); return; }
      dashboardsApplyUpdated(d.dashboard);
    });
}

// Replace the active dashboard in local state with the server's copy + re-render.
function dashboardsApplyUpdated(dash) {
  if (!dash || !dash.id) { dashboardsLoad(_dashActiveId); return; }
  var replaced = false;
  _dashboards = _dashboards.map(function(x){ if (x.id === dash.id) { replaced = true; return dash; } return x; });
  if (!replaced) _dashboards.push(dash);
  _dashActiveId = dash.id;
  dashboardsRender();
}

function dashboardsShowMdHelp() {
  document.getElementById('dashboards-md-help-overlay').classList.remove('hidden');
}
function dashboardsCloseMdHelp() {
  document.getElementById('dashboards-md-help-overlay').classList.add('hidden');
}

// ---- Data sources admin panel (DVI-1312 P2) ----
// CRUD for named data sources (SharePoint XLSX cell to start). The editor keeps
// a working copy (_dashSourcesDraft) and POSTs the whole list on Save — the same
// replace-all shape the Power BI query library uses. Each saved source folds
// into the metric registry, so it appears in the Stat tile Source picker.
function dashboardsSourcesOpen() {
  if (!_dashCanManageSources && !_dashCanEditManual) return;
  dashboardsSourceCancel();
  _dashLiftOverlay('dashboards-sources-overlay').classList.remove('hidden');
  // Only admins get the Sources tab (SharePoint/report CRUD); everyone gets the
  // Manual tab (DVI-1370 Q1-B).
  document.getElementById('dashboards-sources-tab-sources').classList.toggle('hidden', !_dashCanManageSources);
  if (_dashCanManageSources) {
    document.getElementById('dashboards-sources-status').textContent = 'Loading…';
    // Load the report-type catalog for the report_cell binding picker (best
    // effort — a report source can still be hand-typed if this fails).
    fetch('/dashboards/sources/report-types')
      .then(function(r){ return r.json(); })
      .then(function(d){ _dashSourceReportTypes = (d && d.reports) ? d.reports : []; })
      .catch(function(){ _dashSourceReportTypes = []; });
    fetch('/dashboards/sources')
      .then(function(r){ return r.json(); })
      .then(function(d){
        _dashSourcesDraft = (d && d.sources) ? d.sources : [];
        document.getElementById('dashboards-sources-status').textContent = '';
        dashboardsSourcesRender();
      })
      .catch(function(){ document.getElementById('dashboards-sources-status').textContent = 'Could not load data sources.'; });
    dashboardsSourcesTab('sources');
  } else {
    dashboardsSourcesTab('manual');
  }
}
function dashboardsSourcesClose() {
  document.getElementById('dashboards-sources-overlay').classList.add('hidden');
}
// DVI-1370: switch between the Sources (admin CRUD) and Manual (value entry) tabs.
function dashboardsSourcesTab(which) {
  _dashSourcesActiveTab = which;
  document.getElementById('dashboards-sources-pane-sources').classList.toggle('hidden', which !== 'sources');
  document.getElementById('dashboards-sources-pane-manual').classList.toggle('hidden', which !== 'manual');
  [['dashboards-sources-tab-sources', 'sources'], ['dashboards-sources-tab-manual', 'manual']].forEach(function(pair){
    var b = document.getElementById(pair[0]);
    if (!b) return;
    var on = (pair[1] === which);
    b.classList.toggle('border-togen-teal', on);
    b.classList.toggle('text-togen-teal', on);
    b.classList.toggle('border-transparent', !on);
  });
  // The footer "Save changes" only applies to the admin Sources draft.
  var saveBtn = document.getElementById('dashboards-sources-save-btn');
  if (saveBtn) saveBtn.classList.toggle('hidden', !(which === 'sources' && _dashCanManageSources));
  if (which === 'manual') dashboardsManualLoad();
}
// ---- Manual tab (DVI-1370): tab-through value entry, save on exit ----
function dashboardsManualLoad() {
  var wrap = document.getElementById('dashboards-manual-list');
  wrap.innerHTML = '<div class="text-xs text-gray-500 dark:text-gray-400 italic">Loading…</div>';
  fetch('/dashboards/sources/manual')
    .then(function(r){ return r.json(); })
    .then(function(d){ dashboardsManualRender((d && d.sources) ? d.sources : []); })
    .catch(function(){ wrap.innerHTML = '<div class="text-xs text-red-500">Could not load manual values.</div>'; });
}
function dashboardsManualRender(sources) {
  var wrap = document.getElementById('dashboards-manual-list');
  if (!sources.length) {
    wrap.innerHTML = '<div class="text-xs text-gray-500 dark:text-gray-400 italic">No manual values yet. Add one below.</div>';
    return;
  }
  wrap.innerHTML = sources.map(function(s){
    var idj = "'" + esc(s.id).replace(/'/g, "\\'") + "'";
    var nameAttr = esc(s.name || '').replace(/"/g, '&quot;');
    return '<div class="flex items-center gap-2 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2">' +
      '<input type="text" maxlength="100" value="' + nameAttr + '" data-last="' + nameAttr + '" ' +
        'onblur="dashboardsManualNameBlur(' + idj + ', this)" onkeydown="dashboardsManualNameKey(event, this)" ' +
        'title="Rename this manual value" placeholder="(unnamed)" ' +
        'class="min-w-0 flex-1 px-2 py-1 text-sm font-medium rounded border border-transparent hover:border-gray-300 dark:hover:border-gray-600 focus:border-gray-300 dark:focus:border-gray-600 bg-transparent text-gray-800 dark:text-gray-100 focus:ring-togen-teal" />' +
      '<span class="dash-manual-name-saved text-xs text-green-600 w-14 shrink-0"></span>' +
      '<input type="text" maxlength="200" value="' + esc(s.value || '') + '" data-last="' + esc(s.value || '') + '" ' +
        'onblur="dashboardsManualValueBlur(' + idj + ', this)" onkeydown="dashboardsManualValueKey(event, this)" ' +
        'class="w-40 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 focus:ring-togen-teal" />' +
      '<span class="dash-manual-saved text-xs text-green-600 w-14 shrink-0"></span>' +
      '<button type="button" onclick="dashboardsManualDelete(' + idj + ', this)" title="Delete this manual value" ' +
        'class="px-2 py-1 text-xs rounded text-red-500 hover:text-red-700 cursor-pointer shrink-0">Delete</button>' +
    '</div>';
  }).join('');
}
function dashboardsManualValueKey(ev, inp) {
  // Enter commits (blur) without leaving the field jumping unexpectedly.
  if (ev.key === 'Enter') { ev.preventDefault(); inp.blur(); }
}
function dashboardsManualValueBlur(id, inp) {
  var v = inp.value;
  if (inp.getAttribute('data-last') === v) return;   // unchanged on tab-through
  var mark = inp.parentNode.querySelector('.dash-manual-saved');
  if (mark) { mark.textContent = 'Saving…'; mark.className = 'dash-manual-saved text-xs text-gray-400 w-14 shrink-0'; }
  fetch('/dashboards/sources/' + encodeURIComponent(id) + '/value', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ value: v })
  }).then(function(r){ return r.json(); })
    .then(function(d){
      if (d && d.ok) {
        inp.value = (d.value != null) ? d.value : v;
        inp.setAttribute('data-last', inp.value);
        if (mark) { mark.textContent = 'Saved ✓'; mark.className = 'dash-manual-saved text-xs text-green-600 w-14 shrink-0'; setTimeout(function(){ if (mark) mark.textContent = ''; }, 2000); }
      } else if (mark) { mark.textContent = (d && d.error) ? d.error : 'Failed'; mark.className = 'dash-manual-saved text-xs text-red-500 w-14 shrink-0'; }
    })
    .catch(function(){ if (mark) { mark.textContent = 'Failed'; mark.className = 'dash-manual-saved text-xs text-red-500 w-14 shrink-0'; } });
}
function dashboardsManualAddKey(ev) { if (ev.key === 'Enter') { ev.preventDefault(); dashboardsManualAdd(); } }
function dashboardsManualAdd() {
  var nameEl = document.getElementById('dashboards-manual-add-name');
  var name = (nameEl.value || '').trim();
  var status = document.getElementById('dashboards-manual-status');
  if (!name) { status.textContent = 'Give the manual value a name.'; return; }
  status.textContent = 'Adding…';
  fetch('/dashboards/sources/manual', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name: name })
  }).then(function(r){ return r.json(); })
    .then(function(d){
      if (d && d.ok) {
        nameEl.value = '';
        status.textContent = 'Added. It now appears in the Stat tile Source picker.';
        dashboardsManualRender(d.sources || []);
        // Focus the new row's value input for the add→tab→type flow.
        var newId = d.source && d.source.id;
        if (newId) {
          var el = document.querySelector('#dashboards-manual-list input[onblur*="' + newId + '"]');
          if (el) el.focus();
        }
        // Refresh the metric catalog so the picker lists it without a reload.
        dashboardsRefreshMetricCatalog();
      } else { status.textContent = (d && d.error) ? d.error : 'Add failed.'; }
    })
    .catch(function(){ status.textContent = 'Add failed.'; });
}
// Reload the Stat-tile metric catalog so add/rename/delete of a manual source
// is reflected in the Source picker without a full page reload.
function dashboardsRefreshMetricCatalog() {
  fetch('/dashboards').then(function(r){ return r.json(); }).then(function(dd){
    if (dd && dd.metric_types) _dashMetricTypes = dd.metric_types;
  }).catch(function(){});
}
// Rename a manual source in place (save on exit / Enter), mirroring the value
// field's tab-through UX. A blank name is rejected and the field reverts.
function dashboardsManualNameKey(ev, inp) {
  if (ev.key === 'Enter') { ev.preventDefault(); inp.blur(); }
}
function dashboardsManualNameBlur(id, inp) {
  var v = (inp.value || '').trim();
  var last = inp.getAttribute('data-last') || '';
  if (v === last) { inp.value = last; return; }   // unchanged on tab-through
  var mark = inp.parentNode.querySelector('.dash-manual-name-saved');
  var setMark = function(txt, cls){ if (mark) { mark.textContent = txt; mark.className = 'dash-manual-name-saved text-xs ' + cls + ' w-14 shrink-0'; } };
  if (!v) { inp.value = last; setMark('Name required', 'text-red-500'); setTimeout(function(){ setMark('', 'text-green-600'); }, 2000); return; }
  setMark('Saving…', 'text-gray-400');
  fetch('/dashboards/sources/' + encodeURIComponent(id) + '/rename', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name: v })
  }).then(function(r){ return r.json(); })
    .then(function(d){
      if (d && d.ok) {
        var nm = (d.source && d.source.name != null) ? d.source.name : v;
        inp.value = nm; inp.setAttribute('data-last', nm);
        setMark('Saved ✓', 'text-green-600'); setTimeout(function(){ setMark('', 'text-green-600'); }, 2000);
        dashboardsRefreshMetricCatalog();
      } else { inp.value = last; setMark((d && d.error) ? d.error : 'Failed', 'text-red-500'); }
    })
    .catch(function(){ inp.value = last; setMark('Failed', 'text-red-500'); });
}
// Delete a manual source (confirm first). The name is read from the row so it
// never has to be encoded into the onclick attribute.
function dashboardsManualDelete(id, btn) {
  var row = btn.parentNode;
  var nameInp = row.querySelector('input');
  var name = nameInp ? (nameInp.getAttribute('data-last') || nameInp.value || '') : '';
  if (!window.confirm('Delete the manual value "' + name + '"? Any tile using it will stop showing a value.')) return;
  var status = document.getElementById('dashboards-manual-status');
  if (status) status.textContent = 'Deleting…';
  fetch('/dashboards/sources/' + encodeURIComponent(id), { method: 'DELETE' })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d && d.ok) {
        if (status) status.textContent = 'Deleted.';
        dashboardsManualRender(d.sources || []);
        dashboardsRefreshMetricCatalog();
      } else if (status) { status.textContent = (d && d.error) ? d.error : 'Delete failed.'; }
    })
    .catch(function(){ if (status) status.textContent = 'Delete failed.'; });
}
function _dashSourceVisLabel(v) {
  return v === 'admins' ? 'Administrators only' : (v === 'list' ? 'Specific people' : 'Any signed-in user');
}
function dashboardsSourcesRender() {
  var list = document.getElementById('dashboards-sources-list');
  if (!_dashSourcesDraft.length) {
    list.innerHTML = '<div class="text-xs text-gray-500 dark:text-gray-400 italic">No data sources yet.</div>';
    return;
  }
  list.innerHTML = _dashSourcesDraft.map(function(s, i){
    var desc;
    if (s.type === 'report_cell') {
      var rt = _dashSourceReportTypes.filter(function(r){ return r.id === s.report; })[0];
      var rlabel = rt ? rt.label : (s.report || '(report)');
      var sel = (s.row_mode === 'count') ? 'row count'
                : (esc(s.column || '(column)') + ' · ' + esc(s.row_mode || 'first'));
      desc = 'Report cell — ' + esc(rlabel) + ' &middot; ' + sel;
    } else if (s.type === 'manual') {
      desc = 'Manual value &middot; ' + (s.value ? esc(s.value) : '(no value yet)');
    } else if (s.select_mode === 'lookup') {
      desc = 'SharePoint by-date &middot; ' + esc(s.value_col || '(value)') + ' &middot; ' + esc(s.date_rule || 'on_or_before');
    } else {
      desc = 'SharePoint cell ' + esc(s.sheet || '') + '!' + esc(s.cell || '');
    }
    return '<div class="flex items-center gap-2 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2">' +
      '<div class="min-w-0 flex-1">' +
        '<div class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + esc(s.name || '(unnamed)') + '</div>' +
        '<div class="text-xs text-gray-500 dark:text-gray-400 truncate">' + desc + ' &middot; ' + esc(_dashSourceVisLabel(s.visibility)) + ' &middot; refresh ' + esc(String(s.cache_ttl || 300)) + 's</div>' +
      '</div>' +
      '<button type="button" onclick="dashboardsSourceEdit(' + i + ')" class="px-2 py-1 text-xs rounded 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">Edit</button>' +
      '<button type="button" onclick="dashboardsSourceRemove(' + i + ')" class="px-2 py-1 text-xs rounded text-red-500 hover:text-red-700 cursor-pointer">Remove</button>' +
    '</div>';
  }).join('');
}
function dashboardsSourceVisibilityChanged() {
  var v = document.getElementById('dashboards-source-visibility').value;
  document.getElementById('dashboards-source-emails-wrap').classList.toggle('hidden', v !== 'list');
}
function dashboardsSourceTypeChanged() {
  var t = document.getElementById('dashboards-source-type').value;
  document.getElementById('dashboards-source-sp-fields').classList.toggle('hidden', t !== 'sharepoint_cell');
  document.getElementById('dashboards-source-report-fields').classList.toggle('hidden', t !== 'report_cell');
  document.getElementById('dashboards-source-manual-fields').classList.toggle('hidden', t !== 'manual');
  if (t === 'report_cell') dashboardsSourceRowModeChanged();
  if (t === 'sharepoint_cell') dashboardsSourceSelectModeChanged();
}
function dashboardsSourceSelectModeChanged() {
  // DVI-1319: fixed cell shows the Cell field; by-date-logic shows the lookup
  // fields (the fixed cell is not needed because the row is computed).
  var lookup = document.getElementById('dashboards-source-selectmode').value === 'lookup';
  document.getElementById('dashboards-source-cell-wrap').classList.toggle('hidden', lookup);
  document.getElementById('dashboards-source-lookup-fields').classList.toggle('hidden', !lookup);
  if (lookup) dashboardsSourceDateRuleChanged();
}
function dashboardsSourceDateRuleChanged() {
  var rule = document.getElementById('dashboards-source-daterule').value;
  var anchor = document.getElementById('dashboards-source-dateanchor').value;
  var anchored = (rule === 'on_date' || rule === 'on_or_before');
  // Anchor day only applies to the on_date / on_or_before rules.
  document.getElementById('dashboards-source-anchor-wrap').classList.toggle('hidden', !anchored);
  // The date column is unused for the "latest populated row" rule.
  document.getElementById('dashboards-source-datecol-wrap').classList.toggle('hidden', rule === 'latest');
  // N is used for days-ago/ahead anchors and for the "last N days" window.
  var needN = (anchored && (anchor === 'days_ago' || anchor === 'days_ahead')) || rule === 'last_n_days';
  document.getElementById('dashboards-source-offset-wrap').classList.toggle('hidden', !needN);
  document.getElementById('dashboards-source-offset-label').textContent = (rule === 'last_n_days') ? 'N (days back)' : 'N (days)';
  // An exact date only for the "exact" anchor.
  document.getElementById('dashboards-source-fixeddate-wrap').classList.toggle('hidden', !(anchored && anchor === 'exact'));
}
function dashboardsSourceRowModeChanged() {
  // The row count needs no target column; blank + soften the field for clarity.
  var isCount = document.getElementById('dashboards-source-rowmode').value === 'count';
  var col = document.getElementById('dashboards-source-column');
  col.disabled = isCount;
  col.placeholder = isCount ? '(not needed for a row count)' : 'column name or number';
}
function _dashSourcePopulateReports(selectedId) {
  var sel = document.getElementById('dashboards-source-report');
  var opts = '<option value="">— pick a report —</option>';
  var known = false;
  _dashSourceReportTypes.forEach(function(r){
    if (r.id === selectedId) known = true;
    opts += '<option value="' + esc(r.id) + '">' + esc(r.label) + '</option>';
  });
  // Keep an unknown/stored report id selectable so editing doesn't drop it.
  if (selectedId && !known) opts += '<option value="' + esc(selectedId) + '">' + esc(selectedId) + '</option>';
  sel.innerHTML = opts;
  sel.value = selectedId || '';
}
function dashboardsSourceAdd() { _dashSourceShowForm(null); }
function dashboardsSourceEdit(i) { _dashSourceShowForm(i); }
function _dashSourceShowForm(idx) {
  var s = (idx === null || idx === undefined) ? {} : (_dashSourcesDraft[idx] || {});
  document.getElementById('dashboards-source-id').value = (idx === null || idx === undefined) ? '' : String(idx);
  document.getElementById('dashboards-source-name').value = s.name || '';
  document.getElementById('dashboards-source-type').value =
    (s.type === 'report_cell' || s.type === 'manual') ? s.type : 'sharepoint_cell';
  document.getElementById('dashboards-source-value').value = s.value || '';
  document.getElementById('dashboards-source-url').value = s.share_url || '';
  document.getElementById('dashboards-source-sheet').value = s.sheet || '';
  document.getElementById('dashboards-source-cell').value = s.cell || '';
  document.getElementById('dashboards-source-selectmode').value = (s.select_mode === 'lookup') ? 'lookup' : 'fixed';
  document.getElementById('dashboards-source-valuecol').value = s.value_col || '';
  document.getElementById('dashboards-source-datecol').value = s.date_col || '';
  document.getElementById('dashboards-source-daterule').value = s.date_rule || 'on_or_before';
  document.getElementById('dashboards-source-dateanchor').value = s.date_anchor || 'today';
  document.getElementById('dashboards-source-dateoffset').value = (s.date_offset != null) ? s.date_offset : 0;
  document.getElementById('dashboards-source-fixeddate').value = s.fixed_date || '';
  document.getElementById('dashboards-source-reduce').value = s.reduce || 'value';
  document.getElementById('dashboards-source-headerrow').value = s.header_row || 1;
  document.getElementById('dashboards-source-datarange').value = s.data_range || '';
  document.getElementById('dashboards-source-tz').value = s.tz || '';
  _dashSourcePopulateReports(s.report || '');
  document.getElementById('dashboards-source-column').value = s.column || '';
  document.getElementById('dashboards-source-rowmode').value = s.row_mode || 'first';
  document.getElementById('dashboards-source-matchcol').value = s.match_col || '';
  document.getElementById('dashboards-source-matchval').value = s.match_value || '';
  document.getElementById('dashboards-source-ttl').value = s.cache_ttl || 300;
  document.getElementById('dashboards-source-visibility').value = s.visibility || 'all';
  document.getElementById('dashboards-source-emails').value = (s.allow_emails || []).join('\n');
  document.getElementById('dashboards-source-test-out').textContent = '';
  dashboardsSourceVisibilityChanged();
  dashboardsSourceTypeChanged();
  document.getElementById('dashboards-source-form').classList.remove('hidden');
}
function dashboardsSourceCancel() {
  document.getElementById('dashboards-source-form').classList.add('hidden');
  document.getElementById('dashboards-source-test-out').textContent = '';
}
function _dashSourceFromForm() {
  var t = document.getElementById('dashboards-source-type').value;
  var base = {
    id: '',
    type: t,
    name: document.getElementById('dashboards-source-name').value.trim(),
    cache_ttl: parseInt(document.getElementById('dashboards-source-ttl').value, 10) || 300,
    visibility: document.getElementById('dashboards-source-visibility').value,
    allow_emails: document.getElementById('dashboards-source-emails').value
  };
  if (t === 'report_cell') {
    base.report = document.getElementById('dashboards-source-report').value.trim();
    base.column = document.getElementById('dashboards-source-column').value.trim();
    base.row_mode = document.getElementById('dashboards-source-rowmode').value;
    base.match_col = document.getElementById('dashboards-source-matchcol').value.trim();
    base.match_value = document.getElementById('dashboards-source-matchval').value.trim();
  } else if (t === 'manual') {
    base.value = document.getElementById('dashboards-source-value').value.trim();
  } else {
    base.share_url = document.getElementById('dashboards-source-url').value.trim();
    base.sheet = document.getElementById('dashboards-source-sheet').value.trim();
    base.cell = document.getElementById('dashboards-source-cell').value.trim();
    base.select_mode = document.getElementById('dashboards-source-selectmode').value;
    if (base.select_mode === 'lookup') {
      base.value_col = document.getElementById('dashboards-source-valuecol').value.trim();
      base.date_col = document.getElementById('dashboards-source-datecol').value.trim();
      base.date_rule = document.getElementById('dashboards-source-daterule').value;
      base.date_anchor = document.getElementById('dashboards-source-dateanchor').value;
      base.date_offset = parseInt(document.getElementById('dashboards-source-dateoffset').value, 10) || 0;
      base.fixed_date = document.getElementById('dashboards-source-fixeddate').value.trim();
      base.reduce = document.getElementById('dashboards-source-reduce').value;
      base.header_row = parseInt(document.getElementById('dashboards-source-headerrow').value, 10) || 1;
      base.data_range = document.getElementById('dashboards-source-datarange').value.trim();
      base.tz = document.getElementById('dashboards-source-tz').value.trim();
    }
  }
  return base;
}
function dashboardsSourceApply() {
  var s = _dashSourceFromForm();
  var out = document.getElementById('dashboards-source-test-out');
  if (!s.name) { out.textContent = 'Give the source a name.'; return; }
  if (s.type === 'report_cell') {
    if (!s.report) { out.textContent = 'Pick a report source.'; return; }
    if (s.row_mode !== 'count' && !s.column) { out.textContent = 'Choose the value column (or use Row count).'; return; }
  } else if (s.type === 'manual') {
    /* a manual source needs only a name (already validated above) */
  } else if (!(s.share_url && s.sheet)) {
    out.textContent = 'Fill in file link and sheet.';
    return;
  } else if (s.select_mode === 'lookup') {
    if (!s.value_col) { out.textContent = 'Choose the value column.'; return; }
    if (s.date_rule !== 'latest' && !s.date_col) { out.textContent = 'Choose the date column.'; return; }
  } else if (!s.cell) {
    out.textContent = 'Fill in the cell.';
    return;
  }
  var idxRaw = document.getElementById('dashboards-source-id').value;
  if (idxRaw === '') {
    _dashSourcesDraft.push(s);
  } else {
    var idx = parseInt(idxRaw, 10);
    s.id = (_dashSourcesDraft[idx] || {}).id || '';   // keep the stable id on edit
    _dashSourcesDraft[idx] = s;
  }
  dashboardsSourceCancel();
  dashboardsSourcesRender();
}
function dashboardsSourceRemove(i) {
  if (!confirm('Remove this data source? Tiles bound to it will stop resolving.')) return;
  _dashSourcesDraft.splice(i, 1);
  dashboardsSourcesRender();
}
function dashboardsSourceTest() {
  var out = document.getElementById('dashboards-source-test-out');
  out.textContent = 'Testing…';
  fetch('/dashboards/sources/test', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(_dashSourceFromForm())
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      var cols = (d && d.columns && d.columns.length) ? ' — columns: ' + d.columns.join(', ') : '';
      // DVI-1319: surface which date/row the logic picked so the admin can confirm.
      var picked = '';
      var p = d && d.picked;
      if (p) {
        if (p.picked_date) picked = ' — picked ' + p.picked_date;
        else if (p.window) picked = ' — window ' + p.window[0] + '…' + p.window[1] + ' (' + (p.matched || 0) + ' rows)';
        else if (p.anchor) picked = ' — ' + p.anchor + ' (' + (p.matched || 0) + ' rows)';
        else if (p.matched != null) picked = ' — ' + p.matched + ' row(s)';
        if (p.truncated) picked += ' [truncated to 5000 rows]';
      }
      if (d && d.ok) {
        out.textContent = 'Value: ' + (d.value_str === '' ? '(empty)' : d.value_str) +
          (d.number === null || d.number === undefined ? '' : ' (numeric ' + d.number + ')') + picked + cols;
      } else {
        out.textContent = ((d && d.error) ? d.error : 'Test failed.') + picked + cols;
      }
    })
    .catch(function(){ out.textContent = 'Test failed.'; });
}
function dashboardsSourcesSave() {
  var status = document.getElementById('dashboards-sources-status');
  status.textContent = 'Saving…';
  fetch('/dashboards/sources', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ sources: _dashSourcesDraft })
  })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d && d.ok) {
        _dashSourcesDraft = d.sources || [];
        dashboardsSourcesRender();
        status.textContent = 'Saved. New sources appear in the Stat tile Source picker.';
        // Refresh the metric catalog so the picker lists the change without a reload.
        fetch('/dashboards').then(function(r){ return r.json(); }).then(function(dd){
          if (dd && dd.metric_types) _dashMetricTypes = dd.metric_types;
        });
      } else {
        status.textContent = (d && d.error) ? d.error : 'Save failed.';
      }
    })
    .catch(function(){ status.textContent = 'Save failed.'; });
}

// ---- Sharing (DVI-1196 P3) ----
function dashboardsShare() {
  var active = dashboardsActive();
  if (!active || !dashboardsActiveIsOwned()) return;
  document.getElementById('dashboards-share-name').textContent = active.name || 'Untitled';
  document.getElementById('dashboards-share-users').value = (active.shared_users || []).join('\n');
  document.getElementById('dashboards-share-signage').checked = !!active.signage;
  document.getElementById('dashboards-share-status').textContent = '';
  document.getElementById('dashboards-share-overlay').classList.remove('hidden');
}
function dashboardsCloseShare() {
  document.getElementById('dashboards-share-overlay').classList.add('hidden');
}
// DVI-1245: jump to User Settings → Agents to configure a fullscreen (virtual)
// display for this dashboard.
function dashboardsManageVirtual() {
  dashboardsCloseShare();
  openTool('user-settings');
  setTimeout(function(){ try { usSwitchTab('agents'); } catch(e){} }, 200);
}
function dashboardsSaveShare() {
  var active = dashboardsActive();
  if (!active) return;
  var raw = document.getElementById('dashboards-share-users').value || '';
  var emails = raw.split(/[\s,;]+/)
    .map(function(s){ return s.trim().toLowerCase(); })
    .filter(function(e){ return e && e.indexOf('@') > -1; });
  var signage = !!document.getElementById('dashboards-share-signage').checked;
  var status = document.getElementById('dashboards-share-status');
  status.textContent = 'Saving…';
  fetch('/dashboards/' + encodeURIComponent(active.id) + '/share', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ shared_users: emails, signage: signage })
  }).then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { status.textContent = d.error; return; }
      dashboardsApplyUpdated(d.dashboard);
      dashboardsCloseShare();
    });
}

// ===== Present (DVI-1204 P5) =====
// Push the active (owned) dashboard to a presentable display, with a timed or
// manual revert. Displays come from /display/present/targets (presentable + I'm
// an allowed presenter; admins see all presentable). The modal doubles as the
// live "presenting to X — Stop" surface.
var _dashPresentTargets = [];
// DVI-1220 P3 (B6): consume-once flag — remembered settings may auto-start a
// present only on the panel OPEN, never on the reload after a start/stop.
var _dashPresentAuto = false;

function dashboardsPresent() {
  var active = dashboardsActive();
  if (!active || !dashboardsActiveIsOwned()) return;
  document.getElementById('dashboards-present-name').textContent = active.name || 'Untitled';
  document.getElementById('dashboards-present-status').textContent = '';
  _dashFillColNums(document.getElementById('dashboards-present-columns'));   // DVI-1288 P3
  document.getElementById('dashboards-present-overlay').classList.remove('hidden');
  _dashPresentAuto = true;
  dashboardsLoadPresentTargets();
}
function dashboardsClosePresent() {
  document.getElementById('dashboards-present-overlay').classList.add('hidden');
}
function dashboardsLoadPresentTargets() {
  var empty = document.getElementById('dashboards-present-empty');
  var form = document.getElementById('dashboards-present-form');
  var go = document.getElementById('dashboards-present-go');
  fetch('/display/present/targets')
    .then(function(r){ return r.json(); })
    .then(function(d){
      _dashPresentTargets = (d && d.displays) || [];
      var hasAny = _dashPresentTargets.length > 0;
      empty.classList.toggle('hidden', hasAny);
      form.classList.toggle('hidden', !hasAny);
      go.classList.toggle('hidden', !hasAny);
      if (hasAny) {
        var sel = document.getElementById('dashboards-present-display');
        sel.innerHTML = _dashPresentTargets.map(function(t){
          var st = t.online_status === 'online' ? '' :
            (t.online_status === 'stale' ? ' (offline?)' : ' (never seen)');
          return '<option value="' + esc(t.id) + '">' + esc(t.name || t.id) + st + '</option>';
        }).join('');
      }
      dashboardsRenderPresentActive();
      dashboardsApplyRemembered(hasAny ? (d && d.remembered) : null);
    })
    .catch(function(){ empty.classList.remove('hidden'); form.classList.add('hidden'); go.classList.add('hidden'); });
}
// DVI-1220 P3 (B6): prefill the panel from the user's remembered settings and —
// when the remembered display is still available, online and idle — start the
// present immediately. The panel stays open as the live "presenting — Stop"
// surface. Anything less certain (display gone, offline, or busy) prefils only
// and explains why in the amber note, so one click never stomps blindly.
function dashboardsApplyRemembered(remembered) {
  var auto = _dashPresentAuto;
  _dashPresentAuto = false;
  var note = document.getElementById('dashboards-present-remember-note');
  note.classList.add('hidden'); note.textContent = '';
  if (!remembered) return;
  document.getElementById('dashboards-present-myiface').checked = !!remembered.use_my_interface;
  document.getElementById('dashboards-present-remember').checked = true;
  // DVI-1275 P3: restore the remembered zoom override (0 = keep the display's).
  var zSel = document.getElementById('dashboards-present-zoom');
  var zv = String(remembered.zoom || 0);
  zSel.value = '0';
  for (var zi = 0; zi < zSel.options.length; zi++) {
    if (zSel.options[zi].value === zv) { zSel.value = zv; break; }
  }
  // DVI-1288 P3: restore the remembered columns/flow override ('' → keep).
  var cSel = document.getElementById('dashboards-present-columns');
  _dashFillColNums(cSel);
  var cv = String(remembered.columns || '');
  cSel.value = 'keep';
  for (var ci = 0; ci < cSel.options.length; ci++) {
    if (cSel.options[ci].value === cv) { cSel.value = cv; break; }
  }
  var fSel = document.getElementById('dashboards-present-flow');
  var fv = String(remembered.flow || '');
  fSel.value = 'keep';
  for (var fi = 0; fi < fSel.options.length; fi++) {
    if (fSel.options[fi].value === fv) { fSel.value = fv; break; }
  }
  var durSel = document.getElementById('dashboards-present-duration');
  var dv = String(remembered.duration_minutes || 0);
  for (var i = 0; i < durSel.options.length; i++) {
    if (durSel.options[i].value === dv) { durSel.value = dv; break; }
  }
  var target = _dashPresentTargets.filter(function(t){ return t.id === remembered.display_id; })[0];
  if (!target) {
    if (auto) {
      note.textContent = 'Your remembered display is no longer available to you — pick a display and press Present.';
      note.classList.remove('hidden');
    }
    return;
  }
  document.getElementById('dashboards-present-display').value = target.id;
  if (!auto) return;
  var active = dashboardsActive();
  if (target.present && active && target.present.dashboard_id === active.id) {
    return;   // already presenting this dashboard there — the live box shows it.
  }
  if (target.present) {
    note.textContent = 'Not auto-starting: ' + (target.name || target.id) + ' is currently presenting “' +
      (target.present.dashboard_name || 'another dashboard') + '”. Press Present to replace it.';
    note.classList.remove('hidden');
    return;
  }
  if (target.online_status !== 'online') {
    note.textContent = 'Not auto-starting: ' + (target.name || target.id) + ' looks offline. Press Present to try anyway.';
    note.classList.remove('hidden');
    return;
  }
  dashboardsStartPresent();
}
function dashboardsRenderPresentActive() {
  // Show any of MY displays that are currently presenting THIS dashboard, with a
  // Stop button. (present.presenter/expires come from the targets payload.)
  var active = dashboardsActive();
  var box = document.getElementById('dashboards-present-active');
  var mine = _dashPresentTargets.filter(function(t){
    return t.present && active && t.present.dashboard_id === active.id;
  });
  if (!mine.length) { box.classList.add('hidden'); box.innerHTML = ''; return; }
  box.innerHTML = mine.map(function(t){
    var until = 'until stopped';
    if (t.present.expires_at) {
      var mins = Math.max(0, Math.round((t.present.expires_at * 1000 - Date.now()) / 60000));
      until = 'reverts in ' + mins + ' min';
    }
    return '<div class="flex items-center justify-between gap-2">' +
      '<span class="text-gray-700 dark:text-gray-200">Presenting to <span class="font-medium">' + esc(t.name || t.id) + '</span> &middot; ' + esc(until) + '</span>' +
      '<button onclick="dashboardsStopPresent(\'' + esc(t.id) + '\')" class="togen-control px-2 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">Stop</button>' +
      '</div>';
  }).join('');
  box.classList.remove('hidden');
}
function dashboardsStartPresent() {
  var active = dashboardsActive();
  if (!active) return;
  var displayId = document.getElementById('dashboards-present-display').value;
  if (!displayId) return;
  var dur = parseInt(document.getElementById('dashboards-present-duration').value, 10) || 0;
  var status = document.getElementById('dashboards-present-status');
  status.textContent = 'Starting…';
  fetch('/display/present', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ display_id: displayId, dashboard_id: active.id, duration_minutes: dur,
      // DVI-1220 P3: B5 presenter-interface flag (+ the rendered theme, for a
      // "system" theme the server can't see) and the B6 remember checkbox.
      use_my_interface: document.getElementById('dashboards-present-myiface').checked,
      theme_hint: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
      // DVI-1275 P3: present-time zoom override (0 = keep the display's zoom).
      zoom: parseInt(document.getElementById('dashboards-present-zoom').value, 10) || 0,
      // DVI-1288 P3: present-time columns/flow override ('keep' = keep display's).
      columns: document.getElementById('dashboards-present-columns').value,
      flow: document.getElementById('dashboards-present-flow').value,
      remember: document.getElementById('dashboards-present-remember').checked })
  }).then(function(r){ return r.json().then(function(d){ return { ok: r.ok, d: d }; }); })
    .then(function(res){
      if (!res.ok) { status.textContent = res.d.error || 'Failed to present'; return; }
      status.textContent = 'Presenting.';
      dashboardsLoadPresentTargets();
    });
}
function dashboardsStopPresent(displayId) {
  var status = document.getElementById('dashboards-present-status');
  status.textContent = 'Stopping…';
  fetch('/display/present/stop', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ display_id: displayId })
  }).then(function(r){ return r.json().then(function(d){ return { ok: r.ok, d: d }; }); })
    .then(function(res){
      status.textContent = res.ok ? 'Stopped.' : (res.d.error || 'Failed to stop');
      dashboardsLoadPresentTargets();
    });
}

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 = window._ssoUserEmail;
    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 _fbViewEntries = [];    // entries as last rendered (filtered + sorted) — drives lightbox order
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) {
  _fbViewEntries = 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');
  var name = el.getAttribute('data-name');
  if (isDir) {
    fbNavigate(path);
  } else if (fbIsImageFile(name)) {
    // DVI-1087 Phase 2: images open the in-app lightbox (download stays available in it)
    fbOpenLightbox(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 cursor-zoom-in" alt="' + esc(name) + '" title="Click to view full size" onclick="fbLightboxFromPreview()">';
  } 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 = '';
}

// ----- FB image lightbox (DVI-1087 Phase 2): in-app gallery viewer mirroring the WO Admin lightbox -----
var _fbLbImages = [];
var _fbLbIndex = -1;

function fbIsImageFile(name) {
  return /\.(jpe?g|png|gif|svg|webp|bmp|ico)$/i.test(name || '');
}

function fbOpenLightbox(path) {
  // Rebuild the image index from the current (filtered + sorted) listing so
  // prev/next follows the visible order.
  _fbLbImages = [];
  var idx = -1;
  for (var i = 0; i < _fbViewEntries.length; i++) {
    var e = _fbViewEntries[i];
    if (e.is_dir || !fbIsImageFile(e.name)) continue;
    var full = _fbCurrentPath ? (_fbCurrentPath + '/' + e.name) : e.name;
    if (full === path) idx = _fbLbImages.length;
    _fbLbImages.push({ name: e.name, path: full, size: e.size, modified: e.modified });
  }
  if (idx === -1) return;
  fbLightboxShow(idx);
}

function fbLightboxShow(i) {
  if (!_fbLbImages.length || i < 0 || i >= _fbLbImages.length) return;
  _fbLbIndex = i;
  var ov = document.getElementById('fb-lightbox');
  if (!ov) return;
  var p = _fbLbImages[i];
  var img = document.getElementById('fb-lb-img');
  if (img) { img.src = '/fb/preview?path=' + encodeURIComponent(p.path); img.alt = p.name; }
  var badge = document.getElementById('fb-lb-badge');
  if (badge) badge.textContent = p.name;
  var cap = document.getElementById('fb-lb-caption');
  if (cap) {
    var meta = [p.size != null ? fbFormatSize(p.size) : '', fbFormatDate(p.modified)].filter(Boolean).join(' · ');
    cap.innerHTML = meta ? '<span class="block text-xs text-gray-300">' + esc(meta) + '</span>' : '';
  }
  var counter = document.getElementById('fb-lb-counter');
  if (counter) counter.textContent = (i + 1) + ' / ' + _fbLbImages.length;
  var dl = document.getElementById('fb-lb-download');
  if (dl) dl.href = '/fb/download?path=' + encodeURIComponent(p.path);
  var nav = document.getElementById('fb-lb-nav');
  if (nav) nav.classList.toggle('hidden', _fbLbImages.length < 2);
  ov.classList.remove('hidden');
}

function fbLightboxNav(delta) {
  if (!_fbLbImages.length) return;
  var n = _fbLbImages.length;
  fbLightboxShow((_fbLbIndex + delta + n) % n);
}

function fbLightboxClose() {
  var ov = document.getElementById('fb-lightbox');
  if (ov) ov.classList.add('hidden');
  var img = document.getElementById('fb-lb-img');
  if (img) img.src = '';
  _fbLbIndex = -1;
}

function fbLightboxFromPreview() {
  if (_fbSelectedItem && !_fbSelectedItem.is_dir) fbOpenLightbox(_fbSelectedItem.path);
}

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="togen-inset 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 qcBtn = document.getElementById('fb-tab-btn-qc');
  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');
  var qcPane = document.getElementById('fb-tab-qc');
  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,
    qc: !!qcBtn,
    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.qc ? 'qc'
        : 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');
  if (qcPane) qcPane.classList.add('hidden');
  setActive(projBtn, false); setActive(attBtn, false); setActive(setBtn, false); setActive(visBtn, false); setActive(prodBtn, false); setActive(scadaBtn, false); setActive(odBtn, false); setActive(qcBtn, 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 === 'qc') {
    setActive(qcBtn, true);
    if (qcPane) qcPane.classList.remove('hidden');
    fbQcInit();
  } 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>';
      var fnArg = r.fileName.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
      action = '<button onclick="scadaViewPersistedReport(\'' + fnArg + '\')" class="text-xs font-medium text-togen-teal hover:underline cursor-pointer mr-3">View</button>' +
        '<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');
  var midEl = document.getElementById('fbv-rec-machine');
  var mid = midEl ? midEl.value : '';
  grid.innerHTML = frames.map(function(f){ return _visionRecordingCardHtml(f, mid); }).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', 'services', 'agents', '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();
  if (tab === 'services' || tab === 'all') usLoadServices();
  if (tab === 'agents' || tab === 'all') usLoadAgents();
  if (tab === 'account' || tab === 'all') usRegionalInit();
}

// --- User Settings → Agents (DVI-1245): virtual Display Agents --------------
// A user configures a virtual display for one of their dashboards; opening that
// dashboard in Fullscreen (?agent=) then follows these settings and makes the
// window a Present target for the people they allow.
var _usAgentDashboards = [];
var _usAgentsById = {};
// DVI-1322: displays delegated to this user via "Allow Edit".
var _usEditableById = {};
var _usEditableSignageDashboards = [];
var _usAgentMode = 'virtual';   // 'virtual' (owned) | 'editable' (delegated physical)

// ---- My default display settings (DVI-1321 P3) ----------------------------
function usDisplaySettingsOpen(){
  var ds = (_userSettings && _userSettings.display_settings) || null;
  var lay = (ds && ds.layout) || { fit:'scale', orientation:'auto', columns:'auto', zoom:100, flow:'row', rows:'auto' };
  document.getElementById('us-display-fit').value = lay.fit || 'scale';
  document.getElementById('us-display-orientation').value = lay.orientation || 'auto';
  _dashColsFill('us-display-columns', lay.columns);
  document.getElementById('us-display-zoom').value = String(lay.zoom || 100);
  document.getElementById('us-display-flow').value = lay.flow || 'row';
  _dashRowsFill('us-display-rows', lay.rows);
  document.getElementById('us-display-use-interface').checked = !!(ds && ds.use_interface);
  document.getElementById('us-display-settings-status').textContent = '';
  document.getElementById('us-display-settings-modal').classList.remove('hidden');
}
function usDisplaySettingsClose(){
  document.getElementById('us-display-settings-modal').classList.add('hidden');
}
function _usDisplaySettingsPost(payload, okMsg){
  var btn = document.getElementById('us-display-settings-save');
  var st = document.getElementById('us-display-settings-status');
  btn.disabled = true;
  return fetch('/user-settings/display', { method:'POST', headers:{'Content-Type':'application/json'},
    credentials:'same-origin', body: JSON.stringify(payload) })
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      btn.disabled = false;
      if (!d || !d.ok){ st.textContent = 'Could not save.'; st.className = 'text-xs text-red-500'; return; }
      if (!_userSettings) _userSettings = {};
      if (d.display_settings) _userSettings.display_settings = d.display_settings;
      else delete _userSettings.display_settings;
      st.textContent = okMsg; st.className = 'text-xs text-green-600 dark:text-green-400';
      setTimeout(usDisplaySettingsClose, 600);
    }).catch(function(){ btn.disabled = false; st.textContent = 'Could not save.'; st.className = 'text-xs text-red-500'; });
}
function usDisplaySettingsSave(){
  _usDisplaySettingsPost({
    layout: {
      fit: document.getElementById('us-display-fit').value,
      orientation: document.getElementById('us-display-orientation').value,
      columns: document.getElementById('us-display-columns').value,
      zoom: parseInt(document.getElementById('us-display-zoom').value, 10) || 100,
      flow: document.getElementById('us-display-flow').value,
      rows: document.getElementById('us-display-rows').value
    },
    use_interface: document.getElementById('us-display-use-interface').checked
  }, 'Saved.');
}
function usDisplaySettingsClear(){
  _usDisplaySettingsPost({ clear: true }, 'Reset to organization default.');
}

function usLoadAgents(){
  fetch('/signage/agents/virtual', {credentials:'same-origin'})
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      _usAgentDashboards = d.dashboards || [];
      _usEditableSignageDashboards = d.editable_signage_dashboards || [];
      usRenderAgents(d.agents || [], d.max_per_user || 10);
      usRenderEditable(d.editable_displays || []);
    }).catch(function(){});
}

function usAgentStatusBadge(s){
  var map = {online:['Online','bg-green-500/15 text-green-600 dark:text-green-400'],
             stale:['Stale','bg-amber-500/15 text-amber-600 dark:text-amber-400'],
             offline:['Offline','bg-gray-400/15 text-gray-500 dark:text-gray-400']};
  var m = map[s] || map.offline;
  return '<span class="px-2 py-0.5 rounded-full text-xs font-medium '+m[1]+'">'+m[0]+'</span>';
}

function usAgentDashName(id){
  for (var i=0;i<_usAgentDashboards.length;i++){ if(_usAgentDashboards[i].id===id) return _usAgentDashboards[i].name; }
  return id ? '(dashboard unavailable)' : '(none)';
}

function usRenderAgents(agents, maxPer){
  _usAgentsById = {};
  var list = document.getElementById('us-agents-list');
  var status = document.getElementById('us-agents-status');
  if (!list) return;
  if (status) status.textContent = agents.length + ' of ' + maxPer + ' virtual displays configured.';
  if (!agents.length){
    list.innerHTML = '<div class="text-sm text-gray-500 dark:text-gray-400 border border-dashed border-gray-300 dark:border-gray-700 rounded-lg px-4 py-6 text-center">No virtual displays yet. Add one to make a dashboard presentable in Fullscreen.</div>';
    return;
  }
  var html = '';
  for (var i=0;i<agents.length;i++){
    var a = agents[i];
    _usAgentsById[a.id] = a;
    var presentInfo = '';
    if (a.present){ presentInfo = '<div class="text-xs text-togen-teal mt-1">Presenting: '+esc(a.present.dashboard_name||'')+' — by '+esc(a.present.presenter_name||a.present.presenter||'')+'</div>'; }
    var presCfg = a.presentable ? (a.publish_anyone ? 'Presentable · anyone logged in' : ('Presentable · '+((a.allowed_presenters||[]).length)+' presenter(s)')) : 'Not presentable';
    // Single-quote the id INSIDE the double-quoted onclick attribute (esc() only
    // escapes &<>, so JSON.stringify's double-quotes would terminate the attribute
    // early and silently break every button — DVI-1245 prod-verify fix).
    var idj = "'" + esc(a.id).replace(/'/g, "\\'") + "'";
    html += '<div class="border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 flex items-start justify-between gap-3">'
      + '<div class="min-w-0">'
      +   '<div class="flex items-center gap-2 flex-wrap"><span class="font-medium text-gray-800 dark:text-gray-100">'+esc(a.name||'')+'</span>'+usAgentStatusBadge(a.online_status)+'</div>'
      +   '<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">Dashboard: '+esc(usAgentDashName(a.dashboard_id))+'</div>'
      +   '<div class="text-xs text-gray-400 mt-0.5">'+esc(presCfg)+'</div>'
      +   presentInfo
      + '</div>'
      + '<div class="flex flex-col items-end gap-1.5 shrink-0">'
      +   '<button type="button" onclick="usAgentFullscreen('+idj+')" class="px-2.5 py-1 text-xs rounded-lg bg-togen-teal text-white hover:bg-togen-teal-dark cursor-pointer">Open fullscreen</button>'
      +   '<button type="button" onclick="usAgentOpen('+idj+')" class="togen-control px-2.5 py-1 text-xs 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 cursor-pointer">Edit</button>'
      +   '<button type="button" onclick="usAgentDelete('+idj+')" class="togen-control px-2.5 py-1 text-xs 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 cursor-pointer">Delete</button>'
      + '</div></div>';
  }
  list.innerHTML = html;
}

// DVI-1322: render the delegated "Display Agents you can edit" section. These are
// physical kiosks — no fullscreen (not the user's browser) and no delete (admin-
// only); the row's Edit opens the shared modal in 'editable' mode.
function usRenderEditable(displays){
  _usEditableById = {};
  var wrap = document.getElementById('us-editable-agents-wrap');
  var list = document.getElementById('us-editable-agents-list');
  if (!wrap || !list) return;
  if (!displays.length){ wrap.classList.add('hidden'); list.innerHTML = ''; return; }
  wrap.classList.remove('hidden');
  var html = '';
  for (var i=0;i<displays.length;i++){
    var a = displays[i];
    _usEditableById[a.id] = a;
    var presentInfo = a.present ? ('<div class="text-xs text-togen-teal mt-1">Presenting: '+esc(a.present.dashboard_name||'')+'</div>') : '';
    var plat = (a.platform === 'windows') ? 'Windows' : 'Linux';
    var idj = "'" + esc(a.id).replace(/'/g, "\\'") + "'";
    html += '<div class="border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 flex items-start justify-between gap-3">'
      + '<div class="min-w-0">'
      +   '<div class="flex items-center gap-2 flex-wrap"><span class="font-medium text-gray-800 dark:text-gray-100">'+esc(a.name||'')+'</span>'+usAgentStatusBadge(a.online_status)
      +     '<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-gray-400/15 text-gray-500 dark:text-gray-400">'+plat+'</span>'
      +     '<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-togen-teal/15 text-togen-teal">Managed</span></div>'
      +   '<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">Dashboard: '+esc(a.dashboard_name || '(none)')+'</div>'
      +   presentInfo
      + '</div>'
      + '<div class="flex flex-col items-end gap-1.5 shrink-0">'
      +   '<button type="button" onclick="usAgentOpen('+idj+',\'editable\')" class="togen-control px-2.5 py-1 text-xs 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 cursor-pointer">Edit settings</button>'
      + '</div></div>';
  }
  list.innerHTML = html;
}

function usAgentOpen(id, mode){
  _usAgentMode = (mode === 'editable') ? 'editable' : 'virtual';
  var editable = (_usAgentMode === 'editable');
  var a = id ? (editable ? _usEditableById[id] : _usAgentsById[id]) : null;
  document.getElementById('us-agent-modal-title').textContent =
    editable ? 'Edit display' : (a ? 'Edit virtual display' : 'Add virtual display');
  // Managed-display note + dashboard source differ for a delegated physical display.
  document.getElementById('us-agent-managed-note').classList.toggle('hidden', !editable);
  document.getElementById('us-agent-id').value = a ? a.id : '';
  document.getElementById('us-agent-name').value = a ? (a.name||'') : '';
  var sel = document.getElementById('us-agent-dashboard');
  var opts = '<option value="">(none)</option>';
  // Editable (delegated physical) displays reassign to any signage-flagged
  // dashboard (parity with the admin edit); virtual displays use the caller's
  // own/shared dashboards.
  var dashSrc = editable ? _usEditableSignageDashboards : _usAgentDashboards;
  for (var i=0;i<dashSrc.length;i++){ var d=dashSrc[i];
    opts += '<option value="'+esc(d.id)+'">'+esc(d.name||'')+(d.shared?' (shared with you)':'')+'</option>'; }
  // Keep the current assignment selectable even if it lost its signage flag.
  if (editable && a && a.dashboard_id && dashSrc.every(function(d){ return d.id !== a.dashboard_id; })){
    opts += '<option value="'+esc(a.dashboard_id)+'">'+esc(a.dashboard_name || a.dashboard_id)+' (current)</option>'; }
  sel.innerHTML = opts;
  sel.value = a ? (a.dashboard_id||'') : '';
  var iface = (a && a.interface) || {};
  var bgImgs = (a && a.bg_images) || {};
  // DVI-1248: reuse the shared interface component (custom-bg upload included).
  _tgaIfaceFill('us-agent-iface', iface, a ? a.id : '', 'interface', !!bgImgs.interface);
  var layout = (a && a.layout) || {};
  document.getElementById('us-agent-fit').value = layout.fit || 'scale';
  document.getElementById('us-agent-orientation').value = layout.orientation || 'auto';
  _dashColsFill('us-agent-columns', layout.columns);   // DVI-1288 P1
  document.getElementById('us-agent-zoom').value = String(layout.zoom || 100);
  document.getElementById('us-agent-flow').value = layout.flow || 'row';   // DVI-1288 P2
  _dashRowsFill('us-agent-rows', layout.rows);   // DVI-1288 P2
  // DVI-1248: Edit Splash — text + an optional independent appearance.
  var splash = (a && a.splash) || {};
  document.getElementById('us-agent-splash-text').value = splash.text || '';
  var splashHasIface = !!(splash.interface && Object.keys(splash.interface).length);
  document.getElementById('us-agent-splash-custom').checked = splashHasIface;
  _tgaIfaceFill('us-agent-splash-iface', splash.interface || iface, a ? a.id : '',
                'splash', !!bgImgs.splash);
  usAgentSplashCustomChanged();
  // A brand-new display has no id yet, so a custom-background image can't be
  // uploaded until it exists — say so instead of silently no-op'ing.
  if (!a){
    var note = 'Save this display first, then reopen Edit to upload a custom background image.';
    var s1 = document.getElementById('us-agent-iface-bgimg-status');
    var s2 = document.getElementById('us-agent-splash-iface-bgimg-status');
    if (s1) s1.textContent = note;
    if (s2) s2.textContent = note;
  }
  document.getElementById('us-agent-presentable').checked = !!(a && a.presentable);
  document.getElementById('us-agent-publish-anyone').checked = !!(a && a.publish_anyone);
  document.getElementById('us-agent-presenters').value = a ? (a.allowed_presenters||[]).join('\n') : '';
  // DVI-1322 (D2): Windows window fieldset — only for a delegated Windows display.
  usAgentFillWindow(editable && a && a.platform === 'windows' ? (a.window || {}) : null);
  document.getElementById('us-agent-modal-msg').textContent = '';
  usAgentPresentableChanged(); usAgentPublishAnyoneChanged();
  document.getElementById('us-agent-modal').classList.remove('hidden');
}

// DVI-1322 (D2): fill/hide the Windows window layer/monitor/geometry fieldset in
// the shared Agents modal. Pass null to hide it (non-Windows or virtual display).
function usAgentFillWindow(win){
  var sec = document.getElementById('us-agent-windows-section');
  if (!win){ sec.classList.add('hidden'); return; }
  sec.classList.remove('hidden');
  var layer = win.layer || 'top';
  document.querySelectorAll('input[name="us-agent-win-layer"]').forEach(function(r){
    r.checked = (r.value === layer);
    r.onchange = function(){
      document.getElementById('us-agent-win-geom-section').classList.toggle('hidden', r.value !== 'windowed');
    };
  });
  var monVal = (win.monitor === undefined || win.monitor === 'primary') ? 'primary' : String(win.monitor);
  var monSel = document.getElementById('us-agent-win-monitor');
  if (monVal !== 'primary' && !Array.from(monSel.options).some(function(o){ return o.value === monVal; })){
    var opt = document.createElement('option'); opt.value = monVal; opt.textContent = 'Monitor ' + monVal; monSel.appendChild(opt);
  }
  monSel.value = monVal;
  document.getElementById('us-agent-win-geom-section').classList.toggle('hidden', layer !== 'windowed');
  var g = win.windowed || {};
  document.getElementById('us-agent-win-x').value      = g.x      !== undefined ? g.x      : 0;
  document.getElementById('us-agent-win-y').value      = g.y      !== undefined ? g.y      : 0;
  document.getElementById('us-agent-win-width').value  = g.width  !== undefined ? g.width  : 800;
  document.getElementById('us-agent-win-height').value = g.height !== undefined ? g.height : 600;
}

function usAgentSplashCustomChanged(){
  document.getElementById('us-agent-splash-iface').classList.toggle('hidden',
    !document.getElementById('us-agent-splash-custom').checked);
}

function usAgentClose(){ document.getElementById('us-agent-modal').classList.add('hidden'); }

function usAgentPresentableChanged(){
  document.getElementById('us-agent-present-opts').classList.toggle('hidden',
    !document.getElementById('us-agent-presentable').checked);
}

function usAgentPublishAnyoneChanged(){
  document.getElementById('us-agent-presenters-wrap').classList.toggle('hidden',
    document.getElementById('us-agent-publish-anyone').checked);
}

function usAgentSave(){
  var id = document.getElementById('us-agent-id').value;
  var name = document.getElementById('us-agent-name').value.trim();
  var msg = document.getElementById('us-agent-modal-msg');
  if (!name){ msg.textContent = 'Name is required.'; return; }
  // DVI-1248: interface (incl. custom bg) via the shared component; splash text
  // plus its own appearance only when the user opts to customize it (otherwise
  // the splash inherits the display interface, matching physical semantics).
  var splash = {text: document.getElementById('us-agent-splash-text').value.trim()};
  if (document.getElementById('us-agent-splash-custom').checked){
    splash.interface = _tgaIfaceCollect('us-agent-splash-iface');
  }
  var body = {
    name: name,
    dashboard_id: document.getElementById('us-agent-dashboard').value,
    interface: _tgaIfaceCollect('us-agent-iface'),
    splash: splash,
    layout: {fit: document.getElementById('us-agent-fit').value,
             orientation: document.getElementById('us-agent-orientation').value,
             columns: document.getElementById('us-agent-columns').value,
             zoom: parseInt(document.getElementById('us-agent-zoom').value, 10) || 100,
             flow: document.getElementById('us-agent-flow').value,
             rows: document.getElementById('us-agent-rows').value},
    presentable: document.getElementById('us-agent-presentable').checked,
    publish_anyone: document.getElementById('us-agent-publish-anyone').checked,
    allowed_presenters: document.getElementById('us-agent-presenters').value
  };
  var editable = (_usAgentMode === 'editable');
  var url;
  if (editable){
    // DVI-1322: delegated physical display → the /settings route (no create/delete).
    var winSec = document.getElementById('us-agent-windows-section');
    if (!winSec.classList.contains('hidden')){
      var selLayer = document.querySelector('input[name="us-agent-win-layer"]:checked');
      var monRaw = document.getElementById('us-agent-win-monitor').value;
      body.window = {
        layer: selLayer ? selLayer.value : 'top',
        monitor: (monRaw === 'primary') ? 'primary' : (parseInt(monRaw, 10) || 'primary'),
        windowed: {
          x:      parseInt(document.getElementById('us-agent-win-x').value,      10) || 0,
          y:      parseInt(document.getElementById('us-agent-win-y').value,      10) || 0,
          width:  parseInt(document.getElementById('us-agent-win-width').value,  10) || 800,
          height: parseInt(document.getElementById('us-agent-win-height').value, 10) || 600
        }
      };
    }
    url = '/signage/agents/'+encodeURIComponent(id)+'/settings';
  } else {
    url = id ? '/signage/agents/virtual/'+encodeURIComponent(id) : '/signage/agents/virtual';
  }
  fetch(url, {method:'POST', headers:{'Content-Type':'application/json'},
              credentials:'same-origin', body: JSON.stringify(body)})
    .then(function(r){ return r.json().then(function(j){ return {ok:r.ok, j:j}; }); })
    .then(function(res){ if(!res.ok){ msg.textContent = (res.j && res.j.error) || 'Save failed.'; return; }
                         usAgentClose(); usLoadAgents(); })
    .catch(function(){ msg.textContent = 'Save failed.'; });
}

function usAgentDelete(id){
  var a = _usAgentsById[id];
  if (!confirm('Delete virtual display "'+((a&&a.name)||'')+'"?')) return;
  fetch('/signage/agents/virtual/'+encodeURIComponent(id),
        {method:'DELETE', credentials:'same-origin'})
    .then(function(){ usLoadAgents(); });
}

function usAgentFullscreen(id){ window.open('/display?agent='+encodeURIComponent(id), '_blank'); }

// 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 te = document.getElementById('us-documents-quote-takeoff-location');
      if (te) te.value = d.quote_takeoff_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(),
      quote_takeoff_location: document.getElementById('us-documents-quote-takeoff-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-1226 P1: User Settings → Services (per-user service connections) =====
// Sub-tab pattern mirrors Togen Admin → Services (id-prefix generic switcher).
function usServiceSubtab(name) {
  var tabs = document.querySelectorAll('[id^="us-svc-subtab-"]');
  var panels = document.querySelectorAll('[id^="us-svc-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');
  }
  for (var j = 0; j < panels.length; j++) panels[j].classList.add('hidden');
  var at = document.getElementById('us-svc-subtab-' + name);
  var ap = document.getElementById('us-svc-panel-' + name);
  if (at) { at.classList.add('border-togen-teal', 'text-togen-teal'); at.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400'); }
  if (ap) ap.classList.remove('hidden');
}

function usLoadServices() {
  fetch('/idencia/user-connection')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      var u = document.getElementById('us-idencia-username');
      if (u) u.value = d.username || '';
      var pw = document.getElementById('us-idencia-password');
      if (pw) { pw.value = ''; pw.placeholder = d.has_password ? 'Password set — leave blank to keep' : 'Enter password'; }
      var f = document.getElementById('us-idencia-default-filters');
      if (f) f.value = d.default_filters || '';
      var lu = document.getElementById('us-idencia-login-url');
      if (lu) lu.value = d.login_url || '';
      var gu = document.getElementById('us-idencia-graphql-url');
      if (gu) gu.value = d.graphql_url || '';
      var md = document.getElementById('us-idencia-mode');
      if (md) md.value = d.mode || '';
      var wu = document.getElementById('us-idencia-web-url');
      if (wu) wu.value = d.web_url || '';
      _usIdenciaOrgMode = d.org_mode || 'api';
      usIdenciaModeChanged();
      usIdenciaBadge(d);
      var st = document.getElementById('us-idencia-status');
      if (st) st.textContent = '';
    })
    .catch(function(){});
  fetch('/powerbi/user-connection')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){ if (d) usPowerbiBadge(d); })
    .catch(function(){});
  fetch('/netsuite/user-connection')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){ if (d) usNetsuiteBadge(d); })
    .catch(function(){});
  fetch('/paychex/user-connection')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){ if (d) usPaychexBadge(d); })
    .catch(function(){});
}

function usPaychexBadge(d) {
  var b = document.getElementById('us-paychex-badge');
  if (b) {
    if (d && d.org_available) {
      b.innerHTML = '<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"><span class="inline-block h-1.5 w-1.5 rounded-full bg-green-500"></span>Available — using the organization connection</span>';
    } else {
      b.innerHTML = '<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300"><span class="inline-block h-1.5 w-1.5 rounded-full bg-amber-500"></span>Not available — no organization connection configured yet</span>';
    }
  }
  var c = document.getElementById('us-paychex-roster');
  if (c) {
    if (d && typeof d.worker_count === 'number') {
      c.textContent = 'Synced workers roster: ' + d.worker_count + ' worker' + (d.worker_count === 1 ? '' : 's') + '.';
    } else {
      c.textContent = '';
    }
  }
}

function usNetsuiteBadge(d) {
  var b = document.getElementById('us-netsuite-badge');
  if (b) {
    if (d && d.org_available) {
      b.innerHTML = '<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"><span class="inline-block h-1.5 w-1.5 rounded-full bg-green-500"></span>Available — using the organization connection</span>';
    } else {
      b.innerHTML = '<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300"><span class="inline-block h-1.5 w-1.5 rounded-full bg-amber-500"></span>Not available — no organization connection configured yet</span>';
    }
  }
  var c = document.getElementById('us-netsuite-catalog');
  if (c) {
    if (d && typeof d.catalog_count === 'number') {
      c.textContent = 'Synced product catalog: ' + d.catalog_count + ' item' + (d.catalog_count === 1 ? '' : 's') + '.';
    } else {
      c.textContent = '';
    }
  }
}

function usPowerbiBadge(d) {
  var b = document.getElementById('us-powerbi-badge');
  if (!b) return;
  if (d && d.org_available) {
    b.innerHTML = '<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"><span class="inline-block h-1.5 w-1.5 rounded-full bg-green-500"></span>Available — using the organization connection</span>';
  } else {
    b.innerHTML = '<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300"><span class="inline-block h-1.5 w-1.5 rounded-full bg-amber-500"></span>Not available — no organization connection configured yet</span>';
  }
}

// Effective mode = the user's explicit pick, else the org-wide setting
// (mirrors the server-side resolution in _idencia_user_connection).
var _usIdenciaOrgMode = 'api';
function usIdenciaModeChanged() {
  var sel = (document.getElementById('us-idencia-mode') || {}).value || '';
  var web = (sel || _usIdenciaOrgMode) === 'web';
  var ww = document.getElementById('us-idencia-web-wrap');
  if (ww) ww.classList.toggle('hidden', !web);
  var wn = document.getElementById('us-idencia-web-note');
  if (wn) wn.classList.toggle('hidden', !web);
  var ab = document.getElementById('us-idencia-api-block');
  if (ab) ab.classList.toggle('hidden', web);
}

function usIdenciaBadge(d) {
  var b = document.getElementById('us-idencia-badge');
  if (!b) return;
  if (d && d.configured) {
    b.innerHTML = '<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"><span class="inline-block h-1.5 w-1.5 rounded-full bg-green-500"></span>Connected — your tiles use your Idencia visibility</span>';
  } else if (d && d.org_available) {
    b.innerHTML = '<span 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 connected — using the organization account</span>';
  } else {
    b.innerHTML = '<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300"><span class="inline-block h-1.5 w-1.5 rounded-full bg-amber-500"></span>Not connected — no organization account configured either</span>';
  }
}

function usIdenciaSave() {
  var st = document.getElementById('us-idencia-status');
  if (st) { st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500 dark:text-gray-400'; }
  var pw = document.getElementById('us-idencia-password');
  var body = {
    username: ((document.getElementById('us-idencia-username') || {}).value || '').trim(),
    default_filters: ((document.getElementById('us-idencia-default-filters') || {}).value || '').trim(),
    login_url: ((document.getElementById('us-idencia-login-url') || {}).value || '').trim(),
    graphql_url: ((document.getElementById('us-idencia-graphql-url') || {}).value || '').trim(),
    mode: ((document.getElementById('us-idencia-mode') || {}).value || ''),
    web_url: ((document.getElementById('us-idencia-web-url') || {}).value || '').trim()
  };
  if (pw && pw.value.trim()) body.password = pw.value.trim();
  fetch('/idencia/user-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) {
      if (st) { st.textContent = 'Saved.'; st.className = 'text-sm text-green-600 dark:text-green-400'; }
      usLoadServices();
    } else {
      if (st) { st.textContent = (d && d.error) || 'Save failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Save failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

function usIdenciaTest() {
  var st = document.getElementById('us-idencia-status');
  if (st) { st.textContent = 'Testing…'; st.className = 'text-sm text-gray-500 dark:text-gray-400'; }
  var body = {
    username: ((document.getElementById('us-idencia-username') || {}).value || '').trim(),
    password: ((document.getElementById('us-idencia-password') || {}).value || '').trim(),
    login_url: ((document.getElementById('us-idencia-login-url') || {}).value || '').trim(),
    mode: ((document.getElementById('us-idencia-mode') || {}).value || ''),
    web_url: ((document.getElementById('us-idencia-web-url') || {}).value || '').trim()
  };
  fetch('/idencia/user-test', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body)
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.ok) {
      var msg = 'Connected — Idencia accepted your credentials.';
      if (d.mode === 'web') {
        msg = d.grid_ok ? 'Connected — web sign-in OK and data is readable.'
                        : 'Signed in, but the data grids could not be read — the Idencia site may have changed.';
      }
      if (st) { st.textContent = msg; st.className = 'text-sm ' + (d.mode === 'web' && !d.grid_ok ? 'text-amber-600 dark:text-amber-400' : 'text-green-600 dark:text-green-400'); }
    } else {
      if (st) { st.textContent = (d && d.error) || 'Test failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Test failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

function usIdenciaDisconnect() {
  if (!confirm('Disconnect your Idencia account? Your tiles will fall back to the organization account (when configured).')) return;
  var st = document.getElementById('us-idencia-status');
  if (st) { st.textContent = 'Disconnecting…'; st.className = 'text-sm text-gray-500 dark:text-gray-400'; }
  fetch('/idencia/user-connection', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({disconnect: true})
  }).then(function(r){ return r.json(); }).then(function(d){
    if (d && d.ok) {
      var u = document.getElementById('us-idencia-username'); if (u) u.value = '';
      var f = document.getElementById('us-idencia-default-filters'); if (f) f.value = '';
      var lu = document.getElementById('us-idencia-login-url'); if (lu) lu.value = '';
      var gu = document.getElementById('us-idencia-graphql-url'); if (gu) gu.value = '';
      var md = document.getElementById('us-idencia-mode'); if (md) md.value = '';
      var wu = document.getElementById('us-idencia-web-url'); if (wu) wu.value = '';
      usIdenciaModeChanged();
      if (st) { st.textContent = 'Disconnected.'; st.className = 'text-sm text-green-600 dark:text-green-400'; }
      usLoadServices();
    } else {
      if (st) { st.textContent = (d && d.error) || 'Failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; }
    }
  }).catch(function(){ if (st) { st.textContent = 'Failed.'; st.className = 'text-sm text-red-600 dark:text-red-400'; } });
}

// 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 / DVI-884: User Settings → Assistant pane. Loads the user's
// connection (per-provider keys masked, never displayed) + effective context.
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 || [];
      usRenderAssistantKeys(d.keys || {}, d.provider_enabled || {});
      usRenderAssistantModels(d.default_model || d.model || '');
      usSetAssistantConnState(d.keys || {});
    })
    .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() {});
}

// DVI-884: one masked key field + docs link per provider. keysState is
// {providerId: {configured, masked}} from /assistant/connection.
// DVI-952: enabledState is {providerId: bool} — per-provider "active" toggle.
function usRenderAssistantKeys(keysState, enabledState) {
  var wrap = document.getElementById('us-assistant-keys');
  if (!wrap) return;
  enabledState = enabledState || {};
  wrap.innerHTML = '';
  _usAssistantProviders.forEach(function(p) {
    var st = keysState[p.id] || {};
    var row = document.createElement('div');
    row.className = 'rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-2.5';

    var head = document.createElement('div');
    head.className = 'flex items-center justify-between gap-2 mb-1';
    var name = document.createElement('span');
    name.className = 'text-xs font-medium text-gray-700 dark:text-gray-300';
    name.textContent = p.label;
    var badge = document.createElement('span');
    if (st.configured) {
      badge.className = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-400';
      badge.innerHTML = '<span class="inline-block h-1.5 w-1.5 rounded-full bg-green-500"></span>Configured';
    } else {
      badge.className = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400';
      badge.innerHTML = '<span class="inline-block h-1.5 w-1.5 rounded-full bg-gray-400"></span>Not set';
    }
    head.appendChild(name); head.appendChild(badge);

    var input = document.createElement('input');
    input.type = 'password'; input.autocomplete = 'off';
    input.id = 'us-assistant-key-' + p.id;
    input.setAttribute('data-provider', p.id);
    input.className = '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';
    input.placeholder = st.configured ? (st.masked || 'Key set — leave blank to keep') : 'Enter API key';

    row.appendChild(head);
    row.appendChild(input);

    // Per-provider docs link (reuses provider docs_url wiring from DVI-629).
    if (p.docs_url) {
      var docs = document.createElement('p');
      docs.className = 'text-xs mt-1';
      var a = document.createElement('a');
      a.href = p.docs_url; a.target = '_blank'; a.rel = 'noopener noreferrer';
      a.className = 'inline-flex items-center gap-1 text-togen-teal hover:underline';
      a.innerHTML = '<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>';
      var lbl = document.createElement('span');
      lbl.textContent = 'How to create a ' + (p.label || 'provider') + ' API key';
      a.appendChild(lbl);
      docs.appendChild(a);
      row.appendChild(docs);
    }

    // DVI-952: per-provider "active" toggle. Absent flag defaults to enabled.
    var toggle = document.createElement('label');
    toggle.className = 'flex items-center gap-2 mt-2 cursor-pointer';
    var cb = document.createElement('input');
    cb.type = 'checkbox';
    cb.id = 'us-assistant-enabled-' + p.id;
    cb.setAttribute('data-provider', p.id);
    cb.className = 'h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal cursor-pointer';
    cb.checked = (enabledState[p.id] !== false);
    var tlbl = document.createElement('span');
    tlbl.className = 'text-xs text-gray-600 dark:text-gray-400';
    tlbl.textContent = 'Active';
    toggle.appendChild(cb); toggle.appendChild(tlbl);
    row.appendChild(toggle);

    wrap.appendChild(row);
  });
}

// DVI-884: default-model dropdown offers every model, grouped by provider, so
// the user can pick a default from any configured provider.
function usRenderAssistantModels(selected) {
  var msel = document.getElementById('us-assistant-model');
  if (!msel) return;
  msel.innerHTML = '';
  _usAssistantProviders.forEach(function(p) {
    var grp = document.createElement('optgroup');
    grp.label = p.label;
    (p.models || []).forEach(function(m) {
      var o = document.createElement('option');
      o.value = m.id; o.textContent = m.label;
      grp.appendChild(o);
    });
    msel.appendChild(grp);
  });
  if (selected) msel.value = selected;
}

// DVI-884: overall connection badge is green if any provider has a key.
function usSetAssistantConnState(keysState) {
  var el = document.getElementById('us-assistant-conn-state');
  if (!el) return;
  var anyConfigured = Object.keys(keysState || {}).some(function(k) { return keysState[k] && keysState[k].configured; });
  if (anyConfigured) {
    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';
  } 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';
  }
}

function usSaveAssistantConnection() {
  var status = document.getElementById('us-assistant-conn-status');
  var body = { default_model: document.getElementById('us-assistant-model').value, keys: {}, provider_enabled: {} };
  // Only send keys the user actually typed; never send the bullet mask.
  _usAssistantProviders.forEach(function(p) {
    var input = document.getElementById('us-assistant-key-' + p.id);
    if (input) {
      var v = input.value.trim();
      if (v) body.keys[p.id] = v;
    }
    var cb = document.getElementById('us-assistant-enabled-' + p.id);
    if (cb) body.provider_enabled[p.id] = cb.checked;
  });
  if (!Object.keys(body.keys).length) delete body.keys;  // omit → keep all existing keys
  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';
        usInitAssistant();  // refresh connection state (also clears typed keys)
      } 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', 'evh_waiting'];

// 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) {
      // evh_waiting is enforced (locked on) for any user who is an authorised approver
      var evhLocked = col === 'evh_waiting' && _isEvhApprover;
      var locked = isEnforced || forceReadOnly || evhLocked;
      var checked = (isEnforced || evhLocked || prefs[col]) ? ' checked' : '';
      var disabled = locked ? ' disabled' : '';
      var approverBadge = evhLocked && !isEnforced
        ? ' <span class="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-300 align-middle">Approver</span>'
        : '';
      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 + '" />' + (col === 'evh_waiting' ? approverBadge : '') + '</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 = window._ssoUserEmail;
  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 || _isEvhApprover;
  if (tid === 'auditor') return _isToGenAdmin || _isAuditorScoped;  // DVI-1052: admins + granted users
  if (tid === 'qc') return _isToGenAdmin || _isQcScoped;  // DVI-1373: admins + granted users
  if (tid === 'quality') return _isToGenAdmin || _isQualityScoped;  // DVI-1522: QC Tile Surface
  if (tid === 'logistics') return _isToGenAdmin || _isLogisticsScoped;  // DVI-1387
  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/DVI-1337). Reorderable like a tool and
// removable; its saved position splits the home grid into ordered sections.
// `sepId` is the token stored in the order list ('separator' legacy or a unique
// 'sep_<hex>'); a new row mints one so multiple separators stay distinct.
function _usMakeSeparatorRow(container, sepId) {
  var row = document.createElement('div');
  row.setAttribute('draggable', 'true');
  row.setAttribute('data-tool-id', sepId || _mintSeparatorId());
  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="sep-row-summary text-xs font-medium uppercase tracking-wide text-gray-400 dark:text-gray-500 shrink-0 truncate max-w-[14rem]">Separator</span>' +
      '<span class="sep-row-badge hidden inline-flex items-center rounded-full bg-togen-teal/10 text-togen-teal px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide shrink-0">Collapsible</span>' +
      '<span class="h-px flex-1 bg-gray-300 dark:bg-gray-600"></span>' +
    '</div>' +
    _sepEditGearHtml() +
    '<button type="button" title="Remove separator" ' +
      'class="sep-row-remove 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>';
  // Seed the row from the resolved (user override → admin default) props so an
  // unedited row shows the inherited label. It becomes a user override only if
  // the user edits it or already had one (tracked by data-sep-edited on render).
  _sepSeedElementProps(row, _sepProps(row.getAttribute('data-tool-id')));
  // A separator already carried in the user's own tool_separators is an existing
  // override — mark it edited so a Save re-sends it rather than silently dropping.
  var _uSepMap = (_userSettings && _userSettings.tool_separators) || {};
  row.setAttribute('data-sep-edited', _uSepMap.hasOwnProperty(row.getAttribute('data-tool-id')) ? '1' : '0');
  _sepUpdateRowSummary(row);
  row.querySelector('.sep-row-edit').addEventListener('click', function() { _sepEditOpen(row, 'user'); });
  row.querySelector('.sep-row-remove').addEventListener('click', function() {
    if (row.parentNode) row.parentNode.removeChild(row);
  });
  _usAttachRowDrag(row, container);
  return row;
}

// A draggable/removable website-item row for User Settings → Tools (DVI-1344),
// mirroring the separator row. Seeds from the resolved (user override → admin
// default) props; becomes a user override only if edited or user-minted (D3).
function _usMakeWebItemRow(container, itemId) {
  var row = document.createElement('div');
  row.setAttribute('draggable', 'true');
  row.setAttribute('data-tool-id', itemId || _mintWebItemId());
  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 =
    '<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>' +
    _webRowSummaryHtml();
  _webSeedElementProps(row, _webItemProps(row.getAttribute('data-tool-id')) || _adminWebItemProps(row.getAttribute('data-tool-id')));
  var _uItemMap = (_userSettings && _userSettings.tool_items) || {};
  row.setAttribute('data-web-edited', _uItemMap.hasOwnProperty(row.getAttribute('data-tool-id')) ? '1' : '0');
  _webUpdateRowSummary(row);
  row.querySelector('.web-row-edit').addEventListener('click', function() { _webItemEditOpen(row, 'user'); });
  row.querySelector('.web-row-remove').addEventListener('click', function() {
    if (row.parentNode) row.parentNode.removeChild(row);
  });
  _usAttachRowDrag(row, container);
  return row;
}

// A draggable/removable dashboard-tile row for User Settings → Tools (DVI-1346 P2),
// mirroring the website row. Seeds from the resolved (user override → admin
// default) props; becomes a user override only if edited or user-minted (D3).
function _usMakeDashTileRow(container, itemId) {
  var row = document.createElement('div');
  row.setAttribute('draggable', 'true');
  row.setAttribute('data-tool-id', itemId || _mintDashItemId());
  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 =
    '<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>' +
    _dtileRowSummaryHtml();
  _dtileSeedElementProps(row, _dtileItemProps(row.getAttribute('data-tool-id')) || _adminDashItemProps(row.getAttribute('data-tool-id')));
  var _uItemMap = (_userSettings && _userSettings.tool_items) || {};
  row.setAttribute('data-dash-edited', _uItemMap.hasOwnProperty(row.getAttribute('data-tool-id')) ? '1' : '0');
  _dtileUpdateRowSummary(row);
  row.querySelector('.dash-row-edit').addEventListener('click', function() { _dtileEditOpen(row, 'user'); });
  row.querySelector('.dash-row-remove').addEventListener('click', function() {
    if (row.parentNode) row.parentNode.removeChild(row);
  });
  _usAttachRowDrag(row, container);
  return row;
}

// A draggable/removable embedded-dashboard row for User Settings → Tools
// (DVI-1347 P3), mirroring the dashboard-tile row.
function _usMakeDashRefRow(container, itemId) {
  var row = document.createElement('div');
  row.setAttribute('draggable', 'true');
  row.setAttribute('data-tool-id', itemId || _mintDashRefId());
  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 =
    '<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>' +
    _dashRefRowSummaryHtml();
  _dashRefSeedElementProps(row, _dashRefProps(row.getAttribute('data-tool-id')) || _adminDashRefProps(row.getAttribute('data-tool-id')));
  var _uItemMap = (_userSettings && _userSettings.tool_items) || {};
  row.setAttribute('data-dashref-edited', _uItemMap.hasOwnProperty(row.getAttribute('data-tool-id')) ? '1' : '0');
  _dashRefUpdateRowSummary(row);
  row.querySelector('.dashref-row-edit').addEventListener('click', function() { _dashRefEditOpen(row, 'user'); });
  row.querySelector('.dashref-row-remove').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();
});

// Mint a unique separator token (DVI-1337). Client-side only; the server
// re-validates the token shape and caps the count on save.
function _mintSeparatorId() {
  var hex = '';
  for (var i = 0; i < 10; i++) hex += Math.floor(Math.random() * 16).toString(16);
  return 'sep_' + hex;
}

function _countSeparatorRows(container) {
  var n = 0, rows = container.querySelectorAll('[data-tool-id]');
  for (var i = 0; i < rows.length; i++) {
    if (_isSeparatorToken(rows[i].getAttribute('data-tool-id'))) n++;
  }
  return n;
}

function usAddSeparator() {
  usCloseAddMenu();
  var container = document.getElementById('us-tools-list');
  if (!container) return;
  if (_countSeparatorRows(container) >= 10) {
    var status = document.getElementById('us-tools-save-status');
    if (status) {
      status.textContent = 'You can add up to 10 separators.';
      status.className = 'text-sm text-gray-500 dark:text-gray-400';
      setTimeout(function(){ status.textContent = ''; }, 3000);
    }
    return;
  }
  container.appendChild(_usMakeSeparatorRow(container));
}

function usAddWebsite() {
  usCloseAddMenu();
  var container = document.getElementById('us-tools-list');
  if (!container) return;
  var n = 0, rows = container.querySelectorAll(':scope > [data-tool-id]');
  for (var i = 0; i < rows.length; i++) {
    if (_isWebItemToken(rows[i].getAttribute('data-tool-id'))) n++;
  }
  if (n >= 24) {
    var status = document.getElementById('us-tools-save-status');
    if (status) {
      status.textContent = 'You can add up to 24 website tiles.';
      status.className = 'text-sm text-gray-500 dark:text-gray-400';
      setTimeout(function(){ status.textContent = ''; }, 3000);
    }
    return;
  }
  var row = _usMakeWebItemRow(container);
  row.setAttribute('data-web-new', '1');
  container.appendChild(row);
  _webItemEditOpen(row, 'user');
}

function usAddDashTile() {
  usCloseAddMenu();
  var container = document.getElementById('us-tools-list');
  if (!container) return;
  var n = 0, rows = container.querySelectorAll(':scope > [data-tool-id]');
  for (var i = 0; i < rows.length; i++) {
    if (_isDashItemToken(rows[i].getAttribute('data-tool-id'))) n++;
  }
  if (n >= 12) {
    var status = document.getElementById('us-tools-save-status');
    if (status) {
      status.textContent = 'You can add up to 12 dashboard tiles.';
      status.className = 'text-sm text-gray-500 dark:text-gray-400';
      setTimeout(function(){ status.textContent = ''; }, 3000);
    }
    return;
  }
  var row = _usMakeDashTileRow(container);
  row.setAttribute('data-dash-new', '1');
  container.appendChild(row);
  _dtileEditOpen(row, 'user');
}

function usAddDashRef() {
  usCloseAddMenu();
  var container = document.getElementById('us-tools-list');
  if (!container) return;
  var n = 0, rows = container.querySelectorAll(':scope > [data-tool-id]');
  for (var i = 0; i < rows.length; i++) {
    if (_isDashRefToken(rows[i].getAttribute('data-tool-id'))) n++;
  }
  if (n >= 4) {
    var status = document.getElementById('us-tools-save-status');
    if (status) {
      status.textContent = 'You can embed up to 4 dashboards.';
      status.className = 'text-sm text-gray-500 dark:text-gray-400';
      setTimeout(function(){ status.textContent = ''; }, 3000);
    }
    return;
  }
  var row = _usMakeDashRefRow(container);
  row.setAttribute('data-dashref-new', '1');
  container.appendChild(row);
  _dashRefEditOpen(row, 'user');
}

// DVI-856: drag-to-reorder for user-settings submenu rows (WO types, bots).
// Reorders siblings within their own subWrap only; reuses the admin _dragSrcSub.
function _usAttachSubDrag(subRow, subWrap) {
  subRow.setAttribute('draggable', 'true');
  subRow.addEventListener('dragstart', function(e) {
    e.stopPropagation();
    _dragSrcSub = subRow;
    subRow.classList.add('opacity-50');
    e.dataTransfer.effectAllowed = 'move';
  });
  subRow.addEventListener('dragend', function(e) {
    e.stopPropagation();
    subRow.classList.remove('opacity-50');
    var kids = subWrap.children;
    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 && _dragSrcSub.parentNode === subWrap) {
      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);
    }
  });
}

// Whether a WO type is accessible to this user (matches worRenderTypeSelector).
// Inaccessible types (e.g. PM for non-admins) are omitted from the user submenu.
function _usWorTypeAccessible(type) {
  if (type === 'preventive') return _isWorAdmin || _isToGenAdmin;
  return _isToGenAdmin || _worTypeVisibility[type] !== false;
}

// Whether a bot is accessible to this user (admin-enabled or Togen Admin).
function _usBotAccessible(id) {
  return _isToGenAdmin || _botVisibility[id] !== false;
}

// Build a per-user submenu (WO types / bots) beneath a tool row. `items` are
// pre-ordered {id,label}; `userVis` is the per-user visibility override map.
function _usRenderSubmenu(container, listId, attr, items, userVis) {
  var subWrap = document.createElement('div');
  subWrap.id = listId;
  subWrap.className = 'ml-9 mt-2 space-y-2';
  items.forEach(function(it) {
    var checked = !userVis || userVis[it.id] !== false;
    var row = document.createElement('div');
    row.setAttribute(attr, it.id);
    row.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';
    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 cursor-pointer">' +
        '<input type="checkbox" ' + attr + '="' + it.id + '"' + (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-700 dark:text-gray-200">' + esc(it.label) + '</span>' +
      '</label>';
    _usAttachSubDrag(row, subWrap);
    subWrap.appendChild(row);
  });
  container.appendChild(subWrap);
}

// Ordered, accessible WO-type items for the user submenu (user order then admin).
function _usWorTypeItems() {
  var order = (_userSettings.wor_type_order && _userSettings.wor_type_order.length)
    ? _userSettings.wor_type_order.slice() : (_worTypeOrder || []).slice();
  Object.keys(_worTypeLabels).forEach(function(w){ if (order.indexOf(w) === -1) order.push(w); });
  var items = [];
  order.forEach(function(w){
    if (_worTypeLabels[w] && _usWorTypeAccessible(w)) items.push({id: w, label: _worTypeLabels[w]});
  });
  return items;
}

// DVI-1496 P2: sub-tools a user may hide on their own Equipment page — the ones
// that are admin-enabled, accessible, and have a live tab pane (so RPM stays out
// until P3 adds its pane). Mirrors _usWorTypeItems.
function _usSubtoolItems() {
  var cat = (_tileSurfaces && _tileSurfaces.equipment && _tileSurfaces.equipment.catalog) || [];
  var adminVis = (_subtoolVisibilityAdmin && _subtoolVisibilityAdmin.equipment) || {};
  var items = [];
  cat.forEach(function(c){
    if (adminVis[c.id] !== false && _subtoolAccessOk(c) && document.getElementById('em-tab-' + c.tab)) {
      items.push({id: c.id, label: c.title});
    }
  });
  return items;
}

// DVI-1522 (Amendment 1): render the QC surface's per-user GROUP hide rows
// ("Batch Production" / "Kiln Metrics") beneath the QC tool row in User Settings
// -> Tools. Each group checkbox toggles the per-user hide of every member catalog
// tile (data-subtool-ids fan-out; the store stays per-catalog id). A group is
// offered only when at least one member tile is admin-enabled + accessible.
function _qsCatFor(id) {
  var c = _qsCatalog();
  for (var i = 0; i < c.length; i++) { if (c[i].id === id) return c[i]; }
  return null;
}
function _usRenderQualityGroups(container) {
  var groups = (_tileSurfaces && _tileSurfaces.quality && _tileSurfaces.quality.groups) || [];
  var userVis = (_userSettings.subtool_visibility || {}).quality || {};
  var adminVis = (_subtoolVisibilityAdmin && _subtoolVisibilityAdmin.quality) || {};
  var subWrap = document.createElement('div');
  subWrap.id = 'us-quality-subtool-list';
  subWrap.className = 'ml-9 mt-2 space-y-2';
  groups.forEach(function(g) {
    var tiles = g.tiles || [];
    var anyVisible = tiles.some(function(id){
      var cat = _qsCatFor(id);
      return cat && adminVis[id] !== false && _subtoolAccessOk(cat);
    });
    if (!anyVisible) return;
    var checked = tiles.every(function(id){ return userVis[id] !== false; });
    var row = document.createElement('div');
    row.setAttribute('data-subtool-group', g.id);
    row.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';
    row.innerHTML =
      '<label class="flex items-center gap-3 flex-1 cursor-pointer">' +
        '<input type="checkbox" data-subtool-ids="' + esc(tiles.join(',')) + '"' + (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-700 dark:text-gray-200">' + esc(g.label) + '</span>' +
      '</label>';
    subWrap.appendChild(row);
  });
  container.appendChild(subWrap);
}

// Ordered, accessible bot items for the user submenu (user order then admin).
function _usBotItems() {
  var order = (_userSettings.bot_order && _userSettings.bot_order.length)
    ? _userSettings.bot_order.slice() : (_botOrder || []).slice();
  Object.keys(_botLabels).forEach(function(b){ if (order.indexOf(b) === -1) order.push(b); });
  var items = [];
  order.forEach(function(b){
    if (_botLabels[b] && _usBotAccessible(b)) items.push({id: b, label: _botLabels[b]});
  });
  return items;
}

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 (_isSeparatorToken(tid)) return true;          // DVI-524/DVI-1337 separator
    if (_isWebItemToken(tid)) return true;            // DVI-1344 website tile
    if (_isDashItemToken(tid)) return true;           // DVI-1346 dashboard tile
    if (_isDashRefToken(tid)) return true;            // DVI-1347 embedded dashboard
    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: admins always, plus non-admins granted >=1 resource (DVI-1052).
    if (tid === 'auditor') return _isToGenAdmin || _isAuditorScoped;
    if (tid !== 'vision') return true;
    if (_isToGenAdmin) return true;
    return _isTogenVision && _toolVisibility['vision'] !== false;
  });

  ids.forEach(function(tid) {
    if (_isWebItemToken(tid)) { container.appendChild(_usMakeWebItemRow(container, tid)); return; }
    if (_isDashItemToken(tid)) { container.appendChild(_usMakeDashTileRow(container, tid)); return; }
    if (_isDashRefToken(tid)) { container.appendChild(_usMakeDashRefRow(container, tid)); return; }
    if (_isSeparatorToken(tid)) { container.appendChild(_usMakeSeparatorRow(container, tid)); 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);

    // DVI-856: per-user submenus mirroring the admin Tools-Enabled submenus.
    if (tid === 'work-order') {
      _usRenderSubmenu(container, 'us-wortype-list', 'data-wor-type',
        _usWorTypeItems(), _userSettings.wor_type_visibility || {});
    } else if (tid === 'bots') {
      _usRenderSubmenu(container, 'us-bot-list', 'data-bot-id',
        _usBotItems(), _userSettings.bot_visibility || {});
    } else if (tid === 'equipment-manuals') {
      // DVI-1496 P2: per-user hide of Equipment sub-tools (surface 'equipment').
      _usRenderSubmenu(container, 'us-subtool-list', 'data-subtool',
        _usSubtoolItems(), (_userSettings.subtool_visibility || {}).equipment || {});
    } else if (tid === 'quality') {
      // DVI-1522 (Amendment 1): per-user hide of QC surface GROUPS (Batch
      // Production / Kiln Metrics), each toggling its member catalog tiles.
      _usRenderQualityGroups(container);
    }
  });
  _dtileRefreshEditorLabels();
  _dashRefRefreshEditorLabels();
}

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[data-tool-id]');
  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'));
  }

  // DVI-856: submenu visibility + order (WO types under Work Order Request,
  // bots under Bots). Only sent when the submenu is present so a hidden tool
  // does not wipe the user's saved override.
  // DVI-1340 P2 / D3: per-user separator overrides. Only send a separator that
  // the user actually edited (data-sep-edited) or that is user-minted (absent
  // from the admin order) — an untouched inherited admin separator stays
  // inherited (no override) so later admin edits still propagate.
  var sepPayload = {};
  var itemsPayload = {};   // DVI-1344: per-user website-tile overrides
  var usToolsList = document.getElementById('us-tools-list');
  if (usToolsList) {
    usToolsList.querySelectorAll(':scope > [data-tool-id]').forEach(function(r){
      var tid = r.getAttribute('data-tool-id');
      if (_isSeparatorToken(tid)) {
        var isAdminSep = _toolOrder.indexOf(tid) !== -1;
        var editedSep = r.getAttribute('data-sep-edited') === '1';
        if (editedSep || !isAdminSep) sepPayload[tid] = _sepReadElementProps(r);
      } else if (_isWebItemToken(tid)) {
        var isAdminItem = _toolOrder.indexOf(tid) !== -1;
        var editedItem = r.getAttribute('data-web-edited') === '1';
        if (editedItem || !isAdminItem) itemsPayload[tid] = _webReadElementProps(r);
      } else if (_isDashItemToken(tid)) {
        var isAdminDash = _toolOrder.indexOf(tid) !== -1;
        var editedDash = r.getAttribute('data-dash-edited') === '1';
        if (editedDash || !isAdminDash) itemsPayload[tid] = _dtileReadElementProps(r);
      } else if (_isDashRefToken(tid)) {
        var isAdminRef = _toolOrder.indexOf(tid) !== -1;
        var editedRef = r.getAttribute('data-dashref-edited') === '1';
        if (editedRef || !isAdminRef) itemsPayload[tid] = _dashRefReadElementProps(r);
      }
    });
  }

  var body = {
    tool_visibility: visPayload,
    tool_order: orderPayload,
    tool_separators: sepPayload,
    tool_items: itemsPayload,
    theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light'
  };
  var wtl = document.getElementById('us-wortype-list');
  if (wtl) {
    var wtVis = {}, wtOrder = [];
    wtl.querySelectorAll('input[data-wor-type]').forEach(function(c){ wtVis[c.getAttribute('data-wor-type')] = c.checked; });
    wtl.querySelectorAll(':scope > [data-wor-type]').forEach(function(r){ wtOrder.push(r.getAttribute('data-wor-type')); });
    body.wor_type_visibility = wtVis;
    body.wor_type_order = wtOrder;
  }
  var btl = document.getElementById('us-bot-list');
  if (btl) {
    var bVis = {}, bOrder = [];
    btl.querySelectorAll('input[data-bot-id]').forEach(function(c){ bVis[c.getAttribute('data-bot-id')] = c.checked; });
    btl.querySelectorAll(':scope > [data-bot-id]').forEach(function(r){ bOrder.push(r.getAttribute('data-bot-id')); });
    body.bot_visibility = bVis;
    body.bot_order = bOrder;
  }
  // DVI-1496 P2: per-user Equipment sub-tool hide overrides. Only sent when the
  // submenu is present so a collapsed/absent list never wipes the saved override.
  var stl = document.getElementById('us-subtool-list');
  var svMap = {};
  if (stl) {
    var stVis = {};
    stl.querySelectorAll('input[data-subtool]').forEach(function(c){ stVis[c.getAttribute('data-subtool')] = c.checked; });
    svMap.equipment = stVis;
  }
  // DVI-1522 (Amendment 1): the QC surface groups fan out to per-catalog ids.
  var qstl = document.getElementById('us-quality-subtool-list');
  if (qstl) {
    var qVis = {};
    qstl.querySelectorAll('input[data-subtool-ids]').forEach(function(c){
      (c.getAttribute('data-subtool-ids') || '').split(',').forEach(function(id){ if (id) qVis[id] = c.checked; });
    });
    svMap.quality = qVis;
  }
  if (Object.keys(svMap).length) body.subtool_visibility = svMap;

  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(body)
  })
    .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();
        if (typeof worRenderTypeSelector === 'function') worRenderTypeSelector();
        if (typeof _emRecomputeVisibility === 'function') _emRecomputeVisibility();
        if (typeof _qsRecomputeVisibility === 'function') _qsRecomputeVisibility();
        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 = window._ssoUserName;
    var ssoEmail = window._ssoUserEmail;
    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 TOOL — tab bar (DVI-1012) =====

var _emLastTab = 'manuals';

// ===========================================================================
// Tile Surfaces (DVI-1496 P1) — the Equipment "sub-home" tiles page. The tiles
// page is the landing view; a sub-tool tab is shown only after clicking a tile
// (or the gear). The layout order comes from GET /tools/subpage/equipment
// (admin default / catalog seed + user overrides, D8-A); catalog metadata comes
// from the _tileSurfaces JS mirror. P1 is read-only navigation: Edit / + Add are
// stubbed; the partition core (_surfacePartitionEntries) is surface-generic so
// P4 can add separators/web/dtile/dashref items behind the same seam.
// ===========================================================================
var _emSubpage = null;        // cached MERGED {order, separators, items} (read render)
var _emSubpageAdmin = null;   // admin-default {order, separators, items} (DVI-1496 P4 edit)
var _emSubpageUser = null;    // raw per-user override {order, separators, items} (edit)
var _emEditMode = false;
var _emEditScope = 'user';    // 'user' (My list) | 'admin' (Default list, admins only)
var _emHistNav = false;       // DVI-1496 P5: true while responding to a popstate,
                              // so emSwitchTab/emShowTiles don't push/replace history

function _emCatalog() {
  return (_tileSurfaces && _tileSurfaces.equipment && _tileSurfaces.equipment.catalog) || [];
}
// A catalog tile is renderable when its target tab pane exists in the DOM AND
// the sub-tool is effectively visible to this user (DVI-1496 P2: admin-enabled,
// not user-hidden, access ok). In P1 there is no #em-tab-rpm, so the RPM tile
// stays hidden until P3 adds it.
function _emCatalogRenderable(cat) {
  return !!(cat && cat.tab && document.getElementById('em-tab-' + cat.tab)
            && _subtoolVisibleFor('equipment', cat.id));
}

// Surface-generic partition (DVI-1496 P1, D2-A): split an ordered token list into
// sections around separator tokens. ctx = { order:[], isSep:fn, entryFor:fn(tid),
// appendMissing:fn(bucket) }. Returns {sections:[{sepId, entries, tools}]} — the
// exact shape _partitionToolsBySeparator returns, so home can migrate onto this
// seam in P4. A divider is only emitted between two non-empty sections.
function _surfacePartitionEntries(ctx) {
  // DVI-1521: rawCounts tracks raw (pre-resolution) non-separator tokens per
  // bucket so a genuinely LEADING separator (nothing ordered before it) renders,
  // while a section that only collapsed because its entries resolved away (hidden/
  // inaccessible sub-tools) still drops its divider.
  var buckets = [[]], sepIds = [], rawCounts = [0];
  (ctx.order || []).forEach(function(tid) {
    if (ctx.isSep(tid)) { sepIds.push(tid); buckets.push([]); rawCounts.push(0); return; }
    rawCounts[rawCounts.length - 1]++;
    var e = ctx.entryFor(tid);
    if (e) buckets[buckets.length - 1].push(e);
  });
  if (ctx.appendMissing) ctx.appendMissing(buckets[buckets.length - 1]);
  var sections = [];
  var rawBefore = 0;
  for (var k = 0; k < buckets.length; k++) {
    if (!buckets[k].length) { rawBefore += rawCounts[k]; continue; }
    var sepId = null;
    if (k > 0 && sepIds[k - 1] && (sections.length > 0 || rawBefore === 0)) sepId = sepIds[k - 1];
    var toolsOnly = buckets[k].filter(function(e) { return e.kind === 'tool'; }).map(function(e) { return e.tool; });
    sections.push({ sepId: sepId, entries: buckets[k], tools: toolsOnly });
    rawBefore += rawCounts[k];
  }
  if (!sections.length) sections.push({ sepId: null, entries: [], tools: [] });
  return { sections: sections };
}

// Partition the equipment surface. P1 entries are catalog nav tiles only; the
// web/dtile/dashref branches arrive in P4.
function _emPartition() {
  var layout = _emSubpage || { order: [], separators: {}, items: {} };
  var items = layout.items || {};   // DVI-1496 P4: added web/dtile/dashref items
  var catById = {};
  _emCatalog().forEach(function(c) { catById[c.id] = c; });
  var seen = {};
  return _surfacePartitionEntries({
    order: layout.order || [],
    isSep: _isSeparatorToken,
    entryFor: function(tid) {
      // Added items (DVI-1496 P4) resolve from the merged effective items map.
      if (_isWebItemToken(tid)) { var w = _webItemProps(tid, items, {}); return w ? { kind: 'web', id: tid, item: w } : null; }
      if (_isDashItemToken(tid)) { var dt = _dtileItemProps(tid, items, {}); return dt ? { kind: 'dtile', id: tid, item: dt } : null; }
      if (_isDashRefToken(tid)) { var dr = _dashRefProps(tid, items, {}); return dr ? { kind: 'dashref', id: tid, item: dr } : null; }
      if (catById[tid] && !seen[tid] && _emCatalogRenderable(catById[tid])) {
        seen[tid] = true;
        return { kind: 'catalog', id: tid, cat: catById[tid] };
      }
      return null;
    },
    appendMissing: function(bucket) {
      _emCatalog().forEach(function(c) {
        if (!seen[c.id] && _emCatalogRenderable(c)) { seen[c.id] = true; bucket.push({ kind: 'catalog', id: c.id, cat: c }); }
      });
    }
  });
}

// Build one read-mode entry element for the Equipment tiles page (mirrors the
// home _buildHomeEntry; a catalog entry is the nav tile).
function _emBuildEntry(e) {
  if (e.kind === 'web') return _buildWebTile(e.item);
  if (e.kind === 'dtile') return _buildDashTile(e.item, e.id);
  if (e.kind === 'dashref') return _buildDashRef(e.item, e.id);
  return _emBuildCatalogTile(e.cat);
}

// Land on the tiles page: hide the tab bar + every sub-tool pane, show the tiles
// grid, and render. The last sub-tool is deliberately NOT auto-restored (D5-A).
// DVI-1496 P2: hide the tab button for any catalog sub-tool that is not
// effectively visible to this user (Settings is the gear, not a catalog tile,
// so it always stays). Called whenever the tab bar is (re)shown.
function _emApplyTabVisibility() {
  ['assets', 'manuals', 'oversight', 'rpm'].forEach(function(t) {
    var btn = document.getElementById('em-tab-btn-' + t);
    if (btn) btn.classList.toggle('hidden', !_subtoolVisibleFor('equipment', t));
  });
}

// DVI-1496 P2: client mirror of _subtool_access_ok — admin OR open_default OR
// the access leaf held OR RPM's legacy role. (open_default defaults true.)
function _subtoolAccessOk(cat) {
  if (_isToGenAdmin) return true;
  if (!cat) return false;
  if (cat.open_default !== false) return true;
  if (cat.access_leaf && _toolAccessCan(cat.access_leaf)) return true;
  if (cat.extra_access === '_rpmAccess' && typeof _rpmAccess !== 'undefined' && _rpmAccess) return true;
  // DVI-1522 (D5): Kiln Metrics tiles gate on the togen_scada role; Daily
  // Production gates on any qc.production.<segment> grant. Mirrors _subtool_access_ok.
  if (cat.extra_access === '_km_scada' && typeof _isScada !== 'undefined' && _isScada) return true;
  if (cat.extra_access === '_qc_daily' && (_toolAccessResources || []).some(function(r){ return r.indexOf('qc.production.') === 0; })) return true;
  return false;
}

// Recompute _subtoolVisibility.equipment from the admin defaults + this user's
// own hide overrides + access, then re-render the Equipment surface if it is
// currently open (so a Tools-Enabled / User-Settings save takes effect live).
function _emRecomputeVisibility() {
  var cat = (_tileSurfaces && _tileSurfaces.equipment && _tileSurfaces.equipment.catalog) || [];
  var adminVis = (_subtoolVisibilityAdmin && _subtoolVisibilityAdmin.equipment) || {};
  var userSub = (_userSettings && _userSettings.subtool_visibility) || {};
  var userVis = userSub.equipment || {};
  var eff = {};
  cat.forEach(function(c) {
    if (_isToGenAdmin) { eff[c.id] = true; return; }
    eff[c.id] = (adminVis[c.id] !== false) && (userVis[c.id] !== false) && _subtoolAccessOk(c);
  });
  _subtoolVisibility = _subtoolVisibility || {};
  _subtoolVisibility.equipment = eff;
  if (_activeTool === 'equipment-manuals') {
    var grid = document.getElementById('em-tiles-grid');
    if (grid && !grid.classList.contains('hidden')) emRenderTiles();
    _emApplyTabVisibility();
  }
}

function emShowTiles() {
  var bar = document.getElementById('em-tabbar');
  if (bar) bar.classList.add('hidden');
  ['assets', 'manuals', 'oversight', 'settings', 'rpm'].forEach(function(t) {
    var p = document.getElementById('em-tab-' + t);
    if (p) p.classList.add('hidden');
  });
  var grid = document.getElementById('em-tiles-grid');
  if (grid) grid.classList.remove('hidden');
  _emLastTab = null;
  // DVI-1496 P5: returning to the tiles landing collapses a pushed sub-tool
  // history entry back to the tool entry, so Back from the tiles page goes to
  // Tools/home (rec 3). Skipped while responding to a popstate.
  if (!_emHistNav && _activeTool === 'equipment-manuals' &&
      history.state && history.state.page === 'subtool') {
    history.replaceState({ page: 'tool', tool: 'equipment-manuals', title: 'Equipment' }, '');
  }
  emRenderTiles();
}

function emRenderTiles() {
  if (_emSubpage) { _emRenderTilesNow(); return; }
  _emLoadSubpage(_emRenderTilesNow);
}

// Fetch the equipment subpage layout and cache the merged effective view (read
// render) plus the admin-default + raw user override (DVI-1496 P4 edit scopes).
function _emLoadSubpage(cb) {
  var empty = { order: [], separators: {}, items: {} };
  fetch('/tools/subpage/equipment')
    .then(function(r) { return r.json(); })
    .then(function(d) {
      d = d || {};
      _emSubpage = (d.order || d.separators || d.items) ? { order: d.order || [], separators: d.separators || {}, items: d.items || {} } : empty;
      _emSubpageAdmin = d.admin || { order: [], separators: {}, items: {} };
      _emSubpageUser = d.user || { order: [], separators: {}, items: {} };
      if (cb) cb();
    })
    .catch(function() { _emSubpage = empty; _emSubpageAdmin = { order: [], separators: {}, items: {} }; _emSubpageUser = { order: [], separators: {}, items: {} }; if (cb) cb(); });
}

function _emRenderTilesNow() {
  var list = document.getElementById('em-tiles-list');
  var empty = document.getElementById('em-tiles-empty');
  if (!list) return;
  // DVI-1496 P4: in edit mode, render the flat draggable edit grid via the shared
  // engine (mirrors renderTileGrid's _homeRenderEditGrid guard).
  if (_emEditMode) { _editCtx = _emEditCtx(); _seRenderEditGrid(); if (empty) empty.classList.add('hidden'); return; }
  list.innerHTML = '';
  var part = _emPartition();
  var seps = (_emSubpage && _emSubpage.separators) || {};
  var count = 0;
  // DVI-1521: a leading separator lives on section 0, so the sectioned path also
  // runs for a single section that carries one.
  if (part.sections.length > 1 || (part.sections[0] && part.sections[0].sepId)) {
    // DVI-1496 P5: N grid sections split by full-width dividers, each honoring its
    // separator's per-section column override (DVI-1356) via _applyTileGridColumns
    // (e.g. a 4-across quick-links strip above a 2-across dashboard-tile section).
    list.className = 'flex flex-col gap-5';
    list.style.gridTemplateColumns = '';
    part.sections.forEach(function(sec, i) {
      if (sec.sepId) list.appendChild(_makeTileDivider(sec.sepId, { props: _sepProps(sec.sepId, seps, {}), onToggle: emRenderTiles }));
      if (sec.sepId && _sepIsCollapsed(sec.sepId, _sepProps(sec.sepId, seps, {}))) return;   // DVI-1516: scope-resolved props (sub-tool store, not home)
      var secEl = document.createElement('div');
      secEl.className = 'tile-grid-section grid grid-cols-1 sm:grid-cols-2 gap-5';
      secEl.setAttribute('data-sep-columns', String(_sepProps(sec.sepId, seps, {}).columns));
      sec.entries.forEach(function(e) { secEl.appendChild(_emBuildEntry(e)); count++; });
      list.appendChild(secEl);
    });
  } else {
    list.className = 'grid grid-cols-1 sm:grid-cols-2 gap-5';
    list.style.gridTemplateColumns = '';
    part.sections[0].entries.forEach(function(e) { list.appendChild(_emBuildEntry(e)); count++; });
  }
  // Equipment tiles page base = 2-across; a section's separator.columns (1..6)
  // overrides just that section at lg+ (mobile keeps the 1/2-col collapse).
  _applyTileGridColumns(list, 2);
  if (empty) empty.classList.toggle('hidden', count > 0);
  if (_emTileData && _emTileData.summaries) _emApplySummaries(_emTileData.summaries);
  _emTileStartPolling();
}

// ---- Equipment surface live-data polling (DVI-1496 P5, D4-A) ----------------
// Poll /tools/tile-data?surface=equipment ONLY while the tiles page is visible:
// updates the four catalog summary lines and any user-added dashboard-tile /
// embedded-dashboard bodies in place (mirrors the home poller). Entering a
// sub-tool, edit mode, or another tool stops it (self-stops on the next tick).
var _emTileTimer = null;
function _emTilesVisible() {
  if (_activeTool !== 'equipment-manuals' || _emEditMode) return false;
  var g = document.getElementById('em-tiles-grid');
  return !!(g && !g.classList.contains('hidden'));
}
function _emTileDataFetch() {
  if (!_emTilesVisible()) { _emTileStopPolling(); return; }
  fetch('/tools/tile-data?surface=equipment').then(function(r){ return r.json(); }).then(function(d){
    if (!d) return;
    _emTileData = d;
    if (d.summaries) _emApplySummaries(d.summaries);
    var items = (_emSubpage && _emSubpage.items) || {};
    if (d.tiles) {
      Object.keys(d.tiles).forEach(function(iid){
        var body = document.querySelector('#em-tiles-list .dash-tile-body[data-tile-id="' + iid + '"]');
        var props = items[iid];
        if (!body || !props || props.type !== 'dtile') return;
        dashboardsRenderTileBody(body, { id: iid, type: props.tile_type, config: props.config }, d.tiles[iid]);
      });
    }
    if (d.dashrefs) {
      Object.keys(d.dashrefs).forEach(function(iid){
        var body = document.querySelector('#em-tiles-list .dashref-body[data-dashref-id="' + iid + '"]');
        var props = items[iid];
        if (!body || !props || props.type !== 'dashref') return;
        _buildDashRefBody(body, iid, props, d.dashrefs[iid]);
      });
    }
  }).catch(function(){});
}
function _emTileStartPolling() {
  _emTileStopPolling();
  if (!_emTilesVisible()) return;
  _emTileDataFetch();
  _emTileTimer = setInterval(_emTileDataFetch, 60000);
}
function _emTileStopPolling() { if (_emTileTimer) { clearInterval(_emTileTimer); _emTileTimer = null; } }

// A navigation tile for a catalog sub-tool. Title + description + a live summary
// line (DVI-1496 P5, D4-A) filled by _emApplySummaries from the surface poller.
function _emBuildCatalogTile(cat) {
  var tile = document.createElement('button');
  tile.setAttribute('data-em-catalog', cat.id);
  tile.onclick = function() { emActivateCatalog(cat); };
  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">' + cat.icon + '</div>' +
    '<div>' +
      '<p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">' + esc(cat.title) + '</p>' +
      '<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 leading-relaxed">' + esc(cat.description) + '</p>' +
      '<p data-em-summary="' + esc(cat.id) + '" class="hidden text-xs font-medium mt-2 text-togen-teal"></p>' +
    '</div>';
  return tile;
}

// DVI-1496 P5 (D4-A): apply the live summary lines onto the catalog tiles. Cached
// in _emTileData so a re-render (which rebuilds the tiles) can re-apply without a
// refetch. A missing/blank summary leaves the slot hidden.
var _emTileData = null;   // { summaries, tiles, dashrefs } from the last poll
function _emApplySummaries(summaries) {
  if (!summaries) return;
  Object.keys(summaries).forEach(function(cid) {
    var el = document.querySelector('#em-tiles-list [data-em-summary="' + cid + '"]');
    if (!el) return;
    var s = summaries[cid] || {};
    if (s.text) {
      el.textContent = s.text;
      el.classList.remove('hidden');
      var warn = s.tone === 'warn';
      el.classList.toggle('text-amber-600', warn);
      el.classList.toggle('dark:text-amber-400', warn);
      el.classList.toggle('text-togen-teal', !warn);
    } else {
      el.classList.add('hidden');
    }
  });
}

function emActivateCatalog(cat) {
  if (cat && cat.tab) emSwitchTab(cat.tab);
}

// ===========================================================================
// Equipment tiles-page EDIT mode (DVI-1496 P4). Full home-page parity, REUSING
// the DVI-1357 edit engine (_homeOverlay / _homeDecorateEntry / _homeEditDivider
// / _homeAttachDrag / _homeCommitOrder / _homeGearOpen / _homeMaybeCommitAfterEdit
// / _homeRemoveEntry / _homeCommit / _seRenderEditGrid) via a surface edit context
// (_editCtx). The four body-scoped dialogs + prop readers/seeders + build fns +
// mint fns are shared. Only the surface bits (grid, order/maps, commit routes,
// entry render, folds) live here in _emEditCtx().
// ===========================================================================

// The user-override maps the resolvers overlay in each scope: {} in the admin-
// default scope (props resolve admin-only), else the user's overrides.
function _emUserSepMap()  { return _emEditScope === 'admin' ? {} : ((_emSubpageUser && _emSubpageUser.separators) || {}); }
function _emUserItemMap() { return _emEditScope === 'admin' ? {} : ((_emSubpageUser && _emSubpageUser.items) || {}); }

// The EQUIPMENT surface's edit context (DVI-1496 P4).
function _emEditCtx() {
  var aItems = function() { return (_emSubpageAdmin && _emSubpageAdmin.items) || {}; };
  var aSeps  = function() { return (_emSubpageAdmin && _emSubpageAdmin.separators) || {}; };
  return {
    surface: 'equipment',
    scope: function() { return _emEditScope; },
    toastId: 'em-edit-toast',
    gridEl: function() { return document.getElementById('em-tiles-list'); },
    adminOrder: function() { return (_emSubpageAdmin && _emSubpageAdmin.order) || []; },
    adminSepMap: aSeps,
    adminItemMap: aItems,
    sepProps: function(id) { return _sepProps(id, aSeps(), _emUserSepMap()); },
    baseOrder: function() {
      var o = (_emEditScope === 'admin')
        ? ((_emSubpageAdmin && _emSubpageAdmin.order) || [])
        : ((_emSubpageUser && _emSubpageUser.order && _emSubpageUser.order.length) ? _emSubpageUser.order : ((_emSubpageAdmin && _emSubpageAdmin.order) || []));
      return o.slice();
    },
    setBaseOrder: function(order) {
      if (_emEditScope === 'admin') { _emSubpageAdmin = _emSubpageAdmin || {}; _emSubpageAdmin.order = order; }
      else { _emSubpageUser = _emSubpageUser || {}; _emSubpageUser.order = order; }
    },
    removeMsg: 'Remove this from the Equipment page?',
    renderInto: function(grid) {
      grid.className = 'grid grid-cols-1 sm:grid-cols-2 gap-4 home-edit-grid';
      grid.style.gridTemplateColumns = '';
      var order = this.baseOrder();
      var catById = {};
      _emCatalog().forEach(function(c) { catById[c.id] = c; });
      var seen = {};
      var aI = aItems(), uI = _emUserItemMap();
      order.forEach(function(tid) {
        var el = null, entry = null;
        if (_isSeparatorToken(tid)) { grid.appendChild(_homeEditDivider(tid)); return; }
        if (_isWebItemToken(tid)) { var w = _webItemProps(tid, aI, uI); if (!w) return; el = _buildWebTile(w); entry = { kind: 'web', id: tid, item: w, editable: true }; }
        else if (_isDashItemToken(tid)) { var dt = _dtileItemProps(tid, aI, uI); if (!dt) return; el = _buildDashTile(dt, tid); entry = { kind: 'dtile', id: tid, item: dt, editable: true }; }
        else if (_isDashRefToken(tid)) { var dr = _dashRefProps(tid, aI, uI); if (!dr) return; el = _buildDashRef(dr, tid); entry = { kind: 'dashref', id: tid, item: dr, editable: true }; }
        else if (catById[tid] && !seen[tid] && _emCatalogRenderable(catById[tid])) { seen[tid] = true; el = _emBuildCatalogTile(catById[tid]); entry = { kind: 'catalog', id: tid, cat: catById[tid] }; }
        else return;   // a non-renderable catalog token is preserved by _homeMergeHiddenOrder
        grid.appendChild(_homeDecorateEntry(el, entry));
      });
      // Append any renderable catalog sub-tools missing from the order.
      _emCatalog().forEach(function(c) {
        if (seen[c.id] || !_emCatalogRenderable(c)) return;
        seen[c.id] = true;
        grid.appendChild(_homeDecorateEntry(_emBuildCatalogTile(c), { kind: 'catalog', id: c.id, cat: c }));
      });
    },
    commit: _emCommitScope,
    foldEditProps: _emFoldEditProps,
    foldRemoveProps: _emFoldRemoveProps,
  };
}

function _emCommitScope() {
  if (_emEditScope === 'admin') {
    _homePost('/togen-admin/tools/subpage/equipment', {
      order: (_emSubpageAdmin.order || []),
      separators: (_emSubpageAdmin.separators || {}),
      items: (_emSubpageAdmin.items || {})
    }, function(d) {
      if (d.admin) _emSubpageAdmin = d.admin;
      if (d.order) _emSubpage = { order: d.order, separators: d.separators || {}, items: d.items || {} };
    });
  } else {
    // Materialize the order so an empty user order (a first gear-edit with no
    // reorder) is never sent — the route treats fully-empty as "reset/clear".
    var ord = (_emSubpageUser.order && _emSubpageUser.order.length) ? _emSubpageUser.order.slice() : ((_emSubpageAdmin.order || []).slice());
    _emSubpageUser = _emSubpageUser || {}; _emSubpageUser.order = ord;
    _homePost('/tools/subpage/equipment', {
      order: ord,
      separators: (_emSubpageUser.separators || {}),
      items: (_emSubpageUser.items || {})
    }, function(d) {
      if (d.user) _emSubpageUser = d.user;
      if (d.order) _emSubpage = { order: d.order, separators: d.separators || {}, items: d.items || {} };
    });
  }
}
function _emFoldEditProps(tid, isSep, props, wasEdited) {
  if (_emEditScope === 'admin') {
    _emSubpageAdmin = _emSubpageAdmin || {};
    if (isSep) { _emSubpageAdmin.separators = _emSubpageAdmin.separators || {}; _emSubpageAdmin.separators[tid] = props; }
    else { _emSubpageAdmin.items = _emSubpageAdmin.items || {}; _emSubpageAdmin.items[tid] = props; }
  } else {
    _emSubpageUser = _emSubpageUser || {};
    if (isSep) { _emSubpageUser.separators = _emSubpageUser.separators || {}; if (wasEdited) _emSubpageUser.separators[tid] = props; else delete _emSubpageUser.separators[tid]; }
    else { _emSubpageUser.items = _emSubpageUser.items || {}; if (wasEdited) _emSubpageUser.items[tid] = props; else delete _emSubpageUser.items[tid]; }
  }
}
function _emFoldRemoveProps(tid, isSep) {
  if (_emEditScope === 'admin') {
    if (isSep) { if (_emSubpageAdmin && _emSubpageAdmin.separators) delete _emSubpageAdmin.separators[tid]; }
    else if (_emSubpageAdmin && _emSubpageAdmin.items) delete _emSubpageAdmin.items[tid];
  } else {
    if (isSep) { if (_emSubpageUser && _emSubpageUser.separators) delete _emSubpageUser.separators[tid]; }
    else if (_emSubpageUser && _emSubpageUser.items) delete _emSubpageUser.items[tid];
  }
}

// ---- Equipment edit chrome (toggle / scope / add menu / reset) --------------
function emEditToggle() {
  _emEditMode = !_emEditMode;
  _homePendingAddId = null;
  _emCloseAddMenu();
  var toolbar = document.getElementById('em-edit-toolbar');
  var editBtn = document.getElementById('em-tiles-edit');
  var lbl = document.getElementById('em-tiles-edit-label');
  if (toolbar) toolbar.classList.toggle('hidden', !_emEditMode);
  if (editBtn) { editBtn.classList.toggle('bg-togen-teal', _emEditMode); editBtn.classList.toggle('text-white', _emEditMode); }
  if (lbl) lbl.textContent = _emEditMode ? 'Done' : 'Edit';
  if (_emEditMode) {
    _emTileStopPolling();   // DVI-1496 P5: no live polling while editing the layout
    if (!_isToGenAdmin) _emEditScope = 'user';
    _emUpdateScopeButtons();
    // Ensure the admin/user scopes are loaded, then render the edit grid.
    if (_emSubpageAdmin && _emSubpageUser) { _editCtx = _emEditCtx(); _seRenderEditGrid(); }
    else _emLoadSubpage(function() { _editCtx = _emEditCtx(); _seRenderEditGrid(); });
  } else {
    _editCtx = null;
    emRenderTiles();   // restore the normal (clickable) tiles page
  }
}
function emEditSetScope(scope) {
  if (!_isToGenAdmin && scope === 'admin') return;   // only admins edit the Default list
  _emEditScope = (scope === 'admin') ? 'admin' : 'user';
  _homePendingAddId = null;
  _emUpdateScopeButtons();
  if (_emEditMode) { _editCtx = _emEditCtx(); _seRenderEditGrid(); }
}
function _emUpdateScopeButtons() {
  var u = document.getElementById('em-scope-user');
  var a = document.getElementById('em-scope-admin');
  var onCls = ['bg-togen-teal', 'text-white'];
  var offCls = ['text-gray-600', 'dark:text-gray-300'];
  function set(btn, on) { if (!btn) return; onCls.forEach(function(c) { btn.classList.toggle(c, on); }); offCls.forEach(function(c) { btn.classList.toggle(c, !on); }); }
  set(u, _emEditScope === 'user');
  set(a, _emEditScope === 'admin');
  // "Reset my layout" is a My-list affordance; hide it in the Default scope.
  var reset = document.getElementById('em-edit-reset');
  if (reset) reset.classList.toggle('hidden', _emEditScope !== 'user');
}
function emEditToggleAddMenu(e) { if (e) e.stopPropagation(); var m = document.getElementById('em-edit-add-menu'); if (m) m.classList.toggle('hidden'); }
function _emCloseAddMenu() { var m = document.getElementById('em-edit-add-menu'); if (m) m.classList.add('hidden'); }
function emEditAddSeparator() {
  _emCloseAddMenu();
  var id = _mintSeparatorId();
  var ord = _editCtx.baseOrder(); ord.push(id); _editCtx.setBaseOrder(ord);
  _homeCommit();   // a bare separator needs no props
}
function emEditAddWebsite() {
  _emCloseAddMenu();
  var id = _mintWebItemId();
  var el = document.createElement('div');
  el.setAttribute('data-tool-id', id); el.setAttribute('data-web-new', '1');
  _webSeedElementProps(el, { type: 'web', name: '', url: '', mode: 'newtab', icon_mode: 'favicon', icon: '', glyph: '' });
  _homePendingAddId = id;
  _webItemEditOpen(el, _seScope());
}
function emEditAddDashTile() {
  _emCloseAddMenu();
  var id = _mintDashItemId();
  var el = document.createElement('div');
  el.setAttribute('data-tool-id', id); el.setAttribute('data-dash-new', '1');
  _dtileSeedElementProps(el, { type: 'dtile', tile_type: '', config: {}, size: 'default', title: '' });
  _homePendingAddId = id;
  _dtileEditOpen(el, _seScope());
}
function emEditAddDashRef() {
  _emCloseAddMenu();
  var id = _mintDashRefId();
  var el = document.createElement('div');
  el.setAttribute('data-tool-id', id); el.setAttribute('data-dashref-new', '1');
  _dashRefSeedElementProps(el, { type: 'dashref', dashboard_id: '', title: '', justify: 'center', collapse: 'none', collapsed: false, columns: 'auto', rows: 'auto' });
  _homePendingAddId = id;
  _dashRefEditOpen(el, _seScope());
}

// DVI-1496 P4 (D10-A): "Reset my layout" — clear this user's override for the
// surface so it inherits the admin Default. Written as a reusable helper so P6
// can port the same affordance to the home grid.
function _seResetUserLayout(url, body, onOk) { _homePost(url, body, function(d) { if (onOk) onOk(d || {}); }); }
function emResetMyLayout() {
  if (!window.confirm('Reset the Equipment page to the default layout? This clears your personal changes.')) return;
  _seResetUserLayout('/tools/subpage/equipment', { order: [], separators: {}, items: {} }, function(d) {
    _emSubpageUser = d.user || { order: [], separators: {}, items: {} };
    if (d.order || d.separators || d.items) _emSubpage = { order: d.order || [], separators: d.separators || {}, items: d.items || {} };
  });
}

function emSwitchTab(tab) {
  // DVI-1472 P2 D4: 'equipment' retired (folded into 'assets'). Legacy
  // emSwitchTab('equipment') callers now land on the Assets tab.
  // DVI-1496 P1/P3: markup/tab order is Assets | Manuals | RPM | Oversight … Settings.
  var tabs = ['assets', 'manuals', 'rpm', 'oversight', 'settings'];
  if (tab === 'equipment') tab = 'assets';
  if (!document.getElementById('em-tab-manuals')) return;
  if (_emEditMode) emEditToggle();   // DVI-1496 P4: leave tiles-page edit before entering a sub-tool
  // DVI-1496 P2: a disabled/hidden catalog sub-tool is not reachable — a stray
  // deep link / stale tile lands back on the tiles page instead (Settings is the
  // gear, always allowed; server routes 403 independently, D9).
  if (tab !== 'settings' && !_subtoolVisibleFor('equipment', tab)) {
    emShowTiles();
    return;
  }
  // DVI-1496 P1: entering a sub-tool leaves the tiles landing page — hide the
  // tiles grid and reveal the tab bar (with the "◂ Equipment" crumb).
  var _emTiles = document.getElementById('em-tiles-grid');
  if (_emTiles) _emTiles.classList.add('hidden');
  _emTileStopPolling();   // DVI-1496 P5: the tiles page is no longer visible
  var _emBar = document.getElementById('em-tabbar');
  if (_emBar) _emBar.classList.remove('hidden');
  _emApplyTabVisibility();
  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); }
  }
  tabs.forEach(function(t) {
    var pane = document.getElementById('em-tab-' + t);
    if (pane) pane.classList.add('hidden');
    setActive(document.getElementById('em-tab-btn-' + t), false);
  });
  if (tabs.indexOf(tab) === -1) tab = 'manuals';
  var activePane = document.getElementById('em-tab-' + tab);
  if (activePane) activePane.classList.remove('hidden');
  setActive(document.getElementById('em-tab-btn-' + tab), true);
  _emLastTab = tab;
  // DVI-1496 P5 (rec 3): entering a sub-tool from the tiles landing pushes a
  // history entry so browser Back returns to the tiles page (not straight to
  // Tools/home). Suppressed while replaying a popstate; deduped per tab.
  if (!_emHistNav && _activeTool === 'equipment-manuals') {
    var _st = history.state;
    if (!(_st && _st.page === 'subtool' && _st.tab === tab)) {
      history.pushState({ page: 'subtool', tool: 'equipment-manuals', title: 'Equipment', tab: tab }, '');
    }
  }
  if (tab === 'assets') {
    assetsInit();
  } else if (tab === 'manuals') {
    emLoadList();          // DVI-1496 P1: manuals loads on tab entry (landing is tiles)
  } else if (tab === 'rpm') {
    rpmSwitchTab('all');   // DVI-1496 P3: RPM sub-tool resets to All Parts on entry
  } else if (tab === 'oversight') {
    emovInit();
  } else if (tab === 'settings') {
    emLoadOversightSettings();
  }
}

// ===========================================================================
// QC TILE SURFACE (DVI-1519 P3 / DVI-1522) — the `quality` tool. A 3rd Tile
// Surface, and the first with CROSS-TOOL catalog activation (D4): its catalog
// tiles open OTHER tools (Batch Production = `qc`, Kiln Metrics = `scada-view`)
// and switch to the right tab there; browser Back returns to the QC surface.
// The surface has NO sub-tabs of its own — the tiles page IS the whole tool.
// The whole Add/Edit stack rides the shared DVI-1357 edit engine (_editCtx /
// _seRenderEditGrid), exactly like the Equipment surface (em*).
// ===========================================================================
var _qsSubpage = null;        // merged {order, separators, items} (read render)
var _qsSubpageAdmin = null;   // admin-default {order, separators, items} (edit)
var _qsSubpageUser = null;    // raw per-user override {order, separators, items} (edit)
var _qsEditMode = false;
var _qsEditScope = 'user';
var _qsTileData = null;       // { summaries, tiles, dashrefs } from the last poll

function _qsSurface() { return (_tileSurfaces && _tileSurfaces.quality) || {}; }
function _qsCatalog() { return _qsSurface().catalog || []; }
function _qsSurfaceLabel() { return _qsSurface().label || 'QC'; }

// A cross-tool catalog tile is renderable when its target tool's tab pane exists
// in the DOM (the generalized "pane exists in target tool" check, D4) AND the
// sub-tool is effectively visible to this user (admin-enabled, not user-hidden,
// access ok — incl. the togen_scada role for Kiln Metrics).
function _qsCatalogRenderable(cat) {
  if (!cat) return false;
  var pane = cat.pane_id || (cat.tab ? 'em-tab-' + cat.tab : '');
  return !!(pane && document.getElementById(pane) && _subtoolVisibleFor('quality', cat.id));
}

function _qsPartition() {
  var layout = _qsSubpage || { order: [], separators: {}, items: {} };
  var items = layout.items || {};
  var catById = {};
  _qsCatalog().forEach(function(c) { catById[c.id] = c; });
  var seen = {};
  return _surfacePartitionEntries({
    order: layout.order || [],
    isSep: _isSeparatorToken,
    entryFor: function(tid) {
      if (_isWebItemToken(tid)) { var w = _webItemProps(tid, items, {}); return w ? { kind: 'web', id: tid, item: w } : null; }
      if (_isDashItemToken(tid)) { var dt = _dtileItemProps(tid, items, {}); return dt ? { kind: 'dtile', id: tid, item: dt } : null; }
      if (_isDashRefToken(tid)) { var dr = _dashRefProps(tid, items, {}); return dr ? { kind: 'dashref', id: tid, item: dr } : null; }
      if (catById[tid] && !seen[tid] && _qsCatalogRenderable(catById[tid])) {
        seen[tid] = true;
        return { kind: 'catalog', id: tid, cat: catById[tid] };
      }
      return null;
    },
    appendMissing: function(bucket) {
      _qsCatalog().forEach(function(c) {
        if (!seen[c.id] && _qsCatalogRenderable(c)) { seen[c.id] = true; bucket.push({ kind: 'catalog', id: c.id, cat: c }); }
      });
    }
  });
}

function _qsBuildEntry(e) {
  if (e.kind === 'web') return _buildWebTile(e.item);
  if (e.kind === 'dtile') return _buildDashTile(e.item, e.id);
  if (e.kind === 'dashref') return _buildDashRef(e.item, e.id);
  return _qsBuildCatalogTile(e.cat);
}

// A navigation tile for a cross-tool catalog sub-tool.
function _qsBuildCatalogTile(cat) {
  // DVI-1523 P4 (D7-A): the Kiln Status tile renders the live SCADA tile in READ
  // mode (the graph backdrop + z-index layers would fight the edit overlay, so
  // edit mode keeps the plain, draggable nav tile).
  if (cat && cat.render_hook === 'kiln_status' && !_qsEditMode) return _qsBuildKilnStatusTile(cat);
  var tile = document.createElement('button');
  tile.type = 'button';
  tile.setAttribute('data-qs-catalog', cat.id);
  tile.onclick = function() { qsActivateCatalog(cat); };
  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">' + cat.icon + '</div>' +
    '<div>' +
      '<p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">' + esc(cat.title) + '</p>' +
      '<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 leading-relaxed">' + esc(cat.description) + '</p>' +
      '<p data-qs-summary="' + esc(cat.id) + '" class="hidden text-xs font-medium mt-2 text-togen-teal"></p>' +
    '</div>';
  return tile;
}

// DVI-1523 P4 (D7-A): the Kiln Status tile. When SCADA readings are loaded it
// renders the EXACT home SCADA live tile (history graph backdrop + out-of-range
// alert badge + "Updated HH:MM") via the shared _scadaLiveTileInner; otherwise a
// plain nav tile (and _qsRefreshKilnStatus fetches /scada/status, then swaps it
// in place). Still opens Kiln Metrics -> Status on click, like any catalog tile.
function _qsBuildKilnStatusTile(cat) {
  var tile = document.createElement('button');
  tile.type = 'button';
  tile.setAttribute('data-qs-catalog', cat.id);
  tile.onclick = function() { qsActivateCatalog(cat); };
  var inner = (typeof _scadaLiveTileInner === 'function')
    ? _scadaLiveTileInner(cat.icon, cat.title, cat.description) : null;
  if (inner) {
    tile.className = _SCADA_LIVE_TILE_CLASS;
    tile.innerHTML = inner;
  } 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">' + cat.icon + '</div>' +
      '<div>' +
        '<p class="text-base font-semibold text-gray-800 dark:text-gray-100 group-hover:text-togen-teal transition-colors">' + esc(cat.title) + '</p>' +
        '<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 leading-relaxed">' + esc(cat.description) + '</p>' +
      '</div>';
  }
  return tile;
}

// Fetch fresh /scada/status (+ one-time config/history) and re-render just the
// Kiln Status tile in place. Called on first render + on each visible poll tick,
// so the live graph/badge/time refresh while the QC surface is open (and never
// while it isn't — _qsTilesVisible gates the poller). No full re-render (which
// would recurse), so no loop.
function _qsRefreshKilnStatus() {
  if (_qsEditMode) return;
  var cat = _qsCatalog().filter(function(c) { return c.render_hook === 'kiln_status'; })[0];
  if (!cat || !_qsCatalogRenderable(cat)) return;
  if (typeof _scadaFetchTileData !== 'function') return;
  _scadaFetchTileData(function() {
    if (_qsEditMode || _activeTool !== 'quality') return;
    var existing = document.querySelector('#qs-tiles-list [data-qs-catalog="' + cat.id + '"]');
    if (existing && existing.parentNode) {
      existing.parentNode.replaceChild(_qsBuildKilnStatusTile(cat), existing);
    }
  });
}

// Cross-tool activation (D4): open the target tool (pushing a history entry so
// browser Back returns to the QC surface), then switch to the requested tab.
function qsActivateCatalog(cat) {
  if (!cat) return;
  var target = cat.target_tool;
  if (!target) { qsRunActivate(cat); return; }
  var title = cat.target_title || _qsSurfaceLabel();
  history.pushState({ page: 'tool', tool: target, title: title }, '');
  _showTool(target, title);
  // The target tool may init asynchronously (e.g. qcRefreshMeta reveals tabs), so
  // defer the tab switch a tick — same as the ?open_subtool deep link.
  setTimeout(function() { qsRunActivate(cat); }, 80);
}

// Dispatch the post-open activation by target tool (no eval of the descriptor
// string). The `activate` field documents the same call.
function qsRunActivate(cat) {
  var target = cat.target_tool, tab = cat.tab;
  if (target === 'qc' && typeof qcSwitchTab === 'function') qcSwitchTab(tab || 'batches');
  else if (target === 'scada-view' && typeof scadaSwitchTab === 'function') scadaSwitchTab(tab || 'status');
}

// Recompute _subtoolVisibility.quality from the admin defaults + this user's own
// hide overrides + access, then re-render if the QC surface is currently open (so
// a Tools-Enabled / User-Settings save takes effect live).
function _qsRecomputeVisibility() {
  var cat = _qsCatalog();
  var adminVis = (_subtoolVisibilityAdmin && _subtoolVisibilityAdmin.quality) || {};
  var userSub = (_userSettings && _userSettings.subtool_visibility) || {};
  var userVis = userSub.quality || {};
  var eff = {};
  cat.forEach(function(c) {
    if (_isToGenAdmin) { eff[c.id] = true; return; }
    eff[c.id] = (adminVis[c.id] !== false) && (userVis[c.id] !== false) && _subtoolAccessOk(c);
  });
  _subtoolVisibility = _subtoolVisibility || {};
  _subtoolVisibility.quality = eff;
  if (_activeTool === 'quality') {
    var grid = document.getElementById('qs-tiles-grid');
    if (grid && !grid.classList.contains('hidden')) qsRenderTiles();
  }
}

function qsShowTiles() {
  var grid = document.getElementById('qs-tiles-grid');
  if (grid) grid.classList.remove('hidden');
  qsRenderTiles();
}

function qsRenderTiles() {
  if (_qsSubpage) { _qsRenderTilesNow(); return; }
  _qsLoadSubpage(_qsRenderTilesNow);
}

function _qsLoadSubpage(cb) {
  var empty = { order: [], separators: {}, items: {} };
  fetch('/tools/subpage/quality')
    .then(function(r) { return r.json(); })
    .then(function(d) {
      d = d || {};
      _qsSubpage = (d.order || d.separators || d.items) ? { order: d.order || [], separators: d.separators || {}, items: d.items || {} } : empty;
      _qsSubpageAdmin = d.admin || { order: [], separators: {}, items: {} };
      _qsSubpageUser = d.user || { order: [], separators: {}, items: {} };
      if (cb) cb();
    })
    .catch(function() { _qsSubpage = empty; _qsSubpageAdmin = { order: [], separators: {}, items: {} }; _qsSubpageUser = { order: [], separators: {}, items: {} }; if (cb) cb(); });
}

function _qsRenderTilesNow() {
  var list = document.getElementById('qs-tiles-list');
  var empty = document.getElementById('qs-tiles-empty');
  if (!list) return;
  if (_qsEditMode) { _editCtx = _qsEditCtx(); _seRenderEditGrid(); if (empty) empty.classList.add('hidden'); return; }
  list.innerHTML = '';
  var part = _qsPartition();
  var seps = (_qsSubpage && _qsSubpage.separators) || {};
  var count = 0;
  // DVI-1521: a leading separator lives on section 0, so the sectioned path also
  // runs for a single section that carries one.
  if (part.sections.length > 1 || (part.sections[0] && part.sections[0].sepId)) {
    list.className = 'flex flex-col gap-5';
    list.style.gridTemplateColumns = '';
    part.sections.forEach(function(sec) {
      if (sec.sepId) list.appendChild(_makeTileDivider(sec.sepId, { props: _sepProps(sec.sepId, seps, {}), onToggle: qsRenderTiles }));
      if (sec.sepId && _sepIsCollapsed(sec.sepId, _sepProps(sec.sepId, seps, {}))) return;
      var secEl = document.createElement('div');
      secEl.className = 'tile-grid-section grid grid-cols-1 sm:grid-cols-2 gap-5';
      secEl.setAttribute('data-sep-columns', String(_sepProps(sec.sepId, seps, {}).columns));
      sec.entries.forEach(function(e) { secEl.appendChild(_qsBuildEntry(e)); count++; });
      list.appendChild(secEl);
    });
  } else {
    list.className = 'grid grid-cols-1 sm:grid-cols-2 gap-5';
    list.style.gridTemplateColumns = '';
    part.sections[0].entries.forEach(function(e) { list.appendChild(_qsBuildEntry(e)); count++; });
  }
  _applyTileGridColumns(list, 2);
  if (empty) empty.classList.toggle('hidden', count > 0);
  if (_qsTileData && _qsTileData.summaries) _qsApplySummaries(_qsTileData.summaries);
  _qsRefreshKilnStatus();   // DVI-1523 P4 (D7-A): live Kiln Status tile
  _qsTileStartPolling();
}

// ---- QC surface live-data polling (added dashboard tiles / summaries) --------
var _qsTileTimer = null;
function _qsTilesVisible() {
  if (_activeTool !== 'quality' || _qsEditMode) return false;
  var g = document.getElementById('qs-tiles-grid');
  return !!(g && !g.classList.contains('hidden'));
}
function _qsTileDataFetch() {
  if (!_qsTilesVisible()) { _qsTileStopPolling(); return; }
  fetch('/tools/tile-data?surface=quality').then(function(r){ return r.json(); }).then(function(d){
    if (!d) return;
    _qsTileData = d;
    if (d.summaries) _qsApplySummaries(d.summaries);
    _qsRefreshKilnStatus();   // DVI-1523 P4: refresh the live Kiln Status tile each tick
    var items = (_qsSubpage && _qsSubpage.items) || {};
    if (d.tiles) {
      Object.keys(d.tiles).forEach(function(iid){
        var body = document.querySelector('#qs-tiles-list .dash-tile-body[data-tile-id="' + iid + '"]');
        var props = items[iid];
        if (!body || !props || props.type !== 'dtile') return;
        dashboardsRenderTileBody(body, { id: iid, type: props.tile_type, config: props.config }, d.tiles[iid]);
      });
    }
    if (d.dashrefs) {
      Object.keys(d.dashrefs).forEach(function(iid){
        var body = document.querySelector('#qs-tiles-list .dashref-body[data-dashref-id="' + iid + '"]');
        var props = items[iid];
        if (!body || !props || props.type !== 'dashref') return;
        _buildDashRefBody(body, iid, props, d.dashrefs[iid]);
      });
    }
  }).catch(function(){});
}
function _qsTileStartPolling() {
  _qsTileStopPolling();
  if (!_qsTilesVisible()) return;
  _qsTileDataFetch();
  _qsTileTimer = setInterval(_qsTileDataFetch, 60000);
}
function _qsTileStopPolling() { if (_qsTileTimer) { clearInterval(_qsTileTimer); _qsTileTimer = null; } }
function _qsApplySummaries(summaries) {
  if (!summaries) return;
  Object.keys(summaries).forEach(function(cid) {
    var el = document.querySelector('#qs-tiles-list [data-qs-summary="' + cid + '"]');
    if (!el) return;
    var s = summaries[cid] || {};
    if (s.text) {
      el.textContent = s.text;
      el.classList.remove('hidden');
      var warn = s.tone === 'warn';
      el.classList.toggle('text-amber-600', warn);
      el.classList.toggle('dark:text-amber-400', warn);
      el.classList.toggle('text-togen-teal', !warn);
    } else {
      el.classList.add('hidden');
    }
  });
}

// ---- QC surface EDIT mode (full home parity via the shared _editCtx engine) --
function _qsUserSepMap()  { return _qsEditScope === 'admin' ? {} : ((_qsSubpageUser && _qsSubpageUser.separators) || {}); }
function _qsUserItemMap() { return _qsEditScope === 'admin' ? {} : ((_qsSubpageUser && _qsSubpageUser.items) || {}); }

function _qsEditCtx() {
  var aItems = function() { return (_qsSubpageAdmin && _qsSubpageAdmin.items) || {}; };
  var aSeps  = function() { return (_qsSubpageAdmin && _qsSubpageAdmin.separators) || {}; };
  return {
    surface: 'quality',
    scope: function() { return _qsEditScope; },
    toastId: 'qs-edit-toast',
    gridEl: function() { return document.getElementById('qs-tiles-list'); },
    adminOrder: function() { return (_qsSubpageAdmin && _qsSubpageAdmin.order) || []; },
    adminSepMap: aSeps,
    adminItemMap: aItems,
    sepProps: function(id) { return _sepProps(id, aSeps(), _qsUserSepMap()); },
    baseOrder: function() {
      var o = (_qsEditScope === 'admin')
        ? ((_qsSubpageAdmin && _qsSubpageAdmin.order) || [])
        : ((_qsSubpageUser && _qsSubpageUser.order && _qsSubpageUser.order.length) ? _qsSubpageUser.order : ((_qsSubpageAdmin && _qsSubpageAdmin.order) || []));
      return o.slice();
    },
    setBaseOrder: function(order) {
      if (_qsEditScope === 'admin') { _qsSubpageAdmin = _qsSubpageAdmin || {}; _qsSubpageAdmin.order = order; }
      else { _qsSubpageUser = _qsSubpageUser || {}; _qsSubpageUser.order = order; }
    },
    removeMsg: 'Remove this from the QC page?',
    renderInto: function(grid) {
      grid.className = 'grid grid-cols-1 sm:grid-cols-2 gap-4 home-edit-grid';
      grid.style.gridTemplateColumns = '';
      var order = this.baseOrder();
      var catById = {};
      _qsCatalog().forEach(function(c) { catById[c.id] = c; });
      var seen = {};
      var aI = aItems(), uI = _qsUserItemMap();
      order.forEach(function(tid) {
        var el = null, entry = null;
        if (_isSeparatorToken(tid)) { grid.appendChild(_homeEditDivider(tid)); return; }
        if (_isWebItemToken(tid)) { var w = _webItemProps(tid, aI, uI); if (!w) return; el = _buildWebTile(w); entry = { kind: 'web', id: tid, item: w, editable: true }; }
        else if (_isDashItemToken(tid)) { var dt = _dtileItemProps(tid, aI, uI); if (!dt) return; el = _buildDashTile(dt, tid); entry = { kind: 'dtile', id: tid, item: dt, editable: true }; }
        else if (_isDashRefToken(tid)) { var dr = _dashRefProps(tid, aI, uI); if (!dr) return; el = _buildDashRef(dr, tid); entry = { kind: 'dashref', id: tid, item: dr, editable: true }; }
        else if (catById[tid] && !seen[tid] && _qsCatalogRenderable(catById[tid])) { seen[tid] = true; el = _qsBuildCatalogTile(catById[tid]); entry = { kind: 'catalog', id: tid, cat: catById[tid] }; }
        else return;   // a non-renderable catalog token is preserved by _homeMergeHiddenOrder
        grid.appendChild(_homeDecorateEntry(el, entry));
      });
      _qsCatalog().forEach(function(c) {
        if (seen[c.id] || !_qsCatalogRenderable(c)) return;
        seen[c.id] = true;
        grid.appendChild(_homeDecorateEntry(_qsBuildCatalogTile(c), { kind: 'catalog', id: c.id, cat: c }));
      });
    },
    commit: _qsCommitScope,
    foldEditProps: _qsFoldEditProps,
    foldRemoveProps: _qsFoldRemoveProps,
  };
}

function _qsCommitScope() {
  if (_qsEditScope === 'admin') {
    _homePost('/togen-admin/tools/subpage/quality', {
      order: (_qsSubpageAdmin.order || []),
      separators: (_qsSubpageAdmin.separators || {}),
      items: (_qsSubpageAdmin.items || {})
    }, function(d) {
      if (d.admin) _qsSubpageAdmin = d.admin;
      if (d.order) _qsSubpage = { order: d.order, separators: d.separators || {}, items: d.items || {} };
    });
  } else {
    var ord = (_qsSubpageUser.order && _qsSubpageUser.order.length) ? _qsSubpageUser.order.slice() : ((_qsSubpageAdmin.order || []).slice());
    _qsSubpageUser = _qsSubpageUser || {}; _qsSubpageUser.order = ord;
    _homePost('/tools/subpage/quality', {
      order: ord,
      separators: (_qsSubpageUser.separators || {}),
      items: (_qsSubpageUser.items || {})
    }, function(d) {
      if (d.user) _qsSubpageUser = d.user;
      if (d.order) _qsSubpage = { order: d.order, separators: d.separators || {}, items: d.items || {} };
    });
  }
}
function _qsFoldEditProps(tid, isSep, props, wasEdited) {
  if (_qsEditScope === 'admin') {
    _qsSubpageAdmin = _qsSubpageAdmin || {};
    if (isSep) { _qsSubpageAdmin.separators = _qsSubpageAdmin.separators || {}; _qsSubpageAdmin.separators[tid] = props; }
    else { _qsSubpageAdmin.items = _qsSubpageAdmin.items || {}; _qsSubpageAdmin.items[tid] = props; }
  } else {
    _qsSubpageUser = _qsSubpageUser || {};
    if (isSep) { _qsSubpageUser.separators = _qsSubpageUser.separators || {}; if (wasEdited) _qsSubpageUser.separators[tid] = props; else delete _qsSubpageUser.separators[tid]; }
    else { _qsSubpageUser.items = _qsSubpageUser.items || {}; if (wasEdited) _qsSubpageUser.items[tid] = props; else delete _qsSubpageUser.items[tid]; }
  }
}
function _qsFoldRemoveProps(tid, isSep) {
  if (_qsEditScope === 'admin') {
    if (isSep) { if (_qsSubpageAdmin && _qsSubpageAdmin.separators) delete _qsSubpageAdmin.separators[tid]; }
    else if (_qsSubpageAdmin && _qsSubpageAdmin.items) delete _qsSubpageAdmin.items[tid];
  } else {
    if (isSep) { if (_qsSubpageUser && _qsSubpageUser.separators) delete _qsSubpageUser.separators[tid]; }
    else if (_qsSubpageUser && _qsSubpageUser.items) delete _qsSubpageUser.items[tid];
  }
}

function qsEditToggle() {
  _qsEditMode = !_qsEditMode;
  _homePendingAddId = null;
  _qsCloseAddMenu();
  var toolbar = document.getElementById('qs-edit-toolbar');
  var editBtn = document.getElementById('qs-tiles-edit');
  var lbl = document.getElementById('qs-tiles-edit-label');
  if (toolbar) toolbar.classList.toggle('hidden', !_qsEditMode);
  if (editBtn) { editBtn.classList.toggle('bg-togen-teal', _qsEditMode); editBtn.classList.toggle('text-white', _qsEditMode); }
  if (lbl) lbl.textContent = _qsEditMode ? 'Done' : 'Edit';
  if (_qsEditMode) {
    _qsTileStopPolling();
    if (!_isToGenAdmin) _qsEditScope = 'user';
    _qsUpdateScopeButtons();
    if (_qsSubpageAdmin && _qsSubpageUser) { _editCtx = _qsEditCtx(); _seRenderEditGrid(); }
    else _qsLoadSubpage(function() { _editCtx = _qsEditCtx(); _seRenderEditGrid(); });
  } else {
    _editCtx = null;
    qsRenderTiles();
  }
}
function qsEditSetScope(scope) {
  if (!_isToGenAdmin && scope === 'admin') return;
  _qsEditScope = (scope === 'admin') ? 'admin' : 'user';
  _homePendingAddId = null;
  _qsUpdateScopeButtons();
  if (_qsEditMode) { _editCtx = _qsEditCtx(); _seRenderEditGrid(); }
}
function _qsUpdateScopeButtons() {
  var u = document.getElementById('qs-scope-user');
  var a = document.getElementById('qs-scope-admin');
  var onCls = ['bg-togen-teal', 'text-white'];
  var offCls = ['text-gray-600', 'dark:text-gray-300'];
  function set(btn, on) { if (!btn) return; onCls.forEach(function(c) { btn.classList.toggle(c, on); }); offCls.forEach(function(c) { btn.classList.toggle(c, !on); }); }
  set(u, _qsEditScope === 'user');
  set(a, _qsEditScope === 'admin');
  var reset = document.getElementById('qs-edit-reset');
  if (reset) reset.classList.toggle('hidden', _qsEditScope !== 'user');
}
function qsEditToggleAddMenu(e) { if (e) e.stopPropagation(); var m = document.getElementById('qs-edit-add-menu'); if (m) m.classList.toggle('hidden'); }
function _qsCloseAddMenu() { var m = document.getElementById('qs-edit-add-menu'); if (m) m.classList.add('hidden'); }
function qsEditAddSeparator() {
  _qsCloseAddMenu();
  var id = _mintSeparatorId();
  var ord = _editCtx.baseOrder(); ord.push(id); _editCtx.setBaseOrder(ord);
  _homeCommit();
}
function qsEditAddWebsite() {
  _qsCloseAddMenu();
  var id = _mintWebItemId();
  var el = document.createElement('div');
  el.setAttribute('data-tool-id', id); el.setAttribute('data-web-new', '1');
  _webSeedElementProps(el, { type: 'web', name: '', url: '', mode: 'newtab', icon_mode: 'favicon', icon: '', glyph: '' });
  _homePendingAddId = id;
  _webItemEditOpen(el, _seScope());
}
function qsEditAddDashTile() {
  _qsCloseAddMenu();
  var id = _mintDashItemId();
  var el = document.createElement('div');
  el.setAttribute('data-tool-id', id); el.setAttribute('data-dash-new', '1');
  _dtileSeedElementProps(el, { type: 'dtile', tile_type: '', config: {}, size: 'default', title: '' });
  _homePendingAddId = id;
  _dtileEditOpen(el, _seScope());
}
function qsEditAddDashRef() {
  _qsCloseAddMenu();
  var id = _mintDashRefId();
  var el = document.createElement('div');
  el.setAttribute('data-tool-id', id); el.setAttribute('data-dashref-new', '1');
  _dashRefSeedElementProps(el, { type: 'dashref', dashboard_id: '', title: '', justify: 'center', collapse: 'none', collapsed: false, columns: 'auto', rows: 'auto' });
  _homePendingAddId = id;
  _dashRefEditOpen(el, _seScope());
}
function qsResetMyLayout() {
  if (!window.confirm('Reset the QC page to the default layout? This clears your personal changes.')) return;
  _seResetUserLayout('/tools/subpage/quality', { order: [], separators: {}, items: {} }, function(d) {
    _qsSubpageUser = d.user || { order: [], separators: {}, items: {} };
    if (d.order || d.separators || d.items) _qsSubpage = { order: d.order || [], separators: d.separators || {}, items: d.items || {} };
    qsRenderTiles();
  });
}

// ===== EQUIPMENT OVERSIGHT (DVI-1124) — view-only equipment floor plan =====
// A self-contained read-only viewer mirroring the SCADA View Oversight tab
// (scadaov*), but rendering the `equipment` layer with green/amber/red
// traffic-light markers. Reuses /oversight/config + /oversight/data.
var _emovCfg = null;                  // last /oversight/config payload
var _emovPlan = null;                 // resolved focused plan object
var _emovData = null;                 // last /oversight/data payload
var _emovZoom = 1, _emovPanX = 0, _emovPanY = 0;
var _emovFull = false;
var _emovBound = false;

function emovInit() {
  if (!_emovBound) {
    _emovBound = true;
    emovBindStage();
    document.addEventListener('keydown', function(e) {
      if (e.key === 'Escape' && _activeTool === 'equipment-manuals' && _emLastTab === 'oversight') {
        if (_emovFull) { emovExitFull(); return; }
        emovCloseDetail();
      }
    });
    window.addEventListener('resize', function() {
      if (_activeTool === 'equipment-manuals' && _emLastTab === 'oversight' &&
          !document.getElementById('emov-viewer-wrap').classList.contains('hidden')) {
        _ovSizeStage('emov-stage', 'emov-viewer-wrap', _emovFull);
        emovApplyTransform();
      }
    });
  }
  emovLoad();
}

function emovLoad() {
  var empty = document.getElementById('emov-empty');
  var wrap = document.getElementById('emov-viewer-wrap');
  var admin = document.getElementById('emov-empty-admin');
  if (admin) admin.classList.toggle('hidden', !_isToGenAdmin);
  fetch('/oversight/config').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      _emovCfg = d;
      var pid = d.equipment_plan || '';
      _emovPlan = pid ? ((d.plans || []).filter(function(p){ return p.id === pid; })[0] || null) : null;
      var legend = document.getElementById('emov-legend');
      if (!pid || !_emovPlan) {
        empty.classList.remove('hidden'); wrap.classList.add('hidden');
        if (legend) legend.innerHTML = '';
        return;
      }
      empty.classList.add('hidden');
      var t = document.getElementById('emov-title');
      if (t) t.textContent = _emovPlan.name || 'Oversight';
      if (legend) legend.innerHTML = emovLegendHtml();
      emovShowPlan();
    }).catch(function(){});
}

function emovLegendHtml() {
  function dot(c, t){ return '<span class="inline-flex items-center gap-1"><span class="inline-block h-2.5 w-2.5 rounded-full ' + c + '"></span>' + t + '</span>'; }
  return '<span class="inline-flex items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400">' +
    dot('bg-emerald-600', 'In service') + dot('bg-amber-500', 'Open WO') + dot('bg-red-600', 'Out of service') +
    '</span>';
}

// theme-aware style resolution (mirrors scadaovResolveStyle, auto only)
function emovResolveStyle(plan) {
  if (!plan || !plan.images) return null;
  var imaged = Object.keys(plan.images);
  if (!imaged.length) return null;
  var isDark = document.documentElement.classList.contains('dark');
  var want = isDark ? _emovCfg.style_default_dark : _emovCfg.style_default_light;
  if (want && plan.images[want]) return want;
  var ordered = (_emovCfg.styles || []).map(function(s){ return s.id; })
    .filter(function(sid){ return plan.images[sid]; });
  return ordered.length ? ordered[0] : imaged[0];
}

function emovShowPlan() {
  var plan = _emovPlan;
  var wrap = document.getElementById('emov-viewer-wrap');
  var noimg = document.getElementById('emov-noimg');
  var stage = document.getElementById('emov-stage');
  wrap.classList.remove('hidden');
  var sid = emovResolveStyle(plan);
  if (!sid) {
    noimg.classList.remove('hidden');
    stage.classList.add('hidden');
    document.getElementById('emov-markers').innerHTML = '';
    return;
  }
  noimg.classList.add('hidden');
  stage.classList.remove('hidden');
  _ovSizeStage('emov-stage', 'emov-viewer-wrap', _emovFull);
  var img = document.getElementById('emov-plan-img');
  var dims = plan.images[sid] || {};
  var wantSrc = '/oversight/plan-image/' + encodeURIComponent(plan.id) +
    '?style=' + encodeURIComponent(sid) + '&v=' + (dims.w || 0) + 'x' + (dims.h || 0);
  if (img.getAttribute('data-src') !== wantSrc) {
    img.setAttribute('data-src', wantSrc);
    img.onload = function(){ _ovSizeStage('emov-stage', 'emov-viewer-wrap', _emovFull); emovZoomReset(); };
    img.src = wantSrc;
  } else {
    emovZoomReset();
  }
  fetch('/oversight/data?plan=' + encodeURIComponent(plan.id) + '&layers=equipment')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){ if (!d) return; _emovData = d; emovRenderMarkers(); })
    .catch(function(){});
}

// re-resolve Auto style on theme flip while this tab is active
function emovThemeChanged() {
  if (_activeTool === 'equipment-manuals' && _emLastTab === 'oversight' && _emovPlan) emovShowPlan();
}

function emovRenderMarkers() {
  var host = document.getElementById('emov-markers');
  var placements = oversightSortLocationFirst((_emovData && _emovData.placements) || []);
  host.innerHTML = '';
  placements.forEach(function(pl){
    var el = document.createElement('div');
    el.className = 'emov-marker';
    el.style.position = 'absolute';
    el.style.left = (pl.x * 100) + '%';
    el.style.top = (pl.y * 100) + '%';
    el.style.transform = 'translate(-50%,-50%) scale(' + (1 / _emovZoom) + ')';
    el.style.transformOrigin = 'center center';
    el.appendChild(oversightIsLocation(pl) ? oversightLocationChip(pl) : emovMarkerChip(pl));
    host.appendChild(el);
  });
}

function emovMarkerColor(pl, d) {
  if (!pl.known || !d) return 'bg-gray-500 text-white';
  if (d.state === 'red') return 'bg-red-600 text-white';
  if (d.state === 'yellow') return 'bg-amber-500 text-white';
  return 'bg-emerald-600 text-white';
}

function emovMarkerChip(pl) {
  var d = pl.data;
  var chip = document.createElement('div');
  chip.className = 'relative inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold shadow-md border border-white/40 whitespace-nowrap ' + emovMarkerColor(pl, d);
  chip.style.cursor = 'pointer';
  var txt = oversightEsc(pl.label || (d && d.label) || pl.ref);
  chip.innerHTML = '<span>' + txt + '</span>' +
    (!pl.known ? '<span class="text-[9px] font-bold uppercase bg-black/20 rounded px-1">unknown</span>' : '');
  chip.onclick = function(e){ emovShowDetail(pl, e); };
  return chip;
}

function emovShowDetail(pl, evt) {
  var d = pl.data;
  var box = document.getElementById('emov-detail');
  var rows = '';
  function row(k, v){ return '<div class="flex justify-between gap-3"><span class="text-gray-500 dark:text-gray-400">' + k + '</span><span class="font-medium text-gray-800 dark:text-gray-100 text-right">' + v + '</span></div>'; }
  var title = oversightEsc((d && d.label) || pl.label || pl.ref);
  if (!pl.known) {
    rows = '<p class="text-xs text-amber-600 dark:text-amber-400">This location references equipment that is no longer included in Oversight.</p>';
  } else if (d) {
    var eqStateLabel = {green: 'In service — no open work orders', yellow: 'Open work order(s)', red: 'Out of service'}[d.state] || d.state;
    var eqStateChip = {green: 'bg-emerald-600', yellow: 'bg-amber-500', red: 'bg-red-600'}[d.state] || 'bg-gray-500';
    rows += row('Status', '<span class="inline-flex items-center gap-1"><span class="inline-block h-2.5 w-2.5 rounded-full ' + eqStateChip + '"></span>' + oversightEsc(eqStateLabel) + '</span>');
    if (d.open_count != null) rows += row('Open work orders', oversightEsc(String(d.open_count)));
    if (d.in_progress_count) rows += row('In progress', oversightEsc(String(d.in_progress_count)));
  } else {
    rows = '<p class="text-xs text-gray-500">No current data.</p>';
  }
  var link = '<button onclick="emSwitchTab(\'equipment\')" class="mt-2 w-full px-2 py-1 text-xs rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 cursor-pointer">View in registry</button>';
  box.innerHTML = '<div class="flex items-center justify-between mb-2"><span class="font-semibold text-gray-900 dark:text-gray-100">' + title + '</span>' +
    '<button onclick="emovCloseDetail()" class="text-gray-400 hover:text-gray-600 cursor-pointer text-lg leading-none">&times;</button></div>' +
    '<div class="space-y-1">' + rows + '</div>' + link;
  box.classList.remove('hidden');
  var x = Math.min(evt.clientX + 12, window.innerWidth - 270);
  var y = Math.min(evt.clientY + 12, window.innerHeight - 200);
  box.style.left = Math.max(8, x) + 'px';
  box.style.top = Math.max(8, y) + 'px';
}

function emovCloseDetail() {
  var box = document.getElementById('emov-detail');
  if (box) box.classList.add('hidden');
}

// ---- pan / zoom (read-only, mirrors scadaov) ----
function emovApplyTransform() {
  var pan = document.getElementById('emov-pan');
  pan.style.transform = 'translate(' + _emovPanX + 'px,' + _emovPanY + 'px) scale(' + _emovZoom + ')';
  var inv = 1 / _emovZoom;
  var ms = document.querySelectorAll('#emov-markers .emov-marker');
  for (var i = 0; i < ms.length; i++) ms[i].style.transform = 'translate(-50%,-50%) scale(' + inv + ')';
}

function emovZoomReset() {
  var stage = document.getElementById('emov-stage');
  var img = document.getElementById('emov-plan-img');
  var iw = img.naturalWidth || img.width, ih = img.naturalHeight || img.height;
  if (!iw || !ih) { _emovZoom = 1; _emovPanX = 0; _emovPanY = 0; emovApplyTransform(); return; }
  var sw = stage.clientWidth, sh = stage.clientHeight;
  _emovZoom = Math.min((sw - 8) / iw, (sh - 8) / ih);
  if (!(_emovZoom > 0)) _emovZoom = Math.min(sw / iw, sh / ih);
  _emovPanX = (sw - iw * _emovZoom) / 2;
  _emovPanY = (sh - ih * _emovZoom) / 2;
  emovApplyTransform();
}

function emovZoomBy(f) {
  var stage = document.getElementById('emov-stage');
  emovZoomAt(stage.clientWidth / 2, stage.clientHeight / 2, f);
}

function emovZoomAt(sx, sy, f) {
  var z2 = Math.max(0.05, Math.min(20, _emovZoom * f));
  var k = z2 / _emovZoom;
  _emovPanX = sx - (sx - _emovPanX) * k;
  _emovPanY = sy - (sy - _emovPanY) * k;
  _emovZoom = z2;
  emovApplyTransform();
}

function emovBindStage() {
  var stage = document.getElementById('emov-stage');
  if (!stage) return;
  var panning = false, lastX = 0, lastY = 0;
  stage.addEventListener('pointerdown', function(e){
    if (e.target.closest('.emov-marker')) return;
    panning = true; lastX = e.clientX; lastY = e.clientY;
    stage.setPointerCapture(e.pointerId);
    emovCloseDetail();
  });
  stage.addEventListener('pointermove', function(e){
    if (!panning) return;
    _emovPanX += (e.clientX - lastX);
    _emovPanY += (e.clientY - lastY);
    lastX = e.clientX; lastY = e.clientY;
    emovApplyTransform();
  });
  function endPan(e){ if (panning) { panning = false; try { stage.releasePointerCapture(e.pointerId); } catch(_){} } }
  stage.addEventListener('pointerup', endPan);
  stage.addEventListener('pointercancel', endPan);
  stage.addEventListener('wheel', function(e){
    e.preventDefault();
    var rect = stage.getBoundingClientRect();
    emovZoomAt(e.clientX - rect.left, e.clientY - rect.top, e.deltaY < 0 ? 1.1 : 0.9);
  }, { passive: false });
}

function emovToggleFull() {
  _emovFull = !_emovFull;
  _ovApplyFull(_emovFull, 'emov-viewer-wrap');
  document.getElementById('emov-header').classList.toggle('hidden', _emovFull);
  document.getElementById('em-tabbar').classList.toggle('hidden', _emovFull);
  document.getElementById('emov-full-btn').title = _emovFull ? 'Exit full view' : 'Full view';
  _ovSetFullIcon('emov-full-icon', _emovFull);
  _ovSizeStage('emov-stage', 'emov-viewer-wrap', _emovFull);
  emovZoomReset();
}

function emovExitFull() {
  if (_emovFull) emovToggleFull();
}

// ===== VISITOR REGISTRY (DVI-1136 Phase 4) =====
var _vrInitialized = false;
var _vrLastTab = 'list';
var _vrCanEdit = false;
var _vrVisitors = [];

function vrInit() {
  vrLoad();
}

function vrSwitchTab(tab) {
  _vrLastTab = tab;
  var isList = (tab === 'list');
  document.getElementById('vr-tab-list').classList.toggle('hidden', !isList);
  document.getElementById('vr-tab-oversight').classList.toggle('hidden', isList);
  var lb = document.getElementById('vr-tab-btn-list');
  var ob = document.getElementById('vr-tab-btn-oversight');
  function on(b){ b.className = b.className.replace('border-transparent', 'border-togen-teal').replace('text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300', 'text-togen-teal'); }
  function off(b){ if (b.className.indexOf('text-togen-teal') !== -1) b.className = b.className.replace('border-togen-teal', 'border-transparent').replace('text-togen-teal', 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'); }
  if (isList) { on(lb); off(ob); } else { on(ob); off(lb); }
  if (!isList) visovInit();
}

function vrLoad() {
  fetch('/visitors').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      _vrCanEdit = !!d.can_edit;
      _vrVisitors = d.visitors || [];
      var btn = document.getElementById('vr-register-btn');
      if (btn) btn.classList.toggle('hidden', !_vrCanEdit);
      vrRenderList();
    }).catch(function(){});
}

function vrStatusBadge(s) {
  if (s === 'onsite') return '<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"><span class="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500"></span>Onsite</span>';
  return '<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">Registered</span>';
}

function vrRenderList() {
  var host = document.getElementById('vr-list');
  if (!_vrVisitors.length) {
    host.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No visitors yet.</p>';
    return;
  }
  var rows = _vrVisitors.map(function(v){
    var name = oversightEsc((v.first_name || '') + ' ' + (v.last_name || '')).trim();
    var meta = [];
    if (v.company) meta.push(oversightEsc(v.company));
    if (v.reason) meta.push(oversightEsc(v.reason));
    var tag = (v.dev_eui || '').trim();
    var actions = '';
    if (_vrCanEdit) {
      if (v.status === 'onsite') {
        actions += '<button onclick="vrCheckout(\'' + v.id + '\')" class="togen-control 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">Check Out</button>';
      } else {
        actions += '<button onclick="vrRegisterOnsite(\'' + v.id + '\')" class="togen-control px-2.5 py-1 text-xs font-semibold rounded-lg border border-togen-teal text-togen-teal hover:bg-togen-teal/10">Register Onsite</button>';
      }
      actions += '<button onclick="vrDelete(\'' + v.id + '\')" title="Delete" class="togen-control px-2.5 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/30">Delete</button>';
    }
    return '<div class="flex items-center justify-between gap-3 py-2.5 border-b border-gray-100 dark:border-gray-800 last:border-0">' +
      '<div class="min-w-0">' +
        '<div class="flex items-center gap-2 flex-wrap"><span class="font-medium text-gray-800 dark:text-gray-100 truncate">' + (name || '&mdash;') + '</span>' + vrStatusBadge(v.status) +
        (tag ? '<span class="text-[10px] font-mono text-gray-400 dark:text-gray-500">tag ' + oversightEsc(tag) + '</span>' : '') + '</div>' +
        (meta.length ? '<div class="text-xs text-gray-500 dark:text-gray-400 truncate">' + meta.join(' · ') + '</div>' : '') +
      '</div>' +
      '<div class="flex items-center gap-1.5 shrink-0">' + actions + '</div>' +
    '</div>';
  }).join('');
  host.innerHTML = rows;
}

function vrOpenRegister() {
  document.getElementById('vr-first').value = '';
  document.getElementById('vr-last').value = '';
  document.getElementById('vr-company').value = '';
  document.getElementById('vr-reason').value = '';
  document.getElementById('vr-tag').value = '';
  document.getElementById('vr-tag-row').classList.add('hidden');
  var st = document.getElementById('vr-register-status');
  st.classList.add('hidden'); st.textContent = '';
  document.getElementById('vr-register-modal').classList.remove('hidden');
  document.getElementById('vr-first').focus();
}

function vrCloseRegister() {
  document.getElementById('vr-register-modal').classList.add('hidden');
}

function vrSubmitRegister(onsite) {
  var first = document.getElementById('vr-first').value.trim();
  var last = document.getElementById('vr-last').value.trim();
  var st = document.getElementById('vr-register-status');
  if (!first || !last) {
    st.textContent = 'First and last name are required.';
    st.className = 'text-sm text-red-500';
    st.classList.remove('hidden');
    return;
  }
  // First click of "Add and Register Onsite" reveals the tag field so a tag can be entered.
  if (onsite && document.getElementById('vr-tag-row').classList.contains('hidden')) {
    document.getElementById('vr-tag-row').classList.remove('hidden');
    document.getElementById('vr-tag').focus();
    st.classList.add('hidden');
    return;
  }
  var body = {
    first_name: first, last_name: last,
    company: document.getElementById('vr-company').value.trim(),
    reason: document.getElementById('vr-reason').value.trim(),
    register_onsite: !!onsite,
    dev_eui: onsite ? document.getElementById('vr-tag').value.trim() : ''
  };
  st.textContent = 'Saving…'; st.className = 'text-sm text-gray-500 dark:text-gray-400'; st.classList.remove('hidden');
  fetch('/visitors', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
    .then(function(r){ return r.json().then(function(j){ return { ok: r.ok, j: j }; }); })
    .then(function(res){
      if (!res.ok) { st.textContent = res.j.error || 'Save failed.'; st.className = 'text-sm text-red-500'; return; }
      vrCloseRegister();
      vrLoad();
    })
    .catch(function(){ st.textContent = 'Save failed.'; st.className = 'text-sm text-red-500'; });
}

function vrRegisterOnsite(vid) {
  var tag = prompt('Tag / DevEUI to assign (leave blank for none):', '');
  if (tag === null) return;
  fetch('/visitors/' + encodeURIComponent(vid) + '/register-onsite', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ dev_eui: (tag || '').trim() })
  })
    .then(function(r){ return r.json().then(function(j){ return { ok: r.ok, j: j }; }); })
    .then(function(res){ if (!res.ok) { alert(res.j.error || 'Failed to register onsite.'); return; } vrLoad(); })
    .catch(function(){ alert('Failed to register onsite.'); });
}

function vrCheckout(vid) {
  fetch('/visitors/' + encodeURIComponent(vid) + '/checkout', { method: 'POST' })
    .then(function(r){ if (!r.ok) throw new Error(); return r.json(); })
    .then(function(){ vrLoad(); })
    .catch(function(){ alert('Failed to check out.'); });
}

function vrDelete(vid) {
  if (!confirm('Remove this visitor from the registry?')) return;
  fetch('/visitors/' + encodeURIComponent(vid), { method: 'DELETE' })
    .then(function(r){ if (!r.ok) throw new Error(); return r.json(); })
    .then(function(){ vrLoad(); })
    .catch(function(){ alert('Failed to delete.'); });
}

// ---- visov*: view-only visitor floor plan (clone of emov*) ----
var _visovCfg = null, _visovPlan = null, _visovData = null;
var _visovZoom = 1, _visovPanX = 0, _visovPanY = 0;
var _visovFull = false, _visovBound = false;

function visovInit() {
  if (!_visovBound) {
    _visovBound = true;
    visovBindStage();
    document.addEventListener('keydown', function(e) {
      if (e.key === 'Escape' && _activeTool === 'visitor-registry' && _vrLastTab === 'oversight') {
        if (_visovFull) { visovExitFull(); return; }
        visovCloseDetail();
      }
    });
    window.addEventListener('resize', function() {
      if (_activeTool === 'visitor-registry' && _vrLastTab === 'oversight' &&
          !document.getElementById('visov-viewer-wrap').classList.contains('hidden')) {
        _ovSizeStage('visov-stage', 'visov-viewer-wrap', _visovFull);
        visovApplyTransform();
      }
    });
  }
  visovLoad();
}

function visovLoad() {
  var empty = document.getElementById('visov-empty');
  var wrap = document.getElementById('visov-viewer-wrap');
  var noaccess = document.getElementById('visov-noaccess');
  var admin = document.getElementById('visov-empty-admin');
  if (admin) admin.classList.toggle('hidden', !_isToGenAdmin);
  // Privacy default (§9.D): the visitors Oversight layer is Togen-admin-only.
  if (!_isToGenAdmin) {
    noaccess.classList.remove('hidden');
    empty.classList.add('hidden');
    wrap.classList.add('hidden');
    var lg0 = document.getElementById('visov-legend'); if (lg0) lg0.innerHTML = '';
    return;
  }
  noaccess.classList.add('hidden');
  fetch('/oversight/config').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      _visovCfg = d;
      var pid = d.visitor_plan || '';
      _visovPlan = pid ? ((d.plans || []).filter(function(p){ return p.id === pid; })[0] || null) : null;
      visovRenderPicker(pid);
      var legend = document.getElementById('visov-legend');
      if (!pid || !_visovPlan) {
        empty.classList.remove('hidden'); wrap.classList.add('hidden');
        if (legend) legend.innerHTML = '';
        return;
      }
      empty.classList.add('hidden');
      var t = document.getElementById('visov-title');
      if (t) t.textContent = _visovPlan.name || 'Oversight';
      if (legend) legend.innerHTML = visovLegendHtml();
      visovShowPlan();
    }).catch(function(){});
}

function visovLegendHtml() {
  function dot(c, t){ return '<span class="inline-flex items-center gap-1"><span class="inline-block h-2.5 w-2.5 rounded-full ' + c + '"></span>' + t + '</span>'; }
  return '<span class="inline-flex items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400">' +
    dot('bg-emerald-600', 'Onsite · live fix') + dot('bg-gray-500', 'Onsite · no fix yet') +
    '</span>';
}

function visovRenderPicker(cur) {
  var picker = document.getElementById('visov-plan-picker');
  if (!picker) return;
  if (!_isToGenAdmin) { picker.classList.add('hidden'); return; }
  picker.classList.remove('hidden');
  var sel = document.getElementById('visov-plan-sel');
  var facs = {};
  (_visovCfg.facilities || []).forEach(function(f){ facs[f.id] = f.name; });
  var opts = '<option value="">— None —</option>';
  (_visovCfg.plans || []).forEach(function(p){
    var fname = facs[p.facility];
    var label = (fname ? fname + ' · ' : '') + (p.name || p.id);
    opts += '<option value="' + oversightEsc(p.id) + '"' + (p.id === cur ? ' selected' : '') + '>' + oversightEsc(label) + '</option>';
  });
  sel.innerHTML = opts;
}

function visovSavePlan() {
  if (!_isToGenAdmin) return;
  var sel = document.getElementById('visov-plan-sel');
  var plan = sel ? sel.value : '';
  var st = document.getElementById('visov-plan-status');
  if (st) st.textContent = 'Saving…';
  fetch('/oversight/visitor-plan', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ plan: plan })
  })
    .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); } _visovPlan = null; visovLoad(); })
    .catch(function(err){ if (st) st.textContent = 'Save failed: ' + err.message; });
}

function visovResolveStyle(plan) {
  if (!plan || !plan.images) return null;
  var imaged = Object.keys(plan.images);
  if (!imaged.length) return null;
  var isDark = document.documentElement.classList.contains('dark');
  var want = isDark ? _visovCfg.style_default_dark : _visovCfg.style_default_light;
  if (want && plan.images[want]) return want;
  var ordered = (_visovCfg.styles || []).map(function(s){ return s.id; })
    .filter(function(sid){ return plan.images[sid]; });
  return ordered.length ? ordered[0] : imaged[0];
}

function visovShowPlan() {
  var plan = _visovPlan;
  var wrap = document.getElementById('visov-viewer-wrap');
  var noimg = document.getElementById('visov-noimg');
  var stage = document.getElementById('visov-stage');
  wrap.classList.remove('hidden');
  var sid = visovResolveStyle(plan);
  if (!sid) {
    noimg.classList.remove('hidden');
    stage.classList.add('hidden');
    document.getElementById('visov-markers').innerHTML = '';
    return;
  }
  noimg.classList.add('hidden');
  stage.classList.remove('hidden');
  _ovSizeStage('visov-stage', 'visov-viewer-wrap', _visovFull);
  var img = document.getElementById('visov-plan-img');
  var dims = plan.images[sid] || {};
  var wantSrc = '/oversight/plan-image/' + encodeURIComponent(plan.id) +
    '?style=' + encodeURIComponent(sid) + '&v=' + (dims.w || 0) + 'x' + (dims.h || 0);
  if (img.getAttribute('data-src') !== wantSrc) {
    img.setAttribute('data-src', wantSrc);
    img.onload = function(){ _ovSizeStage('visov-stage', 'visov-viewer-wrap', _visovFull); visovZoomReset(); };
    img.src = wantSrc;
  } else {
    visovZoomReset();
  }
  fetch('/oversight/data?plan=' + encodeURIComponent(plan.id) + '&layers=visitors')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){ if (!d) return; _visovData = d; visovRenderMarkers(); })
    .catch(function(){});
}

function visovThemeChanged() {
  if (_activeTool === 'visitor-registry' && _vrLastTab === 'oversight' && _visovPlan) visovShowPlan();
}

function visovRenderMarkers() {
  var host = document.getElementById('visov-markers');
  var placements = oversightSortLocationFirst((_visovData && _visovData.placements) || []);
  host.innerHTML = '';
  placements.forEach(function(pl){
    var el = document.createElement('div');
    el.className = 'visov-marker';
    el.style.position = 'absolute';
    el.style.left = (pl.x * 100) + '%';
    el.style.top = (pl.y * 100) + '%';
    el.style.transform = 'translate(-50%,-50%) scale(' + (1 / _visovZoom) + ')';
    el.style.transformOrigin = 'center center';
    el.appendChild(oversightIsLocation(pl) ? oversightLocationChip(pl) : visovMarkerChip(pl));
    host.appendChild(el);
  });
}

function visovMarkerColor(pl, d) {
  if (!pl.known || !d) return 'bg-gray-500 text-white';
  if (d.state === 'green') return 'bg-emerald-600 text-white';
  return 'bg-gray-500 text-white';
}

function visovMarkerChip(pl) {
  var d = pl.data;
  var chip = document.createElement('div');
  chip.className = 'relative inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold shadow-md border border-white/40 whitespace-nowrap ' + visovMarkerColor(pl, d);
  chip.style.cursor = 'pointer';
  var txt = oversightEsc(pl.label || (d && d.label) || pl.ref);
  chip.innerHTML = '<span>' + txt + '</span>' +
    (!pl.known ? '<span class="text-[9px] font-bold uppercase bg-black/20 rounded px-1">unknown</span>' : '');
  chip.onclick = function(e){ visovShowDetail(pl, e); };
  return chip;
}

function visovShowDetail(pl, evt) {
  var d = pl.data;
  var box = document.getElementById('visov-detail');
  var rows = '';
  function row(k, v){ return '<div class="flex justify-between gap-3"><span class="text-gray-500 dark:text-gray-400">' + k + '</span><span class="font-medium text-gray-800 dark:text-gray-100 text-right">' + v + '</span></div>'; }
  var title = oversightEsc((d && d.label) || pl.label || pl.ref);
  if (!pl.known) {
    rows = '<p class="text-xs text-amber-600 dark:text-amber-400">This location references a visitor who is no longer onsite.</p>';
  } else if (d) {
    var stChip = d.state === 'green' ? 'bg-emerald-600' : 'bg-gray-500';
    rows += row('Status', '<span class="inline-flex items-center gap-1"><span class="inline-block h-2.5 w-2.5 rounded-full ' + stChip + '"></span>' + oversightEsc(d.value || 'Onsite') + '</span>');
    if (d.dev_eui) rows += row('Tag', oversightEsc(d.dev_eui));
    if (d.last_seen) rows += row('Last seen', oversightEsc(String(d.last_seen)));
  } else {
    rows = '<p class="text-xs text-gray-500">No current data.</p>';
  }
  var link = '<button onclick="vrSwitchTab(\'list\')" class="mt-2 w-full px-2 py-1 text-xs rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 cursor-pointer">View in registry</button>';
  box.innerHTML = '<div class="flex items-center justify-between mb-2"><span class="font-semibold text-gray-900 dark:text-gray-100">' + title + '</span>' +
    '<button onclick="visovCloseDetail()" class="text-gray-400 hover:text-gray-600 cursor-pointer text-lg leading-none">&times;</button></div>' +
    '<div class="space-y-1">' + rows + '</div>' + link;
  box.classList.remove('hidden');
  var x = Math.min(evt.clientX + 12, window.innerWidth - 270);
  var y = Math.min(evt.clientY + 12, window.innerHeight - 200);
  box.style.left = Math.max(8, x) + 'px';
  box.style.top = Math.max(8, y) + 'px';
}

function visovCloseDetail() {
  var box = document.getElementById('visov-detail');
  if (box) box.classList.add('hidden');
}

function visovApplyTransform() {
  var pan = document.getElementById('visov-pan');
  pan.style.transform = 'translate(' + _visovPanX + 'px,' + _visovPanY + 'px) scale(' + _visovZoom + ')';
  var inv = 1 / _visovZoom;
  var ms = document.querySelectorAll('#visov-markers .visov-marker');
  for (var i = 0; i < ms.length; i++) ms[i].style.transform = 'translate(-50%,-50%) scale(' + inv + ')';
}

function visovZoomReset() {
  var stage = document.getElementById('visov-stage');
  var img = document.getElementById('visov-plan-img');
  var iw = img.naturalWidth || img.width, ih = img.naturalHeight || img.height;
  if (!iw || !ih) { _visovZoom = 1; _visovPanX = 0; _visovPanY = 0; visovApplyTransform(); return; }
  var sw = stage.clientWidth, sh = stage.clientHeight;
  _visovZoom = Math.min((sw - 8) / iw, (sh - 8) / ih);
  if (!(_visovZoom > 0)) _visovZoom = Math.min(sw / iw, sh / ih);
  _visovPanX = (sw - iw * _visovZoom) / 2;
  _visovPanY = (sh - ih * _visovZoom) / 2;
  visovApplyTransform();
}

function visovZoomBy(f) {
  var stage = document.getElementById('visov-stage');
  visovZoomAt(stage.clientWidth / 2, stage.clientHeight / 2, f);
}

function visovZoomAt(sx, sy, f) {
  var z2 = Math.max(0.05, Math.min(20, _visovZoom * f));
  var k = z2 / _visovZoom;
  _visovPanX = sx - (sx - _visovPanX) * k;
  _visovPanY = sy - (sy - _visovPanY) * k;
  _visovZoom = z2;
  visovApplyTransform();
}

function visovBindStage() {
  var stage = document.getElementById('visov-stage');
  if (!stage) return;
  var panning = false, lastX = 0, lastY = 0;
  stage.addEventListener('pointerdown', function(e){
    if (e.target.closest('.visov-marker')) return;
    panning = true; lastX = e.clientX; lastY = e.clientY;
    stage.setPointerCapture(e.pointerId);
    visovCloseDetail();
  });
  stage.addEventListener('pointermove', function(e){
    if (!panning) return;
    _visovPanX += (e.clientX - lastX);
    _visovPanY += (e.clientY - lastY);
    lastX = e.clientX; lastY = e.clientY;
    visovApplyTransform();
  });
  function endPan(e){ if (panning) { panning = false; try { stage.releasePointerCapture(e.pointerId); } catch(_){} } }
  stage.addEventListener('pointerup', endPan);
  stage.addEventListener('pointercancel', endPan);
  stage.addEventListener('wheel', function(e){
    e.preventDefault();
    var rect = stage.getBoundingClientRect();
    visovZoomAt(e.clientX - rect.left, e.clientY - rect.top, e.deltaY < 0 ? 1.1 : 0.9);
  }, { passive: false });
}

function visovToggleFull() {
  _visovFull = !_visovFull;
  _ovApplyFull(_visovFull, 'visov-viewer-wrap');
  document.getElementById('visov-header').classList.toggle('hidden', _visovFull);
  document.getElementById('vr-tabbar').classList.toggle('hidden', _visovFull);
  document.getElementById('visov-full-btn').title = _visovFull ? 'Exit full view' : 'Full view';
  _ovSetFullIcon('visov-full-icon', _visovFull);
  _ovSizeStage('visov-stage', 'visov-viewer-wrap', _visovFull);
  visovZoomReset();
}

function visovExitFull() {
  if (_visovFull) visovToggleFull();
}

// ---- Settings → Oversight pane (admin-only global plan selector) ----
function emLoadOversightSettings() {
  var sel = document.getElementById('em-oversight-plan');
  if (!sel) return;
  var note = document.getElementById('em-oversight-note');
  if (note) note.classList.toggle('hidden', !!_isToGenAdmin);
  sel.disabled = !_isToGenAdmin;
  fetch('/oversight/config').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      var cur = d.equipment_plan || '';
      var facs = {};
      (d.facilities || []).forEach(function(f){ facs[f.id] = f.name; });
      var opts = '<option value="">— None —</option>';
      (d.plans || []).forEach(function(p){
        var fname = facs[p.facility];
        var label = (fname ? fname + ' · ' : '') + (p.name || p.id);
        opts += '<option value="' + oversightEsc(p.id) + '"' + (p.id === cur ? ' selected' : '') + '>' + oversightEsc(label) + '</option>';
      });
      sel.innerHTML = opts;
    }).catch(function(){});
}

function emSaveOversightPlan() {
  if (!_isToGenAdmin) return;
  var sel = document.getElementById('em-oversight-plan');
  var plan = sel ? sel.value : '';
  var st = document.getElementById('em-oversight-status');
  if (st) { st.textContent = 'Saving…'; st.className = 'text-xs text-gray-500 dark:text-gray-400'; }
  fetch('/oversight/equipment-plan', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ plan: plan })
  })
    .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); }
      _emovPlan = null; // force the Oversight tab to pick up the new plan next open
    })
    .catch(function(err) { if (st) { st.textContent = 'Save failed: ' + err.message; st.className = 'text-xs text-red-500'; } });
}

function oversightFromEquipment() {
  var tool = TOOLS.filter(function(t){ return t.id === 'oversight'; })[0];
  if (!tool) { alert('The Oversight tool is not available for your account.'); return; }
  _showTool('oversight', tool.title);
}

// ===== ASSET INVENTORY (DVI-1185 P1) =====
// Read-only view over assets.db (Togen-native source of truth for the Asset
// File spreadsheet). Search/filter/paging via GET /assets; admin-only
// full-replace import via POST /assets/import with an exceptions report.

var _astPage = 1;
var _astMeta = null;
var _astRows = [];
var _astSearchTimer = null;
var _astViewLogged = {};   // DVI-1250 D3: tags whose view was logged this session

function _astEsc(s) {
  return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
    .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

function assetsInit() {
  fetch('/assets/meta')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      _astMeta = d;
      var imp = document.getElementById('ast-import-btn');
      if (imp) imp.classList.toggle('hidden', !d.can_import);
      var rep = document.getElementById('ast-report-btn');
      if (rep) rep.classList.toggle('hidden', !(d.can_import && d.health && d.health.last_import_at));
      var addBtn = document.getElementById('ast-add-btn');
      if (addBtn) addBtn.classList.toggle('hidden', !d.can_edit);
      var detEdit = document.getElementById('ast-detail-edit-btn');
      if (detEdit) detEdit.classList.toggle('hidden', !d.can_edit);
      _astCanCatalog = !!d.can_catalog;
      var modelsBtn = document.getElementById('ast-models-btn');
      if (modelsBtn) modelsBtn.classList.toggle('hidden', !_astCanCatalog);
      // DVI-1472 P4 (D8-A): the parts/stock UI is gated by assets.inventory.
      _astCanInventory = !!d.can_inventory;
      var partsBtn = document.getElementById('ast-parts-btn');
      if (partsBtn) partsBtn.classList.toggle('hidden', !_astCanInventory);
      if (_astCanInventory) assetsPartsRefreshLowBadge();
      assetModelsRefresh();
      var st = document.getElementById('ast-import-status');
      if (st) {
        st.textContent = (d.health && d.health.last_import_at)
          ? (d.health.assets + ' assets · imported ' + d.health.last_import_at)
          : 'No import yet.';
      }
      assetsFillFilters(d.options || {});
      assetsLoad(1);
    })
    .catch(function(){
      var list = document.getElementById('ast-list');
      if (list) list.innerHTML = '<p class="text-sm text-red-500">Failed to load asset metadata.</p>';
    });
}

function assetsFillFilters(options) {
  function fill(id, values, current) {
    var sel = document.getElementById(id);
    if (!sel) return;
    var keep = current !== undefined ? current : sel.value;
    while (sel.options.length > 1) sel.remove(1);
    (values || []).forEach(function(v) {
      var opt = document.createElement('option');
      opt.value = v; opt.textContent = v;
      sel.appendChild(opt);
    });
    sel.value = keep;
    if (sel.value !== keep) sel.value = '';
  }
  fill('ast-f-type', options.asset_type);
  fill('ast-f-area', options.area);
  fill('ast-f-form', options.form_id);
  fill('ast-f-sheet', options.sheet);
}

function assetsSearchDebounced() {
  if (_astSearchTimer) clearTimeout(_astSearchTimer);
  _astSearchTimer = setTimeout(function(){ assetsLoad(1); }, 300);
}

function assetsClearFilters() {
  ['ast-q', 'ast-f-type', 'ast-f-area', 'ast-f-form', 'ast-f-sheet'].forEach(function(id) {
    var el = document.getElementById(id);
    if (el) el.value = '';
  });
  assetsLoad(1);
}

function assetsLoad(page) {
  _astPage = Math.max(1, page || 1);
  var params = new URLSearchParams();
  params.set('page', _astPage);
  var pp = document.getElementById('ast-per-page');
  if (pp && pp.value) params.set('per_page', pp.value);
  var q = document.getElementById('ast-q');
  if (q && q.value.trim()) params.set('q', q.value.trim());
  [['ast-f-type', 'type'], ['ast-f-area', 'area'], ['ast-f-form', 'form_id'], ['ast-f-sheet', 'sheet']]
    .forEach(function(pair) {
      var el = document.getElementById(pair[0]);
      if (el && el.value) params.set(pair[1], el.value);
    });
  fetch('/assets?' + params.toString())
    .then(function(r){ if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
    .then(function(d){ assetsRender(d); })
    .catch(function(err){
      var list = document.getElementById('ast-list');
      if (list) list.innerHTML = '<p class="text-sm text-red-500">Failed to load assets: ' + _astEsc(err.message) + '</p>';
    });
}

function assetsRender(d) {
  var list = document.getElementById('ast-list');
  if (!list) return;
  _astRows = d.assets || [];
  if (!_astRows.length) {
    list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 py-4">' +
      (d.total === 0 && !document.getElementById('ast-q').value
        ? 'No assets imported yet. A Togen admin can import the Asset File spreadsheet above.'
        : 'No assets match the current search/filters.') + '</p>';
  } else {
    var head = '<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 font-medium">Tag</th><th class="py-2 pr-3 font-medium">Type</th>' +
      '<th class="py-2 pr-3 font-medium">Description</th><th class="py-2 pr-3 font-medium">Area</th>' +
      '<th class="py-2 pr-3 font-medium">Form ID</th><th class="py-2 pr-3 font-medium">Manufacturer</th>' +
      '<th class="py-2 pr-3 font-medium">Model #</th><th class="py-2 pr-3 font-medium">Serial #</th>' +
      '<th class="py-2 pr-0 font-medium"></th></tr>';
    var body = _astRows.map(function(a, i) {
      // Photo indicator: same camera-icon + count convention as the WO Admin
      // list (DVI-1063); click opens the detail popup where photos live.
      var photoIcon = '';
      if (a.photos && a.photos.length) {
        photoIcon = '<button type="button" title="' + a.photos.length + ' photo(s) attached — click to view" ' +
          'onclick="event.stopPropagation();assetsDetailOpen(' + i + ')" ' +
          'class="inline-flex items-center gap-0.5 text-togen-teal hover:text-togen-teal-dark transition-colors 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="1.7">' +
          '<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>' +
          '</svg>' +
          '<span class="text-[11px] font-semibold leading-none">' + a.photos.length + '</span>' +
          '</button>';
      }
      return '<tr onclick="assetsDetailOpen(' + i + ')" class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/60 cursor-pointer">' +
        '<td class="py-2 pr-3 font-mono text-xs text-togen-teal whitespace-nowrap">' + _astEsc(a.asset_tag) + '</td>' +
        '<td class="py-2 pr-3 whitespace-nowrap">' + _astEsc(a.asset_type) + '</td>' +
        '<td class="py-2 pr-3">' + _astEsc(a.description) + '</td>' +
        '<td class="py-2 pr-3 whitespace-nowrap">' + _astEsc(a.area) + '</td>' +
        '<td class="py-2 pr-3 whitespace-nowrap">' + _astEsc(a.form_id) + '</td>' +
        '<td class="py-2 pr-3 whitespace-nowrap">' + _astEsc(a.manufacturer) + '</td>' +
        '<td class="py-2 pr-3 whitespace-nowrap">' + _astEsc(a.model_no) + '</td>' +
        '<td class="py-2 pr-3 whitespace-nowrap">' + _astEsc(a.serial_no) + '</td>' +
        '<td class="py-2 pr-0 whitespace-nowrap text-right">' + photoIcon + '</td></tr>';
    }).join('');
    list.innerHTML = '<table class="w-full text-sm text-gray-800 dark:text-gray-100"><thead>' + head + '</thead><tbody>' + body + '</tbody></table>';
  }
  var count = document.getElementById('ast-count');
  if (count) {
    var from = d.total ? ((d.page - 1) * d.per_page + 1) : 0;
    var to = Math.min(d.page * d.per_page, d.total);
    count.textContent = d.total ? (from + '–' + to + ' of ' + d.total + ' assets') : '';
  }
  // Page indicator + jump-to-page selector (DVI-1250 D4-A).
  var totalPages = Math.max(1, Math.ceil((d.total || 0) / (d.per_page || 50)));
  var pageTotal = document.getElementById('ast-page-total');
  if (pageTotal) pageTotal.textContent = totalPages;
  var pageSel = document.getElementById('ast-page-select');
  if (pageSel) {
    if (pageSel.options.length !== totalPages) {
      var opts = '';
      for (var p = 1; p <= totalPages; p++) opts += '<option value="' + p + '">' + p + '</option>';
      pageSel.innerHTML = opts;
    }
    pageSel.value = String(d.page);
  }
  var prev = document.getElementById('ast-prev');
  var next = document.getElementById('ast-next');
  if (prev) prev.disabled = d.page <= 1;
  if (next) next.disabled = d.page * d.per_page >= d.total;
}

function assetsLogView(tag) {
  // DVI-1250 D3-A: usage tracking. Log an asset open once per session per
  // tag (server also throttles per user + tag); fire-and-forget beacon.
  if (!tag || _astViewLogged[tag]) return;
  _astViewLogged[tag] = true;
  try {
    var payload = JSON.stringify({ tag: tag });
    if (navigator.sendBeacon) {
      navigator.sendBeacon('/assets/view-log',
        new Blob([payload], { type: 'application/json' }));
    } else {
      fetch('/assets/view-log', { method: 'POST', keepalive: true,
        headers: { 'Content-Type': 'application/json' }, body: payload });
    }
  } catch (e) { /* usage logging is best-effort */ }
}

function assetsDetailOpen(idx) {
  var a = _astRows[idx];
  if (!a) return;
  _astDetailIdx = idx;
  assetsLogView(a.asset_tag);
  var title = document.getElementById('ast-detail-title');
  if (title) title.textContent = 'Asset ' + a.asset_tag;
  var fields = [
    ['Asset Type', a.asset_type], ['Model', assetModelLabel(a.model_id)],
    ['Description', a.description],
    ['Area', a.area], ['Form ID', a.form_id],
    ['Length', a.length], ['Width', a.width], ['Height', a.height],
    ['Manufacturer', a.manufacturer], ['Model #', a.model_no],
    ['Serial #', a.serial_no], ['Notes', a.notes], ['Material', a.material],
    ['Inventory sheet', a.source_sheet]
  ];
  Object.keys(a.extra || {}).forEach(function(k) { fields.push([k, a.extra[k]]); });
  var body = document.getElementById('ast-detail-body');
  if (body) {
    var html = '<dl class="grid grid-cols-[9rem_1fr] gap-y-1.5 gap-x-3">' +
      fields.filter(function(f){ return f[1]; }).map(function(f) {
        return '<dt class="text-xs text-gray-500 dark:text-gray-400 pt-0.5">' + _astEsc(f[0]) + '</dt>' +
               '<dd class="text-gray-800 dark:text-gray-100">' + _astEsc(f[1]) + '</dd>';
      }).join('') + '</dl>';
    _astLbImages = [];
    _astLbTag = a.asset_tag;
    if (a.photos && a.photos.length) {
      // Board reject on card 1e5f35e8: photos open the WO Admin-style
      // in-app lightbox (was: raw image in a new tab).
      html += '<p class="text-xs text-gray-500 dark:text-gray-400 mt-4 mb-1.5">Photos (' + a.photos.length + ')</p>' +
        '<div class="flex flex-wrap gap-2">' + a.photos.map(function(p, j) {
          var url = '/assets/photo/' + encodeURIComponent(a.asset_tag) + '/' + encodeURIComponent(p);
          _astLbImages.push({ file: p, url: url });
          return '<button type="button" onclick="assetsPhotoLightbox(' + j + ')" title="' + _astEsc(p) + '" ' +
            'class="block w-24 h-24 rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 hover:ring-2 hover:ring-togen-teal transition-all cursor-pointer">' +
            '<img src="' + url + '" loading="lazy" alt="' + _astEsc(p) + '" class="w-full h-full object-cover"></button>';
        }).join('') + '</div>';
    }
    body.innerHTML = html +
      '<div id="ast-sub-section" class="mt-1"></div>' +
      '<div id="ast-parts-section" class="mt-1"></div>' +
      '<div id="ast-eq-section" class="mt-1"></div>';
  }
  document.getElementById('ast-detail-overlay').classList.remove('hidden');
  // DVI-1472 P3 (D7): own + model-inherited sub-assemblies.
  assetsLoadSubAssemblies(a.asset_tag);
  // DVI-1472 P4 (D8-A): associated parts + stock (assets.inventory only).
  if (_astCanInventory) assetsLoadAssetParts(a.asset_tag);
  // DVI-1472 P2 D4: the Equipment tab folded into the Assets detail — load the
  // asset's equipment view (RtS / Service / Oversight / WO history / manual).
  assetsLoadEquipment(a.asset_tag);
}

function assetsDetailClose() {
  document.getElementById('ast-detail-overlay').classList.add('hidden');
}

// ----- DVI-1472 P2 D4: equipment section inside the Assets detail -----
// Replaces the old Equipment (Registry) tab. Reads /assets/<tag>/equipment and
// edits ops fields via the existing PATCH /equipment/registry/<asset_id> (an
// untracked inventory asset is linked first via .../equipment/track). One
// asset is shown at a time, so the current record lives in _astEq — no asset
// id is ever interpolated into an onclick (avoids the quote-escaping trap).
var _astEq = null;

function assetsLoadEquipment(tag) {
  var wrap = document.getElementById('ast-eq-section');
  if (!wrap) return;
  wrap.innerHTML = '<p class="text-xs text-gray-400 dark:text-gray-500 mt-4">Loading equipment…</p>';
  fetch('/assets/' + encodeURIComponent(tag) + '/equipment')
    .then(function(r) { return r.json(); })
    .then(function(d) { _astEq = { tag: tag, data: d }; assetsRenderEquipment(); })
    .catch(function() { wrap.innerHTML = ''; });
}

function _astEqPatch(payload) {
  if (!_astEq || !_astEq.data.record) return;
  fetch('/equipment/registry/' + encodeURIComponent(_astEq.data.record.asset_id),
        { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(payload) })
    .then(function(r) { return r.json().then(function(b) { return { ok: r.ok, b: b }; }); })
    .then(function(res) { if (!res.ok) alert((res.b && res.b.error) || 'Update failed.');
                          assetsLoadEquipment(_astEq.tag); })
    .catch(function() { assetsLoadEquipment(_astEq.tag); });
}

function astEqTrack() {
  if (!_astEq) return;
  fetch('/assets/' + encodeURIComponent(_astEq.tag) + '/equipment/track',
        { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
    .then(function(r) { return r.json().then(function(b) { return { ok: r.ok, b: b }; }); })
    .then(function(res) { if (!res.ok) alert((res.b && res.b.error) || 'Failed to track.');
                          assetsLoadEquipment(_astEq.tag); })
    .catch(function() { assetsLoadEquipment(_astEq.tag); });
}

function astEqToggleRts(el) { _astEqPatch({ rts_required: el.checked }); }
function astEqToggleDept(dept, el) { var m = {}; m[dept] = el.checked; _astEqPatch({ rts_departments: m }); }
function astEqToggleService(field, el) { var m = {}; m[field] = el.checked; _astEqPatch(m); }
function astEqSaveDevEui(el) { _astEqPatch({ oversight_dev_eui: (el.value || '').trim() }); }
function astEqSaveAssetNo(el) { _astEqPatch({ asset_no: (el.value || '').trim() }); }
function astEqSetOversight(val) {
  var payload = { include_in_oversight: val === '' ? false : val };
  if (val === 'mobile') {
    var an = document.getElementById('ast-eq-assetno');
    payload.asset_no = an ? (an.value || '').trim() : ((_astEq.data.record || {}).asset_no || '');
  }
  _astEqPatch(payload);
}

function assetsRenderEquipment() {
  var wrap = document.getElementById('ast-eq-section');
  if (!wrap || !_astEq) return;
  var d = _astEq.data, rec = d.record, ce = !!d.can_edit, dis = ce ? '' : ' disabled';
  var h = '<div class="mt-5 pt-4 border-t border-gray-200 dark:border-gray-700">' +
          '<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-2">Equipment</p>';
  if (!d.tracked) {
    h += '<p class="text-sm text-gray-500 dark:text-gray-400">Not tracked as equipment.</p>';
    if (ce) h += '<button type="button" onclick="astEqTrack()" class="mt-2 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 cursor-pointer">Track as equipment</button>';
  } else {
    h += '<div class="mb-3"><p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Service status</p>' +
         '<label class="mr-4 text-sm text-gray-700 dark:text-gray-200"><input type="checkbox" onchange="astEqToggleService(\'out_of_service\', this)" class="mr-1"' + (rec.out_of_service ? ' checked' : '') + dis + '>Out of service (down)</label>' +
         '<label class="text-sm text-gray-700 dark:text-gray-200"><input type="checkbox" onchange="astEqToggleService(\'offsite\', this)" class="mr-1"' + (rec.offsite ? ' checked' : '') + dis + '>Offsite</label></div>';
    h += '<div class="mb-3"><label class="text-sm font-medium text-gray-700 dark:text-gray-200"><input type="checkbox" onchange="astEqToggleRts(this)" class="mr-1"' + (rec.rts_required ? ' checked' : '') + dis + '>Require Return-to-Service approval</label>';
    var depts = rec.rts_departments || {};
    var keys = Object.keys(depts); if (!keys.length) keys = ['maintenance', 'operations', 'safety', 'quality'];
    h += '<div class="mt-1 pl-5 flex flex-wrap gap-x-4 gap-y-1">' + keys.map(function(dp) {
      return '<label class="text-xs capitalize text-gray-600 dark:text-gray-300"><input type="checkbox" onchange="astEqToggleDept(\'' + dp + '\', this)" class="mr-1"' + (depts[dp] ? ' checked' : '') + dis + '>' + _astEsc(dp) + '</label>';
    }).join('') + '</div></div>';
    var inc = rec.include_in_oversight, isMobile = inc === 'mobile',
        isStat = inc === 'stationary' || inc === true, off = !inc;
    h += '<div class="mb-3"><p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Oversight</p>' +
         '<label class="mr-3 text-sm text-gray-700 dark:text-gray-200"><input type="radio" name="ast-eq-ov" onchange="astEqSetOversight(\'\')" class="mr-1"' + (off ? ' checked' : '') + dis + '>Off</label>' +
         '<label class="mr-3 text-sm text-gray-700 dark:text-gray-200"><input type="radio" name="ast-eq-ov" onchange="astEqSetOversight(\'stationary\')" class="mr-1"' + (isStat ? ' checked' : '') + dis + '>Stationary</label>' +
         '<label class="text-sm text-gray-700 dark:text-gray-200"><input type="radio" name="ast-eq-ov" onchange="astEqSetOversight(\'mobile\')" class="mr-1"' + (isMobile ? ' checked' : '') + dis + '>Mobile</label>';
    if (isMobile) {
      h += '<div class="mt-2 flex flex-wrap gap-2">' +
           '<input id="ast-eq-assetno" placeholder="Asset #" value="' + _astEsc(rec.asset_no || '') + '" onchange="astEqSaveAssetNo(this)"' + dis + ' class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100">' +
           '<input id="ast-eq-deveui" placeholder="LoRaWAN DevEUI" value="' + _astEsc(rec.oversight_dev_eui || '') + '" onchange="astEqSaveDevEui(this)"' + dis + ' class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100"></div>';
    }
    h += '</div>';
  }
  if (d.manual && (d.manual.url || d.manual.filename)) {
    h += '<div class="mb-3"><p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Linked manual</p>' +
         '<a href="' + _astEsc(d.manual.url || '#') + '" target="_blank" class="text-sm text-togen-teal hover:underline">' +
         _astEsc(d.manual.original_name || d.manual.filename || 'View manual') + '</a></div>';
  }
  var wos = d.wo_history || [];
  h += '<div><p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Work order history (' + wos.length + ')</p>';
  if (wos.length) {
    h += '<ul class="space-y-1 max-h-48 overflow-y-auto">' + wos.slice(0, 25).map(function(w) {
      return '<li class="text-xs text-gray-700 dark:text-gray-300">' + _astEsc(w.date || '') + ' · ' +
             _astEsc(w.wor_type || '') + ' · ' + _astEsc(w.status || '') + ' — ' +
             _astEsc(w.title || w.filename || '') + '</li>';
    }).join('') + '</ul>';
    if (wos.length > 25) h += '<p class="text-xs text-gray-400 mt-1">+' + (wos.length - 25) + ' more</p>';
  } else {
    h += '<p class="text-xs text-gray-400 dark:text-gray-500">No work orders reference this equipment.</p>';
  }
  h += '</div></div>';
  wrap.innerHTML = h;
}

// ----- Edit / add (DVI-1185 P2) -----

var _astDetailIdx = null;
var _astEditTag = null;   // original tag when editing; null when adding
var _AST_EDIT_FIELDS = ['asset_tag', 'asset_type', 'description', 'area',
  'form_id', 'length', 'width', 'height', 'manufacturer', 'model_no',
  'serial_no', 'material', 'notes'];
// DVI-1472 P4 (D8-A): parts/inventory state.
var _astCanInventory = false;
var _astParts = [];           // current parts-overlay list
var _astPartSel = null;       // selected part in the detail pane
var _astPartEditId = null;    // part id being edited (null = add)
var _astPartsQTimer = null;

function assetsEditFromDetail() {
  assetsDetailClose();
  assetsEditOpen(_astDetailIdx);
}

// ===== DVI-1472 P3 (D6/D7): asset models & sub-assemblies =====
var _astModels = [];
var _astCanCatalog = false;
var _astModelSelId = null;   // model selected in the catalog modal
var _astModelEditId = null;  // model being add/edited (null = add)
var _astModelSubs = [];      // sub-assemblies of the selected model (by index)
var _astDetailSubs = [];     // own sub-assemblies of the open asset (by index)
var _astSubCtx = null;       // {parent_kind, parent_ref, sub_id, refresh}

function assetModelLabel(mid) {
  if (!mid) return '';
  for (var i = 0; i < _astModels.length; i++) {
    if (_astModels[i].id === mid) {
      var m = _astModels[i];
      return m.name + (m.model_no ? (' (' + m.model_no + ')') : '');
    }
  }
  return '';
}

function assetModelsFillSelect(current) {
  var sel = document.getElementById('ast-e-model_id');
  if (!sel) return;
  var html = '<option value="">— none —</option>';
  _astModels.forEach(function(m) {
    html += '<option value="' + m.id + '">' + _astEsc(m.name) +
      (m.manufacturer ? (' — ' + _astEsc(m.manufacturer)) : '') +
      (m.model_no ? (' (' + _astEsc(m.model_no) + ')') : '') + '</option>';
  });
  sel.innerHTML = html;
  sel.value = current ? String(current) : '';
}

function assetModelsRefresh() {
  return fetch('/assets/models')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      _astModels = d.models || [];
      if (typeof d.can_catalog === 'boolean') _astCanCatalog = d.can_catalog;
      // Keep an open editor's dropdown current without disturbing its value.
      var sel = document.getElementById('ast-e-model_id');
      if (sel) assetModelsFillSelect(sel.value);
      if (!document.getElementById('ast-models-overlay').classList.contains('hidden')) {
        assetModelsRenderList();
        if (_astModelSelId) assetModelSelect(_astModelSelId);
      }
    })
    .catch(function(){});
}

function assetModelsOpen() {
  _astModelSelId = null;
  document.getElementById('ast-models-overlay').classList.remove('hidden');
  assetModelsRefresh().then(function(){ assetModelsRenderList(); });
}
function assetModelsClose() {
  document.getElementById('ast-models-overlay').classList.add('hidden');
}

function assetModelsRenderList() {
  var box = document.getElementById('ast-models-list');
  if (!box) return;
  if (!_astModels.length) {
    box.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 py-3">No models yet. Create one to start.</p>';
    return;
  }
  box.innerHTML = _astModels.map(function(m, i) {
    var active = m.id === _astModelSelId;
    return '<button type="button" onclick="assetModelSelect(' + m.id + ')" ' +
      'class="w-full text-left px-3 py-2 rounded-lg border transition-colors cursor-pointer ' +
      (active ? 'border-togen-teal bg-togen-teal/10' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/60') + '">' +
      '<div class="text-sm font-medium text-gray-800 dark:text-gray-100">' + _astEsc(m.name) + '</div>' +
      '<div class="text-xs text-gray-500 dark:text-gray-400">' +
      [m.manufacturer, m.model_no].filter(Boolean).map(_astEsc).join(' · ') +
      (m.asset_count ? ('<span class="ml-2 text-togen-teal">' + m.asset_count + ' asset' + (m.asset_count === 1 ? '' : 's') + '</span>') : '') +
      '</div></button>';
  }).join('');
}

function assetModelSelect(id) {
  _astModelSelId = id;
  assetModelsRenderList();
  var m = null;
  for (var i = 0; i < _astModels.length; i++) if (_astModels[i].id === id) m = _astModels[i];
  var box = document.getElementById('ast-model-detail');
  if (!m || !box) return;
  box.innerHTML =
    '<div class="flex items-start justify-between gap-2 mb-2">' +
      '<div><div class="text-sm font-semibold text-gray-800 dark:text-gray-100">' + _astEsc(m.name) + '</div>' +
      '<div class="text-xs text-gray-500 dark:text-gray-400">' + [m.manufacturer, m.model_no].filter(Boolean).map(_astEsc).join(' · ') + '</div></div>' +
      '<div class="flex items-center gap-1.5 shrink-0">' +
        '<button onclick="assetModelEditOpen(' + m.id + ')" class="togen-control px-2.5 py-1 text-xs rounded-lg border border-togen-teal/40 text-togen-teal hover:bg-togen-teal/10 transition-colors cursor-pointer">Edit</button>' +
        '<button onclick="assetModelDelete(' + m.id + ')" class="togen-control px-2.5 py-1 text-xs 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/30 transition-colors cursor-pointer">Delete</button>' +
      '</div>' +
    '</div>' +
    (m.description ? ('<p class="text-xs text-gray-600 dark:text-gray-300 mb-3">' + _astEsc(m.description) + '</p>') : '') +
    '<div class="flex items-center justify-between mb-1.5 mt-2">' +
      '<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Sub-assemblies</span>' +
      '<button onclick="assetModelAddSub()" class="px-2 py-0.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">+ Add</button>' +
    '</div>' +
    '<p class="text-[11px] text-gray-400 dark:text-gray-500 mb-2">Inherited by every asset of this model.</p>' +
    '<div id="ast-model-subs" class="space-y-1"><p class="text-xs text-gray-400">Loading…</p></div>';
  assetModelLoadSubs(id);
}

function assetModelLoadSubs(id) {
  fetch('/assets/sub-assemblies?parent_kind=model&parent_ref=' + encodeURIComponent(id))
    .then(function(r){ return r.ok ? r.json() : { sub_assemblies: [] }; })
    .then(function(d){
      _astModelSubs = d.sub_assemblies || [];
      var box = document.getElementById('ast-model-subs');
      if (!box) return;
      if (!_astModelSubs.length) {
        box.innerHTML = '<p class="text-xs text-gray-400 dark:text-gray-500">None yet.</p>';
        return;
      }
      box.innerHTML = _astModelSubs.map(function(s, i) {
        return '<div class="flex items-start justify-between gap-2 px-2.5 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700">' +
          '<div><div class="text-sm text-gray-800 dark:text-gray-100">' + _astEsc(s.name) + '</div>' +
          (s.description ? ('<div class="text-xs text-gray-500 dark:text-gray-400">' + _astEsc(s.description) + '</div>') : '') + '</div>' +
          '<div class="flex items-center gap-1 shrink-0">' +
            '<button onclick="assetModelEditSub(' + i + ')" class="text-xs text-togen-teal hover:underline cursor-pointer">Edit</button>' +
            '<button onclick="assetModelDeleteSub(' + i + ')" class="text-xs text-red-500 hover:underline cursor-pointer">Delete</button>' +
          '</div></div>';
      }).join('');
    })
    .catch(function(){});
}

function assetModelEditOpen(id) {
  _astModelEditId = id;
  var m = null;
  if (id) for (var i = 0; i < _astModels.length; i++) if (_astModels[i].id === id) m = _astModels[i];
  document.getElementById('ast-model-edit-title').textContent = m ? 'Edit model' : 'Add model';
  document.getElementById('ast-m-name').value = m ? m.name : '';
  document.getElementById('ast-m-manufacturer').value = m ? m.manufacturer : '';
  document.getElementById('ast-m-model_no').value = m ? m.model_no : '';
  document.getElementById('ast-m-description').value = m ? m.description : '';
  document.getElementById('ast-model-edit-status').textContent = '';
  document.getElementById('ast-model-edit-overlay').classList.remove('hidden');
  document.getElementById('ast-m-name').focus();
}
function assetModelEditClose() {
  document.getElementById('ast-model-edit-overlay').classList.add('hidden');
}
function assetModelEditSave() {
  var body = {
    name: document.getElementById('ast-m-name').value,
    manufacturer: document.getElementById('ast-m-manufacturer').value,
    model_no: document.getElementById('ast-m-model_no').value,
    description: document.getElementById('ast-m-description').value
  };
  var st = document.getElementById('ast-model-edit-status');
  if (!body.name.trim()) { st.textContent = 'Model name is required.'; return; }
  var btn = document.getElementById('ast-model-edit-save');
  btn.disabled = true; st.textContent = '';
  var url = _astModelEditId ? ('/assets/models/' + _astModelEditId) : '/assets/models';
  fetch(url, { 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;
      if (!res.ok) { st.textContent = res.d.error || 'Save failed.'; return; }
      var newId = res.d.model && res.d.model.id;
      assetModelEditClose();
      assetModelsRefresh().then(function(){
        assetModelsRenderList();
        assetModelSelect(newId || _astModelSelId);
      });
    })
    .catch(function(err){ btn.disabled = false; st.textContent = 'Save failed: ' + err.message; });
}
function assetModelDelete(id) {
  if (!confirm('Delete this model? This cannot be undone.')) return;
  fetch('/assets/models/' + id, { method: 'DELETE' })
    .then(function(r){ return r.json().then(function(d){ return { ok: r.ok, d: d }; }); })
    .then(function(res){
      if (!res.ok) { alert(res.d.error || 'Delete failed.'); return; }
      _astModelSelId = null;
      document.getElementById('ast-model-detail').innerHTML =
        '<p class="text-sm text-gray-400 dark:text-gray-500 py-6 text-center">Select a model to edit it and manage its sub-assemblies.</p>';
      assetModelsRefresh().then(function(){ assetModelsRenderList(); });
    })
    .catch(function(err){ alert('Delete failed: ' + err.message); });
}

// ----- Sub-assembly add/edit dialog (shared by model + asset scopes) -----
function _assetSubOpen(ctx, sub) {
  _astSubCtx = ctx;
  document.getElementById('ast-sub-edit-title').textContent = sub ? 'Edit sub-assembly' : 'Add sub-assembly';
  document.getElementById('ast-sub-edit-scope').textContent = ctx.scopeLabel || '';
  document.getElementById('ast-sub-name').value = sub ? sub.name : '';
  document.getElementById('ast-sub-description').value = sub ? sub.description : '';
  document.getElementById('ast-sub-edit-status').textContent = '';
  document.getElementById('ast-sub-edit-overlay').classList.remove('hidden');
  document.getElementById('ast-sub-name').focus();
}
function assetSubEditClose() {
  document.getElementById('ast-sub-edit-overlay').classList.add('hidden');
  _astSubCtx = null;
}
function assetSubEditSave() {
  if (!_astSubCtx) return;
  var st = document.getElementById('ast-sub-edit-status');
  var name = document.getElementById('ast-sub-name').value;
  if (!name.trim()) { st.textContent = 'Sub-assembly name is required.'; return; }
  var body = { name: name, description: document.getElementById('ast-sub-description').value };
  var url = '/assets/sub-assemblies';
  if (_astSubCtx.sub_id) {
    url = '/assets/sub-assemblies/' + _astSubCtx.sub_id;
  } else {
    body.parent_kind = _astSubCtx.parent_kind;
    body.parent_ref = _astSubCtx.parent_ref;
  }
  var btn = document.getElementById('ast-sub-edit-save');
  btn.disabled = true; st.textContent = '';
  var refresh = _astSubCtx.refresh;
  fetch(url, { 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;
      if (!res.ok) { st.textContent = res.d.error || 'Save failed.'; return; }
      assetSubEditClose();
      if (refresh) refresh();
    })
    .catch(function(err){ btn.disabled = false; st.textContent = 'Save failed: ' + err.message; });
}
function _assetSubDelete(id, refresh) {
  if (!confirm('Delete this sub-assembly?')) return;
  fetch('/assets/sub-assemblies/' + id, { method: 'DELETE' })
    .then(function(r){ return r.json().then(function(d){ return { ok: r.ok, d: d }; }); })
    .then(function(res){
      if (!res.ok) { alert(res.d.error || 'Delete failed.'); return; }
      if (refresh) refresh();
    })
    .catch(function(err){ alert('Delete failed: ' + err.message); });
}

// Model-scoped sub-assembly actions (catalog modal)
function assetModelAddSub() {
  if (!_astModelSelId) return;
  _assetSubOpen({ parent_kind: 'model', parent_ref: String(_astModelSelId),
    scopeLabel: 'Attached to this model (inherited by all its assets).',
    refresh: function(){ assetModelLoadSubs(_astModelSelId); } });
}
function assetModelEditSub(i) {
  var s = _astModelSubs[i];
  if (!s) return;
  _assetSubOpen({ sub_id: s.id, scopeLabel: 'Model sub-assembly.',
    refresh: function(){ assetModelLoadSubs(_astModelSelId); } }, s);
}
function assetModelDeleteSub(i) {
  var s = _astModelSubs[i];
  if (s) _assetSubDelete(s.id, function(){ assetModelLoadSubs(_astModelSelId); });
}

// ----- Asset-detail sub-assemblies (own + model-inherited) -----
function assetsLoadSubAssemblies(tag) {
  var box = document.getElementById('ast-sub-section');
  if (!box) return;
  fetch('/assets/' + encodeURIComponent(tag) + '/sub-assemblies')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) { box.innerHTML = ''; return; }
      _astDetailSubs = d.own || [];
      var canCat = !!d.can_catalog;
      var rows = '';
      (d.own || []).forEach(function(s, i) {
        rows += '<div class="flex items-start justify-between gap-2 px-2.5 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700">' +
          '<div><div class="text-sm text-gray-800 dark:text-gray-100">' + _astEsc(s.name) + '</div>' +
          (s.description ? ('<div class="text-xs text-gray-500 dark:text-gray-400">' + _astEsc(s.description) + '</div>') : '') + '</div>' +
          (canCat ? ('<div class="flex items-center gap-1 shrink-0">' +
            '<button onclick="assetsDetailEditSub(' + i + ')" class="text-xs text-togen-teal hover:underline cursor-pointer">Edit</button>' +
            '<button onclick="assetsDetailDeleteSub(' + i + ')" class="text-xs text-red-500 hover:underline cursor-pointer">Delete</button>' +
          '</div>') : '') + '</div>';
      });
      (d.inherited || []).forEach(function(s) {
        rows += '<div class="flex items-start justify-between gap-2 px-2.5 py-1.5 rounded-lg border border-dashed border-gray-200 dark:border-gray-700 bg-gray-50/60 dark:bg-gray-800/40">' +
          '<div><div class="text-sm text-gray-700 dark:text-gray-200">' + _astEsc(s.name) + '</div>' +
          (s.description ? ('<div class="text-xs text-gray-500 dark:text-gray-400">' + _astEsc(s.description) + '</div>') : '') + '</div>' +
          '<span class="text-[11px] text-gray-400 dark:text-gray-500 shrink-0 whitespace-nowrap">from model ' + _astEsc(s.model_name || '') + '</span></div>';
      });
      if (!rows) rows = '<p class="text-xs text-gray-400 dark:text-gray-500">None.</p>';
      box.innerHTML =
        '<div class="flex items-center justify-between mt-4 mb-1.5">' +
          '<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Sub-assemblies</p>' +
          (canCat ? '<button onclick="assetsDetailAddSub()" class="px-2 py-0.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">+ Add</button>' : '') +
        '</div><div class="space-y-1">' + rows + '</div>';
    })
    .catch(function(){ box.innerHTML = ''; });
}
function _astDetailTag() {
  var a = _astRows[_astDetailIdx];
  return a ? a.asset_tag : '';
}
function assetsDetailAddSub() {
  var tag = _astDetailTag();
  if (!tag) return;
  _assetSubOpen({ parent_kind: 'asset', parent_ref: tag,
    scopeLabel: 'Attached to this asset only.',
    refresh: function(){ assetsLoadSubAssemblies(tag); } });
}
function assetsDetailEditSub(i) {
  var s = _astDetailSubs[i]; var tag = _astDetailTag();
  if (s) _assetSubOpen({ sub_id: s.id, scopeLabel: 'Asset sub-assembly.',
    refresh: function(){ assetsLoadSubAssemblies(tag); } }, s);
}
function assetsDetailDeleteSub(i) {
  var s = _astDetailSubs[i]; var tag = _astDetailTag();
  if (s) _assetSubDelete(s.id, function(){ assetsLoadSubAssemblies(tag); });
}

function assetsEditOpen(idx) {
  var a = idx === null ? null : _astRows[idx];
  _astEditTag = a ? a.asset_tag : null;
  document.getElementById('ast-edit-title').textContent =
    a ? ('Edit asset ' + a.asset_tag) : 'Add asset';
  _AST_EDIT_FIELDS.forEach(function(f) {
    var el = document.getElementById('ast-e-' + f);
    if (el) el.value = a ? (a[f] || '') : '';
  });
  assetModelsFillSelect(a && a.model_id ? a.model_id : '');
  var opts = (_astMeta && _astMeta.options) || {};
  [['ast-dl-type', opts.asset_type], ['ast-dl-area', opts.area],
   ['ast-dl-form', opts.form_id]].forEach(function(pair) {
    var dl = document.getElementById(pair[0]);
    if (!dl) return;
    dl.innerHTML = (pair[1] || []).map(function(v) {
      return '<option value="' + _astEsc(v) + '"></option>';
    }).join('');
  });
  var st = document.getElementById('ast-edit-status');
  if (st) st.textContent = '';
  _astEditPhotos = a ? (a.photos || []).slice() : [];
  _astPendingPhotos.forEach(function(p){ URL.revokeObjectURL(p.url); });
  _astPendingPhotos = [];
  var pst = document.getElementById('ast-e-photo-status');
  if (pst) pst.textContent = '';
  assetsEditRenderPhotos();
  document.getElementById('ast-edit-overlay').classList.remove('hidden');
  var tagInput = document.getElementById('ast-e-asset_tag');
  if (tagInput && !a) tagInput.focus();
}

function assetsEditClose() {
  _astPendingPhotos.forEach(function(p){ URL.revokeObjectURL(p.url); });
  _astPendingPhotos = [];
  document.getElementById('ast-edit-overlay').classList.add('hidden');
}

function assetsEditSave() {
  var body = {};
  _AST_EDIT_FIELDS.forEach(function(f) {
    var el = document.getElementById('ast-e-' + f);
    if (el) body[f] = el.value;
  });
  var modelSel = document.getElementById('ast-e-model_id');
  if (modelSel) body.model_id = modelSel.value;  // blank clears the model
  if (_astEditTag) body.original_tag = _astEditTag;
  var st = document.getElementById('ast-edit-status');
  var btn = document.getElementById('ast-edit-save');
  if (!String(body.asset_tag || '').trim()) {
    if (st) st.textContent = 'Asset Tag # is required.';
    return;
  }
  if (btn) btn.disabled = true;
  if (st) st.textContent = '';
  fetch('/assets/save', {
    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) {
        if (btn) btn.disabled = false;
        if (st) st.textContent = res.d.error || 'Save failed.';
        return;
      }
      var finish = function() {
        if (btn) btn.disabled = false;
        assetsEditClose();
        assetsRefreshMeta();
        assetsLoad(_astEditTag ? _astPage : 1);
      };
      // Add mode: photos were staged in the dialog; upload them to the
      // freshly created tag before closing.
      var files = (!_astEditTag && _astPendingPhotos.length)
        ? _astPendingPhotos.map(function(p){ return p.file; }) : [];
      if (!files.length) { finish(); return; }
      if (st) { st.textContent = 'Uploading ' + files.length + ' photo(s)…'; }
      assetsUploadPhotos(res.d.asset.asset_tag, files)
        .then(finish)
        .catch(function(err){
          alert('Asset saved, but photo upload failed: ' + err.message);
          finish();
        });
    })
    .catch(function(err){
      if (btn) btn.disabled = false;
      if (st) st.textContent = 'Save failed: ' + err.message;
    });
}

// Refresh filter dropdowns/import status without resetting the current page
// ===== DVI-1472 P4 (D8-A): parts & inventory =====
function assetsPartsRefreshLowBadge() {
  fetch('/assets/parts/low-stock').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      var badge = document.getElementById('ast-parts-low-badge');
      if (!badge) return;
      var n = d && d.parts ? d.parts.length : 0;
      badge.textContent = n;
      badge.classList.toggle('hidden', !n);
    }).catch(function(){});
}
function assetsPartsOpen() {
  document.getElementById('ast-parts-overlay').classList.remove('hidden');
  _astPartSel = null;
  assetsPartDetailClear();
  assetsPartsLoad();
}
function assetsPartsClose() {
  document.getElementById('ast-parts-overlay').classList.add('hidden');
}
var _astNotifyLoaded = false;
function assetsPartsNotifyLoad() {
  fetch('/assets/parts/notify-config').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d || !d.config) return;
      var c = d.config;
      document.getElementById('ast-notify-enabled').checked = !!c.enabled;
      document.getElementById('ast-notify-emails').value = (c.emails || []).join(', ');
      document.getElementById('ast-notify-cooldown').value = (c.cooldown_hours != null ? c.cooldown_hours : 24);
      document.getElementById('ast-notify-teams').value = c.teams_webhook || '';
      var st = document.getElementById('ast-notify-state');
      if (st) { st.textContent = c.enabled ? 'on' : 'off'; st.classList.toggle('bg-emerald-500/15', !!c.enabled); st.classList.toggle('text-emerald-600', !!c.enabled); st.classList.toggle('dark:text-emerald-400', !!c.enabled); }
      _astNotifyLoaded = true;
    }).catch(function(){});
}
function assetsPartsNotifySave() {
  var msg = document.getElementById('ast-notify-msg');
  msg.textContent = 'Saving…';
  fetch('/assets/parts/notify-config', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({
    enabled: document.getElementById('ast-notify-enabled').checked,
    emails: document.getElementById('ast-notify-emails').value,
    cooldown_hours: document.getElementById('ast-notify-cooldown').value,
    teams_webhook: document.getElementById('ast-notify-teams').value
  })}).then(function(r){ return r.ok ? r.json() : null; }).then(function(d){
    if (!d || !d.config) { msg.textContent = 'Save failed.'; return; }
    msg.textContent = 'Saved.';
    var st = document.getElementById('ast-notify-state');
    if (st) { st.textContent = d.config.enabled ? 'on' : 'off'; st.classList.toggle('bg-emerald-500/15', !!d.config.enabled); st.classList.toggle('text-emerald-600', !!d.config.enabled); st.classList.toggle('dark:text-emerald-400', !!d.config.enabled); }
    document.getElementById('ast-notify-emails').value = (d.config.emails || []).join(', ');
  }).catch(function(){ msg.textContent = 'Save failed.'; });
}
function assetsPartsNotifyTest() {
  var msg = document.getElementById('ast-notify-msg');
  msg.textContent = 'Sending test…';
  fetch('/assets/parts/notify-test', {method:'POST'}).then(function(r){ return r.json().then(function(j){ return {ok:r.ok, j:j}; }); })
    .then(function(res){
      if (!res.ok) { msg.textContent = (res.j && res.j.error) || 'Test failed.'; return; }
      var parts = [];
      if (res.j.email_sent !== null && res.j.email_sent !== undefined) parts.push('email ' + (res.j.email_sent ? 'sent ✓' : 'failed ✗'));
      if (res.j.teams_sent !== null && res.j.teams_sent !== undefined) parts.push('Teams ' + (res.j.teams_sent ? 'sent ✓' : 'failed ✗'));
      msg.textContent = parts.length ? parts.join(', ') : 'Nothing configured to send.';
    }).catch(function(){ msg.textContent = 'Test failed.'; });
}
function assetsPartsSearchDebounced() {
  clearTimeout(_astPartsQTimer);
  _astPartsQTimer = setTimeout(assetsPartsLoad, 250);
}
function assetsPartsLoad() {
  var q = (document.getElementById('ast-parts-q').value || '').trim();
  var low = document.getElementById('ast-parts-low-only').checked;
  var url = '/assets/parts?';
  if (q) url += 'q=' + encodeURIComponent(q) + '&';
  if (low) url += 'low_stock=1';
  var list = document.getElementById('ast-parts-list');
  fetch(url).then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) { list.innerHTML = '<p class="text-xs text-red-500">Failed to load parts.</p>'; return; }
      _astParts = d.parts || [];
      var digest = document.getElementById('ast-parts-digest');
      if (d.low_stock_count) {
        digest.textContent = d.low_stock_count + ' part' + (d.low_stock_count === 1 ? '' : 's') + ' at or below minimum / negative — turn on “Low stock” to review.';
        digest.classList.remove('hidden');
      } else { digest.classList.add('hidden'); }
      var badge = document.getElementById('ast-parts-low-badge');
      if (badge) { badge.textContent = d.low_stock_count || 0; badge.classList.toggle('hidden', !d.low_stock_count); }
      if (!_astParts.length) { list.innerHTML = '<p class="text-xs text-gray-400 dark:text-gray-500 py-4 text-center">No parts.</p>'; return; }
      list.innerHTML = _astParts.map(function(p) {
        return '<button onclick="assetsPartSelect(' + p.id + ')" class="w-full text-left px-2.5 py-1.5 rounded-lg border ' +
          ((_astPartSel && _astPartSel.id === p.id) ? 'border-togen-teal bg-togen-teal/10 ' : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/60 ') +
          'transition-colors cursor-pointer"><div class="flex items-center justify-between gap-2">' +
          '<span class="text-sm text-gray-800 dark:text-gray-100 truncate">' + _astEsc(p.part_no) + (p.name ? (' · ' + _astEsc(p.name)) : '') + '</span>' +
          assetsPartStockBadge(p) + '</div></button>';
      }).join('');
    }).catch(function(){ list.innerHTML = '<p class="text-xs text-red-500">Failed to load parts.</p>'; });
}
function assetsPartStockBadge(p) {
  var cls = p.negative ? 'bg-red-500/15 text-red-600 dark:text-red-400'
    : (p.below_min ? 'bg-amber-500/15 text-amber-700 dark:text-amber-400'
    : 'bg-gray-500/10 text-gray-600 dark:text-gray-300');
  return '<span class="shrink-0 px-1.5 py-0.5 rounded-full text-[10px] font-semibold ' + cls + '" title="On hand">' + _astEsc(String(p.on_hand)) + (p.low ? ' ⚠' : '') + '</span>';
}
function assetsPartSelect(id) {
  fetch('/assets/parts/' + id).then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d || !d.part) return;
      _astPartSel = d.part;
      assetsPartsLoad();
      assetsPartRenderDetail(d.part, d.movements || [], d.links || []);
    });
}
function assetsPartDetailClear() {
  document.getElementById('ast-part-detail').innerHTML =
    '<p class="text-sm text-gray-400 dark:text-gray-500 py-6 text-center">Select a part to view its stock, adjust it, and manage links.</p>';
}
function _astMovLabel(m) {
  if (m.kind === 'usage') return 'WO usage' + (m.wo_ref ? (' · ' + _astEsc(m.wo_ref)) : '') + (m.asset_tag ? (' → ' + _astEsc(m.asset_tag)) : '');
  return 'Adjustment' + (m.reason ? (' · ' + _astEsc(m.reason)) : '');
}
function assetsPartRenderDetail(p, movements, links) {
  var box = document.getElementById('ast-part-detail');
  var badge = assetsPartStockBadge(p);
  var flag = p.negative ? '<span class="text-red-600 dark:text-red-400 font-medium"> — negative stock</span>'
    : (p.below_min ? '<span class="text-amber-700 dark:text-amber-400 font-medium"> — at/below minimum</span>' : '');
  var linkRows = (links || []).length ? links.map(function(l) {
    return '<div class="flex items-center justify-between gap-2 px-2 py-1 rounded border border-gray-200 dark:border-gray-700 text-xs">' +
      '<span class="text-gray-700 dark:text-gray-200 truncate">' + _astEsc(l.target_kind.replace("_"," ")) + ': ' + _astEsc(l.label) + '</span>' +
      '<button onclick="assetsPartUnlink(' + l.link_id + ')" class="text-red-500 hover:underline cursor-pointer shrink-0">Unlink</button></div>';
  }).join('') : '<p class="text-xs text-gray-400 dark:text-gray-500">Not linked to any model, asset, or sub-assembly.</p>';
  var movRows = (movements || []).length ? movements.map(function(m) {
    var d = Number(m.delta);
    var sign = d > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400';
    return '<div class="flex items-center justify-between gap-2 px-2 py-1 text-xs border-b border-gray-100 dark:border-gray-800">' +
      '<span class="text-gray-600 dark:text-gray-300 truncate">' + _astMovLabel(m) + '</span>' +
      '<span class="shrink-0"><span class="' + sign + ' font-medium">' + (d > 0 ? '+' : '') + _astEsc(String(m.delta)) + '</span>' +
      ' <span class="text-gray-400">→ ' + _astEsc(String(m.on_hand_after)) + '</span></span></div>';
  }).join('') : '<p class="text-xs text-gray-400 dark:text-gray-500">No movements yet.</p>';
  box.innerHTML =
    '<div class="flex items-center justify-between gap-2 mb-1">' +
      '<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">' + _astEsc(p.part_no) + '</h4>' +
      '<div class="flex items-center gap-1 shrink-0">' +
        '<button onclick="assetsPartPrintLabel(' + p.id + ')" class="text-xs text-togen-teal hover:underline cursor-pointer">Label</button>' +
        '<button onclick="assetsPartEditOpen(' + p.id + ')" class="text-xs text-togen-teal hover:underline cursor-pointer">Edit</button>' +
        '<button onclick="assetsPartDelete(' + p.id + ')" class="text-xs text-red-500 hover:underline cursor-pointer">Delete</button>' +
      '</div></div>' +
    (p.name ? ('<div class="text-xs text-gray-500 dark:text-gray-400">' + _astEsc(p.name) + '</div>') : '') +
    (p.description ? ('<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">' + _astEsc(p.description) + '</div>') : '') +
    '<div class="mt-2 text-sm text-gray-800 dark:text-gray-100">On hand ' + badge + flag +
      '<span class="text-xs text-gray-500 dark:text-gray-400"> · min ' + _astEsc(String(p.min_qty)) + (p.location ? (' · ' + _astEsc(p.location)) : '') + '</span></div>' +
    '<div class="mt-3 flex items-end gap-2">' +
      '<label class="text-xs text-gray-500 dark:text-gray-400">Adjust (±)<input id="ast-part-adj-delta" type="number" step="any" class="mt-1 w-20 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"></label>' +
      '<label class="text-xs text-gray-500 dark:text-gray-400 flex-1">Reason<input id="ast-part-adj-reason" class="mt-1 w-full 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"></label>' +
      '<button onclick="assetsPartAdjust(' + p.id + ')" class="px-2.5 py-1 text-xs font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">Apply</button>' +
    '</div><span id="ast-part-adj-status" class="text-xs text-red-500"></span>' +
    '<div class="mt-4"><div class="flex items-center justify-between mb-1"><span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Links</span></div>' + linkRows +
      '<div class="mt-1.5 flex items-center gap-1">' +
        '<select id="ast-part-link-kind" 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-100 cursor-pointer"><option value="asset">Asset</option><option value="model">Model</option><option value="sub_assembly">Sub-assembly</option></select>' +
        '<input id="ast-part-link-ref" placeholder="tag / 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-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal">' +
        '<button onclick="assetsPartLink(' + p.id + ')" 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">Link</button>' +
      '</div><span id="ast-part-link-status" class="text-xs text-red-500"></span></div>' +
    '<div class="mt-4"><span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Movements</span>' +
      '<div class="mt-1 max-h-40 overflow-y-auto">' + movRows + '</div></div>';
}
function assetsPartAdjust(id) {
  var delta = (document.getElementById('ast-part-adj-delta').value || '').trim();
  var reason = (document.getElementById('ast-part-adj-reason').value || '').trim();
  var st = document.getElementById('ast-part-adj-status');
  if (!delta || Number(delta) === 0) { if (st) st.textContent = 'Enter a non-zero amount.'; return; }
  fetch('/assets/parts/' + id + '/adjust', { method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ delta: Number(delta), reason: reason }) })
    .then(function(r){ return r.json().then(function(d){ return { ok: r.ok, d: d }; }); })
    .then(function(res){
      if (!res.ok) { if (st) st.textContent = res.d.error || 'Adjust failed.'; return; }
      assetsPartSelect(id); assetsPartsRefreshLowBadge();
    }).catch(function(){ if (st) st.textContent = 'Adjust failed.'; });
}
function assetsPartLink(id) {
  var kind = document.getElementById('ast-part-link-kind').value;
  var ref = (document.getElementById('ast-part-link-ref').value || '').trim();
  var st = document.getElementById('ast-part-link-status');
  if (!ref) { if (st) st.textContent = 'Enter a target.'; return; }
  fetch('/assets/parts/' + id + '/link', { method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ target_kind: kind, target_ref: ref }) })
    .then(function(r){ return r.json().then(function(d){ return { ok: r.ok, d: d }; }); })
    .then(function(res){
      if (!res.ok) { if (st) st.textContent = res.d.error || 'Link failed.'; return; }
      assetsPartSelect(id);
    }).catch(function(){ if (st) st.textContent = 'Link failed.'; });
}
function assetsPartUnlink(linkId) {
  if (!_astPartSel) return;
  var pid = _astPartSel.id;
  fetch('/assets/parts/links/' + linkId, { method: 'DELETE' })
    .then(function(r){ return r.ok; })
    .then(function(ok){ if (ok) assetsPartSelect(pid); });
}
function assetsPartDelete(id) {
  if (!confirm('Delete this part?')) return;
  fetch('/assets/parts/' + id, { method: 'DELETE' })
    .then(function(r){ return r.json().then(function(d){ return { ok: r.ok, d: d }; }); })
    .then(function(res){
      if (!res.ok) { alert(res.d.error || 'Delete failed.'); return; }
      _astPartSel = null; assetsPartDetailClear(); assetsPartsLoad(); assetsPartsRefreshLowBadge();
    });
}
function assetsPartEditOpen(id) {
  _astPartEditId = id;
  var p = id ? _astParts.filter(function(x){ return x.id === id; })[0] : null;
  if (id && !p && _astPartSel && _astPartSel.id === id) p = _astPartSel;
  document.getElementById('ast-part-edit-title').textContent = id ? 'Edit part' : 'Add part';
  document.getElementById('ast-part-part_no').value = p ? (p.part_no || '') : '';
  document.getElementById('ast-part-name').value = p ? (p.name || '') : '';
  document.getElementById('ast-part-description').value = p ? (p.description || '') : '';
  document.getElementById('ast-part-min_qty').value = p ? (p.min_qty != null ? p.min_qty : '') : '';
  document.getElementById('ast-part-location').value = p ? (p.location || '') : '';
  document.getElementById('ast-part-on_hand').value = '';
  // Opening on-hand only applies when creating (edits move stock via Adjust).
  document.getElementById('ast-part-opening-wrap').classList.toggle('hidden', !!id);
  document.getElementById('ast-part-edit-status').textContent = '';
  document.getElementById('ast-part-edit-overlay').classList.remove('hidden');
}
function assetsPartEditClose() {
  document.getElementById('ast-part-edit-overlay').classList.add('hidden');
}
function assetsPartEditSave() {
  var body = {
    part_no: (document.getElementById('ast-part-part_no').value || '').trim(),
    name: (document.getElementById('ast-part-name').value || '').trim(),
    description: (document.getElementById('ast-part-description').value || '').trim(),
    min_qty: (document.getElementById('ast-part-min_qty').value || '').trim(),
    location: (document.getElementById('ast-part-location').value || '').trim()
  };
  if (!_astPartEditId) body.on_hand = (document.getElementById('ast-part-on_hand').value || '').trim();
  var st = document.getElementById('ast-part-edit-status');
  if (!body.part_no) { st.textContent = 'Part number is required.'; return; }
  var url = _astPartEditId ? ('/assets/parts/' + _astPartEditId) : '/assets/parts';
  fetch(url, { 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) { st.textContent = res.d.error || 'Save failed.'; return; }
      assetsPartEditClose();
      assetsPartsLoad(); assetsPartsRefreshLowBadge();
      if (res.d.part) assetsPartSelect(res.d.part.id);
    }).catch(function(){ st.textContent = 'Save failed.'; });
}
// DVI-1478 (P6): printable QR labels. Single label for one part, or a sheet
// for the parts currently listed (respecting the search / low-stock filter).
function assetsPartPrintLabel(id) {
  window.open('/assets/parts/labels.pdf?single=1&ids=' + encodeURIComponent(id), '_blank');
}
function assetsPartsPrintSheet() {
  if (!_astParts || !_astParts.length) { alert('No parts to print.'); return; }
  var q = (document.getElementById('ast-parts-q').value || '').trim();
  var low = document.getElementById('ast-parts-low-only').checked;
  var url = '/assets/parts/labels.pdf?all=1';
  if (q) url += '&q=' + encodeURIComponent(q);
  if (low) url += '&low_stock=1';
  window.open(url, '_blank');
}
// Asset-detail associated-parts section (read + surface stock).
function assetsLoadAssetParts(tag) {
  var box = document.getElementById('ast-parts-section');
  if (!box) return;
  fetch('/assets/' + encodeURIComponent(tag) + '/parts')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) { box.innerHTML = ''; return; }
      var parts = d.parts || [];
      var rows = parts.map(function(p) {
        var via = p.via === 'asset' ? '' : (' <span class="text-[11px] text-gray-400 dark:text-gray-500">via ' + _astEsc(p.via === 'model' ? ('model ' + (p.via_label || '')) : ('sub-assembly ' + (p.via_label || ''))) + '</span>');
        return '<div class="flex items-center justify-between gap-2 px-2.5 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700">' +
          '<div class="min-w-0"><div class="text-sm text-gray-800 dark:text-gray-100 truncate">' + _astEsc(p.part_no) + (p.name ? (' · ' + _astEsc(p.name)) : '') + via + '</div></div>' +
          assetsPartStockBadge(p) + '</div>';
      }).join('');
      if (!rows) rows = '<p class="text-xs text-gray-400 dark:text-gray-500">None.</p>';
      box.innerHTML =
        '<div class="flex items-center justify-between mt-4 mb-1.5">' +
          '<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Associated parts</p>' +
          '<button onclick="assetsPartsOpen()" class="px-2 py-0.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</button>' +
        '</div><div class="space-y-1">' + rows + '</div>';
    }).catch(function(){ box.innerHTML = ''; });
}

// (a save can introduce new type/area/form values).
function assetsRefreshMeta() {
  fetch('/assets/meta')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      _astMeta = d;
      assetsFillFilters(d.options || {});
      var st = document.getElementById('ast-import-status');
      if (st && d.health && d.health.last_import_at) {
        st.textContent = d.health.assets + ' assets · imported ' + d.health.last_import_at;
      }
    })
    .catch(function(){});
}

// ----- Asset photos: dialog editing + lightbox (board reject on card 1e5f35e8) -----

var _astEditPhotos = [];    // existing filenames while editing
var _astPendingPhotos = []; // {file, url} staged in add mode until Save
var _astLbImages = [];
var _astLbIndex = -1;
var _astLbTag = '';

function assetsEditRenderPhotos() {
  var box = document.getElementById('ast-e-photos');
  if (!box) return;
  var removeBtn = function(onclick) {
    return '<button type="button" onclick="' + onclick + '" title="Remove photo" ' +
      'class="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-red-500 text-white text-xs leading-none flex items-center justify-center hover:bg-red-600 cursor-pointer">&times;</button>';
  };
  var html;
  if (_astEditTag) {
    html = _astEditPhotos.map(function(p) {
      var url = '/assets/photo/' + encodeURIComponent(_astEditTag) + '/' + encodeURIComponent(p);
      return '<div class="relative">' +
        '<img src="' + url + '" loading="lazy" alt="' + _astEsc(p) + '" class="w-20 h-20 object-cover rounded-lg border border-gray-200 dark:border-gray-700">' +
        removeBtn("assetsEditPhotoDelete('" + _astEsc(p).replace(/'/g, "\\'") + "')") + '</div>';
    }).join('');
  } else {
    html = _astPendingPhotos.map(function(p, j) {
      return '<div class="relative">' +
        '<img src="' + p.url + '" alt="" class="w-20 h-20 object-cover rounded-lg border border-gray-200 dark:border-gray-700">' +
        removeBtn('assetsEditPhotoUnstage(' + j + ')') + '</div>';
    }).join('');
  }
  box.innerHTML = html || '<span class="text-xs text-gray-400 dark:text-gray-500">No photos yet.</span>';
}

function assetsEditPhotosPicked(input) {
  var files = Array.prototype.slice.call(input.files || []);
  input.value = '';
  if (!files.length) return;
  var st = document.getElementById('ast-e-photo-status');
  if (_astEditTag) {
    // Editing an existing asset: attach immediately (WO Admin convention).
    if (st) st.textContent = 'Uploading…';
    assetsUploadPhotos(_astEditTag, files)
      .then(function(photos){
        _astEditPhotos = photos;
        if (st) st.textContent = '';
        assetsEditRenderPhotos();
        assetsLoad(_astPage); // refresh the list's photo indicator
      })
      .catch(function(err){ if (st) st.textContent = 'Upload failed: ' + err.message; });
  } else {
    // Adding: the asset has no tag yet — stage until Save creates it.
    files.forEach(function(f){
      _astPendingPhotos.push({ file: f, url: URL.createObjectURL(f) });
    });
    if (st) st.textContent = 'Photos upload when you Save.';
    assetsEditRenderPhotos();
  }
}

function assetsEditPhotoUnstage(j) {
  var p = _astPendingPhotos.splice(j, 1)[0];
  if (p) URL.revokeObjectURL(p.url);
  assetsEditRenderPhotos();
}

function assetsUploadPhotos(tag, files) {
  var fd = new FormData();
  fd.append('tag', tag);
  files.forEach(function(f){ fd.append('photos', f); });
  return fetch('/assets/photo-upload', { 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) throw new Error(res.d.error || 'Upload failed');
      if (res.d.skipped && res.d.skipped.length) {
        alert('Skipped (not an image): ' + res.d.skipped.join(', '));
      }
      return res.d.photos || [];
    });
}

function assetsEditPhotoDelete(name) {
  if (!_astEditTag) return;
  if (!confirm('Remove this photo from asset ' + _astEditTag + '?')) return;
  var st = document.getElementById('ast-e-photo-status');
  fetch('/assets/photo-delete', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ tag: _astEditTag, filename: name })
  })
    .then(function(r){ return r.json().then(function(d){ return { ok: r.ok, d: d }; }); })
    .then(function(res){
      if (!res.ok) { if (st) st.textContent = res.d.error || 'Remove failed.'; return; }
      _astEditPhotos = res.d.photos || [];
      assetsEditRenderPhotos();
      assetsLoad(_astPage); // refresh the list's photo indicator
    })
    .catch(function(err){ if (st) st.textContent = 'Remove failed: ' + err.message; });
}

// In-app lightbox mirroring the WO Admin photo lightbox (DVI-1076).
function assetsPhotoLightbox(i) {
  if (!_astLbImages.length || i < 0 || i >= _astLbImages.length) return;
  _astLbIndex = i;
  var ov = document.getElementById('ast-photos-lightbox');
  if (!ov) return;
  var p = _astLbImages[i];
  var img = document.getElementById('ast-lb-img');
  if (img) { img.src = p.url; img.alt = p.file; }
  var badge = document.getElementById('ast-lb-badge');
  if (badge) badge.textContent = _astLbTag ? ('Asset ' + _astLbTag) : '';
  var cap = document.getElementById('ast-lb-caption');
  if (cap) cap.innerHTML = '<span class="block text-xs text-gray-300">' + _astEsc(p.file) + '</span>';
  var counter = document.getElementById('ast-lb-counter');
  if (counter) counter.textContent = (i + 1) + ' / ' + _astLbImages.length;
  var dl = document.getElementById('ast-lb-download');
  if (dl) dl.href = p.url;
  var nav = document.getElementById('ast-lb-nav');
  if (nav) nav.classList.toggle('hidden', _astLbImages.length < 2);
  ov.classList.remove('hidden');
}

function assetsPhotoLightboxNav(delta) {
  if (!_astLbImages.length) return;
  var n = _astLbImages.length;
  assetsPhotoLightbox((_astLbIndex + delta + n) % n);
}

function assetsPhotoLightboxClose() {
  var ov = document.getElementById('ast-photos-lightbox');
  if (ov) ov.classList.add('hidden');
  var img = document.getElementById('ast-lb-img');
  if (img) img.src = '';
  _astLbIndex = -1;
}

function assetsImportPick() {
  var input = document.getElementById('ast-import-file');
  if (input) { input.value = ''; input.click(); }
}

function assetsImportUpload(input) {
  var file = input && input.files && input.files[0];
  if (!file) return;
  var st = document.getElementById('ast-import-status');
  if (st) st.textContent = 'Importing …';
  var fd = new FormData();
  fd.append('file', file);
  fetch('/assets/import', { 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) throw new Error(res.d.error || 'Import failed');
      assetsShowReport(res.d.report);
      assetsInit();
    })
    .catch(function(err){
      if (st) st.textContent = '';
      alert('Import failed: ' + err.message);
    });
}

function assetsShowLastReport() {
  fetch('/assets/import-report')
    .then(function(r){ if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
    .then(function(d){ assetsShowReport(d.report); })
    .catch(function(){ alert('No import report available.'); });
}

function assetsShowReport(report) {
  if (!report) return;
  var html = '<p class="text-gray-700 dark:text-gray-200 mb-2"><b>' + report.imported +
    '</b> assets imported from <b>' + _astEsc(report.source_name) + '</b> (' + _astEsc(report.imported_at) + ').</p>';
  // Explicit text color: the modal sets none, so cells rendered near-black
  // in dark mode (board reject on card 1e5f35e8).
  html += '<table class="w-full text-xs mb-3 text-gray-800 dark:text-gray-100"><thead><tr class="text-left text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">' +
    '<th class="py-1 pr-2 font-medium">Sheet</th><th class="py-1 pr-2 font-medium">Team</th><th class="py-1 pr-2 font-medium">Rows</th><th class="py-1 pr-0 font-medium">Imported</th></tr></thead><tbody>' +
    (report.sheets || []).map(function(s) {
      return '<tr class="border-b border-gray-100 dark:border-gray-800"><td class="py-1 pr-2">' + _astEsc(s.name) +
        '</td><td class="py-1 pr-2">' + _astEsc(s.team || '') + '</td><td class="py-1 pr-2">' + s.data_rows +
        '</td><td class="py-1 pr-0">' + s.imported + '</td></tr>';
    }).join('') + '</tbody></table>';
  var counts = report.exception_counts || {};
  var keys = Object.keys(counts);
  if (keys.length) {
    html += '<p class="font-medium text-gray-700 dark:text-gray-200 mb-1">Flagged (not silently fixed):</p><ul class="list-disc ml-5 mb-3 text-gray-600 dark:text-gray-300">' +
      keys.map(function(k){ return '<li>' + _astEsc(k) + ': <b>' + counts[k] + '</b></li>'; }).join('') + '</ul>';
  } else {
    html += '<p class="text-gray-600 dark:text-gray-300 mb-3">No exceptions flagged.</p>';
  }
  var ex = report.exceptions || [];
  if (ex.length) {
    html += '<details class="mb-1"><summary class="cursor-pointer text-xs text-gray-500 dark:text-gray-400">Exception details (' + ex.length + ')</summary>' +
      '<div class="mt-2 max-h-60 overflow-y-auto"><table class="w-full text-xs text-gray-800 dark:text-gray-100"><tbody>' +
      ex.map(function(e) {
        return '<tr class="border-b border-gray-100 dark:border-gray-800"><td class="py-0.5 pr-2 whitespace-nowrap">' + _astEsc(e.sheet) + ' r' + e.row +
          '</td><td class="py-0.5 pr-2 font-mono">' + _astEsc(e.tag) + '</td><td class="py-0.5 pr-2">' + _astEsc(e.issue) +
          '</td><td class="py-0.5 pr-0 text-gray-500 dark:text-gray-400">' + _astEsc(e.value) + '</td></tr>';
      }).join('') + '</tbody></table></div></details>';
  }
  var body = document.getElementById('ast-report-body');
  if (body) body.innerHTML = html;
  document.getElementById('ast-report-overlay').classList.remove('hidden');
}

function assetsReportClose() {
  document.getElementById('ast-report-overlay').classList.add('hidden');
}

// ===== EQUIPMENT REGISTRY (DVI-1012) =====

var _eqRegistry = [];

function eqLoadRegistry() {
  var container = document.getElementById('eq-list');
  if (!container) return;
  container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>';
  fetch('/equipment/registry')
    .then(function(r){ return r.json(); })
    .then(function(d){
      _eqRegistry = d.equipment || [];
      eqRenderRegistry();
    })
    .catch(function(){ container.innerHTML = '<p class="text-sm text-red-500">Failed to load equipment.</p>'; });
}

function eqRenderRegistry() {
  var container = document.getElementById('eq-list');
  if (!container) return;
  if (!_eqRegistry.length) {
    container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No equipment found. Click Add to create the first entry.</p>';
    return;
  }
  var html = '<div class="space-y-3">';
  for (var i = 0; i < _eqRegistry.length; i++) {
    html += eqRowHtml(_eqRegistry[i]);
  }
  html += '</div>';
  container.innerHTML = html;
}

function eqRowHtml(eq) {
  var aid = eq.asset_id || '';
  var name = eq.name || eq.asset_id || '';
  var rtsRequired = !!eq.rts_required;
  var depts = eq.rts_departments || {};
  var deptNames = ['maintenance', 'operations', 'safety', 'quality'];
  var deptLabels = {maintenance: 'Maintenance', operations: 'Operations', safety: 'Safety', quality: 'Quality'};

  var deptHtml = '';
  for (var di = 0; di < deptNames.length; di++) {
    var d = deptNames[di];
    var checked = depts[d] !== false ? 'checked' : '';
    deptHtml +=
      '<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">' +
        '<input type="checkbox" ' + checked + ' ' +
               'onchange="eqToggleDept(\'' + esc(aid) + '\',\'' + d + '\',this.checked)" ' +
               'class="h-4 w-4 rounded border-gray-300 text-togen-teal focus:ring-togen-teal">' +
        deptLabels[d] +
      '</label>';
  }

  var rtsExpanded = rtsRequired ? '' : ' hidden';
  var rtsChecked = rtsRequired ? 'checked' : '';
  // include_in_oversight may be true (legacy), 'stationary', 'mobile', or falsy.
  var ovType = eq.include_in_oversight === true ? 'stationary' : (eq.include_in_oversight || '');
  var devEui = eq.oversight_dev_eui || '';
  var assetNo = eq.asset_no || '';
  var ovTagExpanded = (ovType === 'stationary' || ovType === 'mobile') ? '' : ' hidden';
  var ovAssetExpanded = (ovType === 'mobile') ? '' : ' hidden';
  var downChecked = eq.out_of_service ? 'checked' : '';
  var offsiteChecked = eq.offsite ? 'checked' : '';

  return '' +
    '<div class="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden" id="eq-row-' + esc(aid) + '">' +
      '<div class="px-4 py-3 flex items-center justify-between gap-3 bg-gray-50 dark:bg-gray-800">' +
        '<span class="text-sm font-medium text-gray-900 dark:text-gray-100">' + esc(name) + '</span>' +
        '<button onclick="eqToggleDetail(\'' + esc(aid) + '\')" ' +
                'class="text-xs text-togen-teal hover:underline flex-shrink-0">Details</button>' +
      '</div>' +
      '<div id="eq-detail-' + esc(aid) + '" class="hidden px-4 py-4 space-y-4 border-t border-gray-200 dark:border-gray-700">' +
        '<div>' +
          '<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">RtS Approval</p>' +
          '<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">' +
            '<input type="checkbox" id="eq-rts-' + esc(aid) + '" ' + rtsChecked + ' ' +
                   'onchange="eqToggleRts(\'' + esc(aid) + '\',this.checked)" ' +
                   'class="h-4 w-4 rounded border-gray-300 text-togen-teal focus:ring-togen-teal">' +
            'Requires Return-to-Service approval' +
          '</label>' +
          '<div id="eq-depts-' + esc(aid) + '" class="mt-3 ml-6 grid grid-cols-2 gap-2' + rtsExpanded + '">' +
            deptHtml +
          '</div>' +
        '</div>' +
        '<div>' +
          '<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">Oversight</p>' +
          '<div class="flex flex-wrap gap-4">' +
            ['', 'stationary', 'mobile'].map(function(v) {
              var lbl = v === '' ? 'Off' : v.charAt(0).toUpperCase() + v.slice(1);
              return '<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">'
                + '<input type="radio" name="eq-ov-' + esc(aid) + '" value="' + v + '" ' + (ovType === v ? 'checked' : '') + ' '
                +        'onchange="eqSetOversightType(\'' + esc(aid) + '\',this.value)" '
                +        'class="h-4 w-4 border-gray-300 text-togen-teal focus:ring-togen-teal">'
                + lbl
                + '</label>';
            }).join('') +
          '</div>' +
          '<div id="eq-ov-tag-' + esc(aid) + '" class="mt-3' + ovTagExpanded + '">' +
            '<label class="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1 block">LoRaWAN DevEUI (optional — links a tag for live telemetry)</label>' +
            '<input type="text" id="eq-ov-deveui-' + esc(aid) + '" value="' + esc(devEui) + '" placeholder="e.g. 0004A30B001A2B3C" '
            +      'onblur="eqSaveDevEui(\'' + esc(aid) + '\')" '
            +      'class="w-full px-2 py-1 text-xs font-mono 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:border-togen-teal">' +
          '</div>' +
          '<div id="eq-ov-asset-' + esc(aid) + '" class="mt-3' + ovAssetExpanded + '">' +
            '<label class="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1 block">Asset # <span class="text-red-500">*</span> (required for mobile machines — shown on Mobile-Machine work orders)</label>' +
            '<input type="text" id="eq-ov-assetno-' + esc(aid) + '" value="' + esc(assetNo) + '" placeholder="e.g. M-104" '
            +      'onblur="eqSaveAssetNo(\'' + esc(aid) + '\')" '
            +      'class="w-full 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:border-togen-teal">' +
            '<p id="eq-ov-asset-err-' + esc(aid) + '" class="text-xs text-red-500 mt-1 hidden"></p>' +
          '</div>' +
          '<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Stationary: fixed floor-plan pin. Mobile: position updates from LoRaWAN tag GPS data.</p>' +
        '</div>' +
        '<div>' +
          '<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">Service Status</p>' +
          '<div class="flex flex-wrap gap-4">' +
            '<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">' +
              '<input type="checkbox" id="eq-down-' + esc(aid) + '" ' + downChecked + ' ' +
                     'onchange="eqToggleService(\'' + esc(aid) + '\',\'out_of_service\',this.checked)" ' +
                     'class="h-4 w-4 rounded border-gray-300 text-togen-teal focus:ring-togen-teal">' +
              'Down (out of service)' +
            '</label>' +
            '<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">' +
              '<input type="checkbox" id="eq-offsite-' + esc(aid) + '" ' + offsiteChecked + ' ' +
                     'onchange="eqToggleService(\'' + esc(aid) + '\',\'offsite\',this.checked)" ' +
                     'class="h-4 w-4 rounded border-gray-300 text-togen-teal focus:ring-togen-teal">' +
              'Offsite' +
            '</label>' +
          '</div>' +
          '<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Feeds the Maintenance dashboard tile equipment down / offsite counts and the Oversight traffic-light.</p>' +
        '</div>' +
        '<div>' +
          '<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">WO History</p>' +
          '<div id="eq-wo-' + esc(aid) + '">' +
            '<button onclick="eqLoadWoHistory(\'' + esc(aid) + '\')" ' +
                    'class="text-xs text-togen-teal hover:underline">Load WO history</button>' +
          '</div>' +
        '</div>' +
        '<div>' +
          '<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">EVH Forms</p>' +
          '<div id="eq-evh-' + esc(aid) + '">' +
            '<p class="text-xs text-gray-400 dark:text-gray-500">Load WO history to view EVH forms.</p>' +
          '</div>' +
        '</div>' +
      '</div>' +
    '</div>';
}

function eqToggleDetail(assetId) {
  var detail = document.getElementById('eq-detail-' + assetId);
  if (!detail) return;
  detail.classList.toggle('hidden');
  // Auto-load WO history + EVH forms the first time the pane is opened.
  if (!detail.classList.contains('hidden') && !detail.dataset.loaded) {
    detail.dataset.loaded = '1';
    eqLoadWoHistory(assetId);
  }
}

function eqToggleRts(assetId, checked) {
  var deptsEl = document.getElementById('eq-depts-' + assetId);
  if (deptsEl) {
    if (checked) deptsEl.classList.remove('hidden');
    else deptsEl.classList.add('hidden');
  }
  fetch('/equipment/registry/' + encodeURIComponent(assetId), {
    method: 'PATCH',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({rts_required: checked})
  }).catch(function(){});
}

function eqToggleService(assetId, field, checked) {
  var body = {};
  body[field] = checked;
  fetch('/equipment/registry/' + encodeURIComponent(assetId), {
    method: 'PATCH',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body)
  }).catch(function(){});
}

function eqSetOversightType(assetId, val) {
  var tagDiv = document.getElementById('eq-ov-tag-' + assetId);
  if (tagDiv) tagDiv.classList.toggle('hidden', val === '');
  var assetDiv = document.getElementById('eq-ov-asset-' + assetId);
  if (assetDiv) assetDiv.classList.toggle('hidden', val !== 'mobile');
  fetch('/equipment/registry/' + encodeURIComponent(assetId), {
    method: 'PATCH',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({include_in_oversight: val || false})
  })
    .then(function(r){ return r.json().then(function(j){ return { ok: r.ok, j: j }; }); })
    .then(function(res){
      var err = document.getElementById('eq-ov-asset-err-' + assetId);
      if (!res.ok && val === 'mobile') {
        // Server rejects mobile without an Asset # — prompt the admin to fill it.
        if (err) { err.textContent = res.j.error || 'An Asset # is required for mobile equipment.'; err.classList.remove('hidden'); }
        var ai = document.getElementById('eq-ov-assetno-' + assetId); if (ai) ai.focus();
      } else if (err) { err.classList.add('hidden'); }
    })
    .catch(function(){});
}

function eqSaveDevEui(assetId) {
  var inp = document.getElementById('eq-ov-deveui-' + assetId);
  if (!inp) return;
  fetch('/equipment/registry/' + encodeURIComponent(assetId), {
    method: 'PATCH',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({oversight_dev_eui: inp.value.trim()})
  }).catch(function(){});
}

function eqSaveAssetNo(assetId) {
  var inp = document.getElementById('eq-ov-assetno-' + assetId);
  if (!inp) return;
  var err = document.getElementById('eq-ov-asset-err-' + assetId);
  fetch('/equipment/registry/' + encodeURIComponent(assetId), {
    method: 'PATCH',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({asset_no: inp.value.trim()})
  })
    .then(function(r){ return r.json().then(function(j){ return { ok: r.ok, j: j }; }); })
    .then(function(res){
      if (!res.ok) { if (err) { err.textContent = res.j.error || 'Save failed.'; err.classList.remove('hidden'); } }
      else if (err) { err.classList.add('hidden'); }
    })
    .catch(function(){});
}

function eqToggleDept(assetId, dept, checked) {
  var body = {rts_departments: {}};
  body.rts_departments[dept] = checked;
  fetch('/equipment/registry/' + encodeURIComponent(assetId), {
    method: 'PATCH',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body)
  }).catch(function(){});
}

function eqRenderEvhForms(assetId, rows) {
  var evhBox = document.getElementById('eq-evh-' + assetId);
  if (!evhBox) return;
  var evhRows = (rows || []).filter(function(r){ return r.evh_status; });
  if (!evhRows.length) {
    evhBox.innerHTML = '<p class="text-xs text-gray-400 dark:text-gray-500">No EVH forms yet for this equipment.</p>';
    return;
  }
  var html = '<ul class="space-y-2">';
  for (var i = 0; i < evhRows.length; i++) {
    var r = evhRows[i];
    var label = r.evh_status === 'approved' ? 'RtS Approved'
      : r.evh_status === 'awaiting_approval' ? 'Awaiting RtS Approval'
      : 'RtS Rejected';
    var released = r.evh_released_at ? (' · released ' + esc(r.evh_released_at.slice(0, 10))) : '';
    var action;
    if (r.evh_status === 'approved') {
      action = '<a href="/wor-evh-pdf?file=' + encodeURIComponent(r.filename) + '" target="_blank" ' +
               'class="text-togen-teal hover:underline">View EVH form (PDF)</a>';
    } else if (r.evh_status === 'awaiting_approval') {
      action = '<span class="text-amber-500">Pending departmental sign-off</span>';
    } else {
      action = '<span class="text-red-500">Rejected — reopened</span>';
    }
    html +=
      '<li class="text-xs text-gray-700 dark:text-gray-300 flex items-start gap-2">' +
        '<span class="flex-1">' + esc(r.title || r.filename) +
          ' <span class="text-gray-400">(' + label + released + ')</span>' +
        '</span>' +
        action +
      '</li>';
  }
  html += '</ul>';
  evhBox.innerHTML = html;
}

function eqLoadWoHistory(assetId) {
  var container = document.getElementById('eq-wo-' + assetId);
  if (!container) return;
  container.innerHTML = '<p class="text-xs text-gray-400">Loading…</p>';
  fetch('/equipment/registry/' + encodeURIComponent(assetId) + '/wo-history')
    .then(function(r){ return r.json(); })
    .then(function(d){
      var rows = d.history || [];
      eqRenderEvhForms(assetId, rows);
      if (!rows.length) {
        container.innerHTML = '<p class="text-xs text-gray-400 dark:text-gray-500">No work orders found for this equipment.</p>';
        return;
      }
      var html = '<ul class="space-y-2">';
      for (var i = 0; i < rows.length; i++) {
        var row = rows[i];
        var date = row.date ? row.date.slice(0, 10) : '';
        var typeLabel = {maintenance: 'Maintenance', mobilemachine: 'Mobile Machine',
                         safety: 'Safety', preventive: 'Preventive'}[row.wor_type] || row.wor_type;
        var evhHtml = '';
        if (row.evh_status) {
          var evhColor = row.evh_status === 'awaiting_approval'
            ? 'border-l-amber-400 bg-amber-50 dark:bg-amber-900/10'
            : row.evh_status === 'approved'
            ? 'border-l-green-400 bg-green-50 dark:bg-green-900/10'
            : 'border-l-red-400 bg-red-50 dark:bg-red-900/10';
          var evhLabel = row.evh_status === 'awaiting_approval' ? 'Awaiting RtS Approval'
            : row.evh_status === 'approved' ? 'RtS Approved'
            : 'RtS Rejected';
          var deptRows = (row.evh_required_depts || []).map(function(dept){
            var so = (row.evh_sign_offs || {})[dept];
            var isAct = (row.evh_actionable_depts || []).indexOf(dept) !== -1;
            var badge = so && so.action === 'approve'
              ? '<span class="text-green-600 dark:text-green-400">&#10003;</span>'
              : so && so.action === 'reject'
              ? '<span class="text-red-600 dark:text-red-400">&#10007;</span>'
              : isAct
              ? '<span class="text-amber-500">&#9679;</span>'
              : '<span class="text-gray-300 dark:text-gray-600">&#9675;</span>';
            var dLabel = {maintenance:'Maint', operations:'Ops', safety:'Safety', quality:'Quality'}[dept] || dept;
            return badge + ' ' + dLabel;
          }).join(' &nbsp; ');
          var daysAwaiting = '';
          if (row.evh_status === 'awaiting_approval' && row.evh_initialized_at) {
            var initMs = new Date(row.evh_initialized_at + (row.evh_initialized_at.endsWith('Z') ? '' : 'Z')).getTime();
            if (!isNaN(initMs)) {
              var days = Math.floor((Date.now() - initMs) / 86400000);
              daysAwaiting = ' &middot; ' + days + 'd';
            }
          }
          evhHtml = '<div class="mt-1 ml-22 pl-2 border-l-2 ' + evhColor + ' rounded-r text-xs">' +
            '<span class="font-medium">' + evhLabel + '</span>' + daysAwaiting +
            '<span class="ml-2 text-gray-500 dark:text-gray-400">' + deptRows + '</span>' +
            '</div>';
        }
        html +=
          '<li class="text-xs text-gray-700 dark:text-gray-300">' +
            '<div class="flex items-start gap-2">' +
              '<span class="text-gray-400 flex-shrink-0 w-20">' + esc(date) + '</span>' +
              '<span class="flex-1">' + esc(row.title || row.filename) +
                ' <span class="text-gray-400">(' + esc(typeLabel) + ')</span>' +
              '</span>' +
            '</div>' +
            evhHtml +
          '</li>';
      }
      html += '</ul>';
      container.innerHTML = html;
    })
    .catch(function(){
      container.innerHTML = '<p class="text-xs text-red-500">Failed to load WO history.</p>';
    });
}

function eqOpenAddModal() {
  var nameInput = document.getElementById('eq-add-name');
  var status = document.getElementById('eq-add-status');
  var btn = document.getElementById('eq-add-submit');
  if (nameInput) nameInput.value = '';
  if (status) { status.classList.add('hidden'); status.textContent = ''; }
  if (btn) btn.disabled = false;
  var modal = document.getElementById('eq-add-modal');
  if (modal) modal.classList.remove('hidden');
  if (nameInput) nameInput.focus();
}

function eqCloseAddModal() {
  var modal = document.getElementById('eq-add-modal');
  if (modal) modal.classList.add('hidden');
}

function eqSubmitAdd() {
  var nameInput = document.getElementById('eq-add-name');
  var status = document.getElementById('eq-add-status');
  var btn = document.getElementById('eq-add-submit');
  var name = (nameInput ? nameInput.value : '').trim();
  if (!name) {
    if (status) { status.textContent = 'Equipment name is required.'; status.className = 'text-sm text-red-500'; status.classList.remove('hidden'); }
    return;
  }
  if (btn) btn.disabled = true;
  if (status) { status.textContent = 'Saving…'; status.className = 'text-sm text-gray-500 dark:text-gray-400'; status.classList.remove('hidden'); }
  fetch('/equipment/registry', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({name: name})
  })
    .then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      if (!res.ok) {
        var msg = res.d.error || 'Failed to add equipment.';
        if (status) { status.textContent = msg; status.className = 'text-sm text-red-500'; status.classList.remove('hidden'); }
        if (btn) btn.disabled = false;
        return;
      }
      eqCloseAddModal();
      eqLoadRegistry();
    })
    .catch(function(){
      if (status) { status.textContent = 'Network error.'; status.className = 'text-sm text-red-500'; status.classList.remove('hidden'); }
      if (btn) btn.disabled = false;
    });
}

// ===== 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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="togen-control 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 _visionDiagLoaded = false;   // DVI-902: Diagnostics tab lazy-load guard
var _visionPolicy = null;        // {default:{...}, machines:{...}} loaded from server
var _visionPolicyDefaults = null;
var _visionPolicyLoaded = false;
var _visionEnrollLoaded = false;
var _visionSettingsLoaded = false;
var _visionMonitorLoaded = 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 === 'diagnostics' && !_visionDiagLoaded) {
    _visionDiagLoaded = true;
    visionLoadDiagnostics();
  }
  if (tab === 'policy' && !_visionPolicyLoaded) {
    _visionPolicyLoaded = true;
    visionPolicyLoad();
    visionWatchCriteriaLoad();
  }
  if (tab === 'enroll' && !_visionEnrollLoaded) {
    _visionEnrollLoaded = true;
    visionEnrollLoad();
  }
  if (tab === 'monitoring' && !_visionMonitorLoaded) {
    _visionMonitorLoaded = true;
    visionMonitorLoad();
  }
  if (tab === 'settings' && !_visionSettingsLoaded) {
    _visionSettingsLoaded = true;
    visionSettingsLoad();
    visionAgentVersionLoad();
    if (document.getElementById('vision-rsync-pane')) visionRsyncConfigLoad();
  }
}

// DVI-977: Vision → Settings → Rsync server pane. Admin-only; loads/saves the
// LAN/WAN endpoints + LAN subnet that drive footage delivery and the per-agent
// local/remote indicator.
function visionRsyncConfigLoad() {
  var loading = document.getElementById('vision-rsync-loading');
  var body = document.getElementById('vision-rsync-body');
  var error = document.getElementById('vision-rsync-error');
  if (!body) return;
  loading.classList.remove('hidden');
  body.classList.add('hidden');
  error.classList.add('hidden');
  fetch('/togen-admin/vision-settings')
    .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 rsync config.';
        error.classList.remove('hidden');
        return;
      }
      var s = res.d.settings || {};
      document.getElementById('vision-rsync-lan-ip').value = s.rsync_lan_ip || '';
      document.getElementById('vision-rsync-lan-port').value = s.rsync_lan_port || '';
      document.getElementById('vision-rsync-wan-ip').value = s.rsync_wan_ip || '';
      document.getElementById('vision-rsync-wan-port').value = s.rsync_wan_port || '';
      document.getElementById('vision-rsync-user').value = s.rsync_user || '';
      document.getElementById('vision-rsync-lan-subnet').value = s.lan_subnet || '';
      body.classList.remove('hidden');
    })
    .catch(function(e){
      loading.classList.add('hidden');
      error.textContent = 'Failed to load rsync config: ' + e.message;
      error.classList.remove('hidden');
    });
}

function visionRsyncConfigSave() {
  var btn = document.getElementById('vision-rsync-save-btn');
  var status = document.getElementById('vision-rsync-save-status');
  var payload = {
    rsync_lan_ip: document.getElementById('vision-rsync-lan-ip').value.trim(),
    rsync_lan_port: parseInt(document.getElementById('vision-rsync-lan-port').value, 10) || undefined,
    rsync_wan_ip: document.getElementById('vision-rsync-wan-ip').value.trim(),
    rsync_wan_port: parseInt(document.getElementById('vision-rsync-wan-port').value, 10) || undefined,
    rsync_user: document.getElementById('vision-rsync-user').value.trim(),
    lan_subnet: document.getElementById('vision-rsync-lan-subnet').value.trim()
  };
  btn.disabled = true;
  status.textContent = 'Saving…';
  status.className = 'text-sm text-gray-700 dark:text-gray-300';
  fetch('/togen-admin/vision-settings', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(payload)
  })
    .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) {
        status.textContent = res.d.error || 'Save failed.';
        status.className = 'text-sm text-red-600 dark:text-red-400';
        return;
      }
      status.textContent = 'Saved.';
      status.className = 'text-sm text-green-600 dark:text-green-400';
      // Reflect any server-normalized values.
      visionRsyncConfigLoad();
      // Refresh tiles so the local/remote badge picks up a changed subnet.
      if (typeof _visionCollectorLoaded !== 'undefined' && _visionCollectorLoaded) visionLoadCollectors();
    })
    .catch(function(e){
      btn.disabled = false;
      status.textContent = 'Save failed: ' + e.message;
      status.className = 'text-sm text-red-600 dark:text-red-400';
    });
}

// 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-871 / DVI-878: Vision → Monitoring pane. Automated Watch-Criteria
// monitoring config. Round-trips GET/POST /vision/monitor-config (admin-only,
// backend DVI-877). Model options come from /vision/assistant-config's
// system_providers (the monitor loop only accepts "system:" org models).
var _visionMonitorConfig = null;
var _visionMonitorCriteria = [];

function visionMonitorLoad() {
  var loading = document.getElementById('vision-monitor-loading');
  var body = document.getElementById('vision-monitor-body');
  var status = document.getElementById('vision-monitor-save-status');
  if (status) status.textContent = '';
  if (loading) { loading.textContent = 'Loading monitor config…'; loading.classList.remove('hidden'); }
  if (body) body.classList.add('hidden');
  Promise.all([
    fetch('/vision/monitor-config').then(function(r) { return r.ok ? r.json() : null; }),
    fetch('/vision/assistant-config').then(function(r) { return r.ok ? r.json() : null; })
  ]).then(function(res) {
    var cfgResp = res[0];
    var asst = res[1];
    if (!cfgResp || !cfgResp.config) {
      if (loading) loading.textContent = 'Failed to load monitor config.';
      return;
    }
    _visionMonitorConfig = cfgResp;
    _visionMonitorCriteria = cfgResp.available_criteria || [];
    var cfg = cfgResp.config;

    document.getElementById('vision-monitor-enabled').checked = !!cfg.enabled;
    document.getElementById('vision-monitor-interval').value = cfg.poll_interval_min || 15;
    document.getElementById('vision-monitor-cap').value = (cfg.daily_call_cap != null ? cfg.daily_call_cap : 500);

    // Model dropdown — system providers only (monitor loop uses org key).
    var sel = document.getElementById('vision-monitor-model');
    var modelEmpty = document.getElementById('vision-monitor-model-empty');
    sel.innerHTML = '';
    var sysModels = (asst && asst.system_providers) || [];
    sysModels.forEach(function(m) {
      var o = document.createElement('option');
      o.value = m.value; o.textContent = m.label;
      sel.appendChild(o);
    });
    // Ensure the currently-saved model is selectable even if not in the list.
    if (cfg.model && !sysModels.some(function(m){ return m.value === cfg.model; })) {
      var o = document.createElement('option');
      o.value = cfg.model; o.textContent = cfg.model + ' (saved)';
      sel.appendChild(o);
    }
    sel.value = cfg.model || '';
    var noModels = sysModels.length === 0;
    modelEmpty.classList.toggle('hidden', !noModels);

    // Live meter.
    var today = (cfgResp.calls_today != null ? cfgResp.calls_today : cfg.calls_today) || 0;
    var cap = (cfgResp.daily_call_cap != null ? cfgResp.daily_call_cap : cfg.daily_call_cap);
    document.getElementById('vision-monitor-meter').textContent = today + ' / ' + (cap != null ? cap : '—');

    visionMonitorRenderMachines(cfgResp.machines || [], cfg.machines || {});

    if (loading) loading.classList.add('hidden');
    if (body) body.classList.remove('hidden');
  }).catch(function() {
    if (loading) loading.textContent = 'Failed to load monitor config.';
  });
}

function visionMonitorRenderMachines(machines, saved) {
  var ul = document.getElementById('vision-monitor-machines');
  var mEmpty = document.getElementById('vision-monitor-machines-empty');
  var cEmpty = document.getElementById('vision-monitor-criteria-empty');
  ul.innerHTML = '';
  var active = machines.filter(function(m){ return !m.revoked; });
  mEmpty.classList.toggle('hidden', active.length !== 0);
  cEmpty.classList.toggle('hidden', _visionMonitorCriteria.length !== 0);

  active.forEach(function(m, idx) {
    var mid = m.machine_id;
    var mcfg = saved[mid] || {};
    var savedCriteria = mcfg.criteria || [];
    var li = document.createElement('li');
    li.className = 'rounded-xl border border-gray-200 dark:border-gray-700 p-3';
    li.setAttribute('data-mid', mid);

    var head = document.createElement('label');
    head.className = 'inline-flex items-center gap-2 cursor-pointer';
    var enCb = document.createElement('input');
    enCb.type = 'checkbox';
    enCb.className = 'vm-enable rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal';
    enCb.checked = !!mcfg.enabled;
    var host = document.createElement('span');
    host.className = 'text-sm font-medium text-gray-800 dark:text-gray-100';
    host.textContent = m.hostname || mid;
    head.appendChild(enCb);
    head.appendChild(host);
    li.appendChild(head);

    if (_visionMonitorCriteria.length) {
      var crWrap = document.createElement('div');
      crWrap.className = 'mt-2 flex flex-wrap gap-x-4 gap-y-1.5 pl-6';
      _visionMonitorCriteria.forEach(function(name) {
        var lbl = document.createElement('label');
        lbl.className = 'inline-flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300 cursor-pointer';
        var cb = document.createElement('input');
        cb.type = 'checkbox';
        cb.className = 'vm-criterion rounded border-gray-300 dark:border-gray-600 text-togen-teal focus:ring-togen-teal';
        cb.value = name;
        cb.checked = savedCriteria.indexOf(name) !== -1;
        var t = document.createElement('span');
        t.textContent = name;
        lbl.appendChild(cb);
        lbl.appendChild(t);
        crWrap.appendChild(lbl);
      });
      li.appendChild(crWrap);
    }
    ul.appendChild(li);
  });
}

function visionMonitorSave() {
  var status = document.getElementById('vision-monitor-save-status');
  var btn = document.getElementById('vision-monitor-save-btn');
  var machines = {};
  var rows = document.querySelectorAll('#vision-monitor-machines > li[data-mid]');
  for (var i = 0; i < rows.length; i++) {
    var row = rows[i];
    var mid = row.getAttribute('data-mid');
    var en = row.querySelector('.vm-enable');
    var criteria = [];
    var cbs = row.querySelectorAll('.vm-criterion');
    for (var j = 0; j < cbs.length; j++) {
      if (cbs[j].checked) criteria.push(cbs[j].value);
    }
    machines[mid] = { enabled: !!(en && en.checked), criteria: criteria };
  }
  var payload = {
    enabled: document.getElementById('vision-monitor-enabled').checked,
    poll_interval_min: parseInt(document.getElementById('vision-monitor-interval').value, 10) || 15,
    daily_call_cap: parseInt(document.getElementById('vision-monitor-cap').value, 10) || 0,
    model: document.getElementById('vision-monitor-model').value || '',
    machines: machines
  };
  if (btn) btn.disabled = true;
  if (status) { status.textContent = 'Saving…'; status.className = 'text-sm text-gray-500 dark:text-gray-400'; }
  fetch('/vision/monitor-config', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload)
  }).then(function(r) { return r.json().then(function(d){ return { ok: r.ok, d: d }; }); })
    .then(function(res) {
      if (btn) btn.disabled = false;
      if (res.ok && res.d && res.d.ok) {
        if (status) { status.textContent = 'Saved ✓'; status.className = 'text-sm text-green-600 dark:text-green-400'; }
        visionMonitorLoad();
      } else {
        var msg = (res.d && res.d.error) ? res.d.error : 'Save failed.';
        if (status) { status.textContent = msg; status.className = 'text-sm text-red-600 dark:text-red-400'; }
      }
    }).catch(function() {
      if (btn) btn.disabled = false;
      if (status) { status.textContent = 'Save failed.'; status.className = 'text-sm text-red-600 dark:text-red-400'; }
    });
}

// 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; });
}

// DVI-860 Phase 1 (Option A): cache the last successful collector snapshot so a
// re-entry into the Vision tool can paint the grid instantly instead of waiting
// on the network. Kept in sessionStorage (per-tab, cleared on browser close) to
// avoid showing very stale data across sessions.
var _VISION_SNAPSHOT_KEY = 'visionCollectorsSnapshot';
function _visionSaveSnapshot(machines) {
  try {
    sessionStorage.setItem(_VISION_SNAPSHOT_KEY, JSON.stringify({ t: Date.now(), machines: machines }));
  } catch (e) { /* quota / disabled — non-fatal */ }
}
function _visionReadSnapshot() {
  try {
    var raw = sessionStorage.getItem(_VISION_SNAPSHOT_KEY);
    if (!raw) return null;
    var snap = JSON.parse(raw);
    return (snap && Array.isArray(snap.machines)) ? snap : null;
  } catch (e) { return null; }
}

// Merge freshly-fetched thumbnail URLs (keyed by machine_id) into the machine
// list, then re-render. Called after the fast metadata render so thumbnails
// stream in without blocking the grid.
function _visionApplyThumbs(thumbs) {
  if (!thumbs || !_visionMachines || !_visionMachines.length) return;
  _visionMachines.forEach(function(m){
    var t = thumbs[m.machine_id];
    if (t) {
      m.thumb_url = t.thumb_url;
      m.full_url = t.full_url;
      m.latest_kind = t.latest_kind;
    }
  });
  visionRenderCollectorGrid(_visionMachines);
  _visionSaveSnapshot(_visionMachines);
}

// Fetch thumbnails off the critical path and stream them into the grid.
function _visionLoadThumbs() {
  fetch('/vision/collector-thumbs')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){ if (d && d.thumbs) _visionApplyThumbs(d.thumbs); })
    .catch(function(){ /* thumbnails are best-effort; metadata already rendered */ });
}

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');
  error.classList.add('hidden');

  // Option A: paint instantly from the cached snapshot (incl. last thumbnails)
  // so the grid is interactive with zero network wait. The fresh fetch below
  // then reconciles metadata + thumbnails in place.
  var snap = _visionReadSnapshot();
  var paintedFromCache = false;
  if (snap && snap.machines.length) {
    _visionMachines = snap.machines;
    visionPopulateActivityMachines();
    visionRenderCollectorGrid(snap.machines);
    loading.classList.add('hidden');
    paintedFromCache = true;
  } else {
    loading.classList.remove('hidden');
    empty.classList.add('hidden');
    grid.classList.add('hidden');
  }

  // Option B: fetch fast SQL-only metadata (thumbs deferred), render, then
  // stream thumbnails in via a separate request.
  fetch('/vision/collectors?thumbs=0')
    .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) {
        if (!paintedFromCache) {
          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; });
      // Carry over cached thumbnails so tiles keep showing the last image until
      // fresh thumbnails arrive (avoids a flash of empty placeholders).
      if (snap && snap.machines.length) {
        var prevThumbs = {};
        snap.machines.forEach(function(pm){ prevThumbs[pm.machine_id] = pm; });
        machines.forEach(function(m){
          var pm = prevThumbs[m.machine_id];
          if (pm && pm.thumb_url && !m.thumb_url) {
            m.thumb_url = pm.thumb_url; m.full_url = pm.full_url; m.latest_kind = pm.latest_kind;
          }
        });
      }
      _visionMachines = machines;
      _visionMockActive = !!res.d.mock_data;
      visionPopulateActivityMachines();
      if (!machines.length) { grid.classList.add('hidden'); empty.classList.remove('hidden'); return; }
      // render manages grid/empty visibility (filters may reduce the set to zero).
      visionRenderCollectorGrid(machines);
      _visionSaveSnapshot(machines);
      // Stream thumbnails in unless the server already inlined them (mock mode).
      if (res.d.thumbs_deferred) _visionLoadThumbs();
    })
    .catch(function(e){
      loading.classList.add('hidden');
      if (!paintedFromCache) {
        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;
    });
}

// ===== VISION DIAGNOSTICS (DVI-902) — fleet rollup + per-agent drill-down =====
var _visionDiagFleet = null;         // cached /vision/diagnostics/fleet payload
var _visionDiagCurrentAgent = null;  // machine_id when a per-agent view is open
var _visionDiagInRecovery = false;   // true when the auto-recovery admin view is open (DVI-917)

var _VISION_DIAG_STATUS_STYLES = {
  active:  'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
  offline: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300',
  pending: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300',
  revoked: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
};
function _visionDiagStatusBadge(st) {
  var cls = _VISION_DIAG_STATUS_STYLES[st] || 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300';
  return '<span class="text-xs px-1.5 py-0.5 rounded ' + cls + '">' + esc(st || '—') + '</span>';
}

// "45s ago" / "12m ago" / "3h ago" / "2d ago" from a unix timestamp (seconds).
function _visionDiagAgo(ts) {
  if (!ts) return '—';
  var s = Math.max(0, Math.floor(Date.now() / 1000 - ts));
  if (s < 60) return s + 's ago';
  if (s < 3600) return Math.floor(s / 60) + 'm ago';
  if (s < 86400) return Math.floor(s / 3600) + 'h ago';
  return Math.floor(s / 86400) + 'd ago';
}

// Numeric line sparkline scaled to the value range. values = array (may hold nulls).
function _visionDiagSparkline(values, color, w, h) {
  var nums = (values || []).filter(function(v){ return typeof v === 'number' && !isNaN(v); });
  if (!nums.length) return '<span class="text-xs text-gray-400 dark:text-gray-500 italic">no data</span>';
  w = w || 200; h = h || 32;
  var max = Math.max.apply(null, nums), min = Math.min.apply(null, nums);
  var range = (max - min) || 1;
  var n = values.length;
  var pts = [];
  for (var i = 0; i < n; i++) {
    var v = values[i];
    if (typeof v !== 'number' || isNaN(v)) continue;
    var x = (n === 1) ? 0 : (i / (n - 1)) * w;
    var y = h - ((v - min) / range) * (h - 2) - 1;
    pts.push(x.toFixed(1) + ',' + y.toFixed(1));
  }
  return '<svg viewBox="0 0 ' + w + ' ' + h + '" height="' + h + '" preserveAspectRatio="none" ' +
         'class="block" style="width:100%;max-width:' + w + 'px">' +
         '<polyline fill="none" stroke="' + color + '" stroke-width="1.5" points="' + pts.join(' ') + '"></polyline></svg>';
}

function _visionDiagShow(which) {
  ['loading', 'error', 'fleet', 'agent', 'recovery'].forEach(function(k){
    var el = document.getElementById('vision-diag-' + k);
    if (el) el.classList.toggle('hidden', k !== which);
  });
}

// Refresh whichever view is currently open (fleet, a specific agent, or auto-recovery).
function visionDiagRefresh() {
  if (_visionDiagInRecovery) visionDiagLoadRecovery();
  else if (_visionDiagCurrentAgent) visionDiagLoadAgent(_visionDiagCurrentAgent);
  else visionLoadDiagnostics();
}

function visionDiagShowFleet() {
  _visionDiagCurrentAgent = null;
  _visionDiagInRecovery = false;
  document.getElementById('vision-diag-back').classList.add('hidden');
  document.getElementById('vision-diag-title').textContent = 'Fleet Diagnostics';
  if (_visionDiagFleet) { _visionDiagRenderFleet(_visionDiagFleet); _visionDiagShow('fleet'); }
  else visionLoadDiagnostics();
}

function visionLoadDiagnostics() {
  _visionDiagCurrentAgent = null;
  _visionDiagInRecovery = false;
  var back = document.getElementById('vision-diag-back');
  if (back) back.classList.add('hidden');
  document.getElementById('vision-diag-title').textContent = 'Fleet Diagnostics';
  var status = document.getElementById('vision-diag-status');
  _visionDiagShow('loading');
  fetch('/vision/diagnostics/fleet')
    .then(function(r){ if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
    .then(function(d){
      _visionDiagFleet = d;
      if (status) status.textContent = 'Last refreshed: ' + new Date().toLocaleTimeString();
      _visionDiagRenderFleet(d);
      _visionDiagShow('fleet');
    })
    .catch(function(err){
      document.getElementById('vision-diag-error-msg').textContent = 'Failed to load diagnostics: ' + err.message;
      _visionDiagShow('error');
    });
}

function _visionDiagRenderFleet(d) {
  var el = document.getElementById('vision-diag-fleet');
  if (!el) return;
  var counts = d.counts || {};
  var errSet = {}; (d.agents_with_errors || []).forEach(function(m){ errSet[m] = true; });
  var staleSet = {}; (d.capturing_but_stale || []).forEach(function(m){ staleSet[m] = true; });

  function card(label, value, cls) {
    return '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-3">' +
      '<div class="text-2xl font-semibold ' + (cls || 'text-gray-800 dark:text-gray-100') + '">' + value + '</div>' +
      '<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">' + esc(label) + '</div>' +
    '</div>';
  }

  var html = '';
  // Auto-recovery admin entry point (DVI-917).
  html += '<div class="flex justify-end mb-3">' +
    '<button onclick="visionDiagShowRecovery()" ' +
      'class="px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 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="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>' +
      'Auto-recovery settings' +
    '</button>' +
  '</div>';
  // Summary count cards.
  html += '<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mb-5">' +
    card('Total agents', d.total || 0) +
    card('Active', counts.active || 0, 'text-green-600 dark:text-green-400') +
    card('Offline', counts.offline || 0, 'text-gray-500 dark:text-gray-400') +
    card('Pending', counts.pending || 0, 'text-amber-600 dark:text-amber-400') +
    card('Revoked', counts.revoked || 0, 'text-red-600 dark:text-red-400') +
  '</div>';

  // Fleet failure-signature rollup (DVI-917 / DVI-914 telemetry).
  var fleetSigs = d.fleet_failure_signature_counts || {};
  var sigKeys = Object.keys(fleetSigs).sort(function(a, b){ return fleetSigs[b] - fleetSigs[a]; });
  var totalFailures = sigKeys.reduce(function(s, k){ return s + (fleetSigs[k] || 0); }, 0);
  html += '<div class="rounded-xl border ' + (totalFailures ? 'togen-inset-danger border-red-200 dark:border-red-900/40 bg-red-50/40 dark:bg-red-900/10' : 'togen-inset border-gray-200 dark:border-gray-700') + ' px-4 py-3 mb-5">' +
    '<div class="text-xs font-semibold ' + (totalFailures ? 'text-red-700 dark:text-red-400' : 'text-gray-600 dark:text-gray-300') + ' mb-2">Failure events by signature (' + totalFailures + ')</div>' +
    (sigKeys.length ? '<div class="flex flex-wrap gap-2">' + sigKeys.map(function(sig){
      return '<span class="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 font-mono">' + esc(sig) + ' · ' + fleetSigs[sig] + '</span>';
    }).join('') + '</div>' : '<div class="text-xs text-gray-400 dark:text-gray-500 italic">No failure events reported.</div>') +
  '</div>';

  // Attention rows: agents with errors + capturing-but-stale.
  var errList = d.agents_with_errors || [];
  var staleList = d.capturing_but_stale || [];
  html += '<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-5">';
  html += '<div class="togen-inset-danger rounded-xl border border-red-200 dark:border-red-900/40 bg-red-50/50 dark:bg-red-900/10 px-4 py-3">' +
    '<div class="text-xs font-semibold text-red-700 dark:text-red-400 mb-1">Agents with errors (' + errList.length + ')</div>' +
    (errList.length ? '<div class="flex flex-wrap gap-1.5">' + errList.map(function(m){
      return '<button onclick="visionDiagLoadAgent(\'' + esc(m).replace(/'/g, "\\'") + '\')" class="text-xs font-mono px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 hover:underline cursor-pointer">' + esc(_visionDiagAgentLabel(m)) + '</button>';
    }).join('') + '</div>' : '<div class="text-xs text-gray-400 dark:text-gray-500 italic">None</div>') +
  '</div>';
  html += '<div class="togen-inset-warn rounded-xl border border-amber-200 dark:border-amber-900/40 bg-amber-50/50 dark:bg-amber-900/10 px-4 py-3">' +
    '<div class="text-xs font-semibold text-amber-700 dark:text-amber-400 mb-1">Capturing but stale (' + staleList.length + ')</div>' +
    (staleList.length ? '<div class="flex flex-wrap gap-1.5">' + staleList.map(function(m){
      return '<button onclick="visionDiagLoadAgent(\'' + esc(m).replace(/'/g, "\\'") + '\')" class="text-xs font-mono px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 hover:underline cursor-pointer">' + esc(_visionDiagAgentLabel(m)) + '</button>';
    }).join('') + '</div>' : '<div class="text-xs text-gray-400 dark:text-gray-500 italic">None</div>') +
  '</div>';
  html += '</div>';

  // Version spread.
  var spread = d.agent_version_spread || {};
  var latest = d.latest_agent_version;
  var vers = Object.keys(spread).sort();
  html += '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-3 mb-5">' +
    '<div class="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-2">Agent version spread' +
      (latest ? ' <span class="font-normal text-gray-400 dark:text-gray-500">(latest ' + esc(latest) + ')</span>' : '') + '</div>' +
    (vers.length ? '<div class="flex flex-wrap gap-2">' + vers.map(function(v){
      var isLatest = latest && v === latest;
      var cls = isLatest ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
                         : 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300';
      return '<span class="text-xs px-2 py-0.5 rounded ' + cls + '">' + esc(v) + ' · ' + spread[v] + '</span>';
    }).join('') + '</div>' : '<div class="text-xs text-gray-400 dark:text-gray-500 italic">No version data</div>') +
  '</div>';

  // Per-agent clickable list.
  var agents = d.agents || [];
  html += '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-3 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">Agent</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">Last seen</th>' +
      '<th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Version</th>' +
      '<th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Capture</th>' +
      '<th class="pb-2 pr-4 font-semibold text-gray-600 dark:text-gray-300">Failures</th>' +
      '<th class="pb-2 font-semibold text-gray-600 dark:text-gray-300">Flags</th>' +
    '</tr></thead><tbody class="divide-y divide-gray-100 dark:divide-gray-800">';
  if (!agents.length) {
    html += '<tr><td colspan="7" class="py-4 text-center text-gray-400 dark:text-gray-500 italic">No agents enrolled.</td></tr>';
  }
  agents.forEach(function(a){
    var name = a.friendly_name || a.hostname || a.machine_id;
    var mid = a.machine_id;
    var verCls = (latest && a.agent_version && a.agent_version !== latest) ? 'text-amber-600 dark:text-amber-400' : 'text-gray-600 dark:text-gray-400';
    var flags = '';
    if (errSet[mid]) flags += '<span class="text-xs px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">error</span> ';
    if (staleSet[mid]) flags += '<span class="text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">stale</span>';
    var cap = a.capture_enabled === true ? 'on' : (a.capture_enabled === false ? 'off' : '—');
    var fc = a.failure_event_count || 0;
    var fcCell = fc > 0
      ? '<span class="text-xs px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 font-medium">' + fc + '</span>'
      : '<span class="text-gray-300 dark:text-gray-600">0</span>';
    html += '<tr onclick="visionDiagLoadAgent(\'' + esc(mid).replace(/'/g, "\\'") + '\')" class="hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer transition-colors">' +
      '<td class="py-2.5 pr-4"><div class="font-medium text-gray-800 dark:text-gray-200 truncate max-w-xs">' + esc(name) + '</div>' +
        '<div class="text-xs text-gray-400 dark:text-gray-500 font-mono truncate max-w-xs">' + esc(mid) + '</div></td>' +
      '<td class="py-2.5 pr-4">' + _visionDiagStatusBadge(a.status) + '</td>' +
      '<td class="py-2.5 pr-4 text-gray-600 dark:text-gray-400 whitespace-nowrap">' + esc(_visionDiagAgo(a.last_seen_at)) + '</td>' +
      '<td class="py-2.5 pr-4 whitespace-nowrap ' + verCls + '">' + esc(a.agent_version || '—') + '</td>' +
      '<td class="py-2.5 pr-4 text-gray-600 dark:text-gray-400">' + cap + '</td>' +
      '<td class="py-2.5 pr-4">' + fcCell + '</td>' +
      '<td class="py-2.5">' + (flags || '<span class="text-gray-300 dark:text-gray-600">—</span>') + '</td>' +
    '</tr>';
  });
  html += '</tbody></table></div>';
  el.innerHTML = html;
}

// Friendly label for a machine_id using the cached fleet agent list.
function _visionDiagAgentLabel(mid) {
  if (_visionDiagFleet && _visionDiagFleet.agents) {
    for (var i = 0; i < _visionDiagFleet.agents.length; i++) {
      var a = _visionDiagFleet.agents[i];
      if (a.machine_id === mid) return a.friendly_name || a.hostname || mid;
    }
  }
  return mid;
}

function visionDiagLoadAgent(mid) {
  _visionDiagCurrentAgent = mid;
  var back = document.getElementById('vision-diag-back');
  if (back) back.classList.remove('hidden');
  var status = document.getElementById('vision-diag-status');
  _visionDiagShow('loading');
  fetch('/vision/diagnostics/agent/' + encodeURIComponent(mid))
    .then(function(r){ if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
    .then(function(d){
      if (status) status.textContent = 'Last refreshed: ' + new Date().toLocaleTimeString();
      var h = d.health || {};
      document.getElementById('vision-diag-title').textContent = h.friendly_name || h.hostname || d.machine_id;
      _visionDiagRenderAgent(d);
      _visionDiagShow('agent');
      _visionDiagLoadAgentLogs(mid);
    })
    .catch(function(err){
      document.getElementById('vision-diag-error-msg').textContent = 'Failed to load agent diagnostics: ' + err.message;
      _visionDiagShow('error');
    });
}

function _visionDiagRenderAgent(d) {
  var el = document.getElementById('vision-diag-agent');
  if (!el) return;
  var h = d.health || {};
  var telemetry = (d.telemetry || []).slice().reverse();  // API returns newest-first; chart chronologically
  var commands = d.commands || [];
  var alerts = d.alerts || [];

  function field(label, value) {
    return '<div><dt class="text-xs text-gray-400 dark:text-gray-500">' + esc(label) + '</dt>' +
      '<dd class="text-sm text-gray-800 dark:text-gray-200 mt-0.5">' + value + '</dd></div>';
  }

  var verMismatch = h.latest_agent_version && h.agent_version && h.agent_version !== h.latest_agent_version;
  var verVal = esc(h.agent_version || '—') +
    (verMismatch ? ' <span class="text-xs text-amber-600 dark:text-amber-400">(latest ' + esc(h.latest_agent_version) + ')</span>' :
      (h.agent_version ? ' <span class="text-xs text-green-600 dark:text-green-400">(latest)</span>' : ''));
  var capVal = h.capture_enabled === true ? '<span class="text-green-600 dark:text-green-400">Enabled</span>' :
    (h.capture_enabled === false ? '<span class="text-gray-500 dark:text-gray-400">Disabled</span>' : '—');

  var html = '';
  // Health card.
  html += '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-4 mb-5">' +
    '<div class="flex items-center gap-2 mb-3">' + _visionDiagStatusBadge(h.status) +
      '<span class="text-sm font-medium text-gray-700 dark:text-gray-200">' + esc(h.hostname || d.machine_id) + '</span>' +
      '<span class="text-xs text-gray-400 dark:text-gray-500 font-mono">' + esc(d.machine_id) + '</span></div>' +
    '<dl class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-x-4 gap-y-3">' +
      field('Last check-in', esc(_visionDiagAgo(h.last_seen_at))) +
      field('Agent version', verVal) +
      field('Capture', capVal) +
      field('Last capture', esc(_visionDiagAgo(h.last_capture_ts))) +
      field('OS', esc(h.os_version || '—')) +
      field('Logged-in user', esc(h.logged_in_user || '—')) +
      field('Uptime', esc(typeof h.agent_uptime_s === 'number' ? _visionFmtUptime(h.agent_uptime_s) : '—')) +
      field('Screens', esc(h.screen_count != null ? String(h.screen_count) : '—')) +
    '</dl>' +
  '</div>';

  // Failure events (DVI-917 / DVI-914): per-signature counts + recent event list.
  var failEvents = d.failure_events || [];
  var failSigs = d.failure_signature_counts || {};
  var failSigKeys = Object.keys(failSigs).sort(function(a, b){ return failSigs[b] - failSigs[a]; });
  html += '<div class="togen-inset rounded-xl border ' + (failEvents.length ? 'border-red-200 dark:border-red-900/40' : 'border-gray-200 dark:border-gray-700') + ' px-4 py-4 mb-5">' +
    '<div class="text-xs font-semibold ' + (failEvents.length ? 'text-red-700 dark:text-red-400' : 'text-gray-600 dark:text-gray-300') + ' mb-3">Failure events</div>';
  if (failSigKeys.length) {
    html += '<div class="flex flex-wrap gap-2 mb-3">' + failSigKeys.map(function(sig){
      return '<span class="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 font-mono">' + esc(sig) + ' · ' + failSigs[sig] + '</span>';
    }).join('') + '</div>';
  }
  if (!failEvents.length) {
    html += '<div class="text-xs text-gray-400 dark:text-gray-500 italic">No failure events reported by this agent.</div>';
  } else {
    html += '<div class="overflow-x-auto"><table class="w-full text-xs"><thead>' +
      '<tr class="border-b border-gray-200 dark:border-gray-700 text-left text-gray-500 dark:text-gray-400">' +
        '<th class="pb-1.5 pr-3 font-medium">When</th><th class="pb-1.5 pr-3 font-medium">Signature</th>' +
        '<th class="pb-1.5 font-medium">Detail</th>' +
      '</tr></thead><tbody class="divide-y divide-gray-100 dark:divide-gray-800">';
    failEvents.forEach(function(e){
      var when = _visionDiagAgo(e.ts || e.received_at);
      var detail = e.detail || '';
      var trace = e.trace ? ('\n' + e.trace) : '';
      html += '<tr class="text-gray-600 dark:text-gray-400 align-top">' +
        '<td class="py-1 pr-3 whitespace-nowrap">' + esc(when) + '</td>' +
        '<td class="py-1 pr-3 whitespace-nowrap"><span class="font-mono text-red-600 dark:text-red-400">' + esc(e.signature || '—') + '</span></td>' +
        '<td class="py-1 whitespace-pre-wrap break-words max-w-[28rem]">' + esc(detail + trace) + '</td>' +
      '</tr>';
    });
    html += '</tbody></table></div>';
  }
  html += '</div>';

  // Telemetry timeline (sparklines) + recent rows table.
  var cpuVals = telemetry.map(function(r){ return typeof r.cpu_pct === 'number' ? r.cpu_pct : null; });
  var idleVals = telemetry.map(function(r){ return typeof r.idle_seconds === 'number' ? r.idle_seconds : null; });
  var upVals = telemetry.map(function(r){ return typeof r.agent_uptime_s === 'number' ? r.agent_uptime_s : null; });
  html += '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-4 mb-5">' +
    '<div class="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-3">Telemetry (last ' + telemetry.length + ' check-ins)</div>';
  if (!telemetry.length) {
    html += '<div class="text-xs text-gray-400 dark:text-gray-500 italic">No telemetry check-ins recorded.</div>';
  } else {
    html += '<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">' +
      '<div><div class="text-xs text-gray-400 dark:text-gray-500 mb-1">CPU %</div>' + _visionDiagSparkline(cpuVals, '#0d9488') + '</div>' +
      '<div><div class="text-xs text-gray-400 dark:text-gray-500 mb-1">Idle seconds</div>' + _visionDiagSparkline(idleVals, '#6366f1') + '</div>' +
      '<div><div class="text-xs text-gray-400 dark:text-gray-500 mb-1">Uptime (s)</div>' + _visionDiagSparkline(upVals, '#f59e0b') + '</div>' +
    '</div>';
    // Recent rows table (newest first).
    var recent = telemetry.slice().reverse().slice(0, 15);
    html += '<div class="overflow-x-auto"><table class="w-full text-xs"><thead>' +
      '<tr class="border-b border-gray-200 dark:border-gray-700 text-left text-gray-500 dark:text-gray-400">' +
        '<th class="pb-1.5 pr-3 font-medium">Time</th><th class="pb-1.5 pr-3 font-medium">CPU%</th>' +
        '<th class="pb-1.5 pr-3 font-medium">Idle</th><th class="pb-1.5 pr-3 font-medium">Capture</th>' +
        '<th class="pb-1.5 pr-3 font-medium">Last capture</th><th class="pb-1.5 font-medium">Foreground</th>' +
      '</tr></thead><tbody class="divide-y divide-gray-100 dark:divide-gray-800 font-mono">';
    recent.forEach(function(r){
      var t = r.ts ? new Date(r.ts * 1000).toLocaleString() : (r.received_at ? new Date(r.received_at * 1000).toLocaleString() : '—');
      var cap = r.capture_enabled === true ? 'on' : (r.capture_enabled === false ? 'off' : '—');
      html += '<tr class="text-gray-600 dark:text-gray-400">' +
        '<td class="py-1 pr-3 whitespace-nowrap">' + esc(t) + '</td>' +
        '<td class="py-1 pr-3">' + esc(typeof r.cpu_pct === 'number' ? r.cpu_pct.toFixed(0) : '—') + '</td>' +
        '<td class="py-1 pr-3">' + esc(typeof r.idle_seconds === 'number' ? r.idle_seconds + 's' : '—') + '</td>' +
        '<td class="py-1 pr-3">' + cap + '</td>' +
        '<td class="py-1 pr-3 whitespace-nowrap">' + esc(_visionDiagAgo(r.last_capture_ts)) + '</td>' +
        '<td class="py-1 truncate max-w-[10rem]">' + esc(r.foreground_app || '—') + '</td>' +
      '</tr>';
    });
    html += '</tbody></table></div>';
  }
  html += '</div>';

  // Command history table (surface error_message).
  html += '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-4 mb-5">' +
    '<div class="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-3">Command history</div>';
  if (!commands.length) {
    html += '<div class="text-xs text-gray-400 dark:text-gray-500 italic">No commands issued to this agent.</div>';
  } else {
    html += '<div class="overflow-x-auto"><table class="w-full text-xs"><thead>' +
      '<tr class="border-b border-gray-200 dark:border-gray-700 text-left text-gray-500 dark:text-gray-400">' +
        '<th class="pb-1.5 pr-3 font-medium">Action</th><th class="pb-1.5 pr-3 font-medium">Created</th>' +
        '<th class="pb-1.5 pr-3 font-medium">Delivered</th><th class="pb-1.5 pr-3 font-medium">Acked</th>' +
        '<th class="pb-1.5 pr-3 font-medium">Status</th><th class="pb-1.5 font-medium">Error</th>' +
      '</tr></thead><tbody class="divide-y divide-gray-100 dark:divide-gray-800">';
    commands.forEach(function(c){
      var stCls = c.acked_status === 'error' ? 'text-red-600 dark:text-red-400' :
                  c.acked_status === 'ok' ? 'text-green-600 dark:text-green-400' :
                  'text-gray-600 dark:text-gray-400';
      var stLabel = c.acked_status || c.state || '—';
      html += '<tr class="text-gray-600 dark:text-gray-400">' +
        '<td class="py-1 pr-3 font-medium text-gray-800 dark:text-gray-200 whitespace-nowrap">' + esc(c.action || '—') + '</td>' +
        '<td class="py-1 pr-3 whitespace-nowrap">' + esc(_visionDiagAgo(c.created_at)) + '</td>' +
        '<td class="py-1 pr-3 whitespace-nowrap">' + esc(c.delivered_at ? _visionDiagAgo(c.delivered_at) : '—') + '</td>' +
        '<td class="py-1 pr-3 whitespace-nowrap">' + esc(c.acked_at ? _visionDiagAgo(c.acked_at) : '—') + '</td>' +
        '<td class="py-1 pr-3 ' + stCls + '">' + esc(stLabel) + '</td>' +
        '<td class="py-1 text-red-600 dark:text-red-400 whitespace-pre-wrap break-words max-w-[16rem]">' + esc(c.error_message || '') + '</td>' +
      '</tr>';
    });
    html += '</tbody></table></div>';
  }
  html += '</div>';

  // Recent alerts for this agent.
  html += '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-4 mb-5">' +
    '<div class="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-3">Recent alerts</div>';
  if (!alerts.length) {
    html += '<div class="text-xs text-gray-400 dark:text-gray-500 italic">No alerts for this agent.</div>';
  } else {
    html += '<ul class="space-y-1.5">';
    alerts.forEach(function(a){
      var typeCls = a.type === 'error' ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300' :
                    a.type === 'offline' ? 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300' :
                    'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300';
      var when = '';
      if (a.created_at) { try { when = new Date(a.created_at).toLocaleString(); } catch(e){} }
      html += '<li class="flex items-start gap-2 text-xs">' +
        '<span class="shrink-0 px-1.5 py-0.5 rounded ' + typeCls + '">' + esc(a.type || '—') + '</span>' +
        '<span class="flex-1 text-gray-700 dark:text-gray-300">' + esc(a.message || '') + '</span>' +
        '<span class="shrink-0 text-gray-400 dark:text-gray-500 whitespace-nowrap">' + esc(when) + '</span>' +
      '</li>';
    });
    html += '</ul>';
  }
  html += '</div>';

  // Ask Assistant to diagnose (DVI-904) — text-only, user-initiated.
  html += '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-4 mb-5">' +
    '<div class="flex items-center justify-between mb-3 gap-2 flex-wrap">' +
      '<div class="text-xs font-semibold text-gray-600 dark:text-gray-300">Assistant diagnosis</div>' +
      '<div class="flex items-center gap-2">' +
      '<select id="vision-diag-ai-model" onchange="visionDiagModelPick()" title="Model" ' +
        'class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 focus:border-togen-teal focus:outline-none">' +
        '<option value="">Default</option>' +
      '</select>' +
      '<button id="vision-diag-ai-btn" onclick="visionDiagAskAssistant()" ' +
        '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="M11 3a1 1 0 10-2 0v1a1 1 0 102 0V3zM15.657 5.757a1 1 0 00-1.414-1.414l-.707.707a1 1 0 001.414 1.414l.707-.707zM18 10a1 1 0 01-1 1h-1a1 1 0 110-2h1a1 1 0 011 1zM5.05 6.464A1 1 0 106.464 5.05l-.707-.707a1 1 0 00-1.414 1.414l.707.707zM5 10a1 1 0 01-1 1H3a1 1 0 110-2h1a1 1 0 011 1zM8 16v-1h4v1a2 2 0 11-4 0zM12 14c.015-.34.208-.646.477-.859a4 4 0 10-4.954 0c.27.213.462.519.476.859h4.002z" /></svg>' +
        'Ask Assistant to diagnose this agent' +
      '</button>' +
      '</div>' +
    '</div>' +
    '<div id="vision-diag-ai-result" class="hidden text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words leading-relaxed"></div>' +
    '<div id="vision-diag-ai-hint" class="text-xs text-gray-400 dark:text-gray-500 italic">Bundles this agent\'s health, telemetry, command errors, alerts, and recent log tail into a prompt and returns a root-cause summary. Requires a configured Assistant API key.</div>' +
  '</div>';

  // Agent logs container — filled by _visionDiagLoadAgentLogs (reuses Vision Logs data).
  html += '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-4">' +
    '<div class="flex items-center justify-between mb-3">' +
      '<div class="text-xs font-semibold text-gray-600 dark:text-gray-300">Agent logs</div>' +
      '<select id="vision-diag-log-file" onchange="_visionDiagRenderSelectedLog()" ' +
        'class="hidden 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"></select>' +
    '</div>' +
    '<div id="vision-diag-log-body"><div class="text-xs text-gray-400 dark:text-gray-500 italic">Loading logs…</div></div>' +
  '</div>';

  el.innerHTML = html;
  visionDiagLoadModels();
}

// DVI-955: per-request model override for the Assistant diagnosis. Uses the shared
// /assistant/chat plumbing, so options are plain model ids from available_models.
var _visionDiagModel = '';
function visionDiagModelPick() {
  var s = document.getElementById('vision-diag-ai-model');
  _visionDiagModel = s ? (s.value || '') : '';
}
function visionDiagLoadModels() {
  var sel = document.getElementById('vision-diag-ai-model');
  if (!sel) return;
  fetch('/assistant/connection')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      var models = d.available_models || [];
      var defLabel = 'Default';
      models.forEach(function(m){ if (m.default) defLabel = 'Default (' + m.label + ')'; });
      sel.innerHTML = '';
      var o0 = document.createElement('option'); o0.value = ''; o0.textContent = defLabel; sel.appendChild(o0);
      var byProv = {};
      models.forEach(function(m){ (byProv[m.provider_id] = byProv[m.provider_id] || { label: m.provider_label, items: [] }).items.push(m); });
      Object.keys(byProv).forEach(function(pid){
        var g = byProv[pid]; var og = document.createElement('optgroup'); og.label = g.label;
        g.items.forEach(function(m){ var op = document.createElement('option'); op.value = m.id; op.textContent = m.label; og.appendChild(op); });
        sel.appendChild(og);
      });
      var valid = models.some(function(m){ return m.id === _visionDiagModel; });
      sel.value = valid ? _visionDiagModel : '';
      if (!valid) _visionDiagModel = '';
    })
    .catch(function(){});
}

// Parse a raw log file's text into rows for winstonLogPanelHtml ({ts, level, message}).
function _visionDiagParseLogLines(text) {
  var lines = (text || '').split(/\r?\n/);
  var out = [];
  lines.forEach(function(line){
    if (!line.trim() && !line.length) return;
    var m = line.match(/\b(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL)\b/);
    out.push({ ts: null, level: m ? m[1] : '', message: line });
  });
  return out;
}

// Reuse the Vision Logs data (GET /vision/agent-logs) filtered to this machine, and
// render the newest uploaded log file's content through the shared Winston log panel
// (level + text filter). A dropdown lets the admin pick an older uploaded file.
var _visionDiagLogFiles = [];
function _visionDiagLoadAgentLogs(mid) {
  _visionDiagLogFiles = [];
  var bodyEl = document.getElementById('vision-diag-log-body');
  var sel = document.getElementById('vision-diag-log-file');
  fetch('/vision/agent-logs?machine_id=' + encodeURIComponent(mid))
    .then(function(r){ return r.ok ? r.json() : { logs: [] }; })
    .then(function(d){
      var files = (d.logs || []).filter(function(l){ return l.machine_id === mid; });
      _visionDiagLogFiles = files;
      if (sel) {
        if (files.length > 1) {
          sel.innerHTML = files.map(function(l, i){
            var when = l.received_at ? new Date(l.received_at * 1000).toLocaleString() : '';
            return '<option value="' + i + '">' + esc(l.file || '') + (when ? ' — ' + esc(when) : '') + '</option>';
          }).join('');
          sel.classList.remove('hidden');
        } else {
          sel.classList.add('hidden');
        }
      }
      if (!files.length) {
        if (bodyEl) bodyEl.innerHTML = '<div class="text-xs text-gray-400 dark:text-gray-500 italic">No agent logs uploaded. Use the <span class="font-medium">Upload log</span> command on the collector to request the latest log.</div>';
        return;
      }
      _visionDiagRenderSelectedLog();
    })
    .catch(function(){
      if (bodyEl) bodyEl.innerHTML = '<div class="text-xs text-red-600 dark:text-red-400">Failed to load agent logs.</div>';
    });
}

function _visionDiagRenderSelectedLog() {
  var bodyEl = document.getElementById('vision-diag-log-body');
  var sel = document.getElementById('vision-diag-log-file');
  if (!bodyEl || !_visionDiagLogFiles.length) return;
  var idx = sel && sel.value ? parseInt(sel.value, 10) : 0;
  var log = _visionDiagLogFiles[idx] || _visionDiagLogFiles[0];
  bodyEl.innerHTML = '<div class="text-xs text-gray-400 dark:text-gray-500 italic">Loading ' + esc(log.file || '') + '…</div>';
  fetch('/vision/agent-log?machine_id=' + encodeURIComponent(log.machine_id) + '&file=' + encodeURIComponent(log.file))
    .then(function(r){ return r.ok ? r.json() : {}; })
    .then(function(d){
      var rows = _visionDiagParseLogLines((d && d.content) || '');
      bodyEl.innerHTML = winstonLogPanelHtml('vision-diag-log', rows);
    })
    .catch(function(){ bodyEl.innerHTML = '<div class="text-xs text-red-600 dark:text-red-400">Failed to load log content.</div>'; });
}

// DVI-904: ask the configured Assistant to diagnose the current agent. Text-only,
// user-initiated; POSTs to /vision/diagnostics/agent/<id>/assistant and shows the
// reply inline. Backend bundles the snapshot + log tail into the prompt.
function visionDiagAskAssistant() {
  var mid = _visionDiagCurrentAgent;
  if (!mid) return;
  var btn = document.getElementById('vision-diag-ai-btn');
  var result = document.getElementById('vision-diag-ai-result');
  var hint = document.getElementById('vision-diag-ai-hint');
  if (hint) hint.classList.add('hidden');
  if (result) {
    result.classList.remove('hidden', 'text-red-600', 'dark:text-red-400');
    result.textContent = 'Asking the Assistant to diagnose this agent…';
  }
  if (btn) { btn.disabled = true; btn.classList.add('opacity-50', 'cursor-not-allowed'); }
  fetch('/vision/diagnostics/agent/' + encodeURIComponent(mid) + '/assistant', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(_visionDiagModel ? { model: _visionDiagModel } : {})
  })
    .then(function(r){ return r.json().then(function(d){ return { ok: r.ok, d: d }; }); })
    .then(function(res){
      if (!res.ok) throw new Error((res.d && res.d.error) || 'Request failed.');
      if (result) result.textContent = (res.d && res.d.reply) || 'No response.';
    })
    .catch(function(err){
      if (result) {
        result.classList.add('text-red-600', 'dark:text-red-400');
        result.textContent = 'Diagnosis failed: ' + err.message;
      }
    })
    .finally(function(){
      if (btn) { btn.disabled = false; btn.classList.remove('opacity-50', 'cursor-not-allowed'); }
    });
}

// ===== VISION AUTO-RECOVERY ADMIN (DVI-917 / DVI-916) =====
// Admin panel: global enable/disable + per-signature toggles (all default OFF),
// read-only allowlist (signature->action), and the audit trail of auto-issued
// commands. Backed by /vision/recovery-config (GET/POST) + /vision/recovery-audit.
var _visionRecoveryCfg = null;   // last-loaded config payload {config, allowlist_defaults}

function visionDiagShowRecovery() {
  _visionDiagCurrentAgent = null;
  _visionDiagInRecovery = true;
  var back = document.getElementById('vision-diag-back');
  if (back) back.classList.remove('hidden');
  document.getElementById('vision-diag-title').textContent = 'Auto-recovery';
  visionDiagLoadRecovery();
}

function visionDiagLoadRecovery() {
  _visionDiagInRecovery = true;
  var status = document.getElementById('vision-diag-status');
  _visionDiagShow('loading');
  Promise.all([
    fetch('/vision/recovery-config').then(function(r){ if (!r.ok) throw new Error('config HTTP ' + r.status); return r.json(); }),
    fetch('/vision/recovery-audit').then(function(r){ return r.ok ? r.json() : { audit: [] }; })
  ]).then(function(res){
    _visionRecoveryCfg = res[0];
    if (status) status.textContent = 'Last refreshed: ' + new Date().toLocaleTimeString();
    _visionDiagRenderRecovery(res[0], (res[1] && res[1].audit) || []);
    _visionDiagShow('recovery');
  }).catch(function(err){
    document.getElementById('vision-diag-error-msg').textContent = 'Failed to load auto-recovery settings: ' + err.message;
    _visionDiagShow('error');
  });
}

function _visionRecoveryOutcomeBadge(outcome) {
  var map = {
    issued:   'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
    cooldown: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300',
    error:    'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
  };
  var cls = map[outcome] || 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300';
  return '<span class="text-xs px-1.5 py-0.5 rounded ' + cls + '">' + esc(outcome || '—') + '</span>';
}

function _visionDiagRenderRecovery(payload, audit) {
  var el = document.getElementById('vision-diag-recovery');
  if (!el) return;
  var cfg = (payload && payload.config) || {};
  var defaults = (payload && payload.allowlist_defaults) || {};
  var allowlist = cfg.allowlist || {};
  var globalOn = cfg.enabled === true;

  var html = '';

  // Master state banner — make the disabled-by-default state visually clear.
  html += '<div class="rounded-xl border ' + (globalOn
      ? 'border-green-300 dark:border-green-800 bg-green-50/60 dark:bg-green-900/15'
      : 'border-gray-200 dark:border-gray-700 bg-gray-50/60 dark:bg-gray-800/40') + ' px-4 py-4 mb-5">' +
    '<div class="flex items-center justify-between gap-4">' +
      '<div>' +
        '<div class="flex items-center gap-2">' +
          '<span class="text-sm font-semibold ' + (globalOn ? 'text-green-700 dark:text-green-300' : 'text-gray-600 dark:text-gray-300') + '">' +
            (globalOn ? 'Auto-recovery is ENABLED' : 'Auto-recovery is DISABLED') + '</span>' +
          (globalOn ? '' : '<span class="text-xs px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300">default</span>') +
        '</div>' +
        '<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">When enabled, matching failure signatures with an enabled toggle below auto-issue their allowlisted recovery command (cooldown ' + esc(String(cfg.cooldown_min != null ? cfg.cooldown_min : 60)) + ' min, poll ' + esc(String(cfg.poll_interval_s != null ? cfg.poll_interval_s : 60)) + 's).</div>' +
      '</div>' +
      '<button id="vision-recovery-global-btn" onclick="visionRecoveryToggleGlobal()" ' +
        'class="shrink-0 px-4 py-2 text-sm font-medium rounded-lg transition-colors cursor-pointer ' +
        (globalOn
          ? 'bg-red-600 text-white hover:bg-red-700'
          : 'bg-green-600 text-white hover:bg-green-700') + '">' +
        (globalOn ? 'Disable' : 'Enable') + '</button>' +
    '</div>' +
  '</div>';

  // Per-signature allowlist table with toggles (all default OFF).
  var sigKeys = Object.keys(defaults);
  html += '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-4 mb-5">' +
    '<div class="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-1">Recovery allowlist</div>' +
    '<div class="text-xs text-gray-500 dark:text-gray-400 mb-3">Each signature maps to a fixed recovery action (read-only). Toggles are <span class="font-medium">OFF by default</span> and only take effect while auto-recovery is enabled above.</div>' +
    '<div class="overflow-x-auto"><table class="w-full text-sm"><thead>' +
      '<tr class="border-b border-gray-200 dark:border-gray-700 text-left text-gray-500 dark:text-gray-400">' +
        '<th class="pb-1.5 pr-4 font-medium">Signature</th><th class="pb-1.5 pr-4 font-medium">Action</th>' +
        '<th class="pb-1.5 font-medium">Enabled</th>' +
      '</tr></thead><tbody class="divide-y divide-gray-100 dark:divide-gray-800">';
  if (!sigKeys.length) {
    html += '<tr><td colspan="3" class="py-3 text-center text-gray-400 dark:text-gray-500 italic">No signatures configured.</td></tr>';
  }
  sigKeys.forEach(function(sig){
    var cur = allowlist[sig] || {};
    var action = (defaults[sig] && defaults[sig].action) || cur.action || '—';
    var on = cur.enabled === true;
    var midSafe = esc(sig).replace(/'/g, "\\'");
    html += '<tr>' +
      '<td class="py-2 pr-4 font-mono text-gray-800 dark:text-gray-200">' + esc(sig) + '</td>' +
      '<td class="py-2 pr-4"><span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 font-mono">' + esc(action) + '</span></td>' +
      '<td class="py-2">' +
        '<button onclick="visionRecoveryToggleSig(\'' + midSafe + '\', ' + (on ? 'false' : 'true') + ')" ' +
          'class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors cursor-pointer ' + (on ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600') + '">' +
          '<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform ' + (on ? 'translate-x-4' : 'translate-x-0.5') + '"></span>' +
        '</button>' +
        '<span class="ml-2 text-xs ' + (on ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500') + '">' + (on ? 'On' : 'Off') + '</span>' +
      '</td>' +
    '</tr>';
  });
  html += '</tbody></table></div></div>';

  // Audit trail of auto-issued commands / decisions.
  html += '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-4">' +
    '<div class="text-xs font-semibold text-gray-600 dark:text-gray-300 mb-3">Audit trail (' + (audit || []).length + ', newest first)</div>';
  if (!audit || !audit.length) {
    html += '<div class="text-xs text-gray-400 dark:text-gray-500 italic">No auto-recovery decisions recorded yet.</div>';
  } else {
    html += '<div class="overflow-x-auto"><table class="w-full text-xs"><thead>' +
      '<tr class="border-b border-gray-200 dark:border-gray-700 text-left text-gray-500 dark:text-gray-400">' +
        '<th class="pb-1.5 pr-3 font-medium">When</th><th class="pb-1.5 pr-3 font-medium">Agent</th>' +
        '<th class="pb-1.5 pr-3 font-medium">Signature</th><th class="pb-1.5 pr-3 font-medium">Action</th>' +
        '<th class="pb-1.5 pr-3 font-medium">Outcome</th><th class="pb-1.5 font-medium">Detail</th>' +
      '</tr></thead><tbody class="divide-y divide-gray-100 dark:divide-gray-800">';
    audit.forEach(function(a){
      html += '<tr class="text-gray-600 dark:text-gray-400 align-top">' +
        '<td class="py-1 pr-3 whitespace-nowrap">' + esc(_visionDiagAgo(a.ts)) + '</td>' +
        '<td class="py-1 pr-3 font-mono whitespace-nowrap">' + esc(_visionDiagAgentLabel(a.machine_id)) + '</td>' +
        '<td class="py-1 pr-3 font-mono whitespace-nowrap text-red-600 dark:text-red-400">' + esc(a.signature || '—') + '</td>' +
        '<td class="py-1 pr-3 whitespace-nowrap">' + esc(a.action || '—') + '</td>' +
        '<td class="py-1 pr-3">' + _visionRecoveryOutcomeBadge(a.outcome) + '</td>' +
        '<td class="py-1 whitespace-pre-wrap break-words max-w-[24rem]">' + esc(a.detail || '') + '</td>' +
      '</tr>';
    });
    html += '</tbody></table></div>';
  }
  html += '</div>';

  el.innerHTML = html;
}

function _visionRecoverySave(body, onDone) {
  fetch('/vision/recovery-config', {
    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) throw new Error((res.d && res.d.error) || 'Save failed.');
      visionDiagLoadRecovery();  // reload to reflect persisted state
      if (onDone) onDone();
    })
    .catch(function(err){
      alert('Failed to save auto-recovery settings: ' + err.message);
    });
}

function visionRecoveryToggleGlobal() {
  var cur = _visionRecoveryCfg && _visionRecoveryCfg.config && _visionRecoveryCfg.config.enabled === true;
  _visionRecoverySave({ enabled: !cur });
}

function visionRecoveryToggleSig(sig, enabled) {
  var body = { allowlist: {} };
  body.allowlist[sig] = { enabled: enabled };
  _visionRecoverySave(body);
}

// 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 localityEl = document.getElementById('vision-filter-locality');
  var onlineOnly = onlineEl && onlineEl.checked;
  var outdatedOnly = outdatedEl && outdatedEl.checked;
  var backend = backendEl ? backendEl.value : 'all';
  var locality = localityEl ? localityEl.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;
    if (locality !== 'all' && m.rsync_locality !== locality) 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');
  var localityEl = document.getElementById('vision-filter-locality');
  if (onlineEl) onlineEl.checked = false;
  if (outdatedEl) outdatedEl.checked = false;
  if (backendEl) backendEl.value = 'all';
  if (localityEl) localityEl.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>' +
        _visionLocalityBadge(m.rsync_locality) +
        _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">' + _visionLocalityBadge(m.rsync_locality) + _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' : '');
}

// Range changes refetch (server-side ?since=); status/q re-render from this cache.
var _visionActivityCheckins = [];

function _visionActivitySince() {
  var range = (document.getElementById('vision-activity-range') || {}).value || '';
  if (!range) return '';
  return String(Math.floor(Date.now() / 1000) - parseInt(range, 10));
}

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');
  var since = _visionActivitySince();
  fetch('/vision/checkins/' + encodeURIComponent(machineId) + (since ? ('?since=' + since) : ''))
    .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();
      _visionActivityCheckins = res.d.checkins || [];
      if (!_visionActivityCheckins.length) { empty.classList.remove('hidden'); return; }
      visionRenderActivity(_visionActivityCheckins);
      content.classList.remove('hidden');
    })
    .catch(function(e){
      loading.classList.add('hidden');
      document.getElementById('vision-activity-error-msg').textContent = e.message;
      error.classList.remove('hidden');
    });
}

// Status/q filters re-derive from the cached check-ins — no refetch needed.
function visionActivityApplyFilter() {
  if (!_visionActivityCheckins.length) return;
  visionRenderActivity(_visionActivityCheckins);
}

function visionActivityExportCsv() {
  var sel = document.getElementById('vision-activity-machine');
  var machineId = sel ? sel.value : '';
  if (!machineId) return;
  var params = new URLSearchParams();
  var since = _visionActivitySince();
  var status = (document.getElementById('vision-activity-status') || {}).value || '';
  var q = ((document.getElementById('vision-activity-q') || {}).value || '').trim();
  if (since) params.set('since', since);
  if (status) params.set('status', status);
  if (q) params.set('q', q);
  var qs = params.toString();
  _auditorDownload('/vision/checkins/' + encodeURIComponent(machineId) + '/export.csv' + (qs ? ('?' + qs) : ''));
}

// 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, user: c.logged_in_user, 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. Status/q filters narrow the
  // timeline list; the summary cards above always reflect the loaded range.
  var fStatus = (document.getElementById('vision-activity-status') || {}).value || '';
  var fQ = (((document.getElementById('vision-activity-q') || {}).value || '').trim()).toLowerCase();
  var filtered = spans.filter(function(s){
    if (fStatus && (s.gap || s.status !== fStatus)) return false;
    if (fQ) {
      var hay = ((s.app || '') + ' ' + (s.user || '') + ' ' + (s.gap ? 'offline' : (s.status || ''))).toLowerCase();
      if (hay.indexOf(fQ) === -1) return false;
    }
    return true;
  });
  var filterActive = !!(fStatus || fQ);
  var recent = filtered.slice(-40).reverse();
  var timelineTitle = 'Timeline' + (filterActive ? (' — ' + filtered.length + ' of ' + spans.length + ' spans match') : '');
  if (filterActive && !filtered.length) {
    content.innerHTML = summary + appsHtml +
      '<div><h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">' + esc(timelineTitle) + '</h4>' +
      '<p class="text-sm text-gray-400 dark:text-gray-500 py-4">No activity matches the current filters.</p></div>';
    return;
  }
  var timeline =
    '<div><h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">' + esc(timelineTitle) + '</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="togen-inset 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>';
}

// DVI-977: local vs remote (to the rsync server) pill. local = green, remote = slate.
function _visionLocalityBadge(locality) {
  if (locality === 'local') {
    return '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300" title="On the rsync LAN subnet">Local</span>';
  }
  if (locality === 'remote') {
    return '<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300" title="Reaches the rsync server over the public network">Remote</span>';
  }
  return '';
}

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);
  // DVI-977: rsync locality + reported IPs.
  if (m.rsync_locality) {
    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">Rsync server</dt>' +
              '<dd class="text-right">' + _visionLocalityBadge(m.rsync_locality) + '</dd>' +
            '</div>';
  }
  if (m.local_ip) rows += _visionDetailRow('Local IP', m.local_ip);
  if (m.public_ip) rows += _visionDetailRow('Public IP', m.public_ip);
  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>';
  }
  var askHtml = '';
  if ((m.full_url || m.thumb_url) && !_visionMockActive && m.machine_id) {
    askHtml =
      '<button type="button" onclick="visionAskAssistantDetail(\'' + esc(m.machine_id) + '\')" ' +
        'class="w-full mb-4 inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg bg-togen-teal text-white hover:bg-togen-teal/90 transition-colors cursor-pointer">' +
        _VISION_ASSISTANT_ICON + 'Ask Assistant</button>';
  }
  document.getElementById('vision-agent-popup-detail').innerHTML =
    shotHtml + askHtml + '<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, machineId) {
  var when = f.ts ? new Date(f.ts * 1000).toLocaleString() : f.frame_id;
  var askBtn = '';
  if (machineId && f.frame_id) {
    askBtn =
      '<button type="button" title="Ask Assistant" ' +
        'onclick="visionAskAssistantRec(\'' + esc(machineId) + '\',\'' + esc(f.frame_id) + '\',\'' + esc(f.kind || '') + '\')" ' +
        'class="shrink-0 text-togen-teal hover:text-togen-teal/80 cursor-pointer">' + _VISION_ASSISTANT_ICON + '</button>';
  }
  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 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 flex-1">' + esc(when) + '</span>' + askBtn + '</div>' +
    '</div>';
  }
  return '<div class="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow">' +
    '<button type="button" onclick="visionLightboxOpen(\'' + esc(f.full_url) + '\',\'' + esc(when) + '\')" ' +
      'class="block w-full text-left cursor-zoom-in">' +
      '<img src="' + esc(f.thumb_url) + '" alt="Screenshot" loading="lazy" class="w-full h-28 object-cover bg-gray-100 dark:bg-gray-800">' +
    '</button>' +
    '<div class="px-2 py-1.5 text-[11px] text-gray-600 dark:text-gray-300 flex items-center gap-1" title="' + esc(when) + '">' +
      '<span class="truncate flex-1">' + esc(when) + '</span>' + askBtn + '</div>' +
  '</div>';
}

function visionRenderRecordings(frames) {
  var grid = document.getElementById('vision-rec-grid');
  var midEl = document.getElementById('vision-rec-machine');
  var mid = midEl ? midEl.value : '';
  grid.innerHTML = frames.map(function(f){ return _visionRecordingCardHtml(f, mid); }).join('');
}

// ===== Vision Assistant panel (DVI-868): interpret a captured frame via the LLM =====
// Opens from the Collector detail popup and the Recordings/File-Browser frame grids.
// Posts to POST /vision/interpret {machine_id, frame_id, kind, prompt, history} and
// keeps the multi-turn history client-side (the backend pins the image on the first
// user turn so it stays in context across follow-ups).
var _visionAssistant = { machineId: '', frameId: '', kind: 'full', segment: false, frames: 4, imgUrl: '', caption: '', history: [], shared: [], busy: false };

var _VISION_ASSISTANT_ICON =
  '<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.8">' +
    '<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg>';

// Extract the frame_id from a /vision/frames/<machine_id>/<frame_id>?size=... URL.
function _visionFrameIdFromUrl(url) {
  if (!url) return '';
  try {
    var path = String(url).split('?')[0];
    var parts = path.split('/');
    return decodeURIComponent(parts[parts.length - 1] || '');
  } catch (e) { return ''; }
}

function visionAskAssistantDetail(machineId) {
  var m = _visionFindMachine(machineId);
  if (!m) return;
  var fid = _visionFrameIdFromUrl(m.full_url || m.thumb_url);
  if (!fid) return;
  _visionAssistantOpen({
    machineId: machineId,
    frameId: fid,
    kind: 'full',
    imgUrl: m.full_url || m.thumb_url,
    caption: m.display_name || m.hostname || machineId
  });
}

function visionAskAssistantRec(machineId, frameId, recKind) {
  if (!machineId || !frameId) return;
  var isVideo = (recKind === 'video');
  var footageKind = isVideo ? 'thumb' : 'full';
  var imgUrl = '/vision/frames/' + encodeURIComponent(machineId) + '/' +
    encodeURIComponent(frameId) + '?size=' + (isVideo ? 'thumb' : 'full');
  _visionAssistantOpen({
    machineId: machineId, frameId: frameId, kind: footageKind,
    segment: isVideo, imgUrl: imgUrl, caption: frameId
  });
}

function _visionAssistantEnsureOverlay() {
  if (document.getElementById('vision-assistant-overlay')) return;
  var ov = document.createElement('div');
  ov.id = 'vision-assistant-overlay';
  ov.className = 'fixed inset-0 z-[120] hidden items-center justify-center bg-black/60 p-4';
  ov.onclick = function(ev){ if (ev.target === ov) visionAssistantClose(); };
  var adminHidden = _isToGenAdmin ? '' : ' hidden';
  ov.innerHTML =
    '<div class="relative w-full max-w-2xl max-h-[92vh] flex flex-col rounded-2xl bg-white dark:bg-gray-900 shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">' +
      '<div class="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700 shrink-0">' +
        '<div class="flex items-center gap-2 min-w-0">' +
          '<h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Ask Assistant</h3>' +
          '<span id="vision-assistant-model" class="text-[11px] px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 truncate"></span>' +
        '</div>' +
        '<button onclick="visionAssistantClose()" class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer text-xl leading-none">&times;</button>' +
      '</div>' +
      '<div class="flex-1 overflow-y-auto px-5 py-4 space-y-3">' +
        '<div class="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700 bg-gray-900">' +
          '<img id="vision-assistant-img" src="" alt="Frame" class="w-full max-h-56 object-contain">' +
        '</div>' +
        '<p id="vision-assistant-caption" class="text-[11px] text-gray-500 dark:text-gray-400 truncate"></p>' +
        '<div id="vision-assistant-thread" class="space-y-1 max-h-64 overflow-y-auto"></div>' +
      '</div>' +
      '<div class="border-t border-gray-200 dark:border-gray-700 px-5 py-3 shrink-0">' +
        '<div id="vision-assistant-disconnected" class="hidden text-sm text-amber-600 dark:text-amber-400">' +
          'The Assistant is not connected. Configure an API key in User Settings &rarr; Assistant (or Vision &rarr; Settings) to interpret frames.' +
        '</div>' +
        '<div id="vision-assistant-composer" class="hidden">' +
          '<div id="vision-assistant-clip" class="hidden flex items-center gap-2 mb-2">' +
            '<label for="vision-assistant-frames" class="text-xs font-medium text-gray-600 dark:text-gray-300">Keyframes</label>' +
            '<select id="vision-assistant-frames" onchange="visionAssistantFramesPick()" ' +
              'class="px-2 py-1.5 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:border-togen-teal focus:outline-none">' +
              '<option value="2">2</option><option value="3">3</option>' +
              '<option value="4" selected>4</option><option value="5">5</option>' +
              '<option value="6">6</option>' +
            '</select>' +
            '<span class="text-[11px] text-gray-500 dark:text-gray-400">sampled evenly across the clip</span>' +
          '</div>' +
          '<div class="flex items-center gap-2 mb-2">' +
            '<label for="vision-assistant-model-sel" class="text-xs font-medium text-gray-600 dark:text-gray-300 flex-shrink-0">Model</label>' +
            '<select id="vision-assistant-model-sel" onchange="visionAssistantModelPick()" ' +
              'class="flex-1 px-2 py-1.5 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:border-togen-teal focus:outline-none">' +
              '<option value="">Default</option>' +
            '</select>' +
          '</div>' +
          '<div class="flex items-center gap-2 mb-2">' +
            '<select id="vision-assistant-preset" onchange="visionAssistantPresetPick()" ' +
              'class="flex-1 px-2 py-1.5 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:border-togen-teal focus:outline-none">' +
              '<option value="">&mdash; Presets &mdash;</option>' +
            '</select>' +
          '</div>' +
          '<div id="vision-assistant-admin" class="flex items-center gap-2 mb-2' + adminHidden + '">' +
            '<input id="vision-assistant-preset-name" type="text" maxlength="60" placeholder="New shared preset name" ' +
              'class="flex-1 px-2 py-1.5 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:border-togen-teal focus:outline-none">' +
            '<button onclick="visionAssistantSavePreset()" class="px-2 py-1.5 text-xs font-medium rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 cursor-pointer whitespace-nowrap">Save as shared</button>' +
            '<span id="vision-assistant-preset-status" class="text-[11px] text-gray-500 dark:text-gray-400"></span>' +
          '</div>' +
          '<div class="flex items-end gap-2">' +
            '<textarea id="vision-assistant-prompt" rows="2" placeholder="Ask about this frame&hellip; (Enter to send, Shift+Enter for newline)" ' +
              'onkeydown="if(event.key===\'Enter\'&&!event.shiftKey){event.preventDefault();visionAssistantSend();}" ' +
              '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 resize-none"></textarea>' +
            '<button id="vision-assistant-send" onclick="visionAssistantSend()" 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">Send</button>' +
          '</div>' +
          '<p id="vision-assistant-error" class="hidden mt-2 text-xs text-red-600 dark:text-red-400"></p>' +
        '</div>' +
      '</div>' +
    '</div>';
  document.body.appendChild(ov);
}

function _visionAssistantOpen(ctx) {
  _visionAssistant = {
    machineId: ctx.machineId, frameId: ctx.frameId, kind: ctx.kind || 'full',
    segment: !!ctx.segment, frames: 4, model: '',
    imgUrl: ctx.imgUrl || '', caption: ctx.caption || '', history: [], shared: [], busy: false
  };
  _visionAssistantEnsureOverlay();
  document.getElementById('vision-assistant-img').src = _visionAssistant.imgUrl;
  document.getElementById('vision-assistant-caption').textContent = _visionAssistant.caption;
  var pb = document.getElementById('vision-assistant-prompt');
  if (pb) { pb.value = ''; pb.placeholder = _visionAssistant.segment
    ? 'Ask about this clip… (Enter to send, Shift+Enter for newline)'
    : 'Ask about this frame… (Enter to send, Shift+Enter for newline)'; }
  var clip = document.getElementById('vision-assistant-clip');
  if (clip) clip.classList.toggle('hidden', !_visionAssistant.segment);
  var frSel = document.getElementById('vision-assistant-frames');
  if (frSel) frSel.value = '4';
  var sendBtn = document.getElementById('vision-assistant-send');
  if (sendBtn) sendBtn.textContent = _visionAssistant.segment ? 'Analyze clip' : 'Send';
  document.getElementById('vision-assistant-error').classList.add('hidden');
  var adminRow = document.getElementById('vision-assistant-admin');
  if (adminRow) adminRow.classList.toggle('hidden', !_isToGenAdmin);
  _visionAssistantRenderThread();
  _visionAssistantApplyGate();
  visionAssistantLoadModel();
  visionAssistantLoadPresets();
  var ov = document.getElementById('vision-assistant-overlay');
  ov.classList.remove('hidden'); ov.classList.add('flex');
}

function visionAssistantClose() {
  var ov = document.getElementById('vision-assistant-overlay');
  if (ov) { ov.classList.add('hidden'); ov.classList.remove('flex'); }
}

// Show the composer only when the Assistant is connected (mirrors the topbar dot).
function _visionAssistantApplyGate() {
  var connected = !!_assistantAvailable;
  var composer = document.getElementById('vision-assistant-composer');
  var disc = document.getElementById('vision-assistant-disconnected');
  if (composer) composer.classList.toggle('hidden', !connected);
  if (disc) disc.classList.toggle('hidden', connected);
}

// Surface the model selected in Vision Settings (badge) AND populate the
// per-request override picker (DVI-955) from the same option list. The picker
// defaults to "Default" (empty), which uses the saved Vision selection.
function visionAssistantLoadModel() {
  var badge = document.getElementById('vision-assistant-model');
  var pick = document.getElementById('vision-assistant-model-sel');
  if (badge) badge.textContent = '';
  fetch('/vision/assistant-config')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) { if (badge) badge.textContent = ''; return; }
      var sel = d.selected || '';
      var label = '';
      function scan(list){ (list || []).forEach(function(o){ if (o.value === sel) label = o.label; }); }
      scan(d.system_providers); scan(d.user_models);
      if (!label && sel) label = sel.replace(/^system:/, '').replace(/^user:/, '');
      if (badge) badge.textContent = label ? ('Model: ' + label) : 'No model selected';
      if (pick) {
        var defLabel = label ? ('Default (' + label + ')') : 'Default';
        pick.innerHTML = '';
        var o0 = document.createElement('option'); o0.value = ''; o0.textContent = defLabel; pick.appendChild(o0);
        function addGroup(gl, arr){
          if (!arr || !arr.length) return;
          var og = document.createElement('optgroup'); og.label = gl;
          arr.forEach(function(o){ var op = document.createElement('option'); op.value = o.value; op.textContent = o.label; og.appendChild(op); });
          pick.appendChild(og);
        }
        addGroup('System providers', d.system_providers);
        addGroup('My models', d.user_models);
        pick.value = _visionAssistant.model || '';
      }
    })
    .catch(function(){ if (badge) badge.textContent = ''; });
}

// Per-request Vision model override; '' = use the saved Vision selection.
function visionAssistantModelPick() {
  var sel = document.getElementById('vision-assistant-model-sel');
  _visionAssistant.model = sel ? (sel.value || '') : '';
}

function visionAssistantLoadPresets() {
  var sel = document.getElementById('vision-assistant-preset');
  if (!sel) return;
  sel.innerHTML = '<option value="">— Presets —</option>';
  fetch('/vision/watch-criteria')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      _visionAssistant.shared = d.shared || [];
      function addGroup(label, arr){
        if (!arr || !arr.length) return;
        var og = document.createElement('optgroup');
        og.label = label;
        arr.forEach(function(c){
          var o = document.createElement('option');
          o.value = c.prompt; o.textContent = c.name;
          og.appendChild(o);
        });
        sel.appendChild(og);
      }
      addGroup('Shared', d.shared);
      addGroup('My criteria', d.personal);
    })
    .catch(function(){});
}

function visionAssistantPresetPick() {
  var sel = document.getElementById('vision-assistant-preset');
  var pb = document.getElementById('vision-assistant-prompt');
  if (sel && pb && sel.value) pb.value = sel.value;
}

// Clip mode: keyframe-count selector (2-6, clamped) drives the segment sample size.
function visionAssistantFramesPick() {
  var sel = document.getElementById('vision-assistant-frames');
  if (!sel) return;
  var n = parseInt(sel.value, 10);
  if (!(n >= 2 && n <= 6)) n = 4;
  _visionAssistant.frames = n;
}

// Admins save the current prompt text as a new shared preset (minimal Phase-1 editor).
function visionAssistantSavePreset() {
  var nameEl = document.getElementById('vision-assistant-preset-name');
  var pb = document.getElementById('vision-assistant-prompt');
  var statusEl = document.getElementById('vision-assistant-preset-status');
  var name = (nameEl.value || '').trim();
  var prompt = (pb.value || '').trim();
  if (!name || !prompt) { statusEl.textContent = 'Enter a name and a prompt.'; return; }
  var shared = (_visionAssistant.shared || []).slice();
  shared.push({ name: name, prompt: prompt });
  statusEl.textContent = 'Saving…';
  fetch('/vision/watch-criteria', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ shared: shared })
  }).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)) { statusEl.textContent = (res.d && res.d.error) || 'Save failed.'; return; }
      statusEl.textContent = 'Saved.';
      nameEl.value = '';
      visionAssistantLoadPresets();
    })
    .catch(function(e){ statusEl.textContent = 'Error: ' + e.message; });
}

function _visionAssistantRenderThread() {
  var box = document.getElementById('vision-assistant-thread');
  if (!box) return;
  var h = _visionAssistant.history;
  if (!h.length) {
    box.innerHTML = '<p class="text-xs text-gray-400 dark:text-gray-500 italic">Ask a question about this ' +
      (_visionAssistant.segment ? 'clip' : 'frame') + ' to begin.</p>';
    return;
  }
  box.innerHTML = h.map(function(turn){
    var isUser = turn.role === 'user';
    return '<div class="' + (isUser ? 'text-right' : 'text-left') + '">' +
      '<div class="inline-block max-w-[85%] px-3 py-2 rounded-lg text-sm whitespace-pre-wrap break-words ' +
        (isUser ? 'bg-togen-teal text-white' : 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-100') + '">' +
        esc(turn.content) + '</div></div>';
  }).join('');
  box.scrollTop = box.scrollHeight;
}

function visionAssistantSend() {
  if (_visionAssistant.busy) return;
  var pb = document.getElementById('vision-assistant-prompt');
  var prompt = (pb.value || '').trim();
  if (!prompt) return;
  var errEl = document.getElementById('vision-assistant-error');
  var sendBtn = document.getElementById('vision-assistant-send');
  errEl.classList.add('hidden');
  _visionAssistant.busy = true;
  var sendLabel = _visionAssistant.segment ? 'Analyze clip' : 'Send';
  sendBtn.disabled = true; sendBtn.textContent = _visionAssistant.segment ? 'Analyzing…' : 'Thinking…';
  var priorHistory = _visionAssistant.history.slice();
  var payload = {
    machine_id: _visionAssistant.machineId,
    frame_id: _visionAssistant.frameId,
    kind: _visionAssistant.segment ? 'segment' : _visionAssistant.kind,
    prompt: prompt,
    history: priorHistory
  };
  if (_visionAssistant.segment) payload.frames = _visionAssistant.frames;
  if (_visionAssistant.model) payload.model = _visionAssistant.model;
  fetch('/vision/interpret', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload)
  }).then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
    .then(function(res){
      _visionAssistant.busy = false;
      sendBtn.disabled = false; sendBtn.textContent = sendLabel;
      if (!res.ok || !res.d || res.d.error) {
        errEl.textContent = (res.d && res.d.error) || 'Interpretation failed.';
        errEl.classList.remove('hidden');
        return;
      }
      _visionAssistant.history.push({ role: 'user', content: prompt });
      _visionAssistant.history.push({ role: 'assistant', content: res.d.reply || '' });
      pb.value = '';
      _visionAssistantRenderThread();
    })
    .catch(function(e){
      _visionAssistant.busy = false;
      sendBtn.disabled = false; sendBtn.textContent = sendLabel;
      errEl.textContent = 'Error: ' + e.message;
      errEl.classList.remove('hidden');
    });
}

// ----- 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="togen-inset 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); });
}

// ----- Watch Criteria editor (DVI-890) -----
// Admin CRUD for the shared Watch Criteria that the frame Assistant offers as
// presets and the Monitoring tab evaluates. Round-trips GET/POST
// /vision/watch-criteria (shared list; admin-gated server-side).
var _visionWatchCriteria = [];

function visionWatchCriteriaLoad() {
  var loading = document.getElementById('vision-wc-loading');
  if (loading) loading.classList.remove('hidden');
  fetch('/vision/watch-criteria')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (loading) loading.classList.add('hidden');
      _visionWatchCriteria = (d && Array.isArray(d.shared)) ? d.shared.slice() : [];
      visionWatchCriteriaRender();
    })
    .catch(function(){ if (loading) loading.classList.add('hidden'); });
}

function visionWatchCriteriaRender() {
  var list = document.getElementById('vision-wc-list');
  var empty = document.getElementById('vision-wc-empty');
  if (!list) return;
  if (empty) empty.classList.toggle('hidden', _visionWatchCriteria.length !== 0);
  list.innerHTML = _visionWatchCriteria.map(function(c, i){
    return '<div class="togen-inset rounded-lg border border-gray-200 dark:border-gray-700 p-3 space-y-2">' +
      '<div class="flex items-center gap-2">' +
        '<input type="text" class="vision-wc-name 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-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-togen-teal" ' +
          'placeholder="Name (e.g. Person present)" value="' + esc(c.name || '') + '">' +
        '<button onclick="visionWatchCriteriaRemove(' + i + ')" ' +
          'class="togen-control flex-shrink-0 px-2.5 py-1.5 text-xs 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 cursor-pointer">Remove</button>' +
      '</div>' +
      '<textarea class="vision-wc-prompt 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" ' +
        'rows="2" placeholder="Prompt the Assistant answers about each frame (e.g. Is anyone present? Answer yes/no and describe.)">' + esc(c.prompt || '') + '</textarea>' +
    '</div>';
  }).join('');
}

// Pull current field values back into the model so add/remove preserve edits.
function _visionWatchCriteriaSync() {
  var names = document.querySelectorAll('.vision-wc-name');
  var prompts = document.querySelectorAll('.vision-wc-prompt');
  var out = [];
  for (var i = 0; i < names.length; i++) {
    out.push({ name: names[i].value, prompt: prompts[i] ? prompts[i].value : '' });
  }
  _visionWatchCriteria = out;
}

function visionWatchCriteriaAddRow() {
  _visionWatchCriteriaSync();
  _visionWatchCriteria.push({ name: '', prompt: '' });
  visionWatchCriteriaRender();
}

function visionWatchCriteriaRemove(i) {
  _visionWatchCriteriaSync();
  _visionWatchCriteria.splice(i, 1);
  visionWatchCriteriaRender();
}

function visionWatchCriteriaSave() {
  _visionWatchCriteriaSync();
  var status = document.getElementById('vision-wc-status');
  var btn = document.getElementById('vision-wc-save-btn');
  var cleaned = _visionWatchCriteria
    .map(function(c){ return { name: (c.name || '').trim(), prompt: (c.prompt || '').trim() }; })
    .filter(function(c){ return c.name && c.prompt; });
  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('/vision/watch-criteria', {
    method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ shared: cleaned })
  })
    .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 && res.d.error)) {
        status.textContent = 'Error: ' + ((res.d && res.d.error) || 'save failed');
        status.className = 'text-sm text-red-500';
        return;
      }
      _visionWatchCriteria = cleaned;
      visionWatchCriteriaRender();
      status.textContent = 'Saved'; status.className = 'text-sm text-green-600 dark:text-green-400';
      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';
    });
}

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: {}, meta: {} };
var _scadaAllReadings = [];
var _scadaStatusTime = '';
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: {}, meta: {} };
          if (!_scadaConfig.aliases) _scadaConfig.aliases = {};
          if (!_scadaConfig.reporting) _scadaConfig.reporting = {};
          if (!_scadaConfig.meta) _scadaConfig.meta = {};
          if (!_scadaConfig.oversight) _scadaConfig.oversight = { plan: '' };
          _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: {}, meta: {} };
      if (!_scadaConfig.aliases) _scadaConfig.aliases = {};
      if (!_scadaConfig.reporting) _scadaConfig.reporting = {};
      if (!_scadaConfig.notify) _scadaConfig.notify = {};
      if (!_scadaConfig.meta) _scadaConfig.meta = {};
      if (!_scadaConfig.oversight) _scadaConfig.oversight = { plan: '' };
      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);
}

// ===== Sensor typing registry (DVI-1095 P2) =====
// Per-sensor metadata lives in _scadaConfig.meta[label] = {type, location,
// unit}. Type defaults are derived from the label/value so existing Kiln
// sensors need no config; an explicit type override reclassifies the sensor
// (Status grouping, Sensors vs Relays tab, history inclusion) — the hook that
// lets future non-kiln PLC inputs slot in without code changes.
var _SCADA_TYPES = ['temperature', 'humidity', 'digital', 'relay', 'other'];

function _scadaMeta(label) {
  return (_scadaConfig && _scadaConfig.meta && _scadaConfig.meta[label]) || {};
}

function _scadaSensorType(label, value) {
  var t = _scadaMeta(label).type;
  if (t && _SCADA_TYPES.indexOf(t) !== -1) return t;
  if (label.indexOf('Temp') !== -1) return 'temperature';
  if (label.indexOf('RH') !== -1) return 'humidity';
  var v = String(value == null ? '' : value).toLowerCase();
  if (v === 'on' || v === 'off') return 'digital';
  return 'other';
}

function _scadaSensorUnit(label) {
  var u = _scadaMeta(label).unit;
  if (u) return u;
  var t = _scadaSensorType(label, null);
  return t === 'temperature' ? '°F' : t === 'humidity' ? '%RH' : '';
}

function _scadaIsSensorLabel(label) {
  var t = _scadaMeta(label).type;
  if (t === 'digital' || t === 'relay') return false;
  if (t === 'temperature' || t === 'humidity' || t === 'other') return true;
  return label.indexOf('Temp') !== -1 || label.indexOf('RH') !== -1;
}

// Parse a reading timestamp (normalized "YYYY-MM-DD HH:MM[:SS]" from scada.db
// or US "MM/DD/YYYY HH:MM:SS" from live Trigger emails) as plant-local time.
function _scadaParseTs(s) {
  if (!s) return null;
  s = String(s).trim();
  var m = s.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?/);
  if (m) return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +(m[6] || 0));
  m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?/);
  if (m) return new Date(+m[3], +m[1] - 1, +m[2], +m[4], +m[5], +(m[6] || 0));
  var d = new Date(s);
  return isNaN(d.getTime()) ? null : d;
}

function _scadaStaleAfterMinutes() {
  var c = (_scadaConfig && _scadaConfig.collector) || {};
  var v = parseInt(c.stale_after_minutes, 10);
  return (!isNaN(v) && v > 0) ? v : 60;
}

// Age of a reading: {mins, text, stale} or null when the ts is unparseable.
// Falls back to the snapshot-level time for live payloads (all readings in a
// Trigger email share one stamp); db-fallback readings carry per-sensor ts.
function _scadaAgeInfo(r) {
  var d = _scadaParseTs(r.ts || _scadaStatusTime);
  if (!d) return null;
  var mins = Math.floor((Date.now() - d.getTime()) / 60000);
  if (mins < 0) mins = 0;
  var text = mins < 1 ? 'just now'
    : mins < 60 ? mins + 'm ago'
    : mins < 1440 ? Math.floor(mins / 60) + 'h ago'
    : Math.floor(mins / 1440) + 'd ago';
  return { mins: mins, text: text, stale: mins > _scadaStaleAfterMinutes() };
}

// 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') {
    // Threshold bands (DVI-1095 P2): tint the excursion zones so a trace
    // crossing a limit is readable at a glance — red above high, blue below low.
    if (gHigh !== null) {
      var bandH = Math.max(0, Y(gHigh) - padT);
      if (bandH > 0) svg += '<rect x="' + padL + '" y="' + padT + '" width="' + plotW.toFixed(1) + '" height="' + bandH.toFixed(1) + '" fill="#dc2626" opacity="0.06"/>';
    }
    if (gLow !== null) {
      var bandL = Math.max(0, (h - padB) - Y(gLow));
      if (bandL > 0) svg += '<rect x="' + padL + '" y="' + Y(gLow).toFixed(1) + '" width="' + plotW.toFixed(1) + '" height="' + bandL.toFixed(1) + '" fill="#3b82f6" opacity="0.07"/>';
    }
    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 _scadaBuildStatusTile(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() { scadaShowSensor(r.label); };
  var stype = _scadaSensorType(r.label, r.value);
  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 (stype === 'temperature') {
    valueClass = isOutOfRange ? 'font-bold text-red-600 dark:text-red-400' : 'font-bold text-togen-teal';
  } else if (stype === 'humidity') {
    valueClass = isOutOfRange ? 'font-bold text-red-600 dark:text-red-400' : 'font-bold text-blue-600 dark:text-blue-400';
  } else if (isOutOfRange) {
    valueClass = 'font-bold text-red-600 dark:text-red-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 unit = (!reportingOff && !isNaN(parseFloat(r.value))) ? _scadaSensorUnit(r.label) : '';
  var unitHtml = unit ? ' <span class="text-xs font-medium text-gray-400 dark:text-gray-500">' + esc(unit) + '</span>' : '';
  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) + unitHtml + '</span>';
  var age = _scadaAgeInfo(r);
  var ageHtml = '';
  if (age && !reportingOff) {
    ageHtml = age.stale
      ? '<span class="mt-1 inline-block rounded-full bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:text-amber-300 uppercase tracking-wide">Stale · ' + esc(age.text) + '</span>'
      : '<span class="mt-1 text-[10px] text-gray-400 dark:text-gray-500">' + esc(age.text) + '</span>';
  }
  var loc = _scadaMeta(r.label).location;
  var locHtml = loc ? '<span class="text-[10px] text-gray-400 dark:text-gray-500 truncate w-full">' + esc(loc) + '</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 + locHtml + ageHtml +
    '</div>';
  return tile;
}

// Digital inputs & relays render as compact read-only state chips (DVI-1095
// P2) — relay CONTROL is a held future phase, so no toggle affordance here.
function _scadaBuildStateChip(r) {
  var on = String(r.value).toLowerCase() === 'on';
  var age = _scadaAgeInfo(r);
  var stale = !!(age && age.stale);
  var chip = document.createElement('button');
  chip.type = 'button';
  chip.onclick = function() { scadaShowSensor(r.label); };
  chip.className = 'togen-pane inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm cursor-pointer transition-colors bg-white dark:bg-gray-900 ' +
    (stale ? 'border-amber-300 dark:border-amber-700' : 'border-gray-200 dark:border-gray-700 hover:border-togen-teal/50');
  var dn = scadaDisplayName(r.label);
  chip.title = (dn !== r.label ? r.label + ' — ' : '') + String(r.value) + (age ? ' · ' + age.text : '');
  chip.innerHTML =
    '<span class="inline-block w-2.5 h-2.5 rounded-full shrink-0 ' + (on ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600') + '"></span>' +
    '<span class="font-medium text-gray-700 dark:text-gray-200 truncate max-w-[10rem]">' + esc(dn) + '</span>' +
    '<span class="text-xs ' + (on ? 'text-green-600 dark:text-green-400 font-semibold' : 'text-gray-400 dark:text-gray-500') + '">' + esc(String(r.value)) + '</span>' +
    (stale ? '<span class="text-[10px] font-semibold text-amber-600 dark:text-amber-400 uppercase">Stale</span>' : '');
  return chip;
}

var _SCADA_GROUP_TITLES = { temperature: 'Temperature', humidity: 'Humidity', other: 'Other Sensors', digital: 'Digital Inputs & Relays' };

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 = '';
  var order = ['temperature', 'humidity', 'other', 'digital'];
  var groups = {};
  filtered.forEach(function(r) {
    var t = _scadaSensorType(r.label, r.value);
    if (t === 'relay') t = 'digital';
    if (order.indexOf(t) === -1) t = 'other';
    (groups[t] = groups[t] || []).push(r);
  });
  order.forEach(function(key) {
    var items = groups[key];
    if (!items || !items.length) return;
    var section = document.createElement('div');
    var head = document.createElement('h4');
    head.className = 'text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-2';
    head.textContent = _SCADA_GROUP_TITLES[key] || key;
    section.appendChild(head);
    var body = document.createElement('div');
    if (key === 'digital') {
      body.className = 'flex flex-wrap gap-2';
      items.forEach(function(r) { body.appendChild(_scadaBuildStateChip(r)); });
    } else {
      body.className = 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3';
      items.forEach(function(r) { body.appendChild(_scadaBuildStatusTile(r)); });
    }
    section.appendChild(body);
    grid.appendChild(section);
  });
}

function scadaLoadStatus() {
  var loading = document.getElementById('scada-status-loading');
  var empty = document.getElementById('scada-status-empty');
  var error = document.getElementById('scada-status-error');
  var warning = document.getElementById('scada-status-warning');
  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');
  warning.classList.add('hidden');
  grid.classList.add('hidden');

  fetch('/scada/status')
    .then(function(r) {
      // Read the body even on failure — the server sends the real Graph
      // error (e.g. the RAOP mailbox block) instead of a bare status code.
      return r.json()
        .catch(function() { return {}; })
        .then(function(d) { return { ok: r.ok, status: r.status, data: d || {} }; });
    })
    .then(function(res) {
      if (!res.ok) throw new Error(res.data.error || ('HTTP ' + res.status));
      loading.classList.add('hidden');
      var data = res.data;
      _scadaAllReadings = data.readings || [];
      _scadaStatusTime = data.time || '';
      if (data.time) {
        timeEl.textContent = 'Last updated: ' + data.time +
          (data.source === 'db' ? ' (last stored reading)' : '');
      } else {
        timeEl.textContent = '';
      }
      if (data.error) {
        warning.classList.remove('hidden');
        document.getElementById('scada-status-warning-msg').textContent =
          'Live mailbox fetch failed — showing the last stored readings. ' + data.error;
      }
      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>' : '';
  var meta = _scadaMeta(label);
  var metaBits = [];
  if (meta.type) metaBits.push(meta.type);
  if (meta.location) metaBits.push(meta.location);
  if (metaBits.length) sub += '<span class="block text-xs text-togen-teal/80 truncate">' + esc(metaBits.join(' · ')) + '</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 meta = _scadaMeta(label);
  document.getElementById('scada-edit-type').value = meta.type || '';
  document.getElementById('scada-edit-unit').value = meta.unit || '';
  document.getElementById('scada-edit-unit').placeholder = _scadaSensorUnit(label) || 'auto';
  document.getElementById('scada-edit-location').value = meta.location || '';
  var rangeBlock = document.getElementById('scada-edit-range-block');
  if (isSensor) {
    rangeBlock.classList.remove('hidden');
    var unit = _scadaSensorUnit(label);
    unit = unit ? '(' + unit + ')' : '';
    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 (!_scadaConfig.meta) _scadaConfig.meta = {};
  var metaEntry = {};
  var mtype = document.getElementById('scada-edit-type').value;
  var munit = document.getElementById('scada-edit-unit').value.trim();
  var mloc = document.getElementById('scada-edit-location').value.trim();
  if (mtype) metaEntry.type = mtype;
  if (munit) metaEntry.unit = munit;
  if (mloc) metaEntry.location = mloc;
  if (Object.keys(metaEntry).length) { _scadaConfig.meta[label] = metaEntry; } else { delete _scadaConfig.meta[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;
  scadaLoadOversightSettings();
  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); });
  });
  scadaLoadApprovers();
  scadaLoadApprovals();
  scadaLoadEmailSettings();
}

// Email content settings (DVI-1165) — what the internal report email includes.
function scadaEmailSummaryToggle() {
  var on = document.getElementById('scada-email-summary').checked;
  var sub = document.getElementById('scada-email-summary-sub');
  if (sub) sub.style.opacity = on ? '1' : '0.4';
  ['scada-email-out-of-spec', 'scada-email-rate-violations', 'scada-email-summary-mode']
    .forEach(function(id) { var el = document.getElementById(id); if (el) el.disabled = !on; });
}

function scadaFillEmailSettings(cfg) {
  cfg = cfg || {};
  document.getElementById('scada-email-viewer-link').checked = cfg.include_viewer_link !== false;
  document.getElementById('scada-email-summary').checked = cfg.include_summary !== false;
  document.getElementById('scada-email-out-of-spec').checked = cfg.include_out_of_spec !== false;
  document.getElementById('scada-email-rate-violations').checked = cfg.include_rate_violations !== false;
  document.getElementById('scada-email-summary-mode').value =
    cfg.summary_mode === 'full' ? 'full' : 'exceptions';
  scadaEmailSummaryToggle();
}

function scadaLoadEmailSettings() {
  fetch('/scada/config')
    .then(function(r) { return r.json(); })
    .then(function(data) { scadaFillEmailSettings((data.config || {}).report_email); })
    .catch(function() { scadaFillEmailSettings(null); });
}

function scadaSaveEmailSettings() {
  var body = { report_email: {
    include_viewer_link: document.getElementById('scada-email-viewer-link').checked,
    include_summary: document.getElementById('scada-email-summary').checked,
    include_out_of_spec: document.getElementById('scada-email-out-of-spec').checked,
    include_rate_violations: document.getElementById('scada-email-rate-violations').checked,
    summary_mode: document.getElementById('scada-email-summary-mode').value
  } };
  var msg = document.getElementById('scada-rn-email-msg');
  if (msg) { msg.textContent = 'Saving...'; msg.className = 'text-xs text-gray-400'; }
  fetch('/scada/config', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  })
    .then(function(r) {
      if (!r.ok) throw new Error('HTTP ' + r.status);
      if (msg) {
        msg.textContent = 'Saved';
        msg.className = 'text-xs text-green-600 dark:text-green-400';
        setTimeout(function() { if (msg.textContent === 'Saved') msg.textContent = ''; }, 2500);
      }
    })
    .catch(function(err) {
      if (msg) { msg.textContent = 'Save failed: ' + err.message; msg.className = 'text-xs text-red-500'; }
    });
}

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'); });
}

// ===== REPORT APPROVERS + PENDING APPROVALS (DVI-1102 F4) =====
var _scadaApprovers = [];

function scadaLoadApprovers() {
  fetch('/scada/report-approvers')
    .then(function(r) { return r.ok ? r.json() : { approvers: [] }; })
    .then(function(d) { _scadaApprovers = d.approvers || []; scadaRenderApprovers(); })
    .catch(function() { scadaRenderApprovers(); });
}

function scadaRenderApprovers() {
  var ul = document.getElementById('scada-rn-approver-list');
  if (!ul) return;
  ul.innerHTML = '';
  if (!_scadaApprovers.length) {
    var li = document.createElement('li');
    li.className = 'py-1 text-sm text-amber-600 dark:text-amber-400 italic';
    li.textContent = 'No approvers set — external reports are held until one is added.';
    ul.appendChild(li);
    return;
  }
  _scadaApprovers.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() { scadaApproverMutate('remove', em); };
    li.appendChild(span);
    li.appendChild(btn);
    ul.appendChild(li);
  });
}

function scadaApproverAdd() {
  var input = document.getElementById('scada-rn-approver-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; }
  scadaApproverMutate('add', email);
}

function scadaApproverMutate(action, email) {
  scadaRNSetStatus('Saving…');
  fetch('/scada/report-approvers', {
    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) {
      _scadaApprovers = d.approvers || [];
      var input = document.getElementById('scada-rn-approver-input');
      if (input) { input.value = ''; input.classList.add('hidden'); }
      scadaRenderApprovers();
      scadaRNSetStatus(action === 'add' ? 'Added' : 'Removed', 'ok');
    })
    .catch(function(err) { scadaRNSetStatus('Error: ' + err.message, 'error'); });
}

function scadaLoadApprovals() {
  fetch('/scada/report-approvals')
    .then(function(r) { return r.ok ? r.json() : { approvals: [] }; })
    .then(function(d) { scadaRenderApprovals(d.approvals || []); })
    .catch(function() { scadaRenderApprovals([]); });
}

function scadaRenderApprovals(items) {
  var empty = document.getElementById('scada-rn-approvals-empty');
  var wrap = document.getElementById('scada-rn-approvals-list');
  if (!wrap) return;
  wrap.innerHTML = '';
  if (!items.length) { if (empty) empty.classList.remove('hidden'); return; }
  if (empty) empty.classList.add('hidden');
  items.forEach(function(a) {
    var card = document.createElement('div');
    card.className = 'rounded-lg border border-gray-200 dark:border-gray-700 p-3';
    var range = (a.date_from || '…') + ' – ' + (a.date_to || '…');
    var recips = (a.recipients || []).join(', ');
    var head = document.createElement('div');
    head.className = 'flex items-start justify-between gap-3';
    var meta = document.createElement('div');
    meta.className = 'min-w-0';
    var title = document.createElement('div');
    title.className = 'text-sm font-medium text-gray-800 dark:text-gray-100 truncate';
    title.textContent = a.report_name || 'Report';
    var sub = document.createElement('div');
    sub.className = 'text-xs text-gray-500 dark:text-gray-400 mt-0.5';
    sub.textContent = range + ' · ' + (a.row_count || 0) + ' rows';
    var to = document.createElement('div');
    to.className = 'text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate';
    to.textContent = 'External: ' + (recips || '(none)');
    meta.appendChild(title); meta.appendChild(sub); meta.appendChild(to);
    var btns = document.createElement('div');
    btns.className = 'shrink-0 flex gap-2';
    var ap = document.createElement('button');
    ap.type = 'button';
    ap.className = 'inline-flex items-center h-8 px-3 rounded-lg text-xs font-medium bg-togen-teal hover:bg-togen-teal-dark text-white transition-colors cursor-pointer';
    ap.textContent = 'Approve & send';
    ap.onclick = function() { scadaApprovalAction(a.id, 'approve'); };
    var rj = document.createElement('button');
    rj.type = 'button';
    rj.className = 'inline-flex items-center h-8 px-3 rounded-lg text-xs font-medium border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:text-red-600 hover:border-red-400 transition-colors cursor-pointer';
    rj.textContent = 'Reject';
    rj.onclick = function() { scadaApprovalAction(a.id, 'reject'); };
    btns.appendChild(ap); btns.appendChild(rj);
    head.appendChild(meta); head.appendChild(btns);
    card.appendChild(head);
    wrap.appendChild(card);
  });
}

function scadaApprovalAction(id, action) {
  var body = {};
  if (action === 'reject') {
    var reason = window.prompt('Reason for rejecting this report (optional):', '');
    if (reason === null) return;
    body.reason = reason;
  }
  scadaRNSetStatus(action === 'approve' ? 'Sending…' : 'Rejecting…');
  fetch('/scada/report-approvals/' + encodeURIComponent(id) + '/' + action, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  })
    .then(function(r) { return r.json().then(function(d) { if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status)); return d; }); })
    .then(function() {
      scadaRNSetStatus(action === 'approve' ? 'Approved & sent' : 'Rejected', 'ok');
      scadaLoadApprovals();
    })
    .catch(function(err) { scadaRNSetStatus('Error: ' + err.message, 'error'); scadaLoadApprovals(); });
}

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'; } });
}

// ---- Oversight settings pane (DVI-1117) — admin-only global plan selector ----
function scadaLoadOversightSettings() {
  var sel = document.getElementById('scada-oversight-plan');
  if (!sel) return;
  var note = document.getElementById('scada-oversight-note');
  if (note) note.classList.toggle('hidden', !!_isToGenAdmin);
  sel.disabled = !_isToGenAdmin;
  var cur = (_scadaConfig.oversight && _scadaConfig.oversight.plan) || '';
  fetch('/oversight/config').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      var plans = (d && d.plans) || [];
      var facs = {};
      ((d && d.facilities) || []).forEach(function(f){ facs[f.id] = f.name; });
      var opts = '<option value="">— None —</option>';
      plans.forEach(function(p){
        var fname = facs[p.facility];
        var label = (fname ? fname + ' · ' : '') + (p.name || p.id);
        opts += '<option value="' + esc(p.id) + '"' + (p.id === cur ? ' selected' : '') + '>' + esc(label) + '</option>';
      });
      sel.innerHTML = opts;
    }).catch(function(){});
}

function scadaSaveOversightPlan() {
  if (!_isToGenAdmin) return;
  var sel = document.getElementById('scada-oversight-plan');
  var plan = sel ? sel.value : '';
  _scadaConfig.oversight = { plan: plan };
  var st = document.getElementById('scada-oversight-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({ oversight: { plan: plan } })
  })
    .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); }
      // reset the viewer so the tab picks up the new plan next time it opens
      _scadaovPlan = null;
    })
    .catch(function(err) { if (st) { st.textContent = 'Save failed: ' + err.message; st.className = 'text-xs text-red-500'; } });
}

// Enabled sensor labels for the History tab, narrowed by the filter box
// (substring on display name or raw label). Shared by render + CSV export so
// the download always matches the on-screen list (Auditor pattern, DVI-1087).
function _scadaHistoryLabels() {
  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);
    });
  }
  var q = (((document.getElementById('scada-history-q') || {}).value || '').trim()).toLowerCase();
  if (q) {
    labels = labels.filter(function(l) {
      return l.toLowerCase().indexOf(q) !== -1 ||
             String(scadaDisplayName(l)).toLowerCase().indexOf(q) !== -1;
    });
  }
  return labels;
}

function _scadaCsvCell(v) {
  var s = v === null || v === undefined ? '' : String(v);
  return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}

// Client-side CSV download. Deliberate divergence from the Auditor's
// server-side export: /scada/history re-parses the SCADA mailbox via Graph on
// every call (~200 messages), so exporting from the already-loaded series is
// faster and guaranteed to match what's on screen.
function _scadaDownloadCsv(filename, header, rows) {
  var lines = [header.map(_scadaCsvCell).join(',')];
  rows.forEach(function(r) { lines.push(r.map(_scadaCsvCell).join(',')); });
  // BOM so Excel keeps unicode, matching the server-side _csv_attachment.
  var blob = new Blob([String.fromCharCode(0xFEFF) + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' });
  var url = URL.createObjectURL(blob);
  var a = document.createElement('a');
  a.href = url; a.download = filename; a.rel = 'noopener';
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
}

function _scadaStamp() {
  var d = new Date();
  return String(d.getFullYear()) + ('0' + (d.getMonth() + 1)).slice(-2) + ('0' + d.getDate()).slice(-2);
}

function scadaHistoryExportCsv() {
  scadaLoadHistory(function() {
    var labels = _scadaHistoryLabels();
    var rows = [];
    labels.forEach(function(label) {
      (_scadaHistory[label] || []).forEach(function(p) {
        rows.push([scadaDisplayName(label), p.time, p.value]);
      });
    });
    if (!rows.length) return;
    _scadaDownloadCsv('scada_history_' + _scadaStamp() + '.csv', ['Sensor', 'Time', 'Value'], rows);
  });
}

var _scadaDetailLabel = '';

function scadaSensorExportCsv() {
  var label = _scadaDetailLabel;
  if (!label) return;
  var points = _scadaHistory[label] || [];
  if (!points.length) return;
  var rows = points.map(function(p) { return [scadaDisplayName(label), p.time, p.value]; });
  var slug = String(scadaDisplayName(label)).replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, '') || 'sensor';
  _scadaDownloadCsv('scada_' + slug + '_' + _scadaStamp() + '.csv', ['Sensor', 'Time', 'Value'], rows);
}

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 = _scadaHistoryLabels();
  if (!labels.length) {
    var q = (((document.getElementById('scada-history-q') || {}).value || '').trim());
    if (empty) empty.classList.add('hidden');
    list.innerHTML = q
      ? '<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8">No sensors match "' + esc(q) + '".</p>'
      : '';
    if (!q && empty) empty.classList.remove('hidden');
    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>';
}

// Detail-view range picker (DVI-1095 P2): [key, button label, minutes] —
// 0 minutes means no window (all stored points).
var _SCADA_DETAIL_WINDOWS = [['24h', '24 h', 1440], ['7d', '7 days', 10080], ['30d', '30 days', 43200], ['all', 'All', 0]];
var _scadaDetailWindow = 'all';

function scadaSetDetailWindow(key) {
  _scadaDetailWindow = key;
  if (_scadaDetailLabel) scadaShowSensor(_scadaDetailLabel);
}

function _scadaRenderDetailPicker() {
  var el = document.getElementById('scada-sensor-range-picker');
  if (!el) return;
  el.innerHTML = '';
  _SCADA_DETAIL_WINDOWS.forEach(function(wdef) {
    var b = document.createElement('button');
    b.type = 'button';
    var active = _scadaDetailWindow === wdef[0];
    b.className = 'px-2.5 py-1 text-xs font-medium rounded-lg border transition-colors cursor-pointer ' +
      (active ? 'bg-togen-teal text-white border-togen-teal'
              : 'bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-togen-teal/60');
    b.textContent = wdef[1];
    b.onclick = function() { scadaSetDetailWindow(wdef[0]); };
    el.appendChild(b);
  });
}

function _scadaDetailPoints(points) {
  var wdef = null;
  for (var i = 0; i < _SCADA_DETAIL_WINDOWS.length; i++) {
    if (_SCADA_DETAIL_WINDOWS[i][0] === _scadaDetailWindow) { wdef = _SCADA_DETAIL_WINDOWS[i]; break; }
  }
  if (!wdef || !wdef[2]) return points;
  var cutoff = Date.now() - wdef[2] * 60000;
  return points.filter(function(p) {
    var d = _scadaParseTs(p.time);
    return d && d.getTime() >= cutoff;
  });
}

function scadaShowSensor(label) {
  _scadaDetailLabel = 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);
    _scadaRenderDetailPicker();

    var allPoints = _scadaHistory[label] || [];
    var points = _scadaDetailPoints(allPoints);
    var noteEl = document.getElementById('scada-sensor-window-note');
    if (noteEl) {
      noteEl.textContent = (points.length < allPoints.length)
        ? points.length + ' of ' + allPoints.length + ' readings in window'
        : (allPoints.length ? allPoints.length + ' readings' : '');
    }
    var range = _scadaConfig.ranges[label] || null;
    var graphEl = document.getElementById('scada-sensor-graph');
    var eventsEl = document.getElementById('scada-sensor-events');
    var isSensor = _scadaIsSensorLabel(label);

    var emptyMsg = (allPoints.length && !points.length)
      ? 'No readings in this window — pick a wider range.'
      : 'No numeric history to graph.';
    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">' + emptyMsg + '</p>';
    } else {
      graphEl.innerHTML = points.length ? scadaBuildRelayTimeline(points)
        : '<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8">' + emptyMsg + '</p>';
    }

    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');
}

// ===================== OVERSIGHT TOOL (DVI-1100) =====================
var _oversightInitialized = false;
var _oversightCfg = { facilities: [], plans: [], layers: [], can_edit: false };
var _oversightFacility = null;
var _oversightPlan = null;
var _oversightActiveLayers = {};      // {layerId: true}
var _oversightData = null;            // last /oversight/data payload
var _oversightEdit = false;
var _oversightEditPlacements = [];    // working copy of placements while editing
var _oversightZoom = 1, _oversightPanX = 0, _oversightPanY = 0;
var _oversightKeyBound = false;
var _oversightFull = false;           // DVI-1117: in-page full-view state
// DVI-1112: 'auto' = follow the admin-set light/dark default for the current
// theme; a style id = sticky manual pick (persisted per browser).
var _oversightStyle = localStorage.getItem('oversightStyle') || 'auto';
// DVI-1103: per-user (per-browser, localStorage) layer-filter persistence.
// Map of {layerId: bool}; a layer defaults ON unless the user explicitly
// turned it off here. Same tradeoff as the DVI-1112 style pick.
var _oversightLayerPref = (function(){
  try { return JSON.parse(localStorage.getItem('oversightLayers') || '{}') || {}; }
  catch (e) { return {}; }
})();

// ---- shared plan-viewer helpers (DVI-1117) — used by the Oversight tool and
// the read-only SCADA View -> Oversight tab. Sizing keeps the plan maximized;
// full-view fixes the viewer below the top bar without hiding it (per spec).
// DVI-1133: size the bordered viewer box to the plan image's aspect ratio and
// center it in the available area (both normal and full view), so the outline
// hugs the plan instead of letterboxing it. The plan fills the box (minus the
// small zoom-reset inset), so no large padding above/below the plan.
function _ovSizeStage(stageId, wrapId, full) {
  var stage = document.getElementById(stageId);
  var wrap = document.getElementById(wrapId);
  if (!stage || !wrap) return;
  var img = stage.querySelector('img');
  var iw = img && (img.naturalWidth || img.width);
  var ih = img && (img.naturalHeight || img.height);
  var edge = 12;  // gap between the plan box and the available-area edges
  var availW, availH, areaTop = 0;
  if (full) {
    var tb = document.querySelector('.togen-topbar');
    areaTop = tb ? Math.round(tb.getBoundingClientRect().bottom) : 0;
    availW = window.innerWidth;
    availH = window.innerHeight - areaTop;
  } else {
    // clear the width first so we can measure the full available width
    wrap.style.width = '';
    wrap.style.marginLeft = ''; wrap.style.marginRight = '';
    availW = wrap.clientWidth;
    availH = Math.max(320, window.innerHeight - wrap.getBoundingClientRect().top - 16);
  }
  var boxW, boxH;
  if (iw && ih) {
    var s = Math.min((availW - edge) / iw, (availH - edge) / ih);
    if (!(s > 0)) s = 1;
    boxW = Math.max(1, Math.round(iw * s));
    boxH = Math.max(1, Math.round(ih * s));
  } else {
    boxW = Math.max(1, availW - edge);
    boxH = Math.max(1, availH - edge);
  }
  wrap.style.width = boxW + 'px';
  wrap.style.height = boxH + 'px';
  if (full) {
    wrap.style.left = Math.round((availW - boxW) / 2) + 'px';
    wrap.style.right = 'auto';
    wrap.style.top = (areaTop + Math.round((availH - boxH) / 2)) + 'px';
    wrap.style.bottom = 'auto';
  } else {
    wrap.style.marginLeft = 'auto';
    wrap.style.marginRight = 'auto';
  }
  stage.style.height = '100%';
}

function _ovApplyFull(on, wrapId) {
  var wrap = document.getElementById(wrapId);
  if (!wrap) return;
  if (on) {
    // geometry (centered box sized to the plan) is applied by _ovSizeStage
    wrap.style.position = 'fixed';
    wrap.style.zIndex = '90';
    wrap.style.marginLeft = ''; wrap.style.marginRight = '';
  } else {
    wrap.style.position = ''; wrap.style.left = ''; wrap.style.right = '';
    wrap.style.bottom = ''; wrap.style.top = ''; wrap.style.zIndex = '';
  }
}

// swap the maximize icon <-> restore (compress) icon
function _ovSetFullIcon(iconId, full) {
  // Accept an id (attached node) or an element directly — the dashboard-tile /
  // embedded-dashboard Oversight scaffold is built while still DETACHED, so it
  // resolves the icon within its own body and passes the element (document
  // getElementById would miss a detached node — DVI-1501 prod-verify fix).
  var ic = (iconId && typeof iconId === 'object') ? iconId : document.getElementById(iconId);
  if (!ic) return;
  ic.innerHTML = full
    ? '<path fill-rule="evenodd" d="M9 3a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 010-2h1.586L3.293 3.707a1 1 0 011.414-1.414L8 5.586V4a1 1 0 011-1zm2 10a1 1 0 011-1h4a1 1 0 010 2h-1.586l3.293 3.293a1 1 0 01-1.414 1.414L15 15.414V17a1 1 0 01-2 0v-4z" clip-rule="evenodd"/>'
    : '<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l3.293 3.293a1 1 0 11-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm14 12a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-3.293-3.293a1 1 0 111.414-1.414L15 13.586V12a1 1 0 012 0v4z" clip-rule="evenodd"/>';
}

function oversightToggleFull() {
  _oversightFull = !_oversightFull;
  _ovApplyFull(_oversightFull, 'oversight-viewer-wrap');
  document.getElementById('oversight-facility-tabs').classList.toggle('hidden', _oversightFull);
  document.getElementById('oversight-toolbar').classList.toggle('hidden', _oversightFull);
  var bc = document.getElementById('oversight-breadcrumb');
  if (_oversightFull && bc) bc.classList.add('hidden');
  var eb = document.getElementById('oversight-editbar');
  if (_oversightFull && eb) eb.classList.add('hidden');
  document.getElementById('oversight-full-btn').title = _oversightFull ? 'Exit full view' : 'Full view';
  _ovSetFullIcon('oversight-full-icon', _oversightFull);
  if (!_oversightFull) {
    // restore per-role control visibility (settings/edit buttons, facility strip)
    oversightRenderShell();
  }
  _ovSizeStage('oversight-stage', 'oversight-viewer-wrap', _oversightFull);
  oversightZoomReset();
}

function oversightExitFull() {
  if (_oversightFull) oversightToggleFull();
}

function oversightInit() {
  if (!_oversightKeyBound) {
    _oversightKeyBound = true;
    document.addEventListener('keydown', function(e) {
      if (e.key === 'Escape' && _activeTool === 'oversight') {
        if (_oversightFull) { oversightExitFull(); return; }
        oversightCloseDetail();
        var s = document.getElementById('oversight-settings');
        if (s && !s.classList.contains('hidden')) oversightCloseSettings();
      }
    });
    window.addEventListener('resize', function() {
      if (_activeTool === 'oversight' &&
          !document.getElementById('oversight-viewer-wrap').classList.contains('hidden')) {
        _ovSizeStage('oversight-stage', 'oversight-viewer-wrap', _oversightFull);
        oversightApplyTransform();
      }
    });
    oversightBindStage();
  }
  oversightLoadConfig();
}

function oversightLoadConfig(cb) {
  fetch('/oversight/config').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      _oversightCfg = d;
      // Each accessible layer defaults ON unless the user persisted it OFF
      // (DVI-1103 per-user layer-filter persistence).
      _oversightActiveLayers = {};
      (d.layers || []).forEach(function(l){
        if (l.access && _oversightLayerPref[l.id] !== false) _oversightActiveLayers[l.id] = true;
      });
      document.getElementById('oversight-settings-btn').classList.toggle('hidden', !d.can_edit);
      document.getElementById('oversight-empty-admin').classList.toggle('hidden', !d.can_edit);
      oversightRenderShell();
      if (cb) cb();
    }).catch(function(){});
}

function oversightVisiblePlans(fid) {
  return (_oversightCfg.plans || []).filter(function(p){ return p.facility === fid; });
}

function oversightRenderShell() {
  var facs = _oversightCfg.facilities || [];
  var empty = document.getElementById('oversight-empty');
  var wrap = document.getElementById('oversight-viewer-wrap');
  if (!facs.length && !(_oversightCfg.plans || []).length) {
    empty.classList.remove('hidden');
    wrap.classList.add('hidden');
    document.getElementById('oversight-plan-tabs').innerHTML = '';
    document.getElementById('oversight-facility-tabs').classList.add('hidden');
    document.getElementById('oversight-edit-btn').classList.add('hidden');
    return;
  }
  empty.classList.add('hidden');
  // pick facility
  if (!_oversightFacility || !facs.some(function(f){ return f.id === _oversightFacility; })) {
    _oversightFacility = facs.length ? facs[0].id : null;
  }
  oversightRenderFacilityTabs();
  oversightRenderPlanTabs();
  oversightRenderLayerToggles();
}

function oversightRenderFacilityTabs() {
  var facs = _oversightCfg.facilities || [];
  var el = document.getElementById('oversight-facility-tabs');
  // auto-skip the facility strip while there is a single facility
  if (facs.length <= 1) { el.classList.add('hidden'); el.innerHTML = ''; return; }
  el.classList.remove('hidden');
  el.innerHTML = facs.map(function(f){
    var on = f.id === _oversightFacility;
    return '<button onclick="oversightSelectFacility(\'' + f.id + '\')" class="flex-shrink-0 whitespace-nowrap px-4 py-2 text-sm font-medium border-b-2 cursor-pointer ' +
      (on ? 'border-togen-teal text-togen-teal' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300') +
      '">' + oversightEsc(f.name) + '</button>';
  }).join('');
}

function oversightSelectFacility(fid) {
  if (_oversightEdit) return;
  _oversightFacility = fid;
  _oversightPlan = null;
  oversightRenderFacilityTabs();
  oversightRenderPlanTabs();
}

function oversightRenderPlanTabs() {
  var plans = oversightVisiblePlans(_oversightFacility);
  var el = document.getElementById('oversight-plan-tabs');
  var wrap = document.getElementById('oversight-viewer-wrap');
  document.getElementById('oversight-edit-btn').classList.toggle('hidden', !(_oversightCfg.can_edit && plans.length));
  if (!plans.length) {
    el.innerHTML = '<span class="text-sm text-gray-400 dark:text-gray-500 px-2 py-2">No plans in this facility.</span>';
    wrap.classList.add('hidden');
    oversightRenderBreadcrumb();
    return;
  }
  // order: top-level plans first, each followed by its sub-views (parent)
  var top = plans.filter(function(p){ return !p.parent; });
  var ordered = [];
  top.forEach(function(p){
    ordered.push({ p: p, depth: 0 });
    plans.filter(function(c){ return c.parent === p.id; }).forEach(function(c){ ordered.push({ p: c, depth: 1 }); });
  });
  // any orphaned sub-views (parent outside this facility) still show
  plans.forEach(function(p){ if (p.parent && !ordered.some(function(o){ return o.p.id === p.id; })) ordered.push({ p: p, depth: 0 }); });
  if (!_oversightPlan || !plans.some(function(p){ return p.id === _oversightPlan; })) {
    _oversightPlan = ordered[0].p.id;
  }
  el.innerHTML = ordered.map(function(o){
    var on = o.p.id === _oversightPlan;
    var pad = o.depth ? ' ml-1 pl-3 border-l border-gray-200 dark:border-gray-700' : '';
    var sub = o.depth ? '<span class="text-gray-400 mr-1">↳</span>' : '';
    return '<button onclick="oversightSelectPlan(\'' + o.p.id + '\')" class="flex-shrink-0 whitespace-nowrap px-3 py-2 text-sm font-medium border-b-2 cursor-pointer' + pad + ' ' +
      (on ? 'border-togen-teal text-togen-teal' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300') +
      '">' + sub + oversightEsc(o.p.name) + '</button>';
  }).join('');
  oversightRenderBreadcrumb();
  oversightLoadData();
}

// DVI-1103: breadcrumb trail for the selected plan — Facility ▸ ancestor
// plan(s) ▸ current plan. Ancestors are clickable to jump up a sub-view;
// hidden when there is no meaningful hierarchy (single top-level plan, no
// parent) or while editing/full-view.
function oversightRenderBreadcrumb() {
  var nav = document.getElementById('oversight-breadcrumb');
  if (!nav) return;
  var plan = oversightCurrentPlan();
  if (!plan || _oversightFull) { nav.classList.add('hidden'); nav.innerHTML = ''; return; }
  // walk the parent chain up from the current plan (guard against cycles)
  var chain = [], cur = plan, seen = {};
  while (cur && !seen[cur.id]) {
    seen[cur.id] = true;
    chain.unshift(cur);
    cur = cur.parent ? (_oversightCfg.plans || []).filter(function(p){ return p.id === cur.parent; })[0] : null;
  }
  var fac = (_oversightCfg.facilities || []).filter(function(f){ return f.id === plan.facility; })[0];
  // Only worth showing when there's a facility label + more than one plan, or a
  // sub-view chain — otherwise the plan tab already says it all.
  if (chain.length < 2 && !(fac && (_oversightCfg.facilities || []).length > 1)) {
    nav.classList.add('hidden'); nav.innerHTML = ''; return;
  }
  var sep = '<span class="text-gray-300 dark:text-gray-600">▸</span>';
  var parts = [];
  if (fac) parts.push('<span class="text-gray-500 dark:text-gray-400">' + oversightEsc(fac.name) + '</span>');
  chain.forEach(function(p, i){
    var last = i === chain.length - 1;
    if (last) {
      parts.push('<span class="font-medium text-gray-700 dark:text-gray-200">' + oversightEsc(p.name) + '</span>');
    } else {
      parts.push('<button onclick="oversightSelectPlan(\'' + p.id + '\')" class="text-togen-teal hover:underline cursor-pointer">' + oversightEsc(p.name) + '</button>');
    }
  });
  nav.innerHTML = parts.join(' ' + sep + ' ');
  nav.classList.remove('hidden');
}

function oversightSelectPlan(pid) {
  if (_oversightEdit) return;
  _oversightPlan = pid;
  oversightRenderPlanTabs();
}

function oversightRenderLayerToggles() {
  var el = document.getElementById('oversight-layer-toggles');
  var layers = (_oversightCfg.layers || []).filter(function(l){ return l.access; });
  var html = layers.map(function(l){
    var on = !!_oversightActiveLayers[l.id];
    return '<label class="inline-flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300 cursor-pointer">' +
      '<input type="checkbox" ' + (on ? 'checked' : '') + ' onchange="oversightToggleLayer(\'' + l.id + '\', this.checked)" class="rounded border-gray-300 text-togen-teal focus:ring-togen-teal">' +
      oversightEsc(l.label) + '</label>';
  }).join('');
  if (layers.some(function(l){ return l.id === 'equipment'; })) {
    html += oversightEquipmentLegendHtml();
  }
  el.innerHTML = html;
}

function oversightEquipmentLegendHtml() {
  function dot(c, t){ return '<span class="inline-flex items-center gap-1"><span class="inline-block h-2.5 w-2.5 rounded-full ' + c + '"></span>' + t + '</span>'; }
  return '<span class="inline-flex items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400 ml-1 pl-2 border-l border-gray-200 dark:border-gray-700">' +
    '<span class="uppercase tracking-wide font-semibold">Equipment</span>' +
    dot('bg-emerald-600', 'In service') + dot('bg-amber-500', 'Open WO') + dot('bg-red-600', 'Out of service') +
    '</span>';
}

function oversightToggleLayer(lid, on) {
  // Display convenience only — the server re-filters by access on every fetch.
  if (on) _oversightActiveLayers[lid] = true; else delete _oversightActiveLayers[lid];
  // Persist the choice per-user (per-browser) so it survives reloads (DVI-1103).
  _oversightLayerPref[lid] = !!on;
  try { localStorage.setItem('oversightLayers', JSON.stringify(_oversightLayerPref)); } catch (e) {}
  oversightLoadData();
}

function oversightActiveLayerList() {
  return Object.keys(_oversightActiveLayers).filter(function(k){ return _oversightActiveLayers[k]; });
}

function oversightCurrentPlan() {
  return (_oversightCfg.plans || []).filter(function(p){ return p.id === _oversightPlan; })[0] || null;
}

// ---- plan styles (DVI-1112) ----
function oversightResolvedStyle(plan) {
  // -> the style id whose image the viewer should show for this plan, or null.
  if (!plan || !plan.images) return null;
  var imaged = Object.keys(plan.images);
  if (!imaged.length) return null;
  if (_oversightStyle !== 'auto' && plan.images[_oversightStyle]) return _oversightStyle;
  var isDark = document.documentElement.classList.contains('dark');
  var want = isDark ? _oversightCfg.style_default_dark : _oversightCfg.style_default_light;
  if (want && plan.images[want]) return want;
  // fall back to the first imaged style in global style order
  var ordered = (_oversightCfg.styles || []).map(function(s){ return s.id; }).filter(function(sid){ return plan.images[sid]; });
  return ordered.length ? ordered[0] : imaged[0];
}

function oversightRenderStylePicker(plan) {
  var sel = document.getElementById('oversight-style-select');
  if (!sel) return;
  var styles = _oversightCfg.styles || [];
  var imaged = styles.filter(function(s){ return plan && plan.images && plan.images[s.id]; });
  if (imaged.length < 2) { sel.classList.add('hidden'); return; }
  sel.classList.remove('hidden');
  var cur = oversightResolvedStyle(plan);
  sel.innerHTML = '<option value="auto"' + (_oversightStyle === 'auto' ? ' selected' : '') + '>Style: Auto</option>' +
    imaged.map(function(s){
      var on = _oversightStyle !== 'auto' && s.id === cur;
      return '<option value="' + oversightEsc(s.id) + '"' + (on ? ' selected' : '') + '>' + oversightEsc(s.name) + '</option>';
    }).join('');
}

function oversightSetStyle(v) {
  _oversightStyle = v || 'auto';
  localStorage.setItem('oversightStyle', _oversightStyle);
  oversightLoadData();
}

function oversightThemeChanged() {
  // theme flip re-resolves Auto style while the Oversight pane is active
  if (_activeTool === 'oversight' && _oversightStyle === 'auto' && _oversightPlan) oversightLoadData();
}

function oversightLoadData() {
  var plan = oversightCurrentPlan();
  var wrap = document.getElementById('oversight-viewer-wrap');
  var noimg = document.getElementById('oversight-noimg');
  var stage = document.getElementById('oversight-stage');
  if (!plan) {
    wrap.classList.add('hidden');
    var stySel = document.getElementById('oversight-style-select');
    if (stySel) stySel.classList.add('hidden');
    return;
  }
  wrap.classList.remove('hidden');
  oversightRenderStylePicker(plan);
  var sid = oversightResolvedStyle(plan);
  if (!sid) {
    noimg.classList.remove('hidden');
    stage.classList.add('hidden');
    document.getElementById('oversight-markers').innerHTML = '';
    return;
  }
  noimg.classList.add('hidden');
  stage.classList.remove('hidden');
  _ovSizeStage('oversight-stage', 'oversight-viewer-wrap', _oversightFull);
  var img = document.getElementById('oversight-plan-img');
  var dims = plan.images[sid] || {};
  var wantSrc = '/oversight/plan-image/' + encodeURIComponent(plan.id) +
    '?style=' + encodeURIComponent(sid) + '&v=' + (dims.w || 0) + 'x' + (dims.h || 0);
  if (img.getAttribute('data-src') !== wantSrc) {
    img.setAttribute('data-src', wantSrc);
    img.onload = function(){ _ovSizeStage('oversight-stage', 'oversight-viewer-wrap', _oversightFull); oversightZoomReset(); };
    img.src = wantSrc;
  } else {
    oversightZoomReset();
  }
  var layers = oversightActiveLayerList();
  fetch('/oversight/data?plan=' + encodeURIComponent(plan.id) + '&layers=' + encodeURIComponent(layers.join(',')))
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){ if (!d) return; _oversightData = d; oversightRenderMarkers(); })
    .catch(function(){});
}

function oversightRenderMarkers() {
  var host = document.getElementById('oversight-markers');
  var placements = oversightSortLocationFirst(_oversightEdit ? _oversightEditPlacements : ((_oversightData && _oversightData.placements) || []));
  host.innerHTML = '';
  placements.forEach(function(pl){
    var el = document.createElement('div');
    el.className = 'ov-marker';
    el.style.position = 'absolute';
    el.style.left = (pl.x * 100) + '%';
    el.style.top = (pl.y * 100) + '%';
    el.style.transform = 'translate(-50%,-50%) scale(' + (1 / _oversightZoom) + ')';
    el.style.transformOrigin = 'center center';
    el.setAttribute('data-id', pl.id);
    el.appendChild(oversightMarkerChip(pl));
    host.appendChild(el);
  });
}

function oversightMarkerChip(pl) {
  if (pl.layer === 'location') return oversightLocationChipEditable(pl);
  var d = pl.data;
  var chip = document.createElement('div');
  var cls = oversightMarkerColor(pl, d);
  chip.className = 'relative inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold shadow-md border border-white/40 whitespace-nowrap ' + cls;
  chip.style.cursor = _oversightEdit ? 'move' : 'pointer';
  var txt = '';
  if (_oversightEdit) {
    txt = oversightEsc(pl.label || pl.ref);
  } else if (!pl.known) {
    txt = oversightEsc(pl.label || pl.ref) + ' · ?';
  } else if (pl.layer === 'equipment') {
    txt = oversightEsc(pl.label || (d && d.label) || pl.ref);
  } else if (d) {
    var unit = d.unit || '';
    txt = oversightEsc(String(d.value)) + (unit ? ' ' + oversightEsc(unit) : '');
  } else {
    txt = oversightEsc(pl.label || pl.ref) + ' · —';
  }
  chip.innerHTML = '<span>' + txt + '</span>' +
    (!_oversightEdit && d && d.stale ? '<span class="text-[9px] font-bold uppercase bg-black/20 rounded px-1">stale</span>' : '') +
    (!_oversightEdit && !pl.known ? '<span class="text-[9px] font-bold uppercase bg-black/20 rounded px-1">unknown</span>' : '');
  if (_oversightEdit) {
    var x = document.createElement('button');
    x.textContent = '×';
    x.className = 'ml-0.5 leading-none opacity-80 hover:opacity-100';
    x.onclick = function(e){ e.stopPropagation(); oversightRemoveMarker(pl.id); };
    chip.appendChild(x);
    // orphan/unknown-ref badge in editor: ref no longer offered by the layer
    if (oversightRefUnknown(pl)) {
      var b = document.createElement('span');
      b.className = 'text-[9px] font-bold uppercase bg-black/25 rounded px-1';
      b.textContent = 'orphan';
      chip.appendChild(b);
    }
    oversightBindMarkerDrag(chip, pl);
  } else {
    chip.onclick = function(e){ oversightShowDetail(pl, e); };
  }
  return chip;
}

function oversightLocationChipEditable(pl) {
  // View mode: flat subdued label (shared with the embeds). Edit mode: a dashed
  // draggable chip with rename (✎) + remove (×). Location tags are never
  // "orphans" (no live layer to bind to) and never enter the toggle list.
  if (!_oversightEdit) return oversightLocationChip(pl);
  var chip = document.createElement('div');
  chip.className = 'inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-medium tracking-wide whitespace-nowrap border border-dashed border-togen-teal/60 bg-white/80 dark:bg-gray-900/80 text-gray-700 dark:text-gray-200';
  chip.style.cursor = 'move';
  var span = document.createElement('span');
  span.textContent = pl.label || '(unnamed)';
  chip.appendChild(span);
  var pencil = document.createElement('button');
  pencil.textContent = '✎';
  pencil.className = 'ml-0.5 leading-none opacity-80 hover:opacity-100';
  pencil.onclick = function(e){
    e.stopPropagation();
    var v = prompt('Location name', pl.label || '');
    if (v !== null) { pl.label = v.trim(); oversightRenderMarkers(); }
  };
  chip.appendChild(pencil);
  var x = document.createElement('button');
  x.textContent = '×';
  x.className = 'ml-0.5 leading-none opacity-80 hover:opacity-100';
  x.onclick = function(e){ e.stopPropagation(); oversightRemoveMarker(pl.id); };
  chip.appendChild(x);
  oversightBindMarkerDrag(chip, pl);
  return chip;
}

function oversightRefUnknown(pl) {
  if (pl.layer === 'location') return false;
  var layer = (_oversightCfg.layers || []).filter(function(l){ return l.id === pl.layer; })[0];
  if (!layer || !layer.items) return false;
  return !layer.items.some(function(it){ return it.ref === pl.ref; });
}

function oversightMarkerColor(pl, d) {
  if (_oversightEdit) {
    return oversightRefUnknown(pl) ? 'bg-purple-600 text-white' : 'bg-togen-teal text-white';
  }
  if (!pl.known || !d) return 'bg-gray-500 text-white';
  if (pl.layer === 'equipment') {
    if (d.state === 'red') return 'bg-red-600 text-white';
    if (d.state === 'yellow') return 'bg-amber-500 text-white';
    return 'bg-emerald-600 text-white';
  }
  if (d.stale) return 'bg-amber-500 text-white';
  var v = parseFloat(d.value);
  if (!isNaN(v)) {
    if (d.low != null && v < d.low) return 'bg-blue-600 text-white';
    if (d.high != null && v > d.high) return 'bg-red-600 text-white';
    return 'bg-emerald-600 text-white';
  }
  return 'bg-slate-600 text-white';
}

function oversightShowDetail(pl, evt) {
  var d = pl.data;
  var box = document.getElementById('oversight-detail');
  var rows = '';
  function row(k, v){ return '<div class="flex justify-between gap-3"><span class="text-gray-500 dark:text-gray-400">' + k + '</span><span class="font-medium text-gray-800 dark:text-gray-100 text-right">' + v + '</span></div>'; }
  var title = oversightEsc((d && d.label) || pl.label || pl.ref);
  if (!pl.known) {
    rows = '<p class="text-xs text-amber-600 dark:text-amber-400">' +
      (pl.layer === 'equipment'
        ? 'This location references equipment that is no longer included in Oversight.'
        : 'This location references a sensor that is no longer reporting.') + '</p>';
  } else if (pl.layer === 'equipment' && d) {
    var eqStateLabel = {green: 'In service — no open work orders', yellow: 'Open work order(s)', red: 'Out of service'}[d.state] || d.state;
    var eqStateChip = {green: 'bg-emerald-600', yellow: 'bg-amber-500', red: 'bg-red-600'}[d.state] || 'bg-gray-500';
    rows += row('Status', '<span class="inline-flex items-center gap-1"><span class="inline-block h-2.5 w-2.5 rounded-full ' + eqStateChip + '"></span>' + oversightEsc(eqStateLabel) + '</span>');
    if (d.open_count != null) rows += row('Open work orders', oversightEsc(String(d.open_count)));
    if (d.in_progress_count) rows += row('In progress', oversightEsc(String(d.in_progress_count)));
  } else if (d) {
    var unit = d.unit ? ' ' + oversightEsc(d.unit) : '';
    rows += row('Value', '<span class="text-base">' + oversightEsc(String(d.value)) + unit + '</span>');
    if (d.type) rows += row('Type', oversightEsc(d.type));
    if (d.location) rows += row('Location', oversightEsc(d.location));
    if (d.low != null || d.high != null) rows += row('Range', (d.low != null ? d.low : '—') + ' – ' + (d.high != null ? d.high : '—'));
    if (d.ts) rows += row('Reading', oversightEsc(d.ts));
    if (d.stale) rows += '<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">Reading is stale.</p>';
  } else {
    rows = '<p class="text-xs text-gray-500">No current data.</p>';
  }
  var link = '';
  if (pl.layer === 'scada') {
    link = '<button onclick="oversightOpenInScada(\'' + encodeURIComponent(pl.ref) + '\')" class="mt-2 w-full px-2 py-1 text-xs rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 cursor-pointer">Open in Kiln Metrics</button>';
  } else if (pl.layer === 'equipment') {
    link = '<button onclick="oversightOpenInEquipment(\'' + encodeURIComponent(pl.ref) + '\')" class="mt-2 w-full px-2 py-1 text-xs rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 cursor-pointer">Open in Equipment</button>';
  }
  box.innerHTML = '<div class="flex items-center justify-between mb-2"><span class="font-semibold text-gray-900 dark:text-gray-100">' + title + '</span>' +
    '<button onclick="oversightCloseDetail()" class="text-gray-400 hover:text-gray-600 cursor-pointer text-lg leading-none">&times;</button></div>' +
    '<div class="space-y-1">' + rows + '</div>' + link;
  box.classList.remove('hidden');
  var x = Math.min(evt.clientX + 12, window.innerWidth - 270);
  var y = Math.min(evt.clientY + 12, window.innerHeight - 200);
  box.style.left = Math.max(8, x) + 'px';
  box.style.top = Math.max(8, y) + 'px';
}

function oversightCloseDetail() {
  var box = document.getElementById('oversight-detail');
  if (box) box.classList.add('hidden');
}

function oversightOpenInScada(ref) {
  ref = decodeURIComponent(ref);
  oversightCloseDetail();
  var tool = TOOLS.filter(function(t){ return t.id === 'scada-view'; })[0];
  if (!tool) { alert('Kiln Metrics is not available for your account.'); return; }
  _showTool('scada-view', tool.title);
  if (typeof scadaSwitchTab === 'function') scadaSwitchTab('sensors');
}

function oversightOpenInEquipment(ref) {
  ref = decodeURIComponent(ref);
  oversightCloseDetail();
  var tool = TOOLS.filter(function(t){ return t.id === 'equipment-manuals'; })[0];
  if (!tool) { alert('Equipment is not available for your account.'); return; }
  _showTool('equipment-manuals', tool.title);
  if (typeof emSwitchTab === 'function') emSwitchTab('equipment');
}

// ---- pan / zoom ----
function oversightApplyTransform() {
  var pan = document.getElementById('oversight-pan');
  pan.style.transform = 'translate(' + _oversightPanX + 'px,' + _oversightPanY + 'px) scale(' + _oversightZoom + ')';
  var inv = 1 / _oversightZoom;
  var ms = document.querySelectorAll('#oversight-markers .ov-marker');
  for (var i = 0; i < ms.length; i++) ms[i].style.transform = 'translate(-50%,-50%) scale(' + inv + ')';
}

function oversightZoomReset() {
  var stage = document.getElementById('oversight-stage');
  var img = document.getElementById('oversight-plan-img');
  var iw = img.naturalWidth || img.width, ih = img.naturalHeight || img.height;
  if (!iw || !ih) { _oversightZoom = 1; _oversightPanX = 0; _oversightPanY = 0; oversightApplyTransform(); return; }
  var sw = stage.clientWidth, sh = stage.clientHeight;
  _oversightZoom = Math.min((sw - 8) / iw, (sh - 8) / ih);
  if (!(_oversightZoom > 0)) _oversightZoom = Math.min(sw / iw, sh / ih);
  _oversightPanX = (sw - iw * _oversightZoom) / 2;
  _oversightPanY = (sh - ih * _oversightZoom) / 2;
  oversightApplyTransform();
}

function oversightZoomBy(f) {
  var stage = document.getElementById('oversight-stage');
  oversightZoomAt(stage.clientWidth / 2, stage.clientHeight / 2, f);
}

function oversightZoomAt(sx, sy, f) {
  var z2 = Math.max(0.05, Math.min(20, _oversightZoom * f));
  var k = z2 / _oversightZoom;
  _oversightPanX = sx - (sx - _oversightPanX) * k;
  _oversightPanY = sy - (sy - _oversightPanY) * k;
  _oversightZoom = z2;
  oversightApplyTransform();
}

function oversightBindStage() {
  var stage = document.getElementById('oversight-stage');
  if (!stage) return;
  var panning = false, lastX = 0, lastY = 0;
  stage.addEventListener('pointerdown', function(e){
    if (e.target.closest('.ov-marker')) return;   // marker drag handles itself
    if (_oversightEdit && e.pointerType === 'touch') return;  // touch edit: no pan
    panning = true; lastX = e.clientX; lastY = e.clientY;
    stage.setPointerCapture(e.pointerId);
    oversightCloseDetail();
  });
  stage.addEventListener('pointermove', function(e){
    if (!panning) return;
    _oversightPanX += (e.clientX - lastX);
    _oversightPanY += (e.clientY - lastY);
    lastX = e.clientX; lastY = e.clientY;
    oversightApplyTransform();
  });
  function endPan(e){ if (panning) { panning = false; try { stage.releasePointerCapture(e.pointerId); } catch(_){} } }
  stage.addEventListener('pointerup', endPan);
  stage.addEventListener('pointercancel', endPan);
  stage.addEventListener('wheel', function(e){
    e.preventDefault();
    var rect = stage.getBoundingClientRect();
    oversightZoomAt(e.clientX - rect.left, e.clientY - rect.top, e.deltaY < 0 ? 1.1 : 0.9);
  }, { passive: false });
}

function oversightScreenToNorm(clientX, clientY) {
  var img = document.getElementById('oversight-plan-img');
  var rect = img.getBoundingClientRect();
  if (!rect.width || !rect.height) return { x: 0.5, y: 0.5 };
  return {
    x: Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)),
    y: Math.min(1, Math.max(0, (clientY - rect.top) / rect.height))
  };
}

// ---- edit mode (admin only) ----
function oversightToggleEdit() {
  if (_oversightEdit) { oversightCancelEdit(); return; }
  if (!_oversightCfg.can_edit) return;
  var plan = oversightCurrentPlan();
  if (!plan || !plan.has_image) { alert('Upload a plan image first (Settings).'); return; }
  _oversightEdit = true;
  // work on a copy of the plan's stored placements (all layers, admin sees all)
  _oversightEditPlacements = (plan.placements || []).map(function(p){ return Object.assign({}, p); });
  document.getElementById('oversight-editbar').classList.remove('hidden');
  document.getElementById('oversight-edit-btn').textContent = 'Editing…';
  oversightRenderAddLayerOptions();
  oversightRenderMarkers();
}

function oversightRenderAddLayerOptions() {
  var sel = document.getElementById('oversight-add-layer');
  var layers = (_oversightCfg.layers || []).filter(function(l){ return l.access && (l.items || []).length; });
  // "Location tag" is always offered (DVI-1158) — it isn't a real toggleable layer.
  var opts = '<option value="location">Location tag</option>' +
    layers.map(function(l){ return '<option value="' + l.id + '">' + oversightEsc(l.label) + '</option>'; }).join('');
  sel.innerHTML = opts;
  var dl = document.getElementById('oversight-loc-presets');
  if (dl) dl.innerHTML = (_oversightCfg.location_presets || []).map(function(n){ return '<option value="' + oversightEsc(n) + '"></option>'; }).join('');
  oversightAddLayerChanged();
}

function oversightAddLayerChanged() {
  var lid = document.getElementById('oversight-add-layer').value;
  var sel = document.getElementById('oversight-add-ref');
  var loc = document.getElementById('oversight-add-loc');
  if (lid === 'location') {
    // Preset palette (14 building names) + free text via a single datalist input.
    sel.classList.add('hidden');
    if (loc) { loc.classList.remove('hidden'); loc.value = ''; }
    return;
  }
  if (loc) loc.classList.add('hidden');
  sel.classList.remove('hidden');
  var layer = (_oversightCfg.layers || []).filter(function(l){ return l.id === lid; })[0];
  sel.innerHTML = ((layer && layer.items) || []).map(function(it){
    return '<option value="' + oversightEsc(it.ref) + '">' + oversightEsc(it.label || it.ref) + (it.location ? ' (' + oversightEsc(it.location) + ')' : '') + '</option>';
  }).join('');
}

function oversightAddMarker() {
  var lid = document.getElementById('oversight-add-layer').value;
  var stage = document.getElementById('oversight-stage');
  var rect = stage.getBoundingClientRect();
  var n = oversightScreenToNorm(rect.left + stage.clientWidth / 2, rect.top + stage.clientHeight / 2);
  var newId = 'new_' + Date.now() + '_' + Math.floor(Math.random() * 1000);
  if (lid === 'location') {
    var name = (document.getElementById('oversight-add-loc').value || '').trim();
    if (!name) return;
    // ref left blank → the server mints a stable location id on save.
    _oversightEditPlacements.push({ id: newId, layer: 'location', ref: '', x: n.x, y: n.y, label: name });
    oversightRenderMarkers();
    return;
  }
  var ref = document.getElementById('oversight-add-ref').value;
  if (!lid || !ref) return;
  var layer = (_oversightCfg.layers || []).filter(function(l){ return l.id === lid; })[0];
  var item = ((layer && layer.items) || []).filter(function(it){ return it.ref === ref; })[0];
  _oversightEditPlacements.push({ id: newId, layer: lid, ref: ref, x: n.x, y: n.y, label: (item && item.label) || ref });
  oversightRenderMarkers();
}

function oversightRemoveMarker(id) {
  _oversightEditPlacements = _oversightEditPlacements.filter(function(p){ return p.id !== id; });
  oversightRenderMarkers();
}

function oversightBindMarkerDrag(chip, pl) {
  chip.addEventListener('pointerdown', function(e){
    e.stopPropagation();
    if (e.target.tagName === 'BUTTON') return;
    var stage = document.getElementById('oversight-stage');
    chip.setPointerCapture(e.pointerId);
    function move(ev){
      var n = oversightScreenToNorm(ev.clientX, ev.clientY);
      pl.x = n.x; pl.y = n.y;
      var el = chip.parentNode;
      el.style.left = (pl.x * 100) + '%';
      el.style.top = (pl.y * 100) + '%';
    }
    function up(ev){ chip.removeEventListener('pointermove', move); chip.removeEventListener('pointerup', up); try { chip.releasePointerCapture(ev.pointerId); } catch(_){} }
    chip.addEventListener('pointermove', move);
    chip.addEventListener('pointerup', up);
  });
}

function oversightSavePlacements() {
  var plan = oversightCurrentPlan();
  if (!plan) return;
  var payload = _oversightEditPlacements.map(function(p){ return { id: (String(p.id).indexOf('new_') === 0 ? '' : p.id), layer: p.layer, ref: p.ref, x: p.x, y: p.y, label: p.label }; });
  fetch('/oversight/placements', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ plan: plan.id, placements: payload }) })
    .then(function(r){ return r.json().then(function(j){ return { ok: r.ok, j: j }; }); })
    .then(function(res){
      if (!res.ok) { alert(res.j.error || 'Save failed'); return; }
      _oversightEdit = false;
      document.getElementById('oversight-editbar').classList.add('hidden');
      document.getElementById('oversight-edit-btn').textContent = 'Edit locations';
      oversightLoadConfig(function(){ oversightLoadData(); });
    }).catch(function(){ alert('Save failed'); });
}

function oversightCancelEdit() {
  _oversightEdit = false;
  _oversightEditPlacements = [];
  document.getElementById('oversight-editbar').classList.add('hidden');
  document.getElementById('oversight-edit-btn').textContent = 'Edit locations';
  oversightRenderMarkers();
}

// ---- settings (admin only) ----
function oversightOpenSettings() {
  if (!_oversightCfg.can_edit) return;
  oversightRenderSettings();
  document.getElementById('oversight-settings').classList.remove('hidden');
}

function oversightCloseSettings() {
  document.getElementById('oversight-settings').classList.add('hidden');
}

function oversightRenderSettings() {
  var body = document.getElementById('oversight-settings-body');
  var facs = _oversightCfg.facilities || [];
  var plans = _oversightCfg.plans || [];
  var styles = _oversightCfg.styles || [];
  var facRows = facs.map(function(f){
    return '<div class="flex items-center gap-2"><input value="' + oversightEsc(f.name) + '" id="ovfac-' + f.id + '" class="flex-1 px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm">' +
      '<button onclick="oversightSaveFacility(\'' + f.id + '\')" class="px-2 py-1 text-xs rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 cursor-pointer">Save</button>' +
      '<button onclick="oversightDeleteFacility(\'' + f.id + '\')" class="togen-control px-2 py-1 text-xs rounded-lg text-red-600 border border-red-300 hover:bg-red-50 cursor-pointer">Delete</button></div>';
  }).join('');
  var facSection = '<div><h4 class="font-semibold text-gray-800 dark:text-gray-100 mb-2">Facilities</h4>' +
    '<div class="space-y-2">' + (facRows || '<p class="text-xs text-gray-500">None yet.</p>') + '</div>' +
    '<div class="flex items-center gap-2 mt-2"><input id="ovfac-new" placeholder="New facility name" class="flex-1 px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm">' +
    '<button onclick="oversightSaveFacility(\'\')" class="px-2 py-1 text-xs rounded-lg bg-togen-teal text-white cursor-pointer">Add</button></div></div>';

  // Plan Styles (DVI-1112): named renderings of the same plans; light/dark
  // pick which style "Auto" resolves to in the viewer.
  var styRows = styles.map(function(s){
    return '<div class="flex items-center gap-2"><input value="' + oversightEsc(s.name) + '" id="ovsty-' + s.id + '" class="flex-1 px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm">' +
      '<button onclick="oversightSaveStyle(\'' + s.id + '\')" class="px-2 py-1 text-xs rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 cursor-pointer">Save</button>' +
      (styles.length > 1 ? '<button onclick="oversightDeleteStyle(\'' + s.id + '\')" class="togen-control px-2 py-1 text-xs rounded-lg text-red-600 border border-red-300 hover:bg-red-50 cursor-pointer">Delete</button>' : '') +
      '</div>';
  }).join('');
  function styOpts(sel) {
    return styles.map(function(s){ return '<option value="' + s.id + '"' + (s.id === sel ? ' selected' : '') + '>' + oversightEsc(s.name) + '</option>'; }).join('');
  }
  var stySection = '<div><h4 class="font-semibold text-gray-800 dark:text-gray-100 mb-2">Plan Styles</h4>' +
    '<div class="space-y-2">' + styRows + '</div>' +
    '<div class="flex items-center gap-2 mt-2"><input id="ovsty-new" placeholder="New style name" class="flex-1 px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm">' +
    '<button onclick="oversightSaveStyle(\'\')" class="px-2 py-1 text-xs rounded-lg bg-togen-teal text-white cursor-pointer">Add</button></div>' +
    '<div class="flex items-center gap-2 mt-3 flex-wrap text-xs text-gray-600 dark:text-gray-300">' +
    '<label class="inline-flex items-center gap-1.5">Light mode default <select id="ovsty-def-light" onchange="oversightSaveStyleDefaults()" class="px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800">' + styOpts(_oversightCfg.style_default_light) + '</select></label>' +
    '<label class="inline-flex items-center gap-1.5">Dark mode default <select id="ovsty-def-dark" onchange="oversightSaveStyleDefaults()" class="px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800">' + styOpts(_oversightCfg.style_default_dark) + '</select></label>' +
    '</div></div>';

  var facOpts = facs.map(function(f){ return '<option value="' + f.id + '">' + oversightEsc(f.name) + '</option>'; }).join('');
  var planRows = plans.map(function(p){
    var parentOpts = '<option value="">— none —</option>' + plans.filter(function(q){ return q.id !== p.id && q.facility === p.facility; }).map(function(q){ return '<option value="' + q.id + '"' + (p.parent === q.id ? ' selected' : '') + '>' + oversightEsc(q.name) + '</option>'; }).join('');
    var facSel = facs.map(function(f){ return '<option value="' + f.id + '"' + (p.facility === f.id ? ' selected' : '') + '>' + oversightEsc(f.name) + '</option>'; }).join('');
    return '<div class="togen-inset rounded-lg border border-gray-200 dark:border-gray-700 p-2 space-y-2">' +
      '<div class="flex items-center gap-2 flex-wrap">' +
      '<input value="' + oversightEsc(p.name) + '" id="ovplan-name-' + p.id + '" class="flex-1 min-w-[8rem] px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm" placeholder="Plan name">' +
      '<select id="ovplan-fac-' + p.id + '" class="px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm">' + facSel + '</select>' +
      '<select id="ovplan-parent-' + p.id + '" class="px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm">' + parentOpts + '</select>' +
      '<button onclick="oversightSavePlan(\'' + p.id + '\')" class="px-2 py-1 text-xs rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 cursor-pointer">Save</button>' +
      '<button onclick="oversightDeletePlan(\'' + p.id + '\')" class="togen-control px-2 py-1 text-xs rounded-lg text-red-600 border border-red-300 hover:bg-red-50 cursor-pointer">Delete</button>' +
      '</div>' +
      '<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 flex-wrap">' +
      styles.map(function(s){
        var im = (p.images || {})[s.id];
        return '<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 border ' +
          (im ? 'border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-400' : 'border-gray-200 dark:border-gray-700') + '">' +
          oversightEsc(s.name) + (im ? ' ' + (im.w || '?') + '×' + (im.h || '?') : ' —') + '</span>';
      }).join('') +
      '</div>' +
      '<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-wrap">' +
      '<select id="ovplan-imgsty-' + p.id + '" class="px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800">' + styOpts(_oversightCfg.style_default_light) + '</select>' +
      '<input type="file" accept=".png,.jpg,.jpeg,.svg,image/png,image/jpeg,image/svg+xml" id="ovplan-img-' + p.id + '" class="text-xs">' +
      '<button onclick="oversightUploadImage(\'' + p.id + '\')" class="px-2 py-1 rounded-lg bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 cursor-pointer">Upload</button>' +
      '</div></div>';
  }).join('');
  var planSection = '<div><h4 class="font-semibold text-gray-800 dark:text-gray-100 mb-2">Plans</h4>' +
    '<div class="space-y-2">' + (planRows || '<p class="text-xs text-gray-500">None yet.</p>') + '</div>' +
    (facs.length ? ('<div class="flex items-center gap-2 mt-2"><input id="ovplan-new-name" placeholder="New plan name" class="flex-1 px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm">' +
      '<select id="ovplan-new-fac" class="px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm">' + facOpts + '</select>' +
      '<button onclick="oversightAddPlan()" class="px-2 py-1 text-xs rounded-lg bg-togen-teal text-white cursor-pointer">Add</button></div>')
      : '<p class="text-xs text-gray-500 mt-2">Add a facility first.</p>') + '</div>';
  // Editors grant manager (DVI-1103) — Togen-admin-only. Lets a non-admin use
  // this editor without an Azure group; loaded lazily below.
  var editorsSection = '';
  if (_oversightCfg.can_manage_editors) {
    editorsSection = '<div><h4 class="font-semibold text-gray-800 dark:text-gray-100 mb-2">Editors</h4>' +
      '<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Grant non-admin users access to this Locations/plan editor. Togen Admins always have access.</p>' +
      '<div id="oversight-editors-list" class="space-y-2 text-sm"><p class="text-xs text-gray-500">Loading…</p></div>' +
      '<div class="flex items-center gap-2 mt-2"><input id="oversight-editor-new" placeholder="user@company.com" class="flex-1 px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm">' +
      '<button onclick="oversightEditorAdd()" class="px-2 py-1 text-xs rounded-lg bg-togen-teal text-white cursor-pointer">Grant</button></div></div>';
  }
  // Default Oversight dashboard (DVI-1213 P2) — admin-only. Configures the
  // system-owned dashboard shown to every user. People layers are excluded.
  var ovdashSection = '';
  if (_oversightCfg.can_manage_editors) {
    var ov = _oversightCfg.overview_dashboard || { enabled: true, plan: '', layers: [], style: '', show_legend: true };
    var ovPlanOpts = '<option value="">Auto (first available plan)</option>' + plans.map(function(p){
      var fac = facs.filter(function(f){ return f.id === p.facility; })[0];
      return '<option value="' + p.id + '"' + (ov.plan === p.id ? ' selected' : '') + '>' + oversightEsc((fac ? fac.name + ' / ' : '') + p.name) + '</option>';
    }).join('');
    var ovStyleOpts = '<option value="">Auto (match theme)</option>' + styles.map(function(s){
      return '<option value="' + s.id + '"' + (ov.style === s.id ? ' selected' : '') + '>' + oversightEsc(s.name) + '</option>';
    }).join('');
    var ovLayerRows = (_oversightCfg.layers || []).filter(function(l){ return l.id !== 'visitors' && l.id !== 'personnel'; }).map(function(l){
      var on = (ov.layers || []).indexOf(l.id) > -1;
      return '<label class="inline-flex items-center gap-1.5 mr-3 text-sm text-gray-700 dark:text-gray-200"><input type="checkbox" data-ovdashlayer="' + l.id + '"' + (on ? ' checked' : '') + ' class="rounded border-gray-300 dark:border-gray-600 text-togen-teal"> ' + oversightEsc(l.label) + '</label>';
    }).join('');
    ovdashSection = '<div><h4 class="font-semibold text-gray-800 dark:text-gray-100 mb-2">Default Oversight Dashboard</h4>' +
      '<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Available to every user (named &ldquo;Oversight&rdquo;) and used as their default when they haven\'t picked their own. Leave Plan/Layers unset to auto-use the first available plan and all non-people layers. Users can &ldquo;Make a copy&rdquo; to customize. People-tracking layers are never included here.</p>' +
      '<label class="flex items-center gap-1.5 mb-2 text-sm text-gray-700 dark:text-gray-200"><input type="checkbox" id="ovdash-enabled"' + (ov.enabled ? ' checked' : '') + ' class="rounded border-gray-300 dark:border-gray-600 text-togen-teal"> Enable the default Oversight dashboard</label>' +
      '<div class="flex items-center gap-2 mb-2"><span class="text-xs text-gray-600 dark:text-gray-300 w-14">Plan</span><select id="ovdash-plan" class="flex-1 px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm">' + ovPlanOpts + '</select></div>' +
      '<div class="mb-2"><div class="text-xs text-gray-600 dark:text-gray-300 mb-1">Layers</div>' + (ovLayerRows || '<span class="text-xs text-gray-500">No layers available.</span>') + '</div>' +
      '<div class="flex items-center gap-2 mb-2"><span class="text-xs text-gray-600 dark:text-gray-300 w-14">Style</span><select id="ovdash-style" class="flex-1 px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm">' + ovStyleOpts + '</select></div>' +
      '<label class="flex items-center gap-1.5 mb-2 text-sm text-gray-700 dark:text-gray-200"><input type="checkbox" id="ovdash-legend"' + (ov.show_legend !== false ? ' checked' : '') + ' class="rounded border-gray-300 dark:border-gray-600 text-togen-teal"> Show legend</label>' +
      '<div><button onclick="oversightSaveOverviewDashboard()" class="px-3 py-1.5 text-xs rounded-lg bg-togen-teal text-white cursor-pointer">Save default dashboard</button> <span id="ovdash-status" class="text-xs text-gray-500 ml-2"></span></div></div>';
  }
  body.innerHTML = facSection + stySection + planSection + ovdashSection + editorsSection;
  if (_oversightCfg.can_manage_editors) oversightLoadEditors();
}

function oversightSaveOverviewDashboard() {
  var layers = [];
  var boxes = document.querySelectorAll('[data-ovdashlayer]');
  for (var i = 0; i < boxes.length; i++) { if (boxes[i].checked) layers.push(boxes[i].getAttribute('data-ovdashlayer')); }
  var payload = {
    enabled: !!document.getElementById('ovdash-enabled').checked,
    plan: document.getElementById('ovdash-plan').value || '',
    layers: layers,
    style: document.getElementById('ovdash-style').value || '',
    show_legend: !!document.getElementById('ovdash-legend').checked
  };
  var st = document.getElementById('ovdash-status'); if (st) st.textContent = 'Saving…';
  fetch('/oversight/overview-dashboard', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if (d.error) { if (st) st.textContent = d.error; return; }
      if (_oversightCfg) _oversightCfg.overview_dashboard = d.overview_dashboard;
      if (st) st.textContent = 'Saved.';
    })
    .catch(function(){ if (st) st.textContent = 'Save failed.'; });
}

function oversightLoadEditors() {
  fetch('/oversight/editors').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){ if (d) oversightRenderEditors(d.editors || []); }).catch(function(){});
}

function oversightRenderEditors(editors) {
  var el = document.getElementById('oversight-editors-list');
  if (!el) return;
  if (!editors.length) { el.innerHTML = '<p class="text-xs text-gray-500">No editors granted yet.</p>'; return; }
  el.innerHTML = editors.map(function(e){
    return '<div class="flex items-center justify-between gap-2 rounded-lg border border-gray-200 dark:border-gray-700 px-2 py-1">' +
      '<span class="truncate text-gray-700 dark:text-gray-200">' + oversightEsc(e) + '</span>' +
      '<button onclick="oversightEditorRemove(\'' + oversightEsc(e).replace(/'/g, "\\'") + '\')" class="togen-control px-2 py-0.5 text-xs rounded-lg text-red-600 border border-red-300 hover:bg-red-50 cursor-pointer flex-shrink-0">Revoke</button></div>';
  }).join('');
}

function oversightEditorAdd() {
  var email = (document.getElementById('oversight-editor-new') || {}).value || '';
  if (!email.trim()) { alert('Enter an email address.'); return; }
  oversightEditorMutate('add', email.trim());
}
function oversightEditorRemove(email) {
  if (!confirm('Revoke Oversight editor access for ' + email + '?')) return;
  oversightEditorMutate('remove', email);
}
function oversightEditorMutate(action, email) {
  fetch('/oversight/editors', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: action, email: email }) })
    .then(function(r){ return r.json().then(function(j){ return { ok: r.ok, j: j }; }); })
    .then(function(res){
      if (!res.ok) { alert(res.j.error || 'Request failed'); return; }
      var input = document.getElementById('oversight-editor-new');
      if (input) input.value = '';
      oversightRenderEditors(res.j.editors || []);
    }).catch(function(){ alert('Request failed'); });
}

function oversightSaveStyle(id) {
  var name = document.getElementById(id ? 'ovsty-' + id : 'ovsty-new').value;
  oversightPost('/oversight/style-save', { id: id, name: name });
}
function oversightDeleteStyle(id) {
  if (!confirm('Delete this style and its uploaded images on every plan?')) return;
  oversightPost('/oversight/style-delete', { id: id });
}
function oversightSaveStyleDefaults() {
  oversightPost('/oversight/style-defaults', {
    light: document.getElementById('ovsty-def-light').value,
    dark: document.getElementById('ovsty-def-dark').value
  });
}

function oversightSaveFacility(id) {
  var name = document.getElementById(id ? 'ovfac-' + id : 'ovfac-new').value;
  oversightPost('/oversight/facility-save', { id: id, name: name });
}
function oversightDeleteFacility(id) {
  if (!confirm('Delete this facility and all its plans?')) return;
  oversightPost('/oversight/facility-delete', { id: id });
}
function oversightAddPlan() {
  oversightPost('/oversight/plan-save', { name: document.getElementById('ovplan-new-name').value, facility: document.getElementById('ovplan-new-fac').value });
}
function oversightSavePlan(id) {
  oversightPost('/oversight/plan-save', { id: id, name: document.getElementById('ovplan-name-' + id).value, facility: document.getElementById('ovplan-fac-' + id).value, parent: document.getElementById('ovplan-parent-' + id).value });
}
function oversightDeletePlan(id) {
  if (!confirm('Delete this plan and its image?')) return;
  oversightPost('/oversight/plan-delete', { id: id });
}

function oversightPost(url, body) {
  fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
    .then(function(r){ return r.json().then(function(j){ return { ok: r.ok, j: j }; }); })
    .then(function(res){
      if (!res.ok) { alert(res.j.error || 'Request failed'); return; }
      oversightLoadConfig(function(){ oversightRenderSettings(); });
    }).catch(function(){ alert('Request failed'); });
}

function oversightUploadImage(id) {
  var input = document.getElementById('ovplan-img-' + id);
  if (!input.files || !input.files[0]) { alert('Choose an image first.'); return; }
  var fd = new FormData();
  fd.append('plan', id);
  fd.append('style', (document.getElementById('ovplan-imgsty-' + id) || {}).value || '');
  fd.append('image', input.files[0]);
  fetch('/oversight/plan-image', { method: 'POST', body: fd })
    .then(function(r){ return r.json().then(function(j){ return { ok: r.ok, j: j }; }); })
    .then(function(res){
      if (!res.ok) { alert(res.j.error || 'Upload failed'); return; }
      if (res.j.warn) alert(res.j.warn);
      oversightLoadConfig(function(){ oversightRenderSettings(); oversightLoadData(); });
    }).catch(function(){ alert('Upload failed'); });
}

function oversightRefresh() { oversightLoadData(); }

function oversightEsc(s) {
  return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){
    return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
  });
}
// ---- location tags (DVI-1158): shared across every plan viewer ----
// A reserved non-toggleable bottom layer of editable text labels that replace
// the building names formerly baked into the drawing. Rendered first (lowest
// paint order) as flat, subdued, non-interactive labels in read-only views.
function oversightIsLocation(pl) { return !!(pl && pl.layer === 'location'); }
function oversightSortLocationFirst(placements) {
  var loc = [], rest = [];
  (placements || []).forEach(function(pl){ (oversightIsLocation(pl) ? loc : rest).push(pl); });
  return loc.concat(rest);
}
function oversightLocationChip(pl) {
  var chip = document.createElement('div');
  chip.className = 'whitespace-nowrap text-[11px] font-medium tracking-wide text-gray-500 dark:text-gray-400 select-none';
  chip.style.pointerEvents = 'none';  // read-only: clicks/pan pass through to live markers
  chip.textContent = (pl && pl.label) || '';
  return chip;
}
// =================== END OVERSIGHT TOOL (DVI-1100) ===================

// ===================== SCADA VIEW -> OVERSIGHT (DVI-1117) =====================
// Read-only floor-plan viewer scoped to the `scada` layer and one admin-chosen
// focused plan (SCADA Settings -> Oversight). The Oversight tool stays the sole
// placements editor; this reuses /oversight/config + /oversight/data (both
// server-side layer-gated) but keeps its own state so it never touches the live
// Oversight tool viewer.
var _scadaovCfg = null;               // last /oversight/config payload
var _scadaovPlan = null;              // resolved focused plan object
var _scadaovData = null;              // last /oversight/data payload
var _scadaovZoom = 1, _scadaovPanX = 0, _scadaovPanY = 0;
var _scadaovFull = false;
var _scadaovBound = false;

function scadaovInit() {
  if (!_scadaovBound) {
    _scadaovBound = true;
    scadaovBindStage();
    document.addEventListener('keydown', function(e) {
      if (e.key === 'Escape' && _activeTool === 'scada-view' && _scadaLastTab === 'oversight') {
        if (_scadaovFull) { scadaovExitFull(); return; }
        scadaovCloseDetail();
      }
    });
    window.addEventListener('resize', function() {
      if (_activeTool === 'scada-view' && _scadaLastTab === 'oversight' &&
          !document.getElementById('scadaov-viewer-wrap').classList.contains('hidden')) {
        _ovSizeStage('scadaov-stage', 'scadaov-viewer-wrap', _scadaovFull);
        scadaovApplyTransform();
      }
    });
  }
  if (!_scadaConfig || _scadaConfig.oversight === undefined) {
    scadaLoadConfig(scadaovLoad);
  } else {
    scadaovLoad();
  }
}

function scadaovConfiguredPlanId() {
  return (_scadaConfig && _scadaConfig.oversight && _scadaConfig.oversight.plan) || '';
}

function scadaovLoad() {
  var empty = document.getElementById('scadaov-empty');
  var wrap = document.getElementById('scadaov-viewer-wrap');
  var admin = document.getElementById('scadaov-empty-admin');
  if (admin) admin.classList.toggle('hidden', !_isToGenAdmin);
  var pid = scadaovConfiguredPlanId();
  if (!pid) { empty.classList.remove('hidden'); wrap.classList.add('hidden'); return; }
  fetch('/oversight/config').then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){
      if (!d) return;
      _scadaovCfg = d;
      _scadaovPlan = (d.plans || []).filter(function(p){ return p.id === pid; })[0] || null;
      if (!_scadaovPlan) { empty.classList.remove('hidden'); wrap.classList.add('hidden'); return; }
      empty.classList.add('hidden');
      var t = document.getElementById('scadaov-title');
      if (t) t.textContent = _scadaovPlan.name || 'Oversight';
      scadaovShowPlan();
    }).catch(function(){});
}

// theme-aware style resolution (mirrors oversightResolvedStyle, auto only)
function scadaovResolveStyle(plan) {
  if (!plan || !plan.images) return null;
  var imaged = Object.keys(plan.images);
  if (!imaged.length) return null;
  var isDark = document.documentElement.classList.contains('dark');
  var want = isDark ? _scadaovCfg.style_default_dark : _scadaovCfg.style_default_light;
  if (want && plan.images[want]) return want;
  var ordered = (_scadaovCfg.styles || []).map(function(s){ return s.id; })
    .filter(function(sid){ return plan.images[sid]; });
  return ordered.length ? ordered[0] : imaged[0];
}

function scadaovShowPlan() {
  var plan = _scadaovPlan;
  var wrap = document.getElementById('scadaov-viewer-wrap');
  var noimg = document.getElementById('scadaov-noimg');
  var stage = document.getElementById('scadaov-stage');
  wrap.classList.remove('hidden');
  var sid = scadaovResolveStyle(plan);
  if (!sid) {
    noimg.classList.remove('hidden');
    stage.classList.add('hidden');
    document.getElementById('scadaov-markers').innerHTML = '';
    return;
  }
  noimg.classList.add('hidden');
  stage.classList.remove('hidden');
  _ovSizeStage('scadaov-stage', 'scadaov-viewer-wrap', _scadaovFull);
  var img = document.getElementById('scadaov-plan-img');
  var dims = plan.images[sid] || {};
  var wantSrc = '/oversight/plan-image/' + encodeURIComponent(plan.id) +
    '?style=' + encodeURIComponent(sid) + '&v=' + (dims.w || 0) + 'x' + (dims.h || 0);
  if (img.getAttribute('data-src') !== wantSrc) {
    img.setAttribute('data-src', wantSrc);
    img.onload = function(){ _ovSizeStage('scadaov-stage', 'scadaov-viewer-wrap', _scadaovFull); scadaovZoomReset(); };
    img.src = wantSrc;
  } else {
    scadaovZoomReset();
  }
  fetch('/oversight/data?plan=' + encodeURIComponent(plan.id) + '&layers=scada')
    .then(function(r){ return r.ok ? r.json() : null; })
    .then(function(d){ if (!d) return; _scadaovData = d; scadaovRenderMarkers(); })
    .catch(function(){});
}

// re-resolve Auto style on theme flip while this tab is active
function scadaovThemeChanged() {
  if (_activeTool === 'scada-view' && _scadaLastTab === 'oversight' && _scadaovPlan) scadaovShowPlan();
}

function scadaovRenderMarkers() {
  var host = document.getElementById('scadaov-markers');
  var placements = oversightSortLocationFirst((_scadaovData && _scadaovData.placements) || []);
  host.innerHTML = '';
  placements.forEach(function(pl){
    var el = document.createElement('div');
    el.className = 'scadaov-marker';
    el.style.position = 'absolute';
    el.style.left = (pl.x * 100) + '%';
    el.style.top = (pl.y * 100) + '%';
    el.style.transform = 'translate(-50%,-50%) scale(' + (1 / _scadaovZoom) + ')';
    el.style.transformOrigin = 'center center';
    el.appendChild(oversightIsLocation(pl) ? oversightLocationChip(pl) : scadaovMarkerChip(pl));
    host.appendChild(el);
  });
}

function scadaovMarkerColor(pl, d) {
  if (!pl.known || !d) return 'bg-gray-500 text-white';
  if (d.stale) return 'bg-amber-500 text-white';
  var v = parseFloat(d.value);
  if (!isNaN(v)) {
    if (d.low != null && v < d.low) return 'bg-blue-600 text-white';
    if (d.high != null && v > d.high) return 'bg-red-600 text-white';
    return 'bg-emerald-600 text-white';
  }
  return 'bg-slate-600 text-white';
}

function scadaovMarkerChip(pl) {
  var d = pl.data;
  var chip = document.createElement('div');
  chip.className = 'relative inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold shadow-md border border-white/40 whitespace-nowrap ' + scadaovMarkerColor(pl, d);
  chip.style.cursor = 'pointer';
  var txt;
  if (!pl.known) {
    txt = oversightEsc(pl.label || pl.ref) + ' · ?';
  } else if (d) {
    var unit = d.unit || '';
    txt = oversightEsc(String(d.value)) + (unit ? ' ' + oversightEsc(unit) : '');
  } else {
    txt = oversightEsc(pl.label || pl.ref) + ' · —';
  }
  chip.innerHTML = '<span>' + txt + '</span>' +
    (d && d.stale ? '<span class="text-[9px] font-bold uppercase bg-black/20 rounded px-1">stale</span>' : '') +
    (!pl.known ? '<span class="text-[9px] font-bold uppercase bg-black/20 rounded px-1">unknown</span>' : '');
  chip.onclick = function(e){ scadaovShowDetail(pl, e); };
  return chip;
}

function scadaovShowDetail(pl, evt) {
  var d = pl.data;
  var box = document.getElementById('scadaov-detail');
  var rows = '';
  function row(k, v){ return '<div class="flex justify-between gap-3"><span class="text-gray-500 dark:text-gray-400">' + k + '</span><span class="font-medium text-gray-800 dark:text-gray-100 text-right">' + v + '</span></div>'; }
  var title = oversightEsc((d && d.label) || pl.label || pl.ref);
  if (!pl.known) {
    rows = '<p class="text-xs text-amber-600 dark:text-amber-400">This location references a sensor that is no longer reporting.</p>';
  } else if (d) {
    var unit = d.unit ? ' ' + oversightEsc(d.unit) : '';
    rows += row('Value', '<span class="text-base">' + oversightEsc(String(d.value)) + unit + '</span>');
    if (d.type) rows += row('Type', oversightEsc(d.type));
    if (d.location) rows += row('Location', oversightEsc(d.location));
    if (d.low != null || d.high != null) rows += row('Range', (d.low != null ? d.low : '—') + ' – ' + (d.high != null ? d.high : '—'));
    if (d.ts) rows += row('Reading', oversightEsc(d.ts));
    if (d.stale) rows += '<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">Reading is stale.</p>';
  } else {
    rows = '<p class="text-xs text-gray-500">No current data.</p>';
  }
  var link = '<button onclick="scadaSwitchTab(\'sensors\')" class="mt-2 w-full px-2 py-1 text-xs rounded-lg bg-togen-teal/10 text-togen-teal border border-togen-teal/30 hover:bg-togen-teal/20 cursor-pointer">Open in Sensors</button>';
  box.innerHTML = '<div class="flex items-center justify-between mb-2"><span class="font-semibold text-gray-900 dark:text-gray-100">' + title + '</span>' +
    '<button onclick="scadaovCloseDetail()" class="text-gray-400 hover:text-gray-600 cursor-pointer text-lg leading-none">&times;</button></div>' +
    '<div class="space-y-1">' + rows + '</div>' + link;
  box.classList.remove('hidden');
  var x = Math.min(evt.clientX + 12, window.innerWidth - 270);
  var y = Math.min(evt.clientY + 12, window.innerHeight - 200);
  box.style.left = Math.max(8, x) + 'px';
  box.style.top = Math.max(8, y) + 'px';
}

function scadaovCloseDetail() {
  var box = document.getElementById('scadaov-detail');
  if (box) box.classList.add('hidden');
}

// ---- pan / zoom (mirrors the Oversight tool viewer, read-only) ----
function scadaovApplyTransform() {
  var pan = document.getElementById('scadaov-pan');
  pan.style.transform = 'translate(' + _scadaovPanX + 'px,' + _scadaovPanY + 'px) scale(' + _scadaovZoom + ')';
  var inv = 1 / _scadaovZoom;
  var ms = document.querySelectorAll('#scadaov-markers .scadaov-marker');
  for (var i = 0; i < ms.length; i++) ms[i].style.transform = 'translate(-50%,-50%) scale(' + inv + ')';
}

function scadaovZoomReset() {
  var stage = document.getElementById('scadaov-stage');
  var img = document.getElementById('scadaov-plan-img');
  var iw = img.naturalWidth || img.width, ih = img.naturalHeight || img.height;
  if (!iw || !ih) { _scadaovZoom = 1; _scadaovPanX = 0; _scadaovPanY = 0; scadaovApplyTransform(); return; }
  var sw = stage.clientWidth, sh = stage.clientHeight;
  _scadaovZoom = Math.min((sw - 8) / iw, (sh - 8) / ih);
  if (!(_scadaovZoom > 0)) _scadaovZoom = Math.min(sw / iw, sh / ih);
  _scadaovPanX = (sw - iw * _scadaovZoom) / 2;
  _scadaovPanY = (sh - ih * _scadaovZoom) / 2;
  scadaovApplyTransform();
}

function scadaovZoomBy(f) {
  var stage = document.getElementById('scadaov-stage');
  scadaovZoomAt(stage.clientWidth / 2, stage.clientHeight / 2, f);
}

function scadaovZoomAt(sx, sy, f) {
  var z2 = Math.max(0.05, Math.min(20, _scadaovZoom * f));
  var k = z2 / _scadaovZoom;
  _scadaovPanX = sx - (sx - _scadaovPanX) * k;
  _scadaovPanY = sy - (sy - _scadaovPanY) * k;
  _scadaovZoom = z2;
  scadaovApplyTransform();
}

function scadaovBindStage() {
  var stage = document.getElementById('scadaov-stage');
  if (!stage) return;
  var panning = false, lastX = 0, lastY = 0;
  stage.addEventListener('pointerdown', function(e){
    if (e.target.closest('.scadaov-marker')) return;
    panning = true; lastX = e.clientX; lastY = e.clientY;
    stage.setPointerCapture(e.pointerId);
    scadaovCloseDetail();
  });
  stage.addEventListener('pointermove', function(e){
    if (!panning) return;
    _scadaovPanX += (e.clientX - lastX);
    _scadaovPanY += (e.clientY - lastY);
    lastX = e.clientX; lastY = e.clientY;
    scadaovApplyTransform();
  });
  function endPan(e){ if (panning) { panning = false; try { stage.releasePointerCapture(e.pointerId); } catch(_){} } }
  stage.addEventListener('pointerup', endPan);
  stage.addEventListener('pointercancel', endPan);
  stage.addEventListener('wheel', function(e){
    e.preventDefault();
    var rect = stage.getBoundingClientRect();
    scadaovZoomAt(e.clientX - rect.left, e.clientY - rect.top, e.deltaY < 0 ? 1.1 : 0.9);
  }, { passive: false });
}

function scadaovToggleFull() {
  _scadaovFull = !_scadaovFull;
  _ovApplyFull(_scadaovFull, 'scadaov-viewer-wrap');
  document.getElementById('scadaov-header').classList.toggle('hidden', _scadaovFull);
  document.getElementById('scada-tabbar').classList.toggle('hidden', _scadaovFull);
  document.getElementById('scadaov-full-btn').title = _scadaovFull ? 'Exit full view' : 'Full view';
  _ovSetFullIcon('scadaov-full-icon', _scadaovFull);
  _ovSizeStage('scadaov-stage', 'scadaov-viewer-wrap', _scadaovFull);
  scadaovZoomReset();
}

function scadaovExitFull() {
  if (_scadaovFull) scadaovToggleFull();
}

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 === 'oversight') {
    scadaovInit();
  } 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 =====
// Report type registry (DVI-1095 F3): the picker drives which engine builds
// the report. Types without criteria hide the Report Criteria editor.
var _scadaReportTypes = {};
var _scadaReportTypesLoaded = false;
var _scadaLastReportData = null;  // last on-demand report (DVI-1163 viewer source)

function scadaLoadReportTypes(cb) {
  fetch('/scada/report/types')
    .then(function(r) { return r.json(); })
    .then(function(data) {
      _scadaReportTypes = {};
      var sel = document.getElementById('scada-report-type');
      sel.innerHTML = '';
      (data.types || []).forEach(function(t) {
        _scadaReportTypes[t.id] = t;
        var opt = document.createElement('option');
        opt.value = t.id;
        opt.textContent = t.name;
        if (t.description) opt.title = t.description;
        sel.appendChild(opt);
      });
      if (data.default && _scadaReportTypes[data.default]) sel.value = data.default;
      _scadaReportTypesLoaded = true;
      scadaApplyReportTypeUI();
      if (cb) cb();
    })
    .catch(function() {
      // Don't loop on failure; leave the criteria editor visible (kiln default).
      _scadaReportTypesLoaded = true;
      if (cb) cb();
    });
}

function scadaCurrentReportType() {
  var sel = document.getElementById('scada-report-type');
  return (sel && sel.value) || '';
}

function scadaApplyReportTypeUI() {
  var t = _scadaReportTypes[scadaCurrentReportType()];
  var block = document.getElementById('scada-report-criteria-block');
  if (block) block.classList.toggle('hidden', !!t && t.has_criteria === false);
}

function scadaReportTypeChanged() {
  scadaApplyReportTypeUI();
  scadaLoadReport();
}

function scadaReportQuery() {
  var params = new URLSearchParams();
  var rt = scadaCurrentReportType();
  if (rt) params.set('report_type', rt);
  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);
  });
}

// ===== REPORT CRITERIA (DVI-1095 P3) =====
var _scadaCriteriaLoaded = false;
var SCADA_CRITERIA_KEYS = ['lowest_overall', 'lowest_sustained', 'highest_sustained', 'highest_overall'];

// Change-over-time rate rules (DVI-1148). Rendered as editable rows; state
// lives in the DOM so add/remove/reorder needs no shadow array. _scadaRateSeq
// only mints unique ids for freshly-added rules.
var _scadaRateSeq = 0;

function scadaRateRuleRow(rule) {
  rule = rule || {};
  var rid = rule.id || ('roc' + (++_scadaRateSeq));
  var md = rule.max_delta != null ? rule.max_delta : 45;
  var wm = rule.window_minutes != null ? rule.window_minutes : 60;
  var en = rule.enabled !== false;
  var nt = rule.notify === true;
  var row = document.createElement('div');
  row.className = 'scada-rate-rule flex flex-wrap items-end gap-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/60 px-3 py-2';
  row.setAttribute('data-rule-id', rid);
  row.innerHTML =
    '<div><label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Max change (&deg;F)</label>' +
    '<input type="number" step="any" min="0" class="scada-rate-delta w-24 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" value="' + esc(String(md)) + '"></div>' +
    '<div><label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Within (min)</label>' +
    '<input type="number" min="1" class="scada-rate-window w-24 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" value="' + esc(String(wm)) + '"></div>' +
    '<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer pb-1.5"><input type="checkbox" class="scada-rate-enabled rounded"' + (en ? ' checked' : '') + '><span>In report</span></label>' +
    '<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer pb-1.5"><input type="checkbox" class="scada-rate-notify rounded"' + (nt ? ' checked' : '') + '><span>Notify</span></label>' +
    '<button type="button" onclick="this.closest(\'.scada-rate-rule\').remove()" class="ml-auto pb-1.5 text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 cursor-pointer">Remove</button>';
  return row;
}

function scadaRenderRateRules(rules) {
  var host = document.getElementById('scada-crit-rate-rules');
  if (!host) return;
  host.innerHTML = '';
  (rules || []).forEach(function(rule) { host.appendChild(scadaRateRuleRow(rule)); });
}

function scadaAddRateRule() {
  var host = document.getElementById('scada-crit-rate-rules');
  if (host) host.appendChild(scadaRateRuleRow(null));
}

function scadaCollectRateRules() {
  var rows = document.querySelectorAll('#scada-crit-rate-rules .scada-rate-rule');
  var out = [];
  rows.forEach(function(row) {
    var md = parseFloat(row.querySelector('.scada-rate-delta').value);
    var wm = parseInt(row.querySelector('.scada-rate-window').value, 10);
    if (!isFinite(md) || md <= 0 || !isFinite(wm) || wm <= 0) return;
    out.push({
      id: row.getAttribute('data-rule-id') || '',
      max_delta: md,
      window_minutes: wm,
      enabled: row.querySelector('.scada-rate-enabled').checked,
      notify: row.querySelector('.scada-rate-notify').checked
    });
  });
  return out;
}

function scadaFillCriteriaForm(crit) {
  crit = crit || {};
  document.getElementById('scada-crit-low').value = crit.low_limit != null ? crit.low_limit : 45;
  document.getElementById('scada-crit-high').value = crit.high_limit != null ? crit.high_limit : 160;
  document.getElementById('scada-crit-sustain').value = crit.sustain_minutes != null ? crit.sustain_minutes : 60;
  var enabled = crit.enabled || {};
  SCADA_CRITERIA_KEYS.forEach(function(k) {
    document.getElementById('scada-crit-' + k).checked = enabled[k] !== false;
  });
  scadaRenderRateRules(crit.rate_rules || []);
}

function scadaLoadCriteria() {
  if (_scadaCriteriaLoaded) return;
  _scadaCriteriaLoaded = true;
  fetch('/scada/config')
    .then(function(r) { return r.json(); })
    .then(function(data) { scadaFillCriteriaForm((data.config || {}).report_criteria); })
    .catch(function() { scadaFillCriteriaForm(null); });
}

function scadaSaveCriteria() {
  var enabled = {};
  SCADA_CRITERIA_KEYS.forEach(function(k) {
    enabled[k] = document.getElementById('scada-crit-' + k).checked;
  });
  var body = { report_criteria: {
    low_limit: parseFloat(document.getElementById('scada-crit-low').value),
    high_limit: parseFloat(document.getElementById('scada-crit-high').value),
    sustain_minutes: parseInt(document.getElementById('scada-crit-sustain').value, 10),
    enabled: enabled,
    rate_rules: scadaCollectRateRules()
  } };
  var msg = document.getElementById('scada-criteria-msg');
  msg.textContent = 'Saving...';
  msg.className = 'text-xs ml-auto text-gray-400';
  fetch('/scada/config', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  })
    .then(function(r) {
      if (!r.ok) throw new Error('HTTP ' + r.status);
      msg.textContent = 'Saved';
      msg.className = 'text-xs ml-auto text-green-600 dark:text-green-400';
      setTimeout(function() { msg.textContent = ''; }, 2500);
      if (_scadaReportLoaded) scadaLoadReport();
    })
    .catch(function(err) {
      msg.textContent = 'Save failed: ' + err.message;
      msg.className = 'text-xs ml-auto text-red-600 dark:text-red-400';
    });
}

// Criteria summary card HTML (shared by the on-demand Reporting pane and the
// DVI-1163 read-only viewer). Returns '' when there is nothing to show.
function scadaCriteriaSummaryHtml(criteria) {
  if (!criteria || !criteria.sensors || !criteria.sensors.length) return '';
  var overall = criteria.pass;
  var badge = overall
    ? '<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">PASS</span>'
    : '<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300">FAIL</span>';
  var rows = '';
  criteria.sensors.forEach(function(s) {
    s.checks.forEach(function(c) {
      var res = c.pass === false
        ? '<span class="font-semibold text-red-600 dark:text-red-400">FAIL</span>'
        : (c.pass == null
          ? '<span class="font-semibold text-gray-400 dark:text-gray-500">N/A</span>'
          : '<span class="font-semibold text-green-600 dark:text-green-400">PASS</span>');
      rows += '<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
        '<td class="py-1 pr-4 whitespace-nowrap text-gray-700 dark:text-gray-300">' + esc(s.sensor) + '</td>' +
        '<td class="py-1 pr-4 text-gray-700 dark:text-gray-300">' + esc(c.label) + '</td>' +
        '<td class="py-1 pr-4 whitespace-nowrap text-gray-700 dark:text-gray-300">' + esc(c.observed == null ? '—' : String(c.observed)) + '</td>' +
        '<td class="py-1 pr-4 whitespace-nowrap text-gray-500 dark:text-gray-400">' + esc(c.when || '') + '</td>' +
        '<td class="py-1">' + res + '</td></tr>';
    });
  });
  return '<div class="togen-inset rounded-xl border border-gray-200 dark:border-gray-700 p-4">' +
    '<div class="flex items-center gap-2 mb-2">' +
    '<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Criteria Summary</h4>' + badge +
    '</div><div class="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-1.5 pr-4 font-semibold text-gray-600 dark:text-gray-300">Sensor</th>' +
    '<th class="pb-1.5 pr-4 font-semibold text-gray-600 dark:text-gray-300">Criterion</th>' +
    '<th class="pb-1.5 pr-4 font-semibold text-gray-600 dark:text-gray-300">Observed</th>' +
    '<th class="pb-1.5 pr-4 font-semibold text-gray-600 dark:text-gray-300">When</th>' +
    '<th class="pb-1.5 font-semibold text-gray-600 dark:text-gray-300">Result</th>' +
    '</tr></thead><tbody class="divide-y divide-gray-100 dark:divide-gray-800">' + rows +
    '</tbody></table></div></div>';
}

function scadaRenderCriteriaSummary(criteria) {
  var box = document.getElementById('scada-criteria-summary');
  var html = scadaCriteriaSummaryHtml(criteria);
  box.innerHTML = html;
  box.classList.toggle('hidden', !html);
}

// One day's log table as a DOM node (shared by the Reporting grid + viewer).
function scadaBuildDayTable(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>';
  return wrap;
}

function scadaLoadReport() {
  if (!_scadaReportTypesLoaded) { scadaLoadReportTypes(scadaLoadReport); return; }
  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');
  scadaLoadCriteria();
  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;
      _scadaLastReportData = data;
      _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 : '';
      scadaRenderCriteriaSummary(data.criteria);
      var viewBtn = document.getElementById('scada-report-view-btn');
      var hasDays = !!(data.days && data.days.length);
      viewBtn.classList.toggle('hidden', !hasDays);
      if (!hasDays) {
        empty.classList.remove('hidden');
        return;
      }
      grid.innerHTML = '';
      data.days.forEach(function(day) { grid.appendChild(scadaBuildDayTable(day)); });
      grid.classList.remove('hidden');
    })
    .catch(function(err) {
      loading.classList.add('hidden');
      _scadaLastReportData = null;
      document.getElementById('scada-report-view-btn').classList.add('hidden');
      scadaRenderCriteriaSummary(null);
      error.classList.remove('hidden');
      document.getElementById('scada-report-error-msg').textContent = 'Failed to load report: ' + err.message;
    });
}

// ===== SCADA REPORT VIEWER (read-only) — DVI-1163 =====
// Renders a normalized view-model {meta, summary, days}. Two entry points feed
// the same renderer: the on-demand path builds a view-model from the loaded
// report (no server round-trip); persisted reports fetch /scada/report-view.
var _scadaViewerName = null;

function scadaViewCurrentReport() {
  var d = _scadaLastReportData;
  if (!d || !d.days || !d.days.length) return;
  var rt = _scadaReportTypes[d.report_type];
  var vm = {
    meta: {
      title: (rt && rt.name) || 'Kiln Metrics Report',
      date_from: d.days[0].date,
      date_to: d.days[d.days.length - 1].date,
      row_count: d.row_count,
      source: 'on-demand'
    },
    summary: d.criteria,
    days: d.days
  };
  scadaOpenReportViewer(vm, null);
}

function scadaViewPersistedReport(name) {
  _scadaViewerName = name || null;
  document.getElementById('scada-report-viewer-title').textContent = name || 'Kiln Metrics Report';
  document.getElementById('scada-report-viewer-meta').innerHTML = '';
  document.getElementById('scada-report-viewer-summary').innerHTML = '';
  document.getElementById('scada-report-viewer-body').innerHTML = '';
  document.getElementById('scada-report-viewer-error').classList.add('hidden');
  document.getElementById('scada-report-viewer-download').classList.add('hidden');
  document.getElementById('scada-report-viewer-loading').classList.remove('hidden');
  document.getElementById('scada-report-viewer-overlay').classList.remove('hidden');
  fetch('/scada/report-view?name=' + encodeURIComponent(name))
    .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) {
      document.getElementById('scada-report-viewer-loading').classList.add('hidden');
      scadaRenderReportViewer(data.view_model || {}, name);
    })
    .catch(function(err) {
      document.getElementById('scada-report-viewer-loading').classList.add('hidden');
      var e = document.getElementById('scada-report-viewer-error');
      document.getElementById('scada-report-viewer-error-msg').textContent = err.message;
      e.classList.remove('hidden');
    });
}

function scadaOpenReportViewer(vm, name) {
  document.getElementById('scada-report-viewer-loading').classList.add('hidden');
  document.getElementById('scada-report-viewer-error').classList.add('hidden');
  scadaRenderReportViewer(vm, name);
  document.getElementById('scada-report-viewer-overlay').classList.remove('hidden');
}

function scadaCloseReportViewer() {
  document.getElementById('scada-report-viewer-overlay').classList.add('hidden');
  _scadaViewerName = null;
}

function scadaRenderReportViewer(vm, name) {
  _scadaViewerName = name || null;
  var meta = (vm && vm.meta) || {};
  document.getElementById('scada-report-viewer-title').textContent =
    meta.title || meta.schedule_name || meta.report_file || name || 'Kiln Metrics Report';
  var bits = [];
  if (meta.date_from || meta.date_to) bits.push(esc((meta.date_from || '?') + ' – ' + (meta.date_to || '?')));
  if (meta.row_count != null) bits.push(esc(meta.row_count + ' rows'));
  if (meta.generated_at) bits.push('Generated ' + esc(String(meta.generated_at).replace('T', ' ').slice(0, 16)));
  if (meta.source) bits.push(esc(meta.source));
  document.getElementById('scada-report-viewer-meta').innerHTML = bits.join(' &middot; ');
  var dl = document.getElementById('scada-report-viewer-download');
  if (_scadaViewerName) {
    dl.href = '/scada/reports/download?name=' + encodeURIComponent(_scadaViewerName);
    dl.classList.remove('hidden');
  } else {
    dl.classList.add('hidden');
  }
  var sumEl = document.getElementById('scada-report-viewer-summary');
  sumEl.innerHTML = scadaCriteriaSummaryHtml(vm && vm.summary);
  var body = document.getElementById('scada-report-viewer-body');
  body.innerHTML = '';
  var days = (vm && vm.days) || [];
  if (!days.length) {
    body.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 py-6 text-center">No log data in this report.</p>';
    return;
  }
  days.forEach(function(day) { body.appendChild(scadaBuildDayTable(day)); });
}

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 rt = scadaCurrentReportType();
  if (rt) params.set('report_type', rt);
  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 ghost = 'px-2 py-1 text-xs font-medium rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors cursor-pointer mr-2';
    var testBtn = document.createElement('button');
    testBtn.className = 'px-2 py-1 text-xs font-medium rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-900/40 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors cursor-pointer mr-2';
    testBtn.textContent = 'Test';
    testBtn.title = 'Generate this report now (no email) and open it in the viewer';
    testBtn.onclick = function() { scadaSchedRunNow(s.id, testBtn); };
    var viewBtn = document.createElement('button');
    viewBtn.className = ghost;
    viewBtn.textContent = 'View';
    viewBtn.title = 'Open the most recent generated report in the viewer';
    viewBtn.onclick = function() { scadaSchedViewLatest(s.id, viewBtn); };
    var histBtn = document.createElement('button');
    histBtn.className = ghost;
    histBtn.textContent = 'History';
    histBtn.onclick = function() { scadaSchedOpenHistory(s.id); };
    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(testBtn);
    actions.appendChild(viewBtn);
    actions.appendChild(histBtn);
    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;
    });
}

// ===== SCADA REPORT SCHEDULE Test / View / History (DVI-1164) =====
var SCADA_REPORT_STATUS_LABELS = {
  sent: 'Emailed (internal)',
  manual_no_email: 'Test run (no email)',
  dry_run: 'Dry run',
  skipped_notify_off: 'No email (notifications off)',
  skipped_no_recipients: 'No email (no recipients)',
  skipped_oversized: 'No email (attachment too large)',
  send_failed: 'Email send failed',
  pending_approval: 'External held for approval',
  'sent+pending_approval': 'Emailed internal; external held for approval',
  pending_approval_no_approver: 'External held — no approver configured',
  'sent+pending_approval_no_approver': 'Emailed internal; external held — no approver configured'
};

function scadaReportStatusLabel(s) {
  return SCADA_REPORT_STATUS_LABELS[s] || s || '—';
}

function scadaFmtDateTime(iso) {
  if (!iso) return '';
  return _fmtDateTime(iso) || iso;  // DVI-1364 P2: regional format + timezone
}

// Test (run now): generate + persist + record the report now with NO email
// (approved plan D3), then open the fresh report in the P1 viewer.
function scadaSchedRunNow(id, btn) {
  var orig = btn ? btn.textContent : '';
  if (btn) { btn.disabled = true; btn.textContent = 'Running…'; }
  fetch('/scada/report-schedules/' + encodeURIComponent(id) + '/run-now', { method: 'POST' })
    .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) { scadaSchedPollRunNow(d.job_id, btn, orig); })
    .catch(function(err) {
      if (btn) { btn.disabled = false; btn.textContent = orig; }
      alert('Test run failed: ' + err.message);
    });
}

function scadaSchedPollRunNow(jobId, btn, orig) {
  fetch('/scada/report-schedules/run-now/' + encodeURIComponent(jobId))
    .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) {
      var job = d.job || {};
      if (job.status === 'running') { setTimeout(function() { scadaSchedPollRunNow(jobId, btn, orig); }, 1500); return; }
      if (btn) { btn.disabled = false; btn.textContent = orig; }
      if (job.status === 'done' && job.report_file) {
        scadaViewPersistedReport(job.report_file);
      } else if (job.status === 'done') {
        alert('Report generated but produced no file (no log data in the window).');
      } else {
        alert('Test run failed: ' + (job.error || 'Unknown error'));
      }
    })
    .catch(function(err) {
      if (btn) { btn.disabled = false; btn.textContent = orig; }
      alert('Test run status check failed: ' + err.message);
    });
}

// View: open the newest persisted report for this schedule in the viewer.
function scadaSchedViewLatest(id, btn) {
  var orig = btn ? btn.textContent : '';
  if (btn) { btn.disabled = true; btn.textContent = '…'; }
  fetch('/scada/report-runs?schedule_id=' + encodeURIComponent(id))
    .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) {
      if (btn) { btn.disabled = false; btn.textContent = orig; }
      var withFile = (d.runs || []).filter(function(r) { return r.report_file; });
      if (!withFile.length) { alert('No report has been generated for this schedule yet. Use Test to generate one now.'); return; }
      scadaViewPersistedReport(withFile[0].report_file);
    })
    .catch(function(err) {
      if (btn) { btn.disabled = false; btn.textContent = orig; }
      alert('Could not load latest report: ' + err.message);
    });
}

// History: list this schedule's runs (from scada_report_log.json) with a
// per-row View link.
function scadaSchedOpenHistory(id) {
  var sched = _scadaSchedules.filter(function(s) { return s.id === id; })[0];
  document.getElementById('scada-sched-hist-name').textContent = sched ? sched.name : '';
  document.getElementById('scada-sched-hist-overlay').classList.remove('hidden');
  var loading = document.getElementById('scada-sched-hist-loading');
  var empty = document.getElementById('scada-sched-hist-empty');
  var error = document.getElementById('scada-sched-hist-error');
  var wrap = document.getElementById('scada-sched-hist-wrap');
  loading.classList.remove('hidden'); empty.classList.add('hidden');
  error.classList.add('hidden'); wrap.classList.add('hidden');
  fetch('/scada/report-runs?schedule_id=' + encodeURIComponent(id))
    .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) {
      loading.classList.add('hidden');
      var runs = d.runs || [];
      if (!runs.length) { empty.classList.remove('hidden'); return; }
      wrap.classList.remove('hidden');
      var tbody = document.getElementById('scada-sched-hist-tbody');
      tbody.innerHTML = '';
      runs.forEach(function(run) {
        var tr = document.createElement('tr');
        tr.className = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
        var rng = (run.date_from || '?') + ' – ' + (run.date_to || '?');
        var srcBadge = run.source === 'manual'
          ? '<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">Test</span>'
          : '<span class="text-xs font-medium px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">Scheduled</span>';
        tr.innerHTML =
          '<td class="py-2 pr-4 text-gray-700 dark:text-gray-300 whitespace-nowrap text-xs">' + esc(scadaFmtDateTime(run.recorded_at)) + '</td>' +
          '<td class="py-2 pr-4 text-gray-700 dark:text-gray-300 whitespace-nowrap">' + esc(rng) + '</td>' +
          '<td class="py-2 pr-4 text-right text-gray-700 dark:text-gray-300">' + (run.row_count || 0) + '</td>' +
          '<td class="py-2 pr-4">' + srcBadge + '</td>' +
          '<td class="py-2 pr-4 text-gray-700 dark:text-gray-300 text-xs">' + esc(scadaReportStatusLabel(run.email_status)) + '</td>' +
          '<td class="py-2 pr-4 text-right whitespace-nowrap"></td>';
        var cell = tr.lastChild;
        if (run.report_file) {
          var vb = document.createElement('button');
          vb.className = 'text-xs font-medium text-togen-teal hover:underline cursor-pointer';
          vb.textContent = 'View';
          vb.onclick = (function(fn) { return function() { scadaCloseSchedHistory(); scadaViewPersistedReport(fn); }; })(run.report_file);
          cell.appendChild(vb);
        } else {
          cell.innerHTML = '<span class="text-xs text-gray-400">—</span>';
        }
        tbody.appendChild(tr);
      });
    })
    .catch(function(err) {
      loading.classList.add('hidden');
      error.classList.remove('hidden');
      error.textContent = 'Failed to load history: ' + err.message;
    });
}

function scadaCloseSchedHistory() {
  document.getElementById('scada-sched-hist-overlay').classList.add('hidden');
}

// ===== 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');

  var lbOvl = document.getElementById('wor-audit-lightbox');
  var lbOpen = lbOvl && !lbOvl.classList.contains('hidden');
  if (lbOpen) {
    if (e.key === 'Escape') { worAuditLightboxClose(); return; }
    if (e.key === 'ArrowLeft') { e.preventDefault(); worAuditLightboxNav(-1); return; }
    if (e.key === 'ArrowRight') { e.preventDefault(); worAuditLightboxNav(1); return; }
  }

  // DVI-1076: WO Admin photo lightbox keyboard navigation.
  var waLbOvl = document.getElementById('woradmin-photos-lightbox');
  var waLbOpen = waLbOvl && !waLbOvl.classList.contains('hidden');
  if (waLbOpen) {
    if (e.key === 'Escape') { woradminPhotoLightboxClose(); return; }
    if (e.key === 'ArrowLeft') { e.preventDefault(); woradminPhotoLightboxNav(-1); return; }
    if (e.key === 'ArrowRight') { e.preventDefault(); woradminPhotoLightboxNav(1); return; }
  }

  // DVI-1185: Assets photo lightbox keyboard navigation.
  var astLbOvl = document.getElementById('ast-photos-lightbox');
  var astLbOpen = astLbOvl && !astLbOvl.classList.contains('hidden');
  if (astLbOpen) {
    if (e.key === 'Escape') { assetsPhotoLightboxClose(); return; }
    if (e.key === 'ArrowLeft') { e.preventDefault(); assetsPhotoLightboxNav(-1); return; }
    if (e.key === 'ArrowRight') { e.preventDefault(); assetsPhotoLightboxNav(1); return; }
  }

  // DVI-1087 Phase 2: Files browser image lightbox keyboard navigation.
  var fbLbOvl = document.getElementById('fb-lightbox');
  var fbLbOpen = fbLbOvl && !fbLbOvl.classList.contains('hidden');
  if (fbLbOpen) {
    if (e.key === 'Escape') { fbLightboxClose(); return; }
    if (e.key === 'ArrowLeft') { e.preventDefault(); fbLightboxNav(-1); return; }
    if (e.key === 'ArrowRight') { e.preventDefault(); fbLightboxNav(1); return; }
  }

  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();

// DVI-968: overlay scroll indicators on horizontally-scrollable tab bars.
// Selects every tab bar (identified by the shared hidden-scrollbar utility
// class) and shows clickable left/right chevrons whenever tabs overflow off an
// edge. Uses ResizeObserver so tab bars inside hidden tool panels are measured
// once they become visible.
(function() {
  var SEL = 'div[class*="scrollbar-width:none"]';
  var CHEV = '<span class="flex items-center justify-center w-6 h-6 rounded-full bg-white dark:bg-gray-700 shadow ring-1 ring-gray-200 dark:ring-gray-600 text-gray-600 dark:text-gray-200">%SVG%</span>';
  var SVG_L = '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4"><path d="M12.5 4l-6 6 6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
  var SVG_R = '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4"><path d="M7.5 4l6 6-6 6" stroke-linecap="round" stroke-linejoin="round"/></svg>';

  function makeBtn(side, svg) {
    var b = document.createElement('button');
    b.type = 'button';
    b.setAttribute('aria-label', side === 'left' ? 'Scroll tabs left' : 'Scroll tabs right');
    b.className = 'absolute top-0 bottom-0 ' + (side === 'left' ? 'left-0' : 'right-0') +
      ' z-10 hidden items-center justify-center px-1 cursor-pointer';
    b.innerHTML = CHEV.replace('%SVG%', svg);
    return b;
  }

  function update(bar, left, right) {
    var max = bar.scrollWidth - bar.clientWidth;
    var canScroll = max > 2;
    left.style.display = (canScroll && bar.scrollLeft > 2) ? 'flex' : 'none';
    right.style.display = (canScroll && bar.scrollLeft < max - 2) ? 'flex' : 'none';
  }

  function enhance(bar) {
    if (bar.dataset.scrollIndicators) return;
    bar.dataset.scrollIndicators = '1';

    var wrap = document.createElement('div');
    wrap.className = 'relative';
    // Move bottom-margin utility to the wrapper so the overlay buttons align to
    // the tab row rather than the margin gap below it.
    Array.prototype.slice.call(bar.classList).forEach(function(c) {
      if (c.indexOf('mb-') === 0) { bar.classList.remove(c); wrap.classList.add(c); }
    });
    bar.parentNode.insertBefore(wrap, bar);
    wrap.appendChild(bar);

    var left = makeBtn('left', SVG_L);
    var right = makeBtn('right', SVG_R);
    wrap.appendChild(left);
    wrap.appendChild(right);

    function step(dir) {
      bar.scrollBy({ left: dir * Math.max(bar.clientWidth * 0.8, 120), behavior: 'smooth' });
    }
    left.addEventListener('click', function() { step(-1); });
    right.addEventListener('click', function() { step(1); });

    var upd = function() { update(bar, left, right); };
    bar.addEventListener('scroll', upd, { passive: true });
    if (window.ResizeObserver) {
      new ResizeObserver(upd).observe(bar);
    } else {
      window.addEventListener('resize', upd);
    }
    upd();
  }

  window.initScrollTabIndicators = function() {
    document.querySelectorAll(SEL).forEach(enhance);
  };
  window.initScrollTabIndicators();
})();

// 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();
  }
  // SCADA report email "View report online" link (DVI-1165): open the SCADA
  // tool and the persisted-report viewer for the named XLSX.
  var scadaReport = params.get('open_scada_report');
  if (scadaReport) {
    window.history.replaceState({}, '', window.location.pathname);
    if (typeof openTool === 'function') openTool('scada-view', 'Kiln Metrics');
    if (typeof scadaViewPersistedReport === 'function') {
      setTimeout(function() { scadaViewPersistedReport(scadaReport); }, 50);
    }
  }
  // Scheduled Report Builder email "View report online" link (DVI-1258 P5):
  // open Auditor → Reporting → Report Viewer for the saved run id.
  var openReportRun = params.get('open_report_run');
  if (openReportRun) {
    window.history.replaceState({}, '', window.location.pathname);
    if (typeof openTool === 'function') openTool('auditor', 'Auditor');
    setTimeout(function() {
      if (typeof auditorSwitchTopTab === 'function') auditorSwitchTopTab('reporting');
      if (typeof reportingSwitchMode === 'function') reportingSwitchMode('view');
      if (typeof reportViewerOpenRun === 'function') reportViewerOpenRun(openReportRun);
    }, 80);
  }
  // Work-order deep link (DVI-1174 P4/P5): open WO Admin focused on a WO number.
  // Sent in approver + WO-Admin notification emails ("View & approve" / "Open").
  var openWo = params.get('open_wo');
  if (openWo) {
    window.history.replaceState({}, '', window.location.pathname);
    if (typeof openTool === 'function') openTool('wor-admin', 'Work Order Admin');
    if (typeof woradminFocusWo === 'function') {
      setTimeout(function() { woradminFocusWo(openWo); }, 60);
    }
  }
  // Part label QR deep link (DVI-1478 P6): open Assets -> Parts & inventory
  // focused on the scanned part. Routes are assets.inventory-gated; a viewer
  // without the grant lands on the parts overlay and the fetch degrades.
  var openPart = params.get('open_part');
  if (openPart) {
    window.history.replaceState({}, '', window.location.pathname);
    if (typeof openTool === 'function') openTool('equipment-manuals', 'Equipment');
    setTimeout(function() {
      if (typeof emSwitchTab === 'function') emSwitchTab('assets');
      setTimeout(function() {
        if (typeof assetsPartsOpen === 'function') assetsPartsOpen();
        if (typeof assetsPartSelect === 'function') assetsPartSelect(openPart);
      }, 140);
    }, 80);
  }
  // Sub-tool deep link (DVI-1496 P5, rec 2): ?open_subtool=<surface>:<catalog id>
  // (e.g. equipment:rpm) opens the surface's owning tool and lands on that
  // sub-tool tab — same shape as open_part, so notifications / QR labels can land
  // directly on a sub-tool. emSwitchTab re-checks visibility/access server-side.
  var openSub = params.get('open_subtool');
  if (openSub) {
    window.history.replaceState({}, '', window.location.pathname);
    var _subParts = openSub.split(':');
    var _sid = _subParts[0], _cid = _subParts[1] || '';
    var _surf = (window._tileSurfaces || {})[_sid];
    if (_surf && typeof openTool === 'function') {
      var _cat = (_surf.catalog || []).filter(function(c){ return c.id === _cid; })[0];
      // DVI-1522 (D4): a cross-tool surface (quality) opens the tile's TARGET tool
      // and switches its tab there; a same-tool surface (equipment) opens its own
      // tool and emSwitchTab's the tab.
      if (_cat && _cat.target_tool) {
        openTool(_cat.target_tool, _cat.target_title || _surf.label || 'QC');
        setTimeout(function() { if (typeof qsRunActivate === 'function') qsRunActivate(_cat); }, 80);
      } else {
        var _tab = _cat ? _cat.tab : _cid;
        openTool(_surf.tool_id, _surf.label || 'Equipment');
        setTimeout(function() {
          if (_tab && typeof emSwitchTab === 'function') emSwitchTab(_tab);
        }, 80);
      }
    }
  }
  if (params.get('news_unsubscribe') === '1') {
    // Clean URL
    window.history.replaceState({}, '', window.location.pathname);
    // Open news page and trigger unsubscribe
    openNewsPage();
    var userEmail = window._ssoUserEmail;
    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(){});
    }
  }
})();
//__TOGEN_JS_EXTRACT_END__
</script>
</body>
</html>z//__TOGEN_JS_EXTRACT_START__z//__TOGEN_JS_EXTRACT_END__staticc                 d   ddl }	  | j                  t              } | j                  t        |      } | j                  d|      t	        d      z   }| |t	        t              z   | j                  d      }d|v sd|v r,t        j                  t              j                  d       | dfS |j                  d	      }|j                  |      j                         dd
 }d|z  }d|z  }		 t        j                  t         d       t        j"                  j%                  t         |      }
t        j"                  j'                  |
      set)        j*                  t         d      \  }}	 t        j,                  |d      5 }|j/                  |       ddd       t        j0                  ||
       	 t        j6                  |
d       d|	z  }| d| |z   | |d z   |	fS # t
        $ r/ t        j                  t              j                  d       | dfcY S w xY w# 1 sw Y   xY w# t2        $ r6 t        j"                  j'                  |      rt        j4                  |        w xY w# t8        $ r Y w xY w# t8        $ r7}t        j                  t              j                  d|       | dfcY d}~S d}~ww xY w)u  Return (html_with_external_script, js_url_or_None).

    Slices the JS between the extract markers, writes it to
    static/togen.<sha8>.js (idempotent, atomic), and replaces the inline block
    with an external <script src>. Returns the original html unchanged if the
    markers are missing, the slice still contains Jinja, or the file cannot be
    written — in every such case the JS stays inline.
    r   Nz	</script>z9DVI-945: JS extract markers not found; serving JS inline.r0  z{{z{%zCDVI-945: extracted JS still contains Jinja markers; serving inline.utf-8r-  ztogen.%s.jsz
/static/%sTrO   z.tmp)dirr!  wbi  z8DVI-945: could not write static JS (%s); serving inline.z$</script>
<script src="%s"></script>)hashlibr  _TOGEN_JS_EXTRACT_START_TOGEN_JS_EXTRACT_ENDrC  r   _logging	getLogger__name__ro   r   encodesha256	hexdigestr  makedirs_STATIC_DIRrb   r  r  tempfilemkstempfdopenwriter  BaseExceptionremover  r   )htmlr  sieiscript_closejs_bodyjs_bytesdigestr  rJ  r  fdrS  fhrl  	new_blocks                   r1   _extract_static_jsr  , sm    TZZ/0TZZ-r2!tzz+r2S5EE 2344R8>>tDG w$'/8$,,Q	
 Tz~~g&H^^H%//1#26Fv%H

!C
K$/k84ww~~f%&&;vFGBYYr4( 'BHHX&'

3'	HHVU# 8#=I9y 4#66;;a  8$,,G	
 Tz	6' ' ! 77>>#&IIcN  		 8$,,F	
 Tz	sy   AG (A<I/ %H ;HH ,I  5HHHH ?II/  	I,)I/ +I,,I/ /	J/8,J*$J/*J/c                  p    t         j                  xs% dt         j                  j                  d      xs dv S )Nr  Acceptr7   )r   is_jsonrx   r   rX   r3   r1   _is_json_requestr  B- s,    ??Y0W__5H5H5R5XVXYYr3   c                 .     t                fd       }|S )zLRedirect unauthenticated requests to /login (return JSON 401 for API calls).c                      t        j                  d      sCt               rt        d      dfS t        j
                  t         d<   t        t        d            S  | i |S )Nr'  9Session expired. Please reload the page and log in again.r    nextloginr   r   r  r   r   rJ  r   r   rh  kwargsr  s     r1   _wrapzlogin_required.<locals>._wrapH- sT    {{6"!%`acfff%kkGFOGG,--$!&!!r3   r   r  r  s   ` r1   login_requiredr  F- s     
1X" " Lr3   c                 .     t                fd       }|S )z+Require login + WOR Admin group membership.c                     t        j                  d      sCt               rt        d      dfS t        j
                  t         d<   t        t        d            S t         d   j                  d      st        d      d	fS  | i |S )
Nr'  r  r  r  r  r  r   :Access denied: Work Order Admin group membership required.  r  r  s     r1   r  z!wor_admin_required.<locals>._wrapU- sx    {{6"!%`acfff%kkGFOGG,--v"";/!]^`ccc$!&!!r3   r   r  s   ` r1   wor_admin_requiredr  S-      
1X" " Lr3   c                 .     t                fd       }|S )zRequire login + WOR Admin / Togen Admin group, OR presence in any
    Authorized Approvers department list (wor_approvers.json). Approver
    status is evaluated per request so Settings changes apply immediately
    without re-login (DVI-1024).c                  ^   t        j                  d      }|sCt               rt        d      dfS t        j
                  t         d<   t        t        d            S |j                  d      s:|j                  d      s)t        |j                  d	d
            st        d      dfS  | i |S )Nr'  r  r  r  r  r  r   r   r  r7   zGAccess denied: Work Order Admin or Authorized Approver access required.r  )	r   r   r  r   r   rJ  r   r   rv  )rh  r  r'  r  s      r1   r  z$wor_approver_required.<locals>._wrapg- s    {{6"!%`acfff%kkGFOGG,--%-)@+DHHWb,AB!jkmppp$!&!!r3   r   r  s   ` r1   wor_approver_requiredr  b- s"    
 1X
" 
" Lr3   c                 .     t                fd       }|S )z-Require login + Togen Admin group membership.c                     t        j                  d      sCt               rt        d      dfS t        j
                  t         d<   t        t        d            S t         d   j                  d      st        d      d	fS  | i |S )
Nr'  r  r  r  r  r  r   z5Access denied: Togen Admin group membership required.r  r  r  s     r1   r  z#togen_admin_required.<locals>._wrapx- sx    {{6"!%`acfff%kkGFOGG,--v""=1!XY[^^^$!&!!r3   r   r  s   ` r1   togen_admin_requiredr  v- r  r3   c                 .     t                fd       }|S )zRequire login + Oversight-editor authority (Togen Admin OR granted editor,
    DVI-1103). Re-checked per request so a client can't reach the editor routes
    without a live grant.c                     t        j                  d      sCt               rt        d      dfS t        j
                  t         d<   t        t        d            S t        t         d         st        d      dfS  | i |S )	Nr'  r  r  r  r  r  zGAccess denied: Oversight editor access is not enabled for your account.r  )	r   r   r  r   r   rJ  r   r   _oversight_can_editr  s     r1   r  z(oversight_editor_required.<locals>._wrap- sr    {{6"!%`acfff%kkGFOGG,--"76?3!jkmppp$!&!!r3   r   r  s   ` r1   oversight_editor_requiredr  - s"     1X" " Lr3   c                      t        d      dfS )NzOAccess denied: Auditor access to this resource is not enabled for your account.r  r  r   rX   r3   r1   _auditor_deniedr  - s    jkmpppr3   c                      t        d      dfS )Nz\Access denied: this tool is not enabled for your account. Ask a Togen Admin to grant access.r  r  r  rX   r3   r1   _tool_access_deniedr  - s    wxz}}}r3   c                       fd}|S )zGate a route behind a tile-surface sub-tool's effective visibility
    (DVI-1496 P2, D9). 403s when the sub-tool is admin-disabled, user-hidden, or
    the caller lacks access; Togen Admins always pass. Login is required first.c                 4     t                fd       }|S )Nc                      t        j                  d      }|sCt               rt        d      dfS t        j
                  t         d<   t        t        d            S t        |      r | i |S t        d      dfS )	Nr'  r  r  r  r  r  z:Access denied: this sub-tool is disabled for your account.r  )	r   r   r  r   r   rJ  r   r   r6  )rh  r  r'  r  r  r  s      r1   r  z9subtool_route_required.<locals>._decorator.<locals>._wrap- sw    ;;v&D#%")degjjj")++ 011
J=$)&))!]^`cccr3   r   )r  r  r  r  s   ` r1   
_decoratorz*subtool_route_required.<locals>._decorator- s"    	q		d 
		d r3   rX   )r  r  r  s   `` r1   subtool_route_requiredr  - s     r3   c                 F     t               rdn
t                fd}|S )u]  Gate a route behind per-user tool access (DVI-1373). Passes for Togen
    Admin, or for a user whose per-user tool grant intersects ``resource_keys``.
    Grants are evaluated per request so Access changes apply without re-login.
    Clone of ``auditor_or_role`` without the role-flag branch.

    ``resource_keys`` may be a set/iterable OR a zero-arg callable returning one
    (DVI-1452 P1): the QC "any qc leaf" gate passes ``_qc_access_leaves`` so the
    dynamic per-segment qc.production.<id> leaves are re-resolved per request —
    a grant for a segment added after boot works without a restart.Nc                 4     t                fd       }|S )Nc                  R   t        j                  d      }|sCt               rt        d      dfS t        j
                  t         d<   t        t        d            S |j                  d      r | i |S t                      n}t        |      |z  r | i |S t               S )Nr'  r  r  r  r  r  r   )r   r   r  r   r   rJ  r   r   r  r#  r  )rh  r  r'  r,  _static_keysr  resource_keyss       r1   r  z7tool_access_required.<locals>._decorator.<locals>._wrap- s    ;;v&D#%")degjjj")++ 011xx&$)&))+7+?3}'\D(.5$)&))&((r3   r   )r  r  r  r  s   ` r1   r  z(tool_access_required.<locals>._decorator- s     	q	) 
	) r3   )callabler  )r  r  r  s   ` @r1   tool_access_requiredr  - s%     $M24M8JL" r3   c                 ,     t                 fd}|S )af  Gate an Auditor data route (DVI-1052). Passes for Togen Admin, for a user
    holding any session flag in ``role_flags`` (used on endpoints shared with the
    owning tool, e.g. Production Run history), or for a user whose per-user
    Auditor grant intersects ``resource_keys``. Grants are evaluated per request
    so Access changes apply without re-login.c                 4     t                fd       }|S )Nc                     t        j                  d      sCt               rt        d      dfS t        j
                  t         d<   t        t        d            S j                  d      r | i |S rt        fdD              r | i |S t        j                  d	d
            z  r | i |S t               S )Nr'  r  r  r  r  r  r   c              3   @   K   | ]  }j                  |        y wrT   r  )r,  flr'  s     r1   r/  zEauditor_or_role.<locals>._decorator.<locals>._wrap.<locals>.<genexpr>- s     !D2$((2,!D   r  r7   )r   r   r  r   r   rJ  r   r   r2  r  r  )rh  r  r'  r  r  
role_flagss     @r1   r  z2auditor_or_role.<locals>._decorator.<locals>._wrap- s    ;;v&D#%")degjjj")++ 011xx&$)&))c!D!DD$)&))"488GR#89MI$)&))"$$r3   r   )r  r  r  r  s   ` r1   r  z#auditor_or_role.<locals>._decorator- s     	q	% 
	% r3   )r  )r  r  r  s   `` r1   auditor_or_roler  - s     &M" r3   c                 .     t                fd       }|S )zGate the generic /history/<tool> routes: the required Auditor resource is
    derived from the tool slug (history.<tool>). Admin passes unconditionally.c                     t        j                  d      }|sCt               rt        d      dfS t        j
                  t         d<   t        t        d            S |j                  d      r | i |S dt        |j                  d	d
            j                         z   }|t        |j                  dd
            v r | i |S t               S )Nr'  r  r  r  r  r  r   zhistory.toolr7   r  )r   r   r  r   r   rJ  r   r   r*   r   r  r  )rh  r  r'  r)  r  s       r1   r  z'auditor_history_required.<locals>._wrap- s    {{6"!%`acfff%kkGFOGG,--88M"d%f%%3vzz&"56<<>>%dhhw&;<<d%f%%  r3   r   r  s   ` r1   auditor_history_requiredr  - s"     1X! ! Lr3   z/diag/slow-requestsc                      t        t              } | j                          t        t        t
        t        t        |       |       S )zDVI-939: recent slow requests (in-memory ring) for latency triage.
    Full history lives in the rotating slow_requests.log for Phase 3 analysis.)r~  threshold_mslog_pathcountrequests)r  rh   r  r   _REQUEST_TELEMETRY_ENABLEDrf   _SLOW_REQUEST_LOGrC  r   s    r1   diag_slow_requestsr   . s8    
 &'E	MMO*%"%j r3   c                 .     t                fd       }|S )zHRequire login + Togen Take-off Workflow or Togen Admin group membership.c                  6   t        j                  d      sCt               rt        d      dfS t        j
                  t         d<   t        t        d            S t         d   }|j                  d      s|j                  d      st        d	      d
fS  | i |S )Nr'  r  r  r  r  r  r   r   zAAccess denied: Togen Take-off Workflow group membership required.r  r  rh  r  ur  s      r1   r  z(takeoff_workflow_required.<locals>._wrap. s    {{6"!%`acfff%kkGFOGG,--FO()QUU=-A!degjjj$!&!!r3   r   r  s   ` r1   takeoff_workflow_requiredr  .      
1X	" 	" Lr3   c                 .     t                fd       }|S )zERequire login + Togen Production Run or Togen Admin group membership.c                  6   t        j                  d      sCt               rt        d      dfS t        j
                  t         d<   t        t        d            S t         d   }|j                  d      s|j                  d      st        d	      d
fS  | i |S )Nr'  r  r  r  r  r  r   r   z>Access denied: Togen Production Run group membership required.r  r  r  s      r1   r  z prod_run_required.<locals>._wrap". s    {{6"!%`acfff%kkGFOGG,--FOj!QUU=%9!abdggg$!&!!r3   r   r  s   ` r1   prod_run_requiredr   . r  r3   c                 .     t                fd       }|S )z:Require login + Togen MTS or Togen Admin group membership.c                  6   t        j                  d      sCt               rt        d      dfS t        j
                  t         d<   t        t        d            S t         d   }|j                  d      s|j                  d      st        d	      d
fS  | i |S )Nr'  r  r  r  r  r  r   r   z3Access denied: Togen MTS group membership required.r  r  r  s      r1   r  z!mts_label_required.<locals>._wrap2. s    {{6"!%`acfff%kkGFOGG,--FOk"aeeM&:!VWY\\\$!&!!r3   r   r  s   ` r1   mts_label_requiredr  0. r  r3   c                 .     t                fd       }|S )z<Require login + Togen SCADA or Togen Admin group membership.c                  6   t        j                  d      sCt               rt        d      dfS t        j
                  t         d<   t        t        d            S t         d   }|j                  d      s|j                  d      st        d	      d
fS  | i |S )Nr'  r  r  r  r  r  r   r   z5Access denied: Togen SCADA group membership required.r  r  r  s      r1   r  zscada_required.<locals>._wrapB. s    {{6"!%`acfff%kkGFOGG,--FOm$m(<!XY[^^^$!&!!r3   r   r  s   ` r1   scada_requiredr  @. r  r3   c                 .     t                fd       }|S )a:  Require login + authority to action a SCADA report approval: a Togen
    SCADA / Togen Admin member, OR an email listed in the configured
    ``report_approvers`` (scada_config.json). Evaluated per request so approver
    changes apply immediately without re-login (mirrors wor_approver_required,
    DVI-1102 F4).c                  r   t        j                  d      }|sCt               rt        d      dfS t        j
                  t         d<   t        t        d            S |j                  d      sD|j                  d      s3t        j                  |j                  d	d
            st        d      dfS  | i |S )Nr'  r  r  r  r  r  r   r   r  r7   z5Access denied: SCADA report approver access required.r  )
r   r   r  r   r   rJ  r   r   scada_report_approvalsis_approverr  s      r1   r  z-scada_report_approver_required.<locals>._wrapV. s    KK!%`acfff%kkGFOGG,--m$m(<)55aeeGR6HI!XY[^^^$!&!!r3   r   r  s   ` r1   scada_report_approver_requiredr  P. s"     1X
" 
" Lr3   c                 .     t                fd       }|S )zRequire login + Vision access: togen_admin always, OR togen_vision only
    while the Vision tool is enabled in Tools Enabled (admin-disabled => 403).c                  z   t        j                  d      sCt               rt        d      dfS t        j
                  t         d<   t        t        d            S t         d   }|j                  d      r | i |S |j                  d      r"t               j                  d	d
      r | i |S t        d      dfS )Nr'  r  r  r  r  r  r   r   r  Fz&Access denied: Vision access required.r  )	r   r   r  r   r   rJ  r   r   r  r  s      r1   r  z%vision_access_required.<locals>._wraph. s    {{6"!%`acfff%kkGFOGG,--FO55d%f%%55 %:%<%@%@5%Qd%f%%EFKKr3   r   r  s   ` r1   vision_access_requiredr  e. s$     1XL L Lr3   c                 .     t                fd       }|S )u  Require Repair Parts Manager access. DVI-1496 P3 (D6-B): RPM is now the
    Equipment ``rpm`` sub-tool. togen_admin always passes; otherwise the RPM
    sub-tool must be admin-enabled (D9 — a disabled sub-tool 403s) AND the caller
    must have access: the ``equipment.rpm`` access leaf OR the legacy wor_admin
    role (``_rpm_role_ok``, both ORed in per the plan). User-hiding is a personal
    convenience and never 403s a route.c                  V   t        j                  d      sCt               rt        d      dfS t        j
                  t         d<   t        t        d            S t         d   }|j                  d      r | i |S t        dd	      rt        dd	|      r | i |S t        d
      dfS )Nr'  r  r  r  r  r  r   r  r  z4Access denied: Repair Parts Manager access required.r  )
r   r   r  r   r   rJ  r   r   r  r*  r  s      r1   r  zrpm_required.<locals>._wrap. s    {{6"!%`acfff%kkGFOGG,--FO55d%f%%!+u5:L[Z_ab:cd%f%%STVYYYr3   r   r  s   ` r1   rpm_requiredr  x. s$     1XZ Z Lr3   c                    | dk(  rt         t        t        t        g}n| dk(  rt        t
        t        t        g}n| dk(  rt        t        t        t        g}nn| dk(  rt        t        t        t        g}nRt        t        t        t        t         t        t        t        t        t
        t        t        t        t        t        t        g}|r6| dn| fD ],  }|j!                  t#        |d      j%                                . |S )aL  Return the ordered list of directories to search for a given WOR type.

    ``include_test`` (DVI-1082) appends the segregated ``_test`` subtree dirs.
    It defaults to False so every production caller stays isolated from test
    WOs by construction; only surfaces that have authorized the viewer for test
    mode pass True.
    r   r   r   r   r#  Trb  )rk  rr  rs  rt  rl  ru  rv  rw  rm  rx  ry  rz  r{  r|  r}  r~  extendr  r  )r  r  dirsr   s       r1   r  r  . s     ?"%'<>R$&	X	 9;S(*	\	!"$A,.JL	]	"#%=?V')  !9;R#!#8:N 57O$ =(*F	
 % K,4;	?AKKqt4;;=>	? Kr3   c                     | rd| v sd| v ryt        ||      D ]\  }|| z  j                         }t        |      j                  t        |j                                     s y|j	                         sZ|c S  y)zFind a WOR file across new / in_progress / completed directories.

    ``include_test`` (DVI-1082) also searches the segregated ``_test`` subtree;
    default False keeps production reads isolated from test WOs.
    r  r6   Nr  )r  r,   r*   rw   r   )r  r  r  rn  r  s        r1   _resolve_wor_filer  . sv     tx'3(?H<@ \**,	9~((QYY[)9: r3   c                  x    t        j                  d      } | r| S t        j                  j	                  d      dz   S )a  Best post-login redirect target, preserving the originating FQDN.

    login_required stores the full pre-login URL (scheme + host + path) in
    session["next"], so a login started on a custom FQDN can land the user
    back on that same host after the canonical /auth/callback completes
    (DVI-1181, Option B). Falls back to the current request's origin so a
    direct hit on /login still returns to the right host.
    r  r6   )r   r   r   host_urlr8   )r  s    r1   _post_login_targetr  . s6     ++f
C

""3'#--r3   c                 x   | syddl m} 	  ||       }|j                  s*| j	                  d      r| j	                  d      s| }|S d}|S |j
                  xs dj                         }t               D ch c]6  }|j                  d      r#|j                  d      xs dj                         8 }}||v s|j                  d      s|dk(  r5|j                  xs d}|j                  r|d	|j                  z   z  }d
| | S |j                  j	                  d      r|j                  S dS # t        $ r Y yw xY wc c}w )a  Validate the OAuth `state` before using it as a redirect target.

    `state` round-trips through Microsoft and is used verbatim as the
    post-login redirect, so it must be constrained to prevent an open
    redirect. Absolute URLs are only honored when the host is an allowed
    Togen FQDN (registered in togen_fqdns.json or any *.icastinc.com host,
    matching the SESSION_COOKIE_DOMAIN scope); otherwise the path is kept and
    the host is dropped. Relative targets must start with "/".
    r6   r   r
   z//r7   fqdnr~   icastinc.comr]   r@  )urllib.parser
   ry   rI  rw   hostnamerE  _load_fqdnsr   r|  rb   query)ro  r
   rL  rb   hostrB  allowed_hoststails           r1   _safe_post_login_redirectr'  . s>    %% ==((-e6F6Ft6Lu SVOO!r((*D 55= 
v	"##%M 
 }o >$.BX {{!c<<C&,,&&D$v&& ++0056;;>3>)  s   D( 3;D7(	D43D4z/loginc                      t        j                  t        t        t              } | j                  t        t        t                     }t        |      S )z5Initiate Microsoft OAuth 2.0 Authorization Code flow.r  )redirect_uriro  )
r  r  r  _AZ_AUTHORITYr  get_authorization_request_url
_AZ_SCOPES_AZ_REDIRECTr  r   )rX  auth_urls     r1   r  r  . sJ     
	+	+*
NB//! " 0 H
 Hr3   z/auth/callbackc                  0   t         j                  j                  dd      j                         dk(  r(t         j                  j                  dd      } d|  ddfS t         j                  j                  d	      }|sy
t	        j
                  t        t        t              }|j                  |t        t              }d|v rd|j                  d|d          dfS |d   }|d   }|j                  dd      }|j                  dd      }t        j                  |      }|st        d| d| dd       y|d   }	|d   }
|j                  di       }t        d|	 d| d | d       |
Wt        j                  d"|
 d#d$| id%&      }|j                  dk7  r(t        d'| d(|
 d)|j                   d       d*|	 d+d,fS i }d-D ]  }|j                  |      }|sd.||<   	 t        j                  d"| d#d$| id%&      }|j                  dk(  ||<   t        d/|j!                          d0| d1|j                   d2||    d        g }	 t        j$                  d4d$| d5d6d7d.id%8      }|j                  dk(  rB|j'                         j                  d9      xs g D cg c]  }t)        |t*              s| }}nt        d:| d1|j                   d       |j                  d      ||	|d=   |d>   |d?   |d@   |dA   |dB   |dC   |dDt,        dE<   	 t/        ||j                  d      |j                  dF      |j                  dG             t1        t         j                  j                  dIdJ            }t3        |      S # t"        $ r1}d.||<   t        d/|j!                          d3| d       Y d!}~d!}~ww xY wc c}w # t"        $ r}t        d;| d<| d       Y d!}~d!}~ww xY w# t"        $ r}t        dH| d<| d       Y d!}~d!}~ww xY w)Kz@Handle the redirect from Microsoft after login or admin consent.admin_consentr7   rd  tenantunknownz.Admin consent granted successfully for tenant z. You may close this window.rK   code)z(Authentication failed: no code returned.r  r  )r  r)  r  zLogin error: error_descriptionr  id_token_claimsr  r   preferred_usernamez*[AUTH] Rejected login from unknown tenant z (user=)Tflush)zqAccess denied: your organization is not authorized to use Togen. Contact your IT administrator to request access.r  r9   r   r   z[AUTH] Login from tenant  (z), user=Nz-https://graph.microsoft.com/v1.0/me/memberOf/r  r  r   rx   r7  z#[AUTH] Base group check failed for z: group=z	, status=zKAccess denied: your account is not a member of the required Togen group in z2. Contact your IT administrator to request access.r  r   F[z] Group check for z	: status=z
, granted=z] Group check failed: z3https://graph.microsoft.com/v1.0/me/getMemberGroupsr  r  securityEnabledOnlyr  r  z"[AUTH] getMemberGroups failed for z![AUTH] getMemberGroups error for : r   r   r   r   r   r   r   )r9   r  r1  r   r   r   r   r   r   r   r  r'  r  r  z'[AUTH] user_directory write failed for ro  r6   )r   rh  r   rE  r  r  r  r*  r  #acquire_token_by_authorization_coder,  r-  _AZ_TENANT_CONFIGprintr  rg   upperry   r  r   r   r*   r   r  r'  r   )r1  r3  rX  r>  claimstokenuser_tenantusername
tenant_cfgtenant_namer   tenant_groups
graph_respr  role_keygroup_idr  rl  r  gmr   next_urls                         r1   auth_callbackrO  
/ s    ||,224>!!(I6<VH E) ) 	
 <<F#D>		+	+*
NB33Zl 4 <F &vzz*=vgOPQSVVV%&F>"E**UB'Kzz.	:H #&&{3J8 FJa!	


 V$KL)JNN8R0M	
#K=;- @z	 #'';J<H$w&78


 !!S(5hZ @#Ij.D.D-EG""- /CC 	  J| Q $$X.#(Jx 	Q!%%?zJ(GE7*;<D
 $(#3#3s#:Jx HNN$%%7z B**+:j6J5KMQ4 IQ  A(/w&7%79'/
 >>S %'WWY]]7%;%ArYqjQRTWFXYIY6xj ANN+-48: F#,!-0&'9:z*,!-0">2GFOWxF);%zz,7M9R	T ))9)9'3)GHHH_  	Q#(Jx Ahnn&''=cUC4PP	Q& Z  Q1(2cUC4PPQ*  W7zC5IQUVVWsb    A&NAO  O 6O : O ;O/ 	N=&N88N= O 	O,O''O,/	P8PPz/logoutc                  V    t        j                          d} t        t         d|        S )z0Clear local session and sign out from Microsoft.https://togen.icastinc.comz-/oauth2/v2.0/logout?post_logout_redirect_uri=)r   clearr   r*  )post_logouts    r1   logoutrT  / s3     MMO.K/ %%0M	3 r3   r6   c                     ddl m}  t        j                  di       }|j                  dd      } | ||j                  dd      t	        |            }|j                  dd      }t               }t        t        fi d|d	|d
t        d||      dt        d||      dt        d||      d|j                  dd      d|d|j                  dd      d|j                  dd      dt        t        |            d|j                  dd      dt        |      dt        t        |            dt        |j                  dd       xr t        |            dt        t        |            dt        |j                  dd       xr t        |      t!        t#                     z        dt        d t        |      v       d!t        |j                  dd       xr2 t        |      t!        t#                     z  xs |j                  d"            d#t        |j                  dd       xr t        |      t!        t$        d$         z        d%|j                  d&d      d'|j                  d(d      d)|j                  d*d      d+|j                  d"d      d,|j                  d-d      d.t'               d/t)               d0t+               d1t-               d2t/               d3t1        |      d4t3               d5t5               d6t7               d7t9               d8t;               d9t=               d:t?               d;tA        tC                     d<tE               d=   d>tE               d?   d@tG               dAtI        |      dBtK        |      dCtM        |      dDtO               dEtP        jS                         dFtU        |      S )GNr   is_assistant_connectedr'  r  r7   r   Fassistant_availabletoolbar_visibilityshow_toolbar_searchr  show_toolbar_assistantr  show_toolbar_calendarr  	user_namer9   Userrp  rE  r1  is_wor_adminr   is_evh_approveris_togen_adminwor_can_testauditor_resourcesis_auditor_scopedtool_access_resourcesis_qc_scopedis_qc_filebrowserr  is_quality_scopedr   is_logistics_scopedr  is_takeoff_workflowr   is_prod_runr   is_togen_mtsr   is_togen_scadais_togen_visionr   tool_visibilityr}  tool_separatorsr~  tile_surfacesr  subtool_visibility_admintool_overrideswor_type_visibilitywor_type_orderbot_visibility	bot_orderpublic_version
about_html
site_titlere  site_tagliner+  shared_url_prefixr9  regionalwor_primary_enforcedbranding_settingsbranding_has_logoprint_can_use)+r  rW  r   r   r  r  r   _HTMLr  r   rv  r`  r  r  r  r#  r  r  r  r  r   r   ry  r	  r8  r  r  r  r  r  r  r!  _render_news_markdownr'  r0  r  r>  _resolve_regionalrL  rd  CORPORATE_LOGO_FILEr   _user_can_print)rW  r'  rp  _assistant_available	_is_admin_toolbar_viss         r1   r  r  / s_    1;;vr"D'2&J1DHH]E24G
4S .I+-L!808 (8 19lS	8
  4KLY8 3:y,W8 ((66*8 8 HHXr*8 XXk518 5jAB8 xxu58 #4(8 !!=d!CD8 488M5#AAeFYZdFef8  %%>t%DE!8" dhh}e<< [8>EVEXAYY\#8& /3LT3RRS'8, 488M5#AA  @%>t%DsK\K^G_%_ %?'+xx'>A-82 !TXXmU%C!C "M&?&E(+,=k,J(K'LN388 !HH%7?98: HHZ/;8< XXk51=8> xxu5?8@ 7A8B ./C8D $%E8F ./G8H $%I8J ,-K8L 8=M8N ":!;O8P ,-Q8R 67S8T ,-U8V ,-W8X "#Y8Z ,-[8\ ))<)>?]8^ #$W-_8` %&y1a8b 23c8d /z:e8f #:.g8h 7zBi8j 23k8l .557m8n &d+o8 8r3   z/upload-csvPOST)methodsc            
         dt         j                  vrt        ddi      dfS t         j                  d   } | j                  r)| j                  j	                         j                  d      st        ddi      dfS t        | j                        }t        |      j                  }t        |z  }|j                  dd       ||z  }| j                  t        |             g }g }t        |d	d
      5 }t        j                  |      }t!        |d        |D ]y  }	t#        |	      dk\  s|	d   j%                         s&|j'                  |	d   j%                                |j'                  t#        |	      dk\  r|	d   j%                         nd	       { 	 d d d        |st        ddi      dfS ||||t)        t#        |            D 
ci c]  }
t        |
      i  c}
i d}t+        ||       t        |||d      S # 1 sw Y   hxY wc c}
w )Ncsvr  zNo CSV file providedr`  .csvzFile must be a .csvTrM   r7   	utf-8-signewlineencodingrP   r   r  r   u>   No items found — expected data in column C from row 2 onward)csv_filenamer  r   rn  r  results)r  r   rn  )r   r  r   r  rE  r|  r   r   r  rb  r  r  r*   r?  r  rO  r  rC  r   ri   r  rm  )r  	safe_namer  r  csv_pathr   rn  r  rO  r  r  ro  s               r1   
upload_csvr  / s    GMM!!7893>>eA::QZZ--/88@!678#==

+I	?DF
LLL-	!HFF3x= EI	h[	9 JRBVT 	JC3x1}QSV\\^,  3s8q=QbI	JJ !abcehhh "&+CJ&78#a&"*8E dUKLL)J J  9s   05G8&G8:AG8H8Hz/upload-zipc                     dt         j                  vrt        ddi      dfS t         j                  d   } | j                  r)| j                  j	                         j                  d      st        ddi      dfS t        | j                        }t        |      j                  }t        |z  }|j                  dd       t        j                  | j                        5 }|j                         D ]c  }||z  j                         }t!        |      j#                  t!        |j                                     rHt        dd	| i      dfc cd d d        S  |j%                  t!        |             d d d        t'        |j)                  d
            xs t'        |j)                  d            }|st        ddi      dfS |d   }g }	g }
g }t+        |dd      5 }t-        j.                  |      }t1        |d        |D ]  }t3        |      dk\  s|d   j5                         s&|	j7                  |d   j5                                |
j7                  t3        |      dk\  r|d   j5                         nd       |j7                  |r|d   j5                         nd        	 d d d        |	st        ddi      dfS t9        ||	|
|      }i }t;        |      D ]  \  }}t=        |      |t!        |      <    t!        |j?                  |            ||	|
||i d}tA        ||       t        ||	|
|d      S # 1 sw Y   xY w# 1 sw Y   xY w)Nzipr  zNo zip file providedr`  .zipzFile must be a .zipTrM   zUnsafe path in zip entry: *.csv**/*.csvzNo CSV file found in zipr   r7   r  r  rP   r   r  r   zECSV in zip has no items (expected data in column C from row 2 onward)r  r  r   rn  ro  r  r  )r  r   rn  r  )!r   r  r   r  rE  r|  r   r   r  rb  r  zipfileZipFiler  namelistr,   r*   rw   
extractallr  r  r?  r  rO  r  rC  r   ri   r  r  r   r{  rm  )r  r  r  r  zfmemberdest	csv_filesr  r   rn  ro  r  rO  r  r  files_stater  r  ro  s                       r1   
upload_zipr  0 s    GMM!!7893>>eA::QZZ--/88@!678#==

+I	?DF
LLL- 
	" #bkkm 	VFVO,,.Dt9''FNN,<(=>+EfX)NOPRUUU	# #	V 	c&k"# v{{7+,Ov{{:7N0OI!;<=sBB|H EIK	h[	9 BRBVT 	BC3x1}QSV\\^,  3s8q=QbI""S3q6<<>bA		BB !hijlooo 'vuiMJ K
+ ,
7"7mCF, H0089"E dUZdeff_# #"B Bs2   AL/7L/L/5L<L<!A<L</L9<Mz/upload-filec                  n   dt         j                  vrt        ddi      dfS t         j                  d   } | j                  r)| j                  j	                         j                  d      st        ddi      dfS t         j                  j                  dd      }t         j                  j                  d	d      }t         j                  j                  d
d      }|dvrt        ddi      dfS t        j                  |      rd|v rt        ddi      dfS t        |z  }|j                         st        ddi      dfS t        | j                        }| j                  t        ||z               t        |      }||j!                  di       j!                  |i       |<   t#        ||       t        ddi      S )Nr  r  No file providedr`  r   zOnly PDF files are acceptedr  r7   
item_index	file_typeru  rv  rx  Invalid file_typer  Invalid folder*   Session not found — please re-upload CSVr  rU  T)r   r  r   r  rE  r|  rg  r   ra  rG  rb  r  r   r  r*   rk  r}  rm  )r  r  r  r  r  	save_namero  s          r1   upload_filer  X0 s    W]]"!345s::fA::QZZ--/88@!>?@#EE,,""8R0K,,""<4J,,"";3I88!456;;k*dk.A!123S88$F==?!MNOQTTT

+IFF3v	!"#EJSEWb!,,Z<YGD$<  r3   z/detach-filec                     t        j                  d      xs i } | j                  dd      }t        | j                  dd            }| j                  dd      }|dvrt	        dd	i      d
fS t
        j                  |      rd|v rt	        ddi      d
fS t        |z  }|j                         st	        ddi      d
fS t        |      }|j                  di       j                  |      }t        |t              rL|j                  |d       |dk(  r|j                  dd       |s|d   j                  |d       t        ||       t	        ddi      S )a)  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.
    Tsilentr  r7   r  r  r  r  r  r`  r  r  r  r  Nrx  createdrU  )r   get_jsonr   r*   r   ra  rG  rb  r  rk  r   r   poprm  )r   r  r  r  r  ro  
item_filess          r1   detach_filer  w0 sE    4(.BD((8R(Kdhh|R01J((;+I88!456;;k*dk.A!123S88$F==?!MNOQTTTE7B'++J7J*d#y$'	!NN9d+'Nz40FE"D$<  r3   )
job_numberproduction_date	spec_typestationcustomer_nameproject_namer  metadataoverridec           
          dt         dt        f fd} |d       |d       |d       |d       |d      d	}t        D ]B  }t        |j                  ||j                  |d
            xs d
      j	                         ||<   D |S )zFields 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.
    idxr$   c                 J    t              | kD  r|    j                         S dS r  rC  r   )r  r  s    r1   r6  z"_build_carrier_fields.<locals>.col0 s#    #&s8c>s3x~~9r9r3   r^   r   r   r   r   )structure_idproduct_codeweightstructure_sizepart_refr7   )r   r*   _CARRIER_META_KEYSr   r   )r  r  r  r6  fieldsr)  s   `     r1   _build_carrier_fieldsr  0 s    : : : a&a&a&a&a&F " R(,,sHLLb,ABHbIOOQsRMr3   /submitc            
         t        j                  d      xs i } | j                  dd      }t        j	                  |      rd|v rt        ddi      dfS t        |z  }t        |      }|j                  d	g       }|st        dd
i      dfS | j                  d      }t        |t              r6|j                         D ci c]  \  }}t        |      t        |       c}}ni }t        | j                  d      t              r| j                  d      nd }	|	ft        |j                               rM|dz  }
|
j                         r8	 t!        j"                  |
j%                  d            }t        |t              r|}	|	xs i }	|	j                  d      }t        |t              si }d }d}t        |j                               r	 ddlm} ||d   z  }t1        |dd      5 }t3        t5        j6                  |            }d d d        i }d}t9              D ]8  \  }}|dk(  rt;        |      dk\  s|d   j=                         s/|||<   |dz  }: g }t9        |      D ]  \  }}|j                  di       j                  t        |      i       }|j                  d      }|j                  d      }|j                  d      }|s|j?                  ddi       {d}|s"|j                  t        |            s|j?                  dd | d!i       |r|j?                  d|i       ||v r|||      ng }tA        ||	|j                  t        |      i             }|d"   r|d#   s|j?                  dd$| d%i       	  |||&      }||jB                  jE                  d'd(      z  } | |k7  r|jE                  |        | jB                  }d}|jI                  di       jI                  t        |      i       }"||"d<   d|"d+<   ||z  }#||z  }$|r||z  nd }%	 tK        |$|#||%      }&tM               }'|'jO                  d,       d,tQ        |d-       }(|( d,tQ        |d,-       })|( d,tQ        |d,-       }*|r|( d,tQ        |d,-       nd}+||v rE|||      }t;        |      d.k  r |j?                  d       t;        |      d.k  r |)|d/<   |+|d0<   |*|d1<   d2|i|jI                  d3i       t        |      <   dd4tQ        |d-       d5tQ        |d,-       d6tQ        |d-       d5tQ        |d,-       d7},|r
d|,d+<   ||,d<   |j?                  |,        t1        |d8dd      5 }t5        jR                  |      jU                  |       d d d        |r||d<   tW        ||       t        d3|i      S c c}}w # t         j&                  t(        f$ r Y Ow xY w# t.        $ r d}Y w xY w# 1 sw Y   xY w# tF        $ r$}!|j?                  dd)| d*|! i       Y d }!~!d }!~!ww xY w# tF        $ r'}!|j?                  dt        |!      i       Y d }!~!d }!~!ww xY w# 1 sw Y   xY w)9NTr  r  r7   r  r  r  r`  r   u,   No items in session — please re-upload CSVcreate_flagsr  project_metadata.jsonr  r  item_overridesr   create_carrier_sheetz^Carrier sheet generation is unavailable on this host (qr_merge.create_carrier_sheet not found)r  r  r  rP   r   r^   r  ru  rx  rv  skippedFzShop Drawing uploaded for "z"" but QR Carrier Sheet is requiredr  r  zXProject metadata (customer and project name) is required to create a carrier sheet for ""r  r"   zCarrierQR blankzCarrierQR generatedz%Carrier sheet generation failed for "z": r  r6   r  r-  ry  r      outputr  zdownload-file?folder=&file=zpreview-file?folder=)rU  download_urlpreview_urlw),r   r  r   ra  rG  r   rb  rk  r   r   r   r*   r   r2  r  r   r   r   r   r   r   qr_merger  r#  r?  r  r  rO  r  rC  r   ri   r  r9   r  ry   r}  r2   r+   r8   r   writer	writerowsrm  )-r   r  r  ro  r   	raw_flagsr  r  r  r  	meta_pathloadedr  _create_carrier_sheet_create_import_errorr  r  rn  item_row_map
item_countrow_idxr  r  r  r  r  	shop_namecarrier_namesubmittal_namecreated_nowr  gen_path
final_pathrl  r  r!   r    r#   r  r  r<   r=   carrier_urlr>   r>  s-                                                r1   submitr  0 s    4(.BD((8R(Kk*dk.A!123S88$F EYYw#E!OPQSVVV(I i& &/__%67TQQa7,.  (2$((:2F'Mtxx
#SWHC 3 3 5644	I$7$7$7$IJfd+%H ~2H\\"23Nnd+ 
< !	N n--H	h[	9 $RCJJrN#$ $&LJ!$ a<s8q=SV\\^'.L$!OJ G!%( N09		'2&**3q6266*	9-;/ NNIt,- ##CF++FykQs)tuv#)=>?+,+<$|A'"C*3.:L:LSQRVUW:XYF/*&2H  ,D  EN  DO  OP  *Q   R  S
0fU $hmm&;&;<MOd&ee
)$$Z0) K))'26AA#a&"MJ$0Jy!$(Jy!*	-6D&>1$ 	0%lIv~VK
 -.H's34AeKb6Q5RSJ)l!E)#,F+GHH)l!E,S,I+JKKQ_zl!E.s,K+LMegM L <?+#hmJJrN #hm"A'B%B7?6NEY+CF3"7kPR8S7TTZ[`amtw[xZy z"6u[r7R6SSYZ_`lsvZwYx yF
 $(y!$0y!NN6"YN0b 
hR'	: 'b

2  &'  ,nIw'((M 	8 (('2   	< !	$ $`  +PQZP[[^_b^c)defV  	0NNGSX.//	0' 's{    V,7V2 W "W$ AW1B,X!B
X!%Y2WWW! W!$W.1	X:XX!	Y*YYYz/download-filec                     t         j                  j                  dd      } t         j                  j                  dd      }| r|syt        j	                  |       rd| v ryt
        | z  j                         }||z  j                         }t        |      j                  t        |      t        j                  z         s||k7  ry|j                         syt        t        |      d	
      S )Nr  r7   r  Missing parametersr`  r  r  r`  Invalid pathr`  File not found  Tas_attachmentr   rh  r   ra  rG  rb  r,   r*   rw   r  sepr  r   r  r  r  ri  s       r1   download_filer  P1 s     ,,""8R0K,,""62.Hh(k*dk.A$[(113F("++-Iy>$$S[266%9:yF?R"$S^488r3   z/download-csvc                     t         j                  j                  dd      } t        j	                  |       rd| v ryt
        | z  }t        |      }||j                  dd      z  }|j                         syt        t        |      d      S )	Nr  r7   r  r  r  )zCSV not foundr  Tr  )
r   rh  r   ra  rG  rb  rk  r  r   r*   )r  r  ro  r  s       r1   download_csvr  c1 sy     ,,""8R0Kk*dk.A$$F E		."55H??#S]$77r3   z/list-shared-foldersc                      t         j                         st        dg i      S t        d t         j	                         D        d      } t        d| i      S )z7Return sorted list of subdirectory names in SHARED_DIR.foldersc              3      K   | ]=  }|j                         s|j                  j                  d       r0|j                   ? ywr  N)r  r9   rw   r,  r  s     r1   r/  z&list_shared_folders.<locals>.<genexpr>x1 s.     [AAFFDUDUVYDZ[s   AAATr  )
SHARED_DIRr  r   r  rY  )r  s    r1   list_shared_foldersr  q1 sO     	2''[++-[G Iw'((r3   z/select-shared-folderc            	      @   t        j                  d      xs i } | j                  dd      }t        j	                  |      rd|v rt        ddi      dfS t        |z  j                         }t        |      j                  t        t        j                                     r|j                         st        dd	i      d
fS t        |z  }|j                         s(t        j                  t        |      t        |             t        |j!                  d            xs t        |j!                  d            }|st        ddi      dfS |d   }g }g }g }t#        |dd      5 }	t%        j&                  |	      }
t)        |
d       |
D ]  }t+        |      dk\  s|d   j-                         s&|j/                  |d   j-                                |j/                  t+        |      dk\  r|d   j-                         nd       |j/                  |r|d   j-                         nd        	 ddd       |st        ddi      dfS t1        ||||      }t3        |      D ci c]  \  }}t        |      t5        |       }}}	 t7        |      j                  di       }t;        |t4              rt=        t+        |            D ]  }|j                  t        |      i       }t;        |t4              s/|j                  d      sA|j                  d      sS||d   z  j?                         sj|d   |t        |         d<   d|t        |         d<   |d   ||   d<    t        |jA                  |            |||||i d}tC        ||       |dz  }|j?                         }d}|r&	 tE        jF                  |jI                  d            }t        ||||||d      S # 1 sw Y   xY wc c}}w # t8        $ r i }Y `w xY w# tD        jJ                  tL        f$ r d}Y Tw xY w) zOLoad a shared folder: copy to WATCH_DIR if needed, parse CSV, auto-match files.Tr  r  r7   r  r  Invalid folder namer`  z$Folder not found in shared directoryr  r  r  zNo CSV file found in folderr   r  r  NrP   r   r  r   z>CSV has no items (expected data in column C from row 2 onward)r  r  rx  r  r  r  r  F)r  r   rn  r  has_metadatar  )'r   r  r   ra  rG  r   r
  r,   r*   rw   r  rb  r  r  copytreer  r  r?  r  rO  r  rC  r   ri   r  r  r   rk  ry   r   r  r   r{  rm  r   r   r   r   r   )r   r  r$  dstr  r  r   rn  ro  r  rO  r  r  r  r  r  
prev_filespfro  r  r  r  s                         r1   select_shared_folderr  ~1 s    4(.BD((8R(Kk*dk.A!678#==#
,
,
.Cs8s:#5#5#789!GHI3NN k
!C::<C#c(+ sxx()IVCHHZ4H-II!>?@#EE|H EIK	h[	9 BRBVT 	BC3x1}QSV\\^,  3s8q=QbI""S3q6<<>bA		BB !abcehhh&sE9kJJ/8/DEtq!3q647?EKE
 %))'26
 *d#s5z" 	9AA+B2t$	):rvvi?Pr)},55713ICF#I.15CF#I.+-i=
1i(	9 H0056"E U--I$$&LH	!zz)"5"5w"5"GHH  $  gB B F  
8 $$g. 	! L	!sC   !5OO+A<OO' O- #%O? O$-O<;O<?PP)r  r  r  r  )r  r  vendor_project_codez/save-project-metadatac                     t        j                  d      xs i } | j                  dd      }t        j	                  |      rd|v rt        ddi      dfS t        |z  j                         }t        |      j                  t        t        j                                     r|j                         st        dd	i      d
fS |dz  }i }|j                         r8	 t        j                  |j                  d            }t        |t               r|}|j'                  t        | j                  d      xs d      j)                         t        | j                  d      xs d      j)                         t        | j                  d      xs d      j)                         |d       t*        D ]6  }|| v st        | j                  |      xs d      j)                         ||<   8 | j                  d      }t        |t               r|j-                         D 	
ci c]c  \  }}	t        |	t               rNt        |      |	j-                         D 
ci c](  \  }
}|
t.        v s|
t        |      j)                         * c}}
e c}}
}	}|d<   |j1                  dt3        j4                         j7                                t3        j4                         j7                         |d<   	 t9        |dd      5 }t        j:                  ||d       ddd       t        d|d      S # t        j"                  t$        f$ r i }Y -w xY wc c}}
w c c}}
}	}w # 1 sw Y   GxY w# t$        $ r}t        dd| i      dfcY d}~S d}~ww xY w)ab  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.
    Tr  r  r7   r  r  r  r`  Folder not foundr  r  r  r  r  r  r  )r  r  r  r  r  r  r  r  r   r   NzFailed to save metadata:   )rU  r  )r   r  r   ra  rG  r   rb  r,   r*   rw   r  r   r   r   r   r   r   r   r   r  r   _META_OPTIONAL_KEYSr   _META_OVERRIDE_KEYSr}  r  r  	isoformatr?  dump)r   r  folder_pathr  r  r  r)  r  r  r  r  r  r  rl  s                 r1   save_project_metadatar  1 s    4(.BD((8R(Kk*dk.A!678#=={*335K{&&s9+<+<+>'?@HZHZH\!345s::55IH	zz)"5"5w"5"GHH(D)# OOTXXo6<"=CCEDHH^4:;AAC"488,A#B#HbIOOQ"	  # =$; 34::<HSM= XX./N.$'  .335&
 &
V&$' HV\\^`TQqL_G_q#a&,,.(``&
!" n&8&8&:&D&D&FG+//1;;=H\J)S73 	.rIIh1-	.
 $H566= $$g. 	H	  a&
	. 	. J#<SE!BCDcIIJsf   7L 5M
M M :M
!M /MM L=<L= M
MM 	M?#M:4M?:M?z/preview-filec                     t         j                  j                  dd      } t         j                  j                  dd      }| r|syt        j	                  |       rd| v ryt
        | z  j                         }||z  j                         }t        |      j                  t        |      t        j                  z         s||k7  ry|j                         syt        t        |      d	
      S )z=Serve a job file inline (no download prompt) for PDF preview.r  r7   r  r  r  r  r  r  application/pdfmimetyper  r   s       r1   preview_filer"  2 s     ,,""8R0K,,""62.Hh(k*dk.A$[(113F("++-Iy>$$S[266%9:yF?R"$S^.?@@r3   z/takeoff/project-filesc            	      ~   t         j                  j                  dd      } t        j	                  |       rd| v rt        d      dfS t        | z  j                         }|j                         st        d      dfS d	h}g }t        |j                  d
      d       D ]  }|j                         r)|j                  j                  d      s|j                  |v r>|j                  |      }t        |j                         j#                  t$        j&                  d      }|j)                  |j                  t        |      j#                  t$        j&                  d      |dk(  rdn||j*                  j-                         j/                  d      |j1                         j2                  d        t        | |      S )zList 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.r  r7   r  r  r  r`  r  r  rh  r1  c                 4    t        |       j                         S rT   r*   rE  r  s    r1   r	  z'takeoff_project_files.<locals>.<lambda>:2 s    SV\\^ r3   r  r  r6   )r9   r  r  r  rd  )r  r  )r   rh  r   ra  rG  r   rb  r,   r  r  rz  r   r9   rw   r{  r*   r  r  r  r  ri   r!  rE  rV  r  st_size)r  r  
skip_namesr  r  r  r  s          r1   takeoff_project_filesr(  ,2 s_    ,,""8R0Kk*dk.A-.33+%..0F==?/0#55JEFLL%+CD yy{aff//4*8LmmF#SZZ((5FFs8##BFFC032F88>>#**3/FFH$$
 	 +U33r3   z/takeoff/file-previewc                     t         j                  j                  dd      } t         j                  j                  dd      }| r|syt        j	                  |       rd| v sd|v ryt
        | z  j                         }||z  j                         }t        |      j                  t        |      t        j                  z         sy|j                         syt        t        |      |j                        S )	zServe 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.r  r7   r  r  r  r  r  download_name)r   rh  r   ra  rG  rb  r,   r*   rw   r  r  r   r   r9   r  r  r  ri  s       r1   takeoff_file_previewr-  I2 s     ,,""8R0K
,,

62
&Cc(k*dk.ATS["+%..0F#&&(Iy>$$S[266%9:"$S^9>>BBr3   z/download-job-zipc                  l   t         j                  j                  dd      } t        j	                  |       rd| v ryt
        | z  j                         }|j                         syt        j                         }t        j                  |dt        j                        5 }t        |j                  d            D ]D  }|j                         s|j                   dk7  s$|j#                  ||j%                  |             F 	 d	d	d	       |j'                  d
       t)        |d|  dd      S # 1 sw Y   ,xY w)zMZip the entire job folder (excluding state.json) and stream it as a download.r  r7   r  r  )r  r  r  r1  rh  Nr   Tr  application/zipr  r+  r!  )r   rh  r   ra  rG  rb  r,   r  r>  rJ  r  r  ZIP_DEFLATEDr  rz  r   r9   r  r{  r  r   )r  r  bufr  r  s        r1   download_job_zipr3  ^2 s     ,,""8R0Kk*dk.A$+%..0F==?&
**,C	c7#7#7	8 3BS)* 	3Ayy{qvv5AMM&12	33 HHQK$T*"	 3 3s   .D*D*#D**D3z/publishc                  "   t        j                  d      xs i } | j                  dd      }t        | j                  dd            }t        j                  |      rd|v rt        dd	i      d
fS t        |z  j                         }|j                         st        ddi      d
fS t        j                  dd       t        |z  j                         }t        |      j                  t        t        j                                     st        ddi      d
fS |j                         r4|st        d| ddd      dfS t        j                   t        |             t        j"                  t        |      t        |             t        ddi      S )zDCopy job folder to SHARED_DIR. Errors if destination already exists.Tr  r  r7   	overwriteFr  r  r  r`  Source folder not foundrM   Invalid destination pathA folder named "z(" already exists in the shared directory)r  r    rU  )r   r  r   r   ra  rG  r   rb  r,   r  r
  r  r*   rw   r  r  rmtreer  )r   r  r5  r$  r  s        r1   publishr;  v2 sd    4(.BD((8R(KTXXk512Ik*dk.A!123S88{"
+
+
-C::<!:;<cAATD1#
,
,
.Cs8s:#5#5#789!;<=sBB
zz|'7}Dl%my}~  BE  E  Ec#h
OOCHc#h'D$<  r3   /duplicate-folderc                     t        j                  d      xs i } | j                  dd      }| j                  dd      xs dj                         }t        j                  |      rd|v rt        ddi      d	fS t        j                  |      rd|v rt        dd
i      d	fS t        |z  j                         }|j                         st        ddi      d	fS t        |z  j                         }t        |      j                  t        t        j                                     st        ddi      d	fS |j                         rt        dd| di      dfS t        j                  t        |      t        |             t        d|d      S )zCopy 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.Tr  r  r7   
new_folderr  r  zInvalid source folderr`  zOInvalid folder name. Use letters, numbers, spaces, dashes, underscores or dots.r6  r7  r8  " already exists.r9  )rU  r  )r   r  r   r   ra  rG  r   rb  r,   r  r*   rw   r  r  r  )r   r  new_namer$  r  s        r1   duplicate_folderrA  2 si   
 4(.BD((8R(Kr*0b779Hk*dk.A!89:C??h'48+;!rstvyyy{"
+
+
-C::<!:;<cAAx
(
(
*Cs8s9#4#4#678!;<=sBB
zz|#3H:=N!OPQSVVV
OOCHc#h'$(344r3   PRODRUN_DIRz/var/www/html/prodrunmergeicastinc.sharepoint.comz/sites/StackITz4/sites/StackIT/Shared Documents/StackIT-to-Netsuite/zStackIT-to-Netsuitezapproved_sources.jsonc                      dg dg dS )NF)zfileshare.icastinc.comr  rC  zdocs.google.com)r~  domainspath_prefixesrX   rX   r3   r1   _prodrun_default_approvedrG  2 s    
 	 	r3   c                     t               } 	 t        j                         r*t        j                  t        j                               }t        |t              rt        |j                  d      t              r|d   | d<   t        |j                  d      t              rG|d   D cg c]5  }t        |      j                         st        |      j                         7 c}| d<   t        |j                  d      t              rG|d   D cg c]5  }t        |      j                         st        |      j                         7 c}| d<   | S c c}w c c}w # t        j                  t        f$ r Y | S w xY w)Nr~  rE  rF  )rG  PRODRUN_APPROVED_FILEr   r   r   r   r   r   r   r   r  r*   r   r   r   )cfgr  rn  r  s       r1   _load_prodrun_approvedrK  2 s!   
#
%C ((*JJ4>>@AE%&eii	2D9%*9%5C	Neii	2D9>CI>N%aRUVWRXR^R^R`c!flln%aC	Neii8$?DI/DZ+mq^abc^d^j^j^lCFLLN+mC( J &b+m  '* Js<   BE %EE,E E*EE 
E E43E4rJ  c                     t         j                  dd       t        j                  t	        j
                  | d             y NTrM   r   r   )rB  r  rI  r   r   r   rJ  s    r1   _save_prodrun_approvedrO  2 s.    dT2$$TZZA%>?r3   rJ  c                 L  	 ddl m} 	  ||       }|j                  xs dj	                         sy|j                  dg       D cg c]I  }t        |      j                         s|j	                         j                  d      j                         K }}t        fd|D              sy|j                  dg       D cg c]  }t        |      j                         s|  }}|r%|j                  xs d	t        	fd	|D              syy
# t        $ r Y yw xY wc c}w c c}w )zuTrue when *url*'s host is in the allowlist and (if any prefixes are set) its
    path starts with an approved prefix.r   r  Fr7   rE  z*.c              3   T   K   | ]  }|k(  xs j                  d |z          ! ywr  )r|  )r,  rn  r$  s     r1   r/  z(_prodrun_url_approved.<locals>.<genexpr>2 s*     Dqtqy2DMM#'22Ds   %(rF  c              3   @   K   | ]  }j                  |        y wrT   )rw   )r,  r  rb   s     r1   r/  z(_prodrun_url_approved.<locals>.<genexpr>2 s     8!4??1%8r  T)r   r
   ry   r!  rE  r   r*   r   rV  r2  rb   )
rJ  rJ  r
   r  rn  rE  r  prefixesr$  rb   s
           @@r1   _prodrun_url_approvedrT  2 s     &SM JJ"##%D7:wwy"7M`!QTUVQWQ]Q]Q_qwwy%++-`G`DGDD77?B7Ja3q6<<>JHJvv|8x88  
 a Ks)   D D&/D D!D!	DDzresolutions.jsonr  zOutside approved domains/paths	Not a PDFz
HTTP errorzSharePoint access deniedzNetwork errorzMerge errorzUnknown error)file_not_foundoutside_approved_domainnot_pdf
http_errorsharepoint_authnetwork_errormerge_errorr2  c                      	 t         j                         r9t        j                  t         j	                               } t        | t              r| S i S # t        j                  t        f$ r Y i S w xY w)zDAll persisted resolutions, keyed by job_id -> {failure_key: record}.)	PRODRUN_RESOLUTIONS_FILEr   r   r   r   r   r   r   r   r  s    r1   _load_prodrun_resolutionsr_  3 se    #++-::6@@BCD$% I   '* Is   AA A.-A.c                     t         j                  dd       t        j                  t	        j
                  | d             y rM  )rB  r  r^  r   r   r   r  s    r1   _save_prodrun_resolutionsra  3 s.    dT2''

4(BCr3   c                 &    |  d|xs d d|xs d S )ztStable identity for a failed item within a run (survives merge_error's
    item_idx of -1 by folding in type + url).rS  r7   rX   )item_idxtype_rJ  s      r1   _prodrun_failure_keyre  3 s#     Zq"Qsybk22r3   r  c           	      X   | j                  d      xs d}t        j                  ||      }| j                  d      dk(  rdnd}ddd	| d
| dd| j                  d      xs d d
| dd| j                  d      xs d d| j                  d      xs d ddg}dj                  |      S )zSeed prompt fed to the existing Assistant so it can suggest a fix for one
    failure and a general rule to auto-resolve that error type in the future.categoryr2  rT  drawingDrawingLabelzA Production Run PDF merge failed to fetch a source document. Help me resolve it, and suggest how Togen could auto-resolve this error type in the future.r7   z- Failure category: r:  r7  z- Document: doc_numz	(unknown)z- Source URL/path: rJ  (none)z- Error detail: reasonz(none captured)zPlease provide: (1) the likely cause, (2) concrete steps to fix this specific failure, and (3) a rule Togen could apply automatically to prevent or auto-correct this category of error.r0  )r   _PRODRUN_CATEGORY_LABELSr  )r  r(  r   rd  rP  s        r1   _prodrun_resolution_promptro  3 s     %%


(yC$((c2Ev)3IE	 	
ugRuA.
quuY'6;7r%B
aeeEl6h78
155??.?@A
	:E 99Ur3   zautotune_rules.jsonzDomain rewritez
Path remapzSource substitution)domain_rewrite
path_remapsource_substitutionc                  n   	 t         j                         rzt        j                  t         j	                               } t        | t              rCt        | j                  d      t              r$| d   D cg c]  }t        |t              s| c}S g S c c}w # t        j                  t        f$ r Y g S w xY w)z1All saved auto-tuning rules (list of rule dicts).rules)PRODRUN_AUTOTUNE_FILEr   r   r   r   r   r   r   r  r   r   r   r  s     r1   _load_prodrun_rulesrw  E3 s     ((*::3==?@D$%*TXXg5F*M#'=HaJq$4GHH I I  '* Is*   A1B 3B	BB B B43B4rt  c                     t         j                  dd       t        j                  t	        j
                  d| id             y )NTrM   rt  r   r   )rB  r  ru  r   r   r   )rt  s    r1   _save_prodrun_rulesry  Q3 s3    dT2$$TZZ%0@%KLr3   c           
         | j                  d      xs dj                         }|t        vry| j                  d      xs dj                         }| j                  d      xs dj                         }|r|sy| j                  d      xs dj                         }|r
|t        vrd}||||| j                  d      xs dj                         t	        | j                  d	            d
dfS )zValidate/normalize the editable fields of a rule payload. Returns
    (fields, None) on success or (None, error_message) on failure. id, author,
    timestamps and provenance are managed by the route, not here.rT  r7   )NzUnknown rule typerG  replacement)Nz'Both match and replacement are requiredrg  notesr~  )rT  rG  r{  rg  r|  r~  N)r   r   _PRODRUN_RULE_TYPESrn  r   )r   rtyperG  replr(  s        r1   _prodrun_rule_cleanr  V3 s     XXf#**,E''(XXg$"++-EHH]#)r002D>88J%2
,
,
.C
s22((7#)r002+,  r3   
viewer_urlc                     ddl m}m} d| v r4 ||       } ||j                        }|j	                  ddg      d   }|r|S | S )a  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.
    r   )r
   parse_qszdocs.google.com/viewerrJ  r7   )r   r
   r  r#  r   )r  r
   r  rL  qsinners         r1   _extract_pdf_urlr  n3 sL     0:-*%fll#urd#A&Lr3   c                 >   ddl m}m}m}  ||       }|j                  t
        k7  ry ||j                        }|j                  ddg      d   }|rI|j                  t              r4|t        t              d }|r t               }| d ||d       }	d	||	|d
S d| dS )ug  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
    r   )r
   r  r   Nr  r7   /shared/r6   r  rb   )rT  relative_pathfileshare_urlsp_server_pathsharing_link)rT  rJ  )r   r
   r  r   r!  _SP_HOSTNAMEr#  r   rw   _SP_SYNC_PREFIXrC  r  )
rJ  r
   r  r   rL  r  id_paramr  _url_prefixr  s
             r1   _resolve_sharepoint_urlr  3 s     76c]F,&	&,,	BvvdRD!!$HH''8 _!5!6713K*m8E-c4R3STM!.!."*	  #3//r3   r  r  c                    	 ddl m} t        j                  t        t
        t              }|j                  dt         dg      }d|vr!t        d|j                  d	d
       d       y || d      }dt         t         d| d}t        j                  |d|d    dddd      }|j                          t        |       j                  }	|	r|	j!                         j#                  d      sd|dd}	t%        |	      }	||	z  }
d}|
j'                         r-|
j(                  }|| d| dz  }
|dz  }|
j'                         r-t+        |
d      5 }|j-                  d      D ]  }|j/                  |        	 ddd       t        d|
j                   d       |
S # 1 sw Y   %xY w# t0        $ r}t        d| d       Y d}~yd}~ww xY w) zDDownload a file directly from SharePoint using app-only credentials.r   r   r  r@  z	/.defaultr  r  z1[PRODRUN-SP] Failed to acquire SharePoint token: r4  r2  Tr8  Nr6   r  z&/_api/web/GetFileByServerRelativeUrl('z	')/$valuer  application/octet-streamr  r  r  rx   r7  r  r   file_04dr^   r  r      z-[PRODRUN-SP] Downloaded from SharePoint API: z-[PRODRUN-SP] SharePoint API download failed: )r   r   r  r  r  r  r  r  r  rA  r   _SP_SITE_PATHr  raise_for_statusr   r9   rE  r|  r   r  r  r?  iter_contentr  ry   )r  r  r  _qr  r  encoded_pathapi_urlr  fnamer  r  r  r  chunkrl  s                   r1   _download_from_sharepointr  3 s   -,77%7:W
!::|nI67 ; 9-C##$7CDF
 .s3|n]O 4++7.	C 	 !!#*<+G*H!I4 
 	^$))EKKM226:E#;d+E&%kkm99Day55DqLG kkm $ 	**40 	 	=dii[IQUV		 	
  =cUC4Ps=   A)F, ,CF, F, (F <#F,  F)%F, ,	G5G

Gc                     ddl } ddlm}m} 	  | j                  g ddd      }d}|j                         D ][  }|j                         }|j                  d
      s%t        j                  |j                  dd      d   j                               } n |r|j                  d      sy|j                  dd      }|r	 d|vr|j                  d      dz   n|}|j                  |      }	|	|j                  |j                         k  rddl }
t        dd	       |
j#                  g ddd       |
j                  g ddd      }|j                         D ][  }|j                         }|j                  d
      s%t        j                  |j                  dd      d   j                               } n |j                  d      S # t        $ r}t        d| d	       Y d}~yd}~ww xY w# t        $ r}t        d| d	       Y d}~Ud}~ww xY w)u   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.
    r   N)r   r   )rclonerc  showiCast_StackITTr   )r&  r7  z([PRODRUN-SP] rclone config read failed: r8  ztoken ==r^   r  expiryr7   +Z+00:00u0   [PRODRUN-SP] rclone token expired, refreshing…)r  lsfziCast_StackIT:z--max-depthrF   r  )r7  r6  z*[PRODRUN-SP] rclone token refresh failed: )r9  r   r   check_outputry   rA  
splitlinesr   rw   r   r   r  r   r8   fromisoformatr  utcr:  )r9  r   r   r  rl  
token_datars   r  exp_strexp_dt_spout2l2s                r1   _get_rclone_sp_tokenr  3 s    +(((9r# J!!# zz|??9%DJJsA$6q$9$?$?$ABJ	
 Z^^N;^^Hb)F	R7:&7HfmmC(83fG++G4Fhll33(HPTUO "4  9''Ar ( + //+ BB}}Y/%)ZZa0@0C0I0I0K%L
	 >>.))?  8>dK:  	R>seDDQQ	Rs6   G >B:G) 99G) 	G&G!!G&)	H2HHc                 h	   dt        j                  | j                               j                  d      j	                         z   }dt
        dz  fd} |       }|rdd| ini }d}|r<t        j                  d| d	|d
did      }|j                  dv rt        dd       d}||j                  dv rCt               }|r)|}dd| i}t        j                  d| d	|d
did      }nt        dd       y	 d}	|r|j                  dk(  r|j                         }
|
j                  dd      }	|
j                  di       j                  dd      }t        d|	d|d       t         d}|j                  |      }|dk\  r|	r||t        |      z   d }t        |z  |	z  }|j!                         rt        d| d       |S t        d| d       ddlm} t'               }| d ||dz   |	z   d        }t        d!| d       	 t        j                  |d"d#      }|j)                          |	rt+        |	      nd$|d%d&}||z  }d'}|j!                         r+||j,                   d(| d&z  }|d'z  }|j!                         r+t/        |d)      5 }|j1                  d*      D ]  }|j3                  |        	 ddd       t        d+|j4                   d       |S |r|j                  nd-}|r|j8                  dd nd}t        d.| d/| d       t        j                  d| d0|d"d1      }|j)                          |	sC|j:                  j                  d2d      }d3|v r#|j=                  d3      d4   j?                  d5      }	|	r|	jA                         jC                  d&      sd$|d%d&}	t+        |	      }	||	z  }d'}|j!                         r+||j,                   d(| d&z  }|d'z  }|j!                         r+t/        |d)      5 }|j1                  d*      D ]  }|j3                  |        	 ddd       t        d6|j4                   d       |S # 1 sw Y   xY w# t6        $ r t        d,d       Y fw xY w# 1 sw Y   OxY w# t6        $ r}t        d7| d       Y d}~yd}~ww xY w)8u#  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.
    u!   =r$   Nc                      t        j                  t        t        t              } | j                  dg      }|j                  d      S )Nr  r  r  r  )r  r  r  r  r  r  r   r  r>  s     r1   _try_graph_tokenz2_resolve_sp_sharing_link.<locals>._try_graph_token4 sE    77%7:W
44:; 5 =zz.))r3   r  r  z(https://graph.microsoft.com/v1.0/shares/z
/driveItem$selectzname,parentReference,sizer  rx   paramsr7  r  r  zM[PRODRUN-SP] App credentials lack Graph file permissions, trying rclone tokenTr8  z-[PRODRUN-SP] No rclone token available eitherr7   rK   r9   parentReferencerb   z)[PRODRUN-SP] Sharing link resolved: name=z parent=r6   r   u*   [PRODRUN-SP] Sharing link → local file: z[PRODRUN-SP] Local not found: r  r  r  z#[PRODRUN-SP] Trying fileshare URL: r  r7  r  r  r  r   r^   r  r  r  z'[PRODRUN-SP] Downloaded via fileshare: z=[PRODRUN-SP] Fileshare download failed, trying Graph downloadzno responsez%[PRODRUN-SP] Graph driveItem lookup: r\   z/driveItem/contentr  Content-Dispositionz	filename=z" z/[PRODRUN-SP] Downloaded via Graph sharing API: z-[PRODRUN-SP] Sharing link resolution failed: )"base64urlsafe_b64encoder  r8   decoder*   r  r   rg   rA  r  r   _SP_DRIVE_SYNC_ROOTrM  rC  r
  r  r   r   r  r  r   r  r?  r  r  r9   ry   r&  rx   r  r   rE  r|  )rJ  r  r  sharing_tokenr  r  rx   	meta_resprclone_tokenr  metaparent_pathmarkerr  
rel_parent
local_pathr   r  r  fs_respdl_fnamer  r  r  r  rd   r  dl_respcdrl  s                                 r1   _resolve_sp_sharing_linkr  3 s    633CJJLAHHNUUWWM*cDj * $%L=I', 89rGI"&&6}oZP:;	
	   J.aimnII11Z?+-'L&',(@AG&**:=/T!#>?	I ANH..#5>>#DHHVR(E((#4b9==fbIK=eYh{o^fjk+,A.F""6*CaxE(s6{):);<
'*4u<
$$&FzlS[_`%%6zlC4P.57#.-xj3>NQV>V]`8a7b c;M?KSWXg,00TXYG,,.9>u5eERU;VZD[H#h.DG++-'TYYKq	*FF1 ++- dD) +Q%,%9%9$%? +EGGEN++ CDII;OW[\K /8Y**]F+49>>$3'"D9&4&IQUV !$$6}oEWX	
 	  "$$%:B?Bb -b177=EKKM226:E#;d+E&%kkm1WIT::DqLG kkm $ 	 --d3 	 	?		{KSWXK+ +
 ! gYaeffg8	 	  =cUC4Ps   7CR AR A=Q% 	Q% (Q>#Q% "DR ?R (R4#R Q"Q% %Q?;R >Q??R RR 	R1R,,R1c                   456 ddl m} ddlm6 t        | z  }t        |      }|st        |ddd       y|j                         j                  d      }||z  }|d	z  }|d
z  }||fD ]  }|j                  dd        t        |ddd       |j                  dg       }	|j                  di       }
|j                  di       }t               5g }g }d}t        |	      D ]  \  }}|
j                  t        |      i       }|j                  dd|dz          }|j                  d      }d}|r|j                  d      dk(  rn|r&|j                  d      dk(  r|d   t        |      d}na|r:|j                  d      dk(  r&|j                  d      rt        |d         |d   d}n%|j                  d      rt        |d         |d   d}|rZ||d<   ||d<   |j                  d |dz         |d <   t        |j                  t        |      d!            |d"<   |j!                  |       |j                  d#      }d}|r|j                  d      dk(  rn|r&|j                  d      dk(  r|d   t        |      d}na|r:|j                  d      dk(  r&|j                  d      rt        |d         |d   d}n%|j                  d$      rt        |d$         |d$   d}|rZ||d<   ||d<   |j                  d |dz         |d <   t        |j                  t        |      d!            |d"<   |j!                  |       |j                  d      rk|j                  d$      r~t        |      |
vs|d!z  } t        |dd%t#        |       d&t#        |       d'd       dt        d(t$        d)t        d*t&        t$        dz  t(        dz  f   f6fd+4d,t(        d(t$        d)t        d*t&        t$        dz  t(        dz  f   f45fd-}g }g }t        |      D ]  \  }} ||||      \  }}|r$|j!                  ||j                  d"d!      f       9|j!                  |d   |d   |j                  d       d|j                  d.|j                  d|j                  dd/                  |xs i j                  d0d1      |xs i j                  d2d3      d4        g }t        |      D ]  \  }} ||||      \  }}|r$|j!                  ||j                  d"d!      f       9|j!                  |d   |d   |j                  d       d#|j                  d.|j                  d|j                  dd/                  |xs i j                  d0d1      |xs i j                  d2d3      d4        t        |dd5t#        |       d&t#        |       d'd       dd6lm} g }|
j/                         D ]  \  } }d7D ]  }!|j                  |!      }"|"r|"j                  d      s(|j!                  t        |       j1                         rt        |       n| |!|"j                  d      |"j                  dd/      |"j                  d8d/      |"j                  d9d/      d:         d;|t#        |      ||t#        |      d<}#|r	  |       }$|D ]/  \  }%}&t3        |&      D ]  }'|$j!                  t        |%              1 | d=}(||(z  })t5        |)d>      5 }*|$j7                  |*       ddd       |(| t#        |$j8                        d?t;        | d/@       dAt;        |(d/@       dBt;        | d/@       dAt;        |(d/@       dC|#dD<   |r	  |       }$|D ]/  \  }%}&t3        |&      D ]  }'|$j!                  t        |%              1 | dF}(||(z  })t5        |)d>      5 }*|$j7                  |*       ddd       |(| t#        |$j8                        d?t;        | d/@       dAt;        |(d/@       dBt;        | d/@       dAt;        |(d/@       dC|#dG<   dID ]3  \  },}-|#j                  |,      s|j!                  dJd/d|-d/dK|#|,   d4       5 t#        |      |#dL<   ||#dM<   t        ||#       	 t?        dN      r+d/x}.}/|	D ]9  }0|.s|0j                  dO      r|0dO   }.|/s|0j                  dP      r|0dP   }/|.s6|/s9 n tA        |D 1ch c]  }1|1j                  d0d1       c}1      }2g }3|.r|3j!                  |.       |/r|3j!                  dQ|/        |3j!                  t#        |       dR       |r|3j!                  t#        |       dS       tC        dNdT|j                  dUd/      dVjE                  |3      | |#j                  dW      t#        |      t#        |      |2|.|/dXY       yy# 1 sw Y   xY w# t<        $ r}+t        |+      |#dE<   Y d}+~+~d}+~+ww xY w# 1 sw Y   "xY w# t<        $ r}+t        |+      |#dH<   Y d}+~+d}+~+ww xY wc c}1w # t<        $ r" tF        jH                  jK                  dZ       Y yw xY w)[zEBackground worker: download PDFs from saved state and merge each set.r   r   r  r  zNo state foundrd   r  Nr  r   labelsTrM   
processingu   Preparing downloads…rd   r  r   	overrides
quantitiesrk  zRow r   rh  rT  removedr  r  )r  job_dirrJ  )rJ  original_urldrawing_urlrc  row_numr^   qtyr   	label_urlzDownloading z drawings and u
    labels…r  r  r$   c                    	 t        j                  | dd      }	 |j                           |       }|j
                  rt        |j
                        j                  nd}|r|j                         j                  d      sd|dd}t        |      }||z  }d}	|j                         r-|j                  }
||
 d|	 dz  }|	dz  }	|j                         r-t        |d      5 }|j                  d      D ]  }|j                  |        	 ddd       |dfS # t        $ r" t	        |dd      }|dv rdnd}d|d	| d
fcY S w xY w# 1 sw Y   ;xY w# t        $ r}ddt!        |      dd d
fcY d}~S d}~ww xY w)zDownload a PDF. Returns (path, None) on success or (None, error) where
        error is {category, reason} for DVI-959 failure diagnostics.r  Tr  rg   Nr  rZ  rY  zHTTP rg  rm  r7   r   r  r  r^   r  r  r  r[  rK   )r  r   r  ry   re   rb   r   r9   rE  r|  r   r  r  r?  r  r  r*   )rJ  r  r  r  r3  r(  rL  r  r  r  r  r  r  rl  r
   s                 r1   _download_pdfz,_prodrun_merge_worker.<locals>._download_pdf4 s   	Q!%%c3tDDI%%'
 c]F.4kkD%**rE 6 6v >c{$/#E*Ee#DG++-yyTF!G9D"991 ++- dD! #Q!..t4 #EGGEN## :%  It]D9+/:+='<#5HHHI# #  	QoS$3PPP	Qs^   E D B+E E $(EE (EE EE EE 	E9E4.E94E9r$  c                    d| v rst        | d         | d   z  }|j                         sd dd| j                  dd       dfS |j                  j	                         dk7  rd dd	|j
                   dfS |d fS | d
   }j                  d      rt        |      sd dd|d d  dfS t        |      }|r|d   dk(  rt        |d
   ||      }|r|d fS d dddfS t        |d   z  }|j                         r1|j                  j	                         dk(  rt        d| d       |d fS t        dd        |d   ||      \  }}	|r|d fS t        dd       t        |d   ||      }
|
r|
d fS |	xs i j                  dd      }|dk(  rd|	xs i j                  dd      v rd}d ||	xs i j                  dd      dfS  |||      S ) Nr  r  rV  zFile does not exist: r7   r  r   rX  zProvided file is not a PDF: rJ  r~  rW  z*Source host/path not in approved domains:    rT  r  rZ  z-SharePoint sharing link could not be resolvedr  z&[PRODRUN-SP] Using local synced file: Tr8  z2[PRODRUN-SP] Local not found, trying fileshare URLr  z4[PRODRUN-SP] Fileshare failed, trying SharePoint APIr  rg  rY  403rm  zSharePoint download failed)r   r  r   r!  rE  r9   rT  r  r  r
  rA  r  )r$  r  r  r  rJ  sp_infor  r  r>  errresult2r(  r  approved_cfgs               r1   _resolve_sourcez._prodrun_merge_worker.<locals>._resolve_source4 s]   S=S^$s6{2A88:*:*?PR@S?T(UW W Wxx~~6))*Fqvvh(OQ Q Qd7N%j I&/DS,/W&?&PQTUYVYQZP[$\^ ^ ^)#.v.0,WU^XuMd7N*;(WY Y Y $go&>>J  "z'8'8'>'>'@F'J>zlKSWX!4''FtT'(@(ERKFCt|#HQUV/8H0I8UZ[G}$9"//*.?@Cl"u"0M'M'c%(YBOOH>Z$[] ] ] S(E22r3   r  r7   rg  r2  rm  Download failed)rc  rk  r  rT  rJ  rg  rm  zMerging 	PdfWriterrh  r   
applied_by
applied_at)rc  rT  correction_typer  r  r  done)rd   skipped_rowsfailed_downloadsfailed_itemscorrectionscorrected_countz Drawing Merge.pdfr  zprodrun-preview?job_id=r  r  zprodrun-download?job_id=)r  r^  r@  r  r  drawing_mergedrawing_merge_errorz Label Merge.pdflabel_mergelabel_merge_error))r  rh  )r	  r   r  r\  r  r  prodruncustomerso_numzSO z failedz
 correctedr:  run_by    · rd   )rd   r  r  
categoriesr	  r	  actorsummaryr  detailzprodrun history record error)&r   r   r   r
   rB  _prodrun_load_state_prodrun_write_statustodayr  r  r   rK  r  r*   r  r   ri   rC  r   tupler   rJ  r  r   isdigitr  r?  r  r  r   ry   _is_history_capture_enabledr  record_historyr  rm   rn   r  )7r^  r   r  ro  date_strdate_dirdrawings_dir
labels_dirrn  r   r  r  drawing_sourceslabel_sourcesr  r  itemr  rk  draw_ovdraw_srclbl_ovlbl_srcr  drawing_filesr  r$  r  r  label_filesr  r  idx_strkindor>  r  r  r  r  merged_namemerged_pathr  rl  _mk_mlabel_cust_so_itfi_cats_summary_bitsr  r  r
   s7                                                       @@@r1   _prodrun_merge_workerr-	  w4 s
   %F"G(Eg'DT'UVzz|$$Z0H!Hj(LH$JJ' -	t,- 'lG_#`aIIgr"E		+r*I<,J)+L #%O "MGU# )4]]3q62&((9QUGn5 &&# $w{{6*i7V,6 ' 0S\JHV,5'++g:N/0@ASZ[bScdHXXm$/]0CDVZ[hVijH#$HZ ")HY"&((9a!e"<HY!*..Q";<HUO""8, #fjj(I5

6*f4%g3w<HG

6*e3

78K.vg?QWX_Q`aGXXk".tK/@ASWXcSdeG"#GJ!(GI!%)QU!;GI A!:;GEN  )xx&txx/DQW`I`qLGS)V 'l!-c/.B-C>RUVcRdQeeop$r sQ3 Q$ Qs QuTD[RVY]R]E]?^ Q:,3T ,3T ,3# ,3%tUY\`U`H`Ba ,3\ -/M!LO, 3 lA63  !SWWUA%6!78
Oy>779-!ww~swwucggfb>Q/RS YBOOJ	B9"//(4EF!  +-KM* 3 j!433775!#456
Oy>779-ww~swwucggfb>Q/RS YBOOJ	B9"//(4EF!  'l!)#m*<)=^CP[L\K]]gh$j k   !K ( ( 	DtAAEE&M,/L,@,@,BCL#$55=w+eeL"5eeL"5  		 -$"{+F 	5[F!. 1#s 1AMM#h-011 &J&89K"[0Kk4(  AQ  ( !&,,/!8vB9O8PPVW\]hoqWrVst":5b;Q:RRXY^_jqsYtXu v'F?# 	3[F!, 1#s 1AMM#h-011 &J&67K"[0Kk4(  AQ  ( !&,,/!8vB9O8PPVW\]hoqWrVst":5b;Q:RRXY^_jqsYtXu v%F=! ] W::c?2$)VC[!  "%\!2F)F>'6*"=&y1EC !4
OEswwx0h-CS LQbBFF:y9QREM$$U+$$s3%[1  C$5#6g!>?$$K(8'9%DE5ii"-M2$jj2(+L(9'*;'7"' %!% 2_     	5,/HF()	5     	3*-c(F&'	36 R.  =

;<=s   +Ag <g Ag /Ag?  g2Ag? Ah) h) h) h$8Ch)  g
g 	g/g**g/2g<7g? ?	h!hh!$h) )(iic                 >   t         | z  j                         }t        |      j                  t        t         j                                     sy|j	                         sy|j                         D ],  }|j                         s||z  }|j	                         s*|c S  y)a  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().
    N)rB  r,   r*   rw   r  rY  r  )r^  r  r  r\  r  s        r1   _prodrun_find_filer/	  5 s     & ))+Dt9K$7$7$9 :;;;=||~ ::<>D{{}	
 r3   r  c                     | dz  S )Nzprodrun_state.jsonrX   )r  s    r1   _prodrun_state_pathr1	  5 s    )))r3   c                     t        |       }|j                         r#t        j                  |j	                               S i S rT   )r1	  r  r   r   r   r  r  s     r1   r	  r	  5 s.    G$A()
4::akkm$::r3   c                 b    t        |       j                  t        j                  |d             y r   )r1	  r   r   r   )r  ro  s     r1   _prodrun_save_stater5	  5 s      ++DJJuQ,GHr3   c                 V    | dz  j                  t        j                  |d             y Nstatus.jsonr   r   r   r   r   r  rd   s     r1   r	  r	  5      }((F1)EFr3   r  c                 \   g }t        | dd      5 }t        j                  |      }t        |d        t	        |d      D ]  \  }}t        |      dkD  r|d   j                         nd}t        |      dkD  r|d   j                         nd}t        |      dkD  r|d   j                         nd}t        |      d	kD  r|d	   j                         nd}	t        |      d
kD  r|d
   j                         nd}
|s|	s|
s|j                  |||||	|
d        	 d d d        |S # 1 sw Y   |S xY w)Nr7   r  r  r   rq   r^   r   r     r  r  rk  r	  r	  r  r  )r?  r  rO  r  r  rC  r   ri   )r  r   r  rO  r  r  rk  r	  r	  col_ocol_ps              r1   _parse_csv_prodrunrB	  5 s   E	h[	9 RBVT%fA6 	LGS(+C1c!flln"G),SAs1v||~2H'*3x!|SV\\^F'*3x"}CGMMO"E'*3x"}CGMMO"E5LL""$ $" 	& L'& Ls   DD!!D+	xlsx_pathc           
      2   t        | dd      }t        |j                        dkD  r|j                  d   n|j                  d   }g }t        |j	                  dd      d      D ]  \  }}|rt        |      dk  r|d   t        |d         j                         nd}t        |      d	kD  r!|d	   t        |d	         j                         nd}t        |      d
kD  r!|d
   t        |d
         j                         nd}t        |      dkD  r!|d   t        |d         j                         nd}	t        |      dkD  r!|d   t        |d         j                         nd}
|s|	s|
s|j                  |||||	|
d       ! |j                          |S )NT)	read_only	data_onlyr^   r   r   )min_rowvalues_onlyr=	  r7   r   r  r>	  r  r?	  )	r   rC  
worksheetsr  	iter_rowsr*   r   ri   r[  )rC	  r  wsr   r  r  rk  r	  r	  r@	  rA	  s              r1   _parse_xlsx_prodrunrL	  5 s   	yDD	AB /!3q	q9IBE!",,qd,"KSTU c#hl),Q);#c!f+##%*-c(Q,3q6;M3s1v;$$&SU(+C1Q9KSV""$QS(+C2#b':MCG""$SU(+C2#b':MCG""$SUuU  
 	$ HHJLr3   z/prodrun-uploadc                     t         j                  j                  d      } | r| j                  st	        ddi      dfS | j                  j                         }|j                  d      s |j                  d      st	        ddi      dfS t        t        j                               dd	 }t        |z  }|j                  d
d
       |t        | j                        z  }| j                  |       g }|j                  d      rt        |      }nt        |      }|s/t!        j"                  t        |      d
       t	        ddi      dfS t%        |t        | j                        |i d       t	        ||d      S )zTAccept a CSV or XLSX, parse items with Document Numbers and URLs, return for review.r  r  r  r`  r  .xlsxzFile must be a .csv or .xlsxNr  TrM   ignore_errorszNo items found in file)r  r   r  )r  r   )r   r  r   r  r   rE  r|  r*   rW  rX  rB  r  r   r  rL	  rB	  r  r:  r5	  )csv_filefname_lowerr^  r  
saved_pathr   s         r1   prodrun_uploadrT	  6 sY    }}  'H8,,!345s::##))+K'0D0DW0M!?@A3FFr"FF"GMM$M.?8+<+<==JMM* EG$#J/":.c'l$7!9:;S@@'(9(9:"  fu566r3   z/prodrun-upload-filec                      t         j                  j                  d      } t         j                  j                  dd      }t         j                  j                  dd      }t         j                  j                  dd      }| r| j                  r|r|st        ddi      dfS d	|v rt        dd
i      dfS | j                  j                         j                  d      st        ddi      dfS t        |z  j                         }t        |      j                  t        t        j                                     st        dd
i      dfS |j                  dd       t        | j                        }| j                  ||z         t        d|i      S )z9Accept a per-item file override for production run merge.r  r  r7   r  r  r  r  r`  r  r  r   File must be a PDFTrM   r  )r   r  r   rg  r  r   rE  r|  rB  r,   r*   rw   r  r   r  )r  r  r  r  r  r  s         r1   prodrun_upload_filerW	  )6 sR    	&!A\\h+F!!,3J  b1IAJJfI!567<<v~!123S88::&&v.!567<<V#,,.Gw<""3{':':'<#=>!123S88MM$M.AJJ'EFF7U?J&''r3   /prodrun-submitc                  8   t        j                  d      xs i } | j                  dd      }| j                  di       }| j                  di       }|rd|v rt        dd	i      d
fS t        |z  j                         }t        |      j                  t        t        j                                     st        dd	i      d
fS t        |      }|st        ddi      d
fS t        j                  d      xs i }|j                  d      xs |j                  d      xs d}t        j                         j                  d      }|j                         D ]r  }	t        |	t              sdD ]Z  }
|	j                  |
      }t        |t              s%|j                  d      s7|j!                  d|       |j!                  d|       \ t ||d<   ||d<   ||d<   t#        ||       t%        j&                  t(        |fd      }|j+                          t        d|i      S )z9Accept reviewed items with overrides and start the merge.Tr  r  r7   r  r  r  r  r  r`  u*   No upload state found — upload CSV firstr'  r9   r  r2  secondstimespecr  rT  r  r  r	  r(  r^  )r   r  r   r   rB  r,   r*   rw   r	  r   r  r  r  r  r   r   r}  r5	  r  r  r-	  rq   )r   r  r  r  r  ro  _u_who_when_ov_kind_or   s                r1   prodrun_submitrc	  C6 s    4(.BDXXh#Fb)I,+JTV^!123S88V#,,.Gw<""3{':':'<#=>!123S88(E!MNOQTTT 
V		"B66&>9RVVG_9	D **I*>E! 3#t$) 	3EB"d#vlD1lE2		33 #E+$E,E(O' 5VIdSAGGIHf%&&r3   z/prodrun-statusc                  T   t         j                  j                  dd      } | rd| v rt        ddi      dfS t        | z  dz  }|j                         st        dd	d
      S 	 t        t        j                  |j                                     S # t        $ r t        ddd
      cY S w xY w)z%Poll for production run merge status.r^  r7   r  r  Invalid job_idr`  r8	  r  u   Initializing…r  u   Reading status…)
r   rh  r   r   rB  r  r   r   r   ry   )r^  status_paths     r1   prodrun_statusrg	  o6 s     \\h+FTV^!123S88&6K)8IJKKNtzz+"7"7"9:;; N)8KLMMNs   !+B B'&B'z/prodrun-previewc                      t         j                  j                  dd      } t         j                  j                  dd      }| r
|rd| v sd|v ryt        | |      }|syt	        t        |      d      S )	z5Serve a merged production run PDF inline for preview.r^  r7   r  r  zInvalid parametersr`  r  r  r   r   rh  r   r/	  r   r*   r^  r  ri  s      r1   prodrun_previewrl	  6 si     \\h+F||+HTV^tx7G("684I$S^.?@@r3   z/prodrun-downloadc                      t         j                  j                  dd      } t         j                  j                  dd      }| r
|rd| v sd|v ryt        | |      }|syt	        t        |      d|      S )	z%Download a merged production run PDF.r^  r7   r  r  ri	  r  Tr  r+  rj	  rk	  s      r1   prodrun_downloadro	  6 sj     \\h+F||+HTV^tx7G("684I$S^4xPPr3   z/prodrun-historyr  )r   c                     t         j                  j                  d      dv } t         j                  j                  d      xs dj                         }g }t        j                         rt        j                         D ]  }|j                         s|dz  }|j                         s+	 t        j                  |j                               }|j                  d      }|dvret        |      }|j                  dg       }d}	d}
|D ]9  }|	s|j                  d	      r|d	   }	|
s|j                  d
      r|d
   }
|	s6|
s9 n |j                  d      xs i }|j                  d      xs i }t        |j                  d      xs g D ch c]  }|j                  dd       c}      }| r|dk7  r|j                  d      sD|r||vrL	 |j                         j                  }|j#                  |j$                  t'        |dz        |j                  dd      t)        |      |	|
|j                  d      |j                  d      |j                  dd      ||j                  dd      |j                  dd      |d        |j+                  d d       t-        d|i      S # t        $ r Y 3w xY wc c}w # t         $ r d}Y w xY w)a  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.

    DVI-959 P2: optional filters ``failures_only=1`` and ``category=<cat>`` let
    the Auditor Production Run sub-tab focus on problem runs.
    failures_onlyrE   rd  rf  rg  r7   r8	  rd   )r  r  r   r	  r	  r  r 	  r  r2  r  r  r     r  r@  r  )r^  timestamp_msr  r  r	  r	  drawing_pageslabel_pagesr  rd   r  r  r	  c                     | d   S )Nrt	  rX   r  s    r1   r	  z!prodrun_history.<locals>.<lambda>6 s    An- r3   Tr
  r  )r   rh  r   r   rB  r  rY  r  r   r   r   ry   r	  r  r  r  r   ri   r9   r   rC  r  r   )f_failures_only
f_categoryr  r  rf	  rd   stro  r   r	  r	  itdmlmr*	  catsmtimes                    r1   prodrun_historyr	  6 s    ll&&7;OOO,,"":.4";;=JD"**, 5	G>>#!M1K%%'K$9$9$;<
 H%B**'0EIIgr*EHF BFF:$6!*~H"&&"2\F O,2BM*0bB&,jj&@&FBI! 66*i8 I JD 2=DV9Wj4#((*33 KK!,, #EDL 1 %		." =!%j$ !#!5!vvl3$*JJ/A1$EGR0#)::.?#C" O5	l 	II-tI<FD>""_  (I  s*   *#J%J5
J:%	J21J2:KKz/prodrun-approved-sourcesGETc                  (    t        t                     S )z?DVI-959 P2: return the approved domains/paths allowlist config.)r   rK  rX   r3   r1   prodrun_approved_sources_getr	  6      )+,,r3   c                  d   t        j                  d      xs i } t               }t        | j	                  dd            |d<   t        | j	                  d      t              rG| d   D cg c]5  }t        |      j                         st        |      j                         7 c}|d<   t        | j	                  d      t              rG| d   D cg c]5  }t        |      j                         st        |      j                         7 c}|d<   t        |       t        d|d      S c c}w c c}w )zCDVI-959 P2: save the approved domains/paths allowlist (admin only).Tr  r~  FrE  rF  )rU  rc  )r   r  rG  r   r   r   r  r*   r   rO  r   )r   rJ  rn  r  s       r1   prodrun_approved_sources_saver	  6 s     4(.BD
#
%C$((9e45C	N$((9%t,26y/TQSV\\^#a&,,.TI$((?+T28<_8M`1QTUVQWQ]Q]Q_A`O3$#.//	 U`s   )D(D(D-.D-z/prodrun-resolutionsc                      t         j                  j                  dd      } | rd| v rt        ddi      dfS t        dt	               j                  | i       i      S )zGDVI-959 P3: resolutions recorded for one run (job_id) -> {key: record}.r^  r7   r  r  re	  r`  resolutions)r   rh  r   r   r_  r^  s    r1   prodrun_resolutions_getr	  6 sZ     \\h+FTV^!123S88M#<#>#B#B62#NOPPr3   z/prodrun-resolve-promptc                  b    t        j                  d      xs i } t        dt        |       i      S )u   DVI-959 P3: build the Assistant seed prompt for a failure. No LLM call
    happens here — the frontend feeds this into the existing Assistant surface.Tr  prompt)r   r  r   ro  r  s    r1   prodrun_resolve_promptr	  7 s/    
 4(.BDH8>?@@r3   z/prodrun-resolve-failurec                     t        j                  d      xs i } | j                  d      xs dj                         }|rd|v rt	        ddi      dfS t
        |z  j                         st	        dd	i      d
fS | j                  d      xs dj                         }|st	        ddi      dfS t        j                  d      xs i }|j                  d      xs |j                  d      xs d}t        | j                  d      | j                  d      | j                  d            }|| j                  d      | j                  d      | j                  d      | j                  d      | j                  d      | j                  d      || j                  d      dk(  rdnd|t        j                         j                  d      d}t               }||j                  |i       |<   t        |       	 t        d      rY|j                  d      xs d}t!        dd|t"        j                  ||       d|d d!  |||j                  d      |d   |d"#       t	        d|d$      S # t$        $ r Y w xY w)%zDVI-959 P3: record a human resolution for one failed item (mark resolved,
    how it was fixed, who/when). Persisted per job as the tuning foundation for
    the future Option 3 rules engine, and mirrored into shared history.db.Tr  r^  r7   r  r  re	  r`  Run not foundr  
resolutionzA resolution note is requiredr'  r9   r  r2  rc  rT  rJ  rk  rg  rm  sourcer  r  rZ	  r[	  )r)  rc  rT  rk  rg  rJ  rm  r	  r	  resolved_byresolved_atr	  r,   r>  Nr   )rg  rJ  r	  r	  r	  rU  r  )r   r  r   r   r   rB  r  r   re  r  r  r  r_  r}  ra  r	  r	  rn  ry   )	r   r^  r	  r]	  whor)  r  store_cats	            r1   prodrun_resolve_failurer	  7 sV    4(.BDhhx &B--/FTV^!123S88& ((*12C77((<(.B557J!@ABCGG	V		"B
&&.
8BFF7O
8yC
txx
3TXXf5EtxxPU
WCHHZ( 88I&HHZ(xx((8$ !%(!3{!B+%))+55y5IF &'E(.EVR %e$&y1::j)6YD9377dCDBzRUSUFWX $!::e,$X.",	 $&122  s   3A$I% %	I10I1z/prodrun-autotune-rulesc                  6    t        t               t        d      S )zDVI-963 O3.P1: list saved auto-tuning rules. Read-only for prod-run users;
    editing is admin-gated below. Rules are inert in P1 (no merge consumes them).)rt  rV  )r   rw  r}  rX   r3   r1   prodrun_autotune_rules_getr	  G7 s    
 02=PQRRr3   z/prodrun-autotune-rulec                     t        j                  d      xs i } t        |       \  }}|rt        d|i      dfS t	        j
                  d      xs i }|j                  d      xs |j                  d      xs d}t        j                         j                  d	
      }ddt        j                         j                  dd z   i|| j                  d      xs dj                         xs d| j                  d      xs dj                         xs d|||d}t               }|j                  |       t        |       t        d|d      S )zDVI-963 O3.P1: create an auto-tuning rule. Optionally seeded from a saved
    resolution (source_job_id/source_key). Created disabled by default; enabling
    is inert until P2 (dry-run) / P3 (live) wire application.Tr  r  r`  r'  r9   r  r2  rZ	  r[	  r  atr_Nr-  source_job_idr7   
source_key)r	  r	  
created_byr  r  rU  rule)r   r  r  r   r   r   r  r  r  rW  rX  rY  r   rw  ri   ry  )r   r  r  r]	  r	  r  r	  rt  s           r1   prodrun_autotune_rule_creater	  O7 s4    4(.BD%d+KFC
~&++	V		"B
&&.
8BFF7O
8yC




(
()
(
<Cftzz|'',,
 ((?39r@@BJdxx-3::<DD  !E	LL$-..r3   z /prodrun-autotune-rule/<rule_id>c                     d v sd v rt        ddi      dfS t        j                  d      xs i }t        |      \  }}|rt        d|i      dfS t	               }t         fdt        |      D        d	      }|d
k  rt        ddi      dfS ||   }|j                  |       t        j                         j                  d      |d<   t        |       t        d|d      S )zHDVI-963 O3.P1: edit an existing rule's fields / toggle its enabled flag.r  r6   r  zInvalid rule idr`  Tr  c              3   R   K   | ]  \  }}|j                  d       k(  s|   ywr  Nr  )r,  r  r  rule_ids      r1   r/  z/prodrun_autotune_rule_update.<locals>.<genexpr>v7 s$     Ida!%%+2HI   ''r  r   Rule not foundr  rZ	  r[	  r  r	  )r   r   r  r  rw  r  r  r  r  r  r  ry  )r	  r   r  r  rt  r  r  s   `      r1   prodrun_autotune_rule_updater	  k7 s     w#.!234c994(.BD%d+KFC
~&++!E
Ii.I2
NC
Qw!123S88SzHOOF+//1;;Y;OH\$122r3   DELETEc                     t               }|D cg c]  }|j                  d      | k7  s| }}t        |      t        |      k(  rt        ddi      dfS t	        |       t        ddi      S c c}w )zDVI-963 O3.P1: delete a rule.r  r  r	  r  rU  T)rw  r   rC  r   ry  )r	  rt  r  	new_ruless       r1   prodrun_autotune_rule_deleter	  7 sp      !E!<qQUU4[G%;<I<
9~U#!123S88	"D$<  	 =s
   A-A-c           
      N  "#$%& t        ddd      ##j                  d       #j                          #j                  dz
  $d%d	}d
&d}d"#j	                  ddd        #j
                  "  #j                  dddd       #j                  dddd       #j                  d       "#$%&fd}de#$fd	} |d        |d| j                  dd      fd| j                  d d      fd!| j                  d"d      fg       #j                  d#        |d$        |d%| j                  d&d      fd'| j                  d(d      fg        |d)| j                  d*d      fd+| j                  d,d      fd-| j                  d.d      fg       | j                  d/      xs i }|j                  d0      ry#j	                  ddd       #j                  $dd1       #j                  d       #j	                  ddd2       #j                  $d3t        |j                  d0d            dd4       #j                  d#        #j                  |  #j	                  ddd       #j                  $d5d6d       #j	                  ddd2       | j                  d7d      }#j                  d8d8d8       #j                         #j                         }	}#j                  ||	$d9       #j                  |d:z   |	dz          #j!                  $dz
  d;|       #j#                  |	d<z          #j	                  ddd       #j                  $d5d=d       | j                  d>g       }
t%        |
t              r|
g}
| j                  d?d      }g d@}$d#z  }t'        dt)        |      d#      D ]~  }|||d#z    }|D ]^  }||
v }#j	                  dAdd2       #j                  d5d5|rdBnddC       #j	                  dddD       #j                  |d5z
  d5dE|z          ` #j                  dF        dG|
v }#j	                  dAdd2       #j                  d5d5|rdBnddC       #j	                  dddD       |r|rdH|z   ndI}#j                  |d5z
  d5dE|z          #j                  d3       #j                         }$d:z  }#j*                  }#j	                  ddd       #j                  |d5dJd       #j	                  ddd2       #j                  |d3| j                  dKd      dd4       #j                  d:       #j	                  ddd       #j                  |d5dLd       #j	                  ddd2       #j                  |d3| j                  dMd      dd4       #j                         }| j                  dNd      }||z   }#j                  ||       #j	                  ddd       #j                  |d5dOd       #j-                  |       dPD ]r  }||k(  }#j	                  dAdd2       #j                  d5dF|rdBnddC       #j	                  dddD       #j                  |d5z
  dFdE|z   d       #j-                  |       t #j                         }#j#                  t/        ||      dz          | j                  dQd      }#j	                  dAdd2       #j                  d5d5|rdBnddC       #j	                  dddD       #j                  $d5z
  d5dRd       |r| j                  dS      xs t0        D ci c]  }|dT c}}|j3                         D cg c]  \  }}|s	|j5                          }}}#j	                  ddUd       #j                  d5dd       #j                  $d5z
  ddV|rdWj7                  |      ndXz   d       #j                  d#        #j                  |   #j
                  &  #j	                  ddd2       #j                  $ddYdTdZ        #j
                  "  |xs i } | j                  d[d      xs | j                  d\      r| d\   d]nd}! |d^| j                  d_d      fd`| j                  dad      fdb|!fgdcd       #j9                         S c c}w c c}}w )fu   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.
    Pr  letterorientationunitformatFr   rx  r         r   r   r      r	  r	           r   r   r   	HelveticaB   2   r-  ICASTr   lnzMaintenance Work Orderr^   r   c                      j                      j                    j                  ddd       j                  dd| z   dd        j                    y 	Nr	  r	  r   r     Tr^   fillr	  set_fill_colorset_text_colorset_fontcellre  blackr6  pwtealwhites    r1   section_headerz)_generate_wor_pdf.<locals>.section_header7 ]    D!E"[#r*Qu4A6E"r3   r   c           	      D   t        |       z  }j                  ddd       | D ]  \  }}j                  |d|        j                  d       j                  ddd       | D ]'  \  }}j                  ||t	        |xs d      d       ) j                  |       y Nr	  r	  r   r   r7   ry  borderrC  r	  r	  r	  r*   pairsrow_hcol_wr   r  rQ  r6  r	  s         r1   label_value_rowz*_generate_wor_pdf.<locals>.label_value_row7     SZ[#q) 	&HE1HHUAu%	&q	["a( 	?FAsHHUE3syb>#H>	?ur3   Requestor informationr1  r  r7   Phone #r  Emailr  rP   Request detailsr2  r3  Locationr  r4  r  Man Holeman_holeEquipment/asset/system affectedr  r  r  !Attached repair / operator manualry  r   r	  r	  r   r5  r  r;  rS  r         @)   $Type of issue (check all that apply)rB  issue_type_otherr6  r7  r8  r9  r:  r;  r<  r=  r>  r?  r  ZapfDingbatsrJ  r	  r  r	        @r@  Other: Other:rC  rD  rE  r  r  Urgency (check one)rF  r  z@  Return-to-Service (RtS) approval required upon work completionr  TIzApproving departments: r     —#  For use by maintenance departmentr	  work_order_numberr  r  Accepted byaccepted_byDater   Assigned work order #r   r	  r   )r   set_auto_page_breakadd_pager  r	  r	  r	  r	  r   r*   r	  set_draw_colorget_xget_yrD  set_xy
multi_cellset_yr   r  rC  l_marginset_xrC  rO  r   
capitalizer  r  )'r   r   	dark_graylight_bgr	  r	  _manualr  x0y0issue_types
other_textrW  r	  	row_start	row_itemsr	  checkedother_checkedother_display	y_sectionhalfr~	  
left_end_yr  urg_xlevelright_end_yr  rn  	rts_deptsr  r  wo_numr	  r6  r	  r	  r	  s'                                     @@@@@r1   _generate_wor_pdfr"
  7 sJ    3T(
;C'LLN	BDIEHE LLc2&CHHRWH#HHQ,H3FF1I# #	 *+	488$4b9:	DHHWb)*	$((7B'( 
 FF1I $%	DHH%6;<	TXXj"-.  	TXXj"-.	TXXj"-.	*DHH[",EF 
 hhx &BG{{?#[#q)Q;<q	["a(QGKK<=caPFF1I C!LLc1%HHR)aH0LLb!$88M2&DsC%YY[#))+BHHRRJJrAvrAvNN263%IIb2g LLc1%HHR:qHA((<,K+s#"m,b1JI FE1c)na0 	i	A6	 	0Dk)GLLQ/HHQ'3r!H<LLb!,HHUQY4$;/	0 	s {*MLLQ'HHQ-3RH:LLb!$.;
I
*PXMHHUQY4-/0FF1I 		I6D	B LLc1%HHT1.1H5LLb!$HHT1dhh|R0HCFF1ILLc1%HHT1/AH6LLb!$HHT1dhh8"=caHPJ hhy"%GIEJJui LLc1%HHT1+H2IIe* U"^R+CR:["a(3u3		% ))+KIIc*k*Q./ 88NE2LLLQ'HHQ,3BqH9LLb!$HHR!VQZ_`HaHH./PZ3PAtG3P	.7oo.?Eda11<<>EE[#q)Ara5w79K\abghiFF1IC	"CLLc1%HHR9HKCrAUU&+pPTPXPXYfPg43Fs2KmoF	mR01	vr"#	 &) 	 ::<) 4QEs   *
b

b!b!c                   !"#$% t        ddd      ""j                  d       "j                          "j                  dz
  #d$d	}d
%d}d!"j	                  ddd        "j
                  !  "j                  dddd       "j	                  ddd       "j                  dddd       "j                  d       !"#$%fd}dp"#fd	} |d        |d| j                  dd      fd | j                  d!d      fd"| j                  d#d      fg       "j                  d$        |d%        |d&| j                  d'd      fd(| j                  d)d      fg        |d*| j                  d+d      fd,| j                  d-d      fd.| j                  d/d      fg       | j                  d0      xs i }|j                  d1      ry"j	                  ddd       "j                  #dd2       "j                  d       "j	                  ddd3       "j                  #d4t        |j                  d1d            dd5       "j                  d$        "j                  |  "j	                  ddd       "j                  #d6d7d       "j	                  ddd3       | j                  d8d      }"j                  d9d9d9       "j                         "j                         }	}"j                  ||	#d:       "j                  |d;z   |	dz          "j!                  #dz
  d<|       "j#                  |	d=z          "j	                  ddd       "j                  #d6d>d       | j                  d?g       }
t%        |
t              r|
g}
| j                  d@d      }g dA}#d$z  }t'        dt)        |      d$      D ]~  }|||d$z    }|D ]^  }||
v }"j	                  dBdd3       "j                  d6d6|rdCnddD       "j	                  dddE       "j                  |d6z
  d6dF|z          ` "j                  dG        dH|
v }"j	                  dBdd3       "j                  d6d6|rdCnddD       "j	                  dddE       |r|rdI|z   ndJ}"j                  |d6z
  d6dF|z          "j                  d4       "j                         }#d;z  }"j*                  }"j	                  ddd       "j                  |d6dKd       "j	                  ddd3       "j                  |d4| j                  dLd      dd5       "j                  d;       "j	                  ddd       "j                  |d6dMd       "j	                  ddd3       "j                  |d4| j                  dNd      dd5       "j                         }| j                  dOd      }||z   }"j                  ||       "j	                  ddd       "j                  |d6dPd       "j-                  |       dQD ]r  }||k(  }"j	                  dBdd3       "j                  d6dG|rdCnddD       "j	                  dddE       "j                  |d6z
  dGdF|z   d       "j-                  |       t "j                         }"j#                  t/        ||      dz           |dR       "j	                  ddd       "j                  #dSz  d6dT       "j                  #dUz  d6dV       "j                  #dSz  d6dW       "j                  #dSz  d6dX       "j                  d6       | j                  dYg       }"j	                  dddE       |dZdE D ]  }"j                  #dSz  d4t        |j                  d[d            dD       "j                  #dUz  d4t        |j                  d\d            dD       "j                  #dSz  d4t        |j                  d]d            dD       "j                  #dSz  d4t        |j                  d^d            dD       "j                  d4        |st'        d$      D ]s  }"j                  #dSz  d4ddD       "j                  #dUz  d4ddD       "j                  #dSz  d4ddD       "j                  #dSz  d4ddD       "j                  d4       u "j                  d       "j	                  dddE       "j                  #d6d_t        | j                  d`d            z   dab       "j                  d        "j                  |   "j
                  %  "j	                  ddd3       "j                  #ddcddde        "j
                  !  |xs i }|j                  dfd      xs | j                  dg      r| dg   dhnd}  |di|j                  djd      fdk|j                  dld      fdm| fgdno       "j1                         S )qu(  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.
    r	  r  r	  r	  Fr	  rx  r	  r	  r	  r	  r	  r	  r	  r	  r	  r-  r	  r   r	     z#Preventative Maintenance Work Orderr^   r   c                      j                      j                    j                  ddd       j                  dd| z   dd        j                    y r	  r	  r	  s    r1   r	  z4_generate_preventive_wor_pdf.<locals>.section_header_8 r	  r3   r   c           	      D   t        |       z  }j                  ddd       | D ]  \  }}j                  |d|        j                  d       j                  ddd       | D ]'  \  }}j                  ||t	        |xs d      d       ) j                  |       y r	  r	  r	  s         r1   r	  z5_generate_preventive_wor_pdf.<locals>.label_value_rowf8 r	  r3   r	  r1  r  r7   r	  r  r	  r  rP   r	  r2  r3  r	  r  r4  r  r	  r	  r	  r  r  r  r	  ry  r   r	  r   r5  r  r;     r   r	     r	  rB  r	  r	  r	  rJ  r	  r  r	  r	  r@  r	  r	  rC  rD  rE  r  r  r	  rF  Parts皙?Part Number皙?	Part NameQuantityAmountpartsNr  r9   r  amountParts Total: parts_totalRalignr	  Tr	  r	  r  r  r	  r	  r	  r   r 
  r   r
  r
  r   r
  r
  r  r	  r	  r	  r	  r   r*   r	  r
  r
  r
  rD  r
  r	
  r

  r   r  rC  r
  r
  rC  r  )&r   r   r
  r
  r	  r	  r
  r  r
  r
  r
  r
  rW  r	  r
  r
  r	  r
  r
  r
  r
  r
  r~	  r
  r  r
  r
  r
  r0
  r  r  r  r!
  r	  r6  r	  r	  r	  s&                                    @@@@@r1   _generate_preventive_wor_pdfr8
  E8 sp	    3T(
;C'LLN	BDIEHE LLc2&CHHRWH#LLc2&HHQ9aH@FF1I# #	 *+	488$4b9:	DHHWb)*	$((7B'( 
 FF1I $%	DHH%6;<	TXXj"-.  	TXXj"-.	TXXj"-.	*DHH[",EF 
 hhx &BG{{?#[#q)Q;<q	["a(QGKK<=caPFF1I C!LLc1%HHR)aH0LLb!$88M2&DsC%YY[#))+BHHRRJJrAvrAvNN263%IIb2g LLc1%HHR:qHA((<,K+s#"m,b1JI FE1c)na0 	i	A6	 	0Dk)GLLQ/HHQ'3r!H<LLb!,HHUQY4$;/	0 	s {*MLLQ'HHQ-3RH:LLb!$.;
I
*PXMHHUQY4-/0FF1I 		I6D	BLLc1%HHT1.1H5LLb!$HHT1dhh|R0HCFF1ILLc1%HHT1/AH6LLb!$HHT1dhh8"=caHPJhhy"%GIEJJui LLc1%HHT1+H2IIe* U"^R+CR:["a(3u3		% ))+KIIc*k*Q./ 7LLc1%HHR#Xq-(HHR#Xq+&HHR#Xq*%HHR#Xq(#FF1IHHWb!ELLb!$bq	 c1c$((8R"89#Fc1c$((62"67Dc1c$((5""56sCc1c$((8R"89#Fq	 q 	AHHR#Xq"SH1HHR#Xq"SH1HHR#Xq"SH1HHR#Xq"SH1FF1I	 FF1ILLb!$HHROc$((="*E&FFcHRFF1I C	"CLLc1%HHR9HKCrAUU&+pPTPXPXYfPg43Fs2KmoF	mR01	vr"#	 &) 	 ::<r3   c                     t        ddd      j                  d       j                          j                  dz
  dd	}d
dd}j	                  ddd        j
                    j                  dddd       j                  dddd       j                  d       fd}dnfd	} |d        |d| j                  dd      fd| j                  d d      xs | j                  d!      r| d!   d"ndfg        |d#| j                  d$d      fd%| j                  d&d      fg        |d'| j                  d(d      fd)| j                  d*d      fd+| j                  d,d      fg       j                  d-        |d.        |d/| j                  d0d      fd1| j                  d2d      fg        |d3| j                  d4d      fd5| j                  d6d      fg        |d7| j                  d8d      fg       j                  d-       j	                  ddd       j                  d9d:d       | j                  d;g       }t        |t              r|g}g d<}d=z  }t        dt        |      d=      D ]~  }|||d=z    }	|	D ]^  }
|
|v }j	                  d>dd?       j                  d9d9|rd@nddA       j	                  ddd       j                  |d9z
  d9dB|
z          ` j                  dC        j                  d-        j                  |  j	                  ddd       j                         }j                  dDz
  d9dEd       j                  dDd9dFd       j	                  ddd?       | j                  dGd      }j                         j                         }}j!                  dHdHdH       j#                  ||dI       j%                  |d-z   |dz          j'                  dz
  dJ|       j)                  |dKz          j	                  ddd       j                  d-z  d9dd       j                  dz  d9dLt        | j                  dMd            z   d       j                  dz  d9dNt        | j                  dOd            z   d       j                  d-        |dP       j	                  ddd       j                  dQz  d9dR       j                  dSz  d9dT       j                  dQz  d9dU       j                  dQz  d9dV       j                  d9       | j                  dWg       }j	                  dddX       |dYdX D ]  }j                  dQz  dZt        |j                  d[d            dA       j                  dSz  dZt        |j                  d\d            dA       j                  dQz  dZt        |j                  d]d            dA       j                  dQz  dZt        |j                  d^d            dA       j                  dZ        |st        d=      D ]s  }j                  dQz  dZddA       j                  dSz  dZddA       j                  dQz  dZddA       j                  dQz  dZddA       j                  dZ       u j                  d       j	                  dddX       j                  d9d_t        | j                  d`d            z   dab       j                  d        j                  |   j
                    j	                  ddd?       j                  ddcddde        j
                     |df| j                  dgd      fdh| j                  did      fg        |dj| j                  dkd      fdl| j                  dmd      fg       j+                         S )ozEGenerate a Mobile Machine Work Order PDF matching the ICAST template.r	  r  r	  r	  Fr	  rx  r	  r	  r	  r	  r	  r	  r	  r	  r	  r-  r	  r   r	  zMobile Machine WORK ORDERr^   r   c                      j                      j                    j                  ddd       j                  dd| z   dd        j                    y r	  r	  r	  s    r1   r	  z,_generate_mm_wor_pdf.<locals>.section_header9 r	  r3   r   c           	      D   t        |       z  }j                  ddd       | D ]  \  }}j                  |d|        j                  d       j                  ddd       | D ]'  \  }}j                  ||t	        |xs d      d       ) j                  |       y r	  r	  r	  s         r1   r	  z-_generate_mm_wor_pdf.<locals>.label_value_row#9 r	  r3   zMachine Informationr=  r(  r7   zOrder Number_order_numberr  r  zAsset #r)  zOrder Received Byorder_received_byzOrder Date & Timeorder_date_timeDate Promiseddate_promisedzDate Delivereddate_deliveredr   zVehicle InformationVINvinzMake and Model
make_modelzOdometer ReadingodometerzMotor #motor_numberzLicense # and Statelicense_stater   zService Typeservice_type)	LubricatezWiper ReplacementzAir Filter Replacementz
Change OilzBoom RepairzSpark Plug ReplacementzTransmission ServicezFuel SystemzExhaust System CheckzBattery Replacement / CheckzTire ReplacementElectronicszDifferential ServicezBrake Inspection / ReplacementzSuspension and Steering CheckzFlat Tire RepairzCoolant System FlushzHydraulic repair / fillrP   r	  ry  rJ  r	  r	  r	  r  zLabor DescriptionTimelabor_descriptionr;     r	     zTime: 
labor_timezLabor Total: labor_totalr)
  r*
  r+
  r,
  r-
  r.
  r/
  r0
  r  Nr   r  r9   r  r1
  r2
  r3
  r4
  r5
  z  AuthorizationTr	  zWork Order Compiled Bycompiled_byr	  compiled_datezWork Authorized Byauthorized_byzAuthorization Dateauthorization_dater
  )r   r
  r
  r  r	  r	  r	  r	  r   r   r*   r  rC  r	  r
  r
  r
  rD  r
  r	
  r

  r  )r   r
  r
  r	  r	  service_typesall_servicesr	  r
  r
  r	  r
  labor_header_yr  r
  r
  r0
  r  r  r	  r6  r	  r	  r	  s                      @@@@@r1   _generate_mm_wor_pdfrX
  9 s   
3T(
;C'LLN	BDIEEH LLc2&CHHRWH#HHQ/AH6FF1I# #	 ()	DHHY+,	/26{[_[c[cdq[rd=>QRU=Vxz|  	DHH^R01	dhh':B?@  	dhh'8"=>	$((?B78	488$4b9: 
 FF1I ()eTXXeR014Ddhh|]_F`3abc	TXXj"56	DHH^R01  +TXXor-JKLMFF1I LLc1%HHRNqH)HH^R0M-%&L FE1c,/3 	 9q=9	 	0Dm+GLLQ/HHQ'3r!H<LLb!,HHUQY4$;/	0 	s FF1I C!LLc1%YY[NHHR"Wa,H3HHRFqH!LLb!$88',DYY[#))+BsC%HHRRJJrAvrAvNN263%IIb2gLLc1%HHR!VQqH!HHR!VQ3txxb'A#BBqHIHHR!VQ#dhh}b.I*JJqHQFF1I 7LLc1%HHR#Xq-(HHR#Xq+&HHR#Xq*%HHR#Xq(#FF1IHHWb!ELLb!$bq	 c1c$((8R"89#Fc1c$((62"67Dc1c$((5""56sCc1c$((8R"89#Fq	 q 	AHHR#Xq"SH1HHR#Xq"SH1HHR#Xq"SH1HHR#Xq"SH1FF1I	 FF1ILLb!$HHROc$((="*E&FFcHRFF1I C	"CLLc1%HHR%DQH7C	!488M2#>?	/2./  	txx<=	txx(<bAB 
 ::<r3   c           
         !" t        ddd      j                  d       j                          j                  dz
   d!d	}d
"d}dj	                  ddd        j
                    j                  dddd       j                  dddd       j                  d        !"fd}d^ fd	} |d        |d| j                  dd      fd| j                  d d      fd!| j                  d"d      fg       j                  d#        |d$        |d%| j                  d&d      fd'| j                  d(d      fg        |d)| j                  d*d      fd+| j                  d,d      fd-| j                  d.d      fg       | j                  d/      xs i }|j                  d0      ryj	                  ddd       j                   dd1       j                  d       j	                  ddd2       j                   d3t        |j                  d0d            dd4       j                  d#        j                  |  j	                  ddd       j                   d5d6d       j	                  ddd2       | j                  d7d      }j                  d8d8d8       j                         j                         }	}j                  ||	 d9       j                  |d:z   |	dz          j!                   dz
  d;|       j#                  |	d<z          j	                  ddd       j                   d5d=d       | j                  d>g       }
t%        |
t              r|
g}
| j                  d?d      }g d@} d#z  }t'        dt)        |      d#      D ]~  }|||d#z    }|D ]^  }||
v }j	                  dAdd2       j                  d5d5|rdBnddC       j	                  dddD       j                  |d5z
  d5dE|z          ` j                  dF        dG|
v }j	                  dAdd2       j                  d5d5|rdBnddC       j	                  dddD       |r|rdH|z   ndI}j                  |d5z
  d5dE|z          j                  d3       j                         } d:z  }j*                  }j	                  ddd       j                  |d5dJd       j	                  ddd2       j                  |d3| j                  dKd      dd4       j                  d:       j	                  ddd       j                  |d5dLd       j	                  ddd2       j                  |d3| j                  dMd      dd4       j                         }| j                  dNd      }||z   }j                  ||       j	                  ddd       j                  |d5dOd       j-                  |       dPD ]r  }||k(  }j	                  dAdd2       j                  d5dF|rdBnddC       j	                  dddD       j                  |d5z
  dFdE|z   d       j-                  |       t j                         }j#                  t/        ||      dz           j                  |   j
                  "  j	                  ddd2       j                   ddQdRdS        j
                    |xs i }|j                  dTd      xs | j                  dU      r| dU   dVnd} |dW|j                  dXd      fdY|j                  dZd      fd[|fgd\]       j1                         S )_u  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.
    r	  r  r	  r	  Fr	  rx  r	  r	  r	  r	  r	  r	  r	  r	  r	  r-  r	  r   r	  zSafety Concernsr^   r   c                      j                      j                    j                  ddd       j                  dd| z   dd        j                    y r	  r	  r	  s    r1   r	  z0_generate_safety_wor_pdf.<locals>.section_header9 r	  r3   r   c           	      D   t        |       z  }j                  ddd       | D ]  \  }}j                  |d|        j                  d       j                  ddd       | D ]'  \  }}j                  ||t	        |xs d      d       ) j                  |       y r	  r	  r	  s         r1   r	  z1_generate_safety_wor_pdf.<locals>.label_value_row9 r	  r3   r	  r1  r  r7   r	  r  r	  r  rP   r	  r2  r3  r	  r  r4  r  r	  r	  r	  r  r  r  r	  ry  r   r	  r   r5  r  r;  rS  r   r	  r	  z+Type of Safety issue (check all that apply)rB  r	  )zSlip/Trip/FallzIsleway or EgresszGeneral SafetyLightingz	Near Missr;  zMobile equipmentr=  r>  z
Tool/Cordsr4  r	  rJ  r	  r  r	  r	  r@  r	  r	  rC  rD  rE  r  r  r	  rF  z  For use by Safety departmentTr	  r	  r  r  r	  r	  r	  r   r 
  r   r
  r
  r7
  )#r   r   r
  r
  r	  r	  r
  r  r
  r
  r
  r
  rW  r	  r
  r
  r	  r
  r
  r
  r
  r
  r~	  r
  r  r
  r
  r
  r  r!
  r	  r6  r	  r	  r	  s#                                 @@@@@r1   _generate_safety_wor_pdfr]
  9 s8    3T(
;C'LLN	BDIEHE LLc2&CHHRWH#HHQ%!H,FF1I# #	 *+	488$4b9:	DHHWb)*	$((7B'( 
 FF1I $%	DHH%6;<	TXXj"-.  	TXXj"-.	TXXj"-.	*DHH[",EF 
 hhx &BG{{?#[#q)Q;<q	["a(QGKK<=caPFF1I C!LLc1%HHR)aH0LLb!$88M2&DsC%YY[#))+BHHRRJJrAvrAvNN263%IIb2g LLc1%HHRAaHH((<,K+s#"m,b1JI FE1c)na0 	i	A6	 	0Dk)GLLQ/HHQ'3r!H<LLb!,HHUQY4$;/	0 	s {*MLLQ'HHQ-3RH:LLb!$.;
I
*PXMHHUQY4-/0FF1I 		I6D	B LLc1%HHT1.1H5LLb!$HHT1dhh|R0HCFF1ILLc1%HHT1/AH6LLb!$HHT1dhh8"=caHPJ hhy"%GIEJJui LLc1%HHT1+H2IIe* U"^R+CR:["a(3u3		% ))+KIIc*k*Q./C	"CLLc1%HHR44AHFC"AUU&+pPTPXPXYfPg43Fs2KmoF	mR01	vr"#	 &) 	 ::<r3   c                   !"#$% |j                  d      xs i }t        ddd      ""j                  dd       "j                          "j                  d	z
  #d
$d%d!d }!"#$%fd}"j                  ddd	        "j                  !  "j                  dddd       "j                  dddd       "j                  d        |d       |j                  dd      }t        |t              rd|dn
t        |      }"j                  ddd       #dz  }"j                  |dd        "j                  |dd!       "j                  d       "j                  ddd"       "j                  |d ||      d#       "j                  |d ||j                  d$      xs d      d#       "j                  d"       "j                  ddd       "j                  #dd%       "j                  d       "j                  ddd"       "j                  #d ||j                  d&      xs d      d#       "j                  d"       d'D ]  \  }}	"j                  ddd       "j                  #d|       "j                  d       "j                  ddd"       |j                  |	      xs dj                         xs d(}
"j                  #d) ||
      d#       "j                  d         |d*       d+d,d-d.d/}|j                  d0      xs i }|j                  d1      xs g }t        D ]q  }||vr	|j                  |      xs i }"j                  ddd"       "j                  #d2|j                  ||      j                         d       "j                  ddd3       |j                  d4      xs d5j!                         }|j                  d6      xs |j                  d7      xs d}|j                  d8      xs d}"j                  #d9z  d2 |d:|              "j                  #dz  d9z  d2 |d;| d<| d=      d       |j                  d>      xs dj                         }|r"j                  #d2 |d?|       d       "j                  d       t  |d@       |j                  dA      xs d(}"j                  ddd       "j                  #ddB       "j                  d       "j                  ddd"       "j                  #d ||      d#       "j                  d       t#        |      D cg c]  }|xs i j                  dC      dDv r| }}|rYddEl} |dFt'        |       d=       "j                  d       t)        |       }|rt+        |      ndE}|D ]  }|j                  dC      dGk(  rdHndI}| dJ|j                  dKd       dL|j                  dMd       d=}|j                  dN      xs dj                         r|dJ|dN   j                          z  }"j                  ddd3       "j                  #d2 ||             |r||j                  dOd      z  ndE}|r4|j-                         r#	 t/        j0                  |      5 }|j3                  dP      }|j5                  dQ       |j7                         }|j9                  |dRdST       |j;                  d       t=        dU#      }||j>                  z  tA        |jB                  d      z  } "jE                         | z   "jF                  dz
  kD  r"j                          "jI                  |dV"jE                         |W       "jK                  "jE                         | z   d9z          dEdEdE       "j                  d        "jO                         S c c}w # 1 sw Y   2xY w# tL        $ r "j                  d       Y Qw xY w)Xa
  Generate an Equipment Validation Handoff Form PDF snapshot.

    Captures the full EVH sign-off audit trail at the moment all required
    departments have approved. Saved alongside the WO PDF as a permanent
    attachment (evh_pdf field in sidecar _evh block).
    r  r	  r  r	  r	  Tr  r   marginrx  r	  r	  r	  c                     t        | xs d      } dD ]  \  }}| j                  ||      }  | j                  dd      j                  d      S Nr7   ))r	  -)u   –rc
  )u   ‘')u   ’rd
  )u   “r  )u   ”r  )   …r"  )   •r1  latin-1r  r*   r  r  r  r  badgoods      r1   _l1z_generate_evh_pdf.<locals>._l1a: sV    
 RL8 	%IC 		#t$A	% xx	9-44Y??r3   c                      j                      j                    j                  ddd       j                  dd| z   dd        j                    y r	  r	  r	  s    r1   r	  z)_generate_evh_pdf.<locals>.section_headerm: r	  r3   r	  r	  rS  r-  r	  r   r	  z!Equipment Validation Handoff Formr^   r   zEquipment Informationr  r7   r  r  r   r   
Work OrderzEquipment / Asset IDry  r	  r  zRepair Completed Date / Timerepair_completed_at))Description of Work Performedwork_description)zOutstanding Follow-up Itemsoutstanding_followuprc
  r   zDepartment Sign-offsr  
Operationsr  Qualityr  r  rk  r   r  r  r  approver_namer  	timestamprP   zStatus: zBy: z  (r7  rm  Reason: zRelease to Productionr  z"Released to Production Date / Timer  )r  r  NzReturn-to-Service Photos (r  Approval	Rejectionr*  r  r:  r  r   r  r9  )X  rz
  r:  r   r<        T@r   )r  yr  )(r   r   r
  r
  r  r	  r	  r	  r	  r   r   r*   r   r	
  rO  rB  r
  r  r>  rC  r  r  r   rI  r?  rL  rN  rJ  r  r  rL  rF  rC  rE  r
  himager

  ry   r  )&r  r  r  rl
  r	  r!
  wo_labelr
  field_label	field_keyr  dept_labelsr  rk  rP  r  r  approverr_   rm  released_atr  _rts_photos_io	_wor_path_pdir_ctx_metarR  r  _buf_disp_w_disp_hr	  r6  r	  r	  r	  s&                                    @@@@@r1   _generate_evh_pdfr
  Q: sK    

6
"
(bC
3T(
;Cb1LLN	BDEE
@# # LLc2&CHHRWH#HHQ7AH>FF1I*+mR0F&0&=fS\"3v;HLLc1%6DHHT1l#HHT1,-FF1ILLb!$HHT1c(mCH0HHT1c#''+.4"5cHBFF1ILLc1%HHR23FF1ILLb!$HHRC 56<"=cHJFF1I# 
Y 	[#q)Q$q	["a(779%+224;r1c'l37q	
 )*$"	K $*IWW-.4"N ~%MM$%2[#q)Qd399;B["a(%%/.Y::<55)JQUU3C-DJUU;%2aC(6( 345a!Qd8*Ct1$= >1E%%/'R..0HHRC(6( 34H;q	" *+''56=#KLLc1%HHR89FF1ILLb!$HHRC$SH1FF1I .l; UwBmmI.2SS  UK U3C4D3EQGHq	%h/	.7	*T 	A"#%%	"2n"DJ$ fCmR 89AEE-QS<T;UUVWEi &B--/3q|113455LLb!,NN2q#e*-165155,,DCs{{}#- =ZZ.Z0"{{}fb9		!"%dB-")BII"5BHHa8H"H99;03552:=LLN		$"		w	G		#))+"7!";<= FF1I3	6 ::<GU&= = ! FF1Is1    __$"D_'_$_!	_$$` `z/wor-submitc                     t        dt        d         st        d      dfS i dt        j                  j                  dd      j                         dt        j                  j                  dd      j                         d	t        j                  j                  d	d      j                         d
t        j                  j                  d
d      j                         dt        j                  j                  dd      j                         dt        j                  j                  dd      j                         dt        j                  j                  dd      j                         dt        j                  j                  dd      j                         dt        j                  j                  dd      j                         dt        j                  j                  d      dt        j                  j                  dd      j                         dt        j                  j                  dd      j                         dt        j                  j                  dd      j                         dt        j                  j                  dd      j                         ddt        j                  v dt        D  ci c]  } | d|  t        j                  v  c} }|d   st        d      dfS |d   st        d      dfS d|d<   t        t        j
                  d            }|rd|d<   |r
t               n	t               }||d<   t        d|      d    }|j                  dd!       t        j
                  d      xs i }|j                  d	d      }d"t        j                  v |j                  d#      xs |d   ||d$d%|d&<   t        |      }t        j                          j#                  d'      }t%        j&                  d(d|d         d)d* j                         j)                  d+d,      }	|rd-nd. d,|d/d,| d,|	 d0}
||
z  }|j+                  |       |j-                  d1      }|j/                  t1        j2                  |d23             t5        |t        j6                  j                  d4      t        j
                  d      xs i        |st9        j:                  t8        j<                  t?        |
d|             tA        d5|d/d6|d    d7|d    d8|j                  d
d       d9|j                  dd       d:|j                  dd      d)d;  d<|j                  dd       d=|j                  dd>       d?|
 d@       tC        dA      rHtE        dAdBt        j
                  d      xs i j                  d#      dC|d/dD|d    dE|d/d|
dFG       t        |
|H      S c c} w )Iz8Accept work order form data, generate PDF, and store it.r   r'  .This work order type is not currently enabled.r  r  r  r7   r  r  r3  r  r  r	  r  r  rB  r	  rD  r  r  r  r  	rts_dept_Requestor name is requiredr`  Request description is required	_wor_typeTr   r  r  r$  rM   notify_enabledr9   Fr~  r  requestor_emailr  disabled_by_adminr  %Y-%m-%d_%H%M%S	[^\w\s\-]NrS  r\   r  TWORWORr  r   r  r   r   
wor_photoszNew Maintenance Work Order #    — z=A new Maintenance Work Order has been submitted.

Requestor: 
Date: 
Location: 
Description: rK   

Urgency: 
Desired Completion: Not set

Filename: >

This notification was sent from the ICAST Togen application.r  r  zMaintenance WO #    created — WO#r  r  r	  r  is_test)#r
  r   r   r   rg  r   r   getlistrO  rj  r  r  r  r  r"
  r  r  r  rF  r\  r  r-   r  r   r   r   r  r  r=  r  EVENT_WO_CREATEDr  r  r	  r	  )rP  r   r
  wor_numr  r'  notify_email	pdf_bytesrv
  r  r  out_path	json_paths                r1   
wor_submitr
  : s    !@MNPSSS',,**+;R@FFH!!'2.446 	!!'2.446 	7<<++,=rBHHJ	
 	GLL$$Z4::< 	GLL$$Z4::< 	GLL$$Z4::< 	W\\%%k26<<> 	w||''r:@@B 	gll**<8 	GLL,,-?DJJL 	gll&&|R8>>@ 	"7<<#3#34Mr#R#X#X#Z 	7<<##Ir288: 	',,6  	"
 Ytf%55
!D,  !9:C??>?DD%D "'++f"56GW07*,=T=VG!Dm':5AHNN4$N/ ;;v$"D88GR(L#w||3((6*Dd3C.D'"DO "$'I""$--.?@I|R.>)?@"EKKMUUVY[^_I#&/qQyk9+UYZH("H#$$W-ID34 '--''5w{{67J7Pb **x=	

 	!*73-uTBR=S<TU/01 2XX/45 6*b12 3 HH]B7=> ?B/0 1##'88,Ey#Q"R S!
 #KL	
 '{3Y{{6*0b55f=*73-}TJZE[D\]'#'$1xH Hg66i
s   W+z/safety-wor-submitc                  
   t        dt        d         st        d      dfS t        j                  j                  dd      j                         t        j                  j                  dd      j                         t        j                  j                  d	d      j                         t        j                  j                  d
d      j                         t        j                  j                  dd      j                         t        j                  j                  dd      j                         t        j                  j                  dd      j                         t        j                  j                  dd      j                         t        j                  j                  dd      j                         t        j                  j                  d      t        j                  j                  dd      j                         t        j                  j                  dd      j                         t        j                  j                  dd      j                         t        j                  j                  dd      j                         d} | d   st        d      dfS | d   st        d      dfS d| d<   t        t        j
                  d            }|rd| d<   |r
t               n	t               }|| d<   t        d|      d   }|j                  dd       t        j
                  d      xs i }|j                  d	d      }d t        j                  v |j                  d!      xs | d   ||d"d#| d$<   t        |       }t        j                         j!                  d%      }t#        j$                  d&d| d         d'd( j                         j'                  d)d*      }|rd+nd, d*|d-d*| d*| d.}	||	z  }
|
j)                  |       |
j+                  d/      }|j-                  t/        j0                  | d01             t3        |
t        j4                  j                  d2      t        j
                  d      xs i        |st7        d3|d-d4| d    d5| d    d6| j                  d
d       d7| j                  dd       d8| j                  dd      d'd9  d:| j                  dd       d;| j                  dd<       d=|	 d>       t9        d?      rHt;        d?d@t        j
                  d      xs i j                  d!      dA|d-dB| d    dC|d-d|	dDE       t        |	|F      S )GzHAccept Safety Concerns work order form data, generate PDF, and store it.r   r'  r
  r  r  r  r7   r  r  r3  r  r  r	  r  r  rB  r	  rD  r  r  r  r  r  r3  r  r  r	  r  r  rB  r	  rD  r  r  r
  r`  r
  r
  Tr   r  r  r$  rM   r
  r9   Fr
  r  r
  r
  NrS  r\   r  TSWOSWOr  r   r  r   r   r
  zNew Safety Work Order #r
  zAA new Safety Concerns Work Order has been submitted.

Requestor: r
  r
  r
  rK   r
  r
  r
  r
  r
  r  r  zSafety WO #r
  r
  r
  r	  r
  )r
  r   r   r   rg  r   r   r
  rj  r  r  r  r  r]
  r  r  r  rF  r\  r  r-   r  r   r   r   r  r  r  r	  r	  )r   r
  r
  r  r'  r
  r
  rv
  r  r  r
  r
  s               r1   safety_wor_submitr
  R; s    !76?;MNPSSS!,,**+;R@FFH!!'2.446!!'2.446"<<++,=rBHHJLL$$Z4::<LL$$Z4::<LL$$Z4::<\\%%k26<<>||''r:@@Bll**<8#LL,,-?DJJLll&&|R8>>@#*<<#3#34Mr#R#X#X#Z<<##Ir288:D"  !9:C??>?DD D!'++f"56GW07*,=T=VG!DhW5e<HNN4$N/
 ;;v$"D88GR(L#w||3((6*Dd3C.D'"DO ).I""$--.?@I|R.>)?@"EKKMUUVY[^_I#&/qQyk9+UYZH("H#$$W-ID34 '--''5w{{67J7Pb
  %gc]%=M8N7OP/01 2XX/45 6*b12 3 HH]B7=> ?B/0 1##'88,Ey#Q"R S!
 #KL	
 '{3Y{{6*0b55f=%gc]-EU@V?WX'#'$,(C Hg66r3   z/mm-wor-submitc                     t        dt        d         st        d      dfS t        t        j                  d            } t
        j                  j                  d      }t
        j                  j                  d      }t
        j                  j                  d      }t
        j                  j                  d	      }g }t        t        |            D ]  }|t        |      k  r||   j                         nd
}|t        |      k  r||   j                         nd
}|t        |      k  r||   j                         nd
}	|t        |      k  r||   j                         nd
}
|s|s|	s|
s|j                  |||	|
d        | s t        |dt        j                  d            }i dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j                  d      dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         d|ddt
        j                  v dt
        j                  v t
        j                  j	                  dd
      j                         t
        j                  j	                  dd
      j                         t
        j                  j	                  d d
      j                         t
        j                  j	                  d!d
      j                         t
        j                  j	                  d"d
      j                         dd#}|d   st        d$      d%fS t        |d   t        j                  d      xs i       \  }}|rt        |      d%fS |r||d&<   | rd'|d(<   | r
t               n	t               }||d)<   | sWt!        d*|z  ||j	                  d      |j	                  d      t        j                  d      xs i j	                  d+      ,       t#        d| -      d.   }|j%                  d'd'/       t        j                  d      xs i }|j	                  d+d
      }d0t
        j                  v |j	                  d1      xs |d   ||d2d3|d4<   t'        |      }t)        j*                         j-                  d5      }t/        j0                  d6d
|d         d7d8 j                         j3                  d9d:      }| rd;nd< d:|d=d:| d:| d>}||z  }|j5                  |       |j7                  d?      }|j9                  t;        j<                  |d@A             t?        |t
        j@                  j                  dB      t        j                  d      xs i        | stC        dC|d=dD|d    dE|d    dF|j	                  dd
       dG|j	                  dd
       dH|j	                  ddI       dJ|j	                  dd
       dK| dL       tE        dM      rHtG        dMdNt        j                  d      xs i j	                  d1      dO|d=dP|d    dQ|d=d|dRS       t        || T      S )UzGAccept Mobile Machine work order form data, generate PDF, and store it.r   r'  r
  r  r  part_number[]part_name[]
part_qty[]part_amount[]r7   r  r9   r  r1
  r(  r)  r>
  r@
  rA
  r=
  rD
  rC
  rE
  rG
  rF
  rH
  rL
  rO
  rP
  r0
  awaiting_approvalparts_orderedr3
  rQ
  rR
  rS
  rT
  r
  r3
  rQ
  rR
  rS
  rT
  r
  Machine name is requiredr`  r  Tr   r  WO#%sr  equipment_namer)  r		  r  r$  rM   r
  r9   Fr
  r  r
  r
  NrS  r\   r  TMMWOMMWOr  r   r  r   r   r
  zNew Mobile Machine Work Order #r
  z>A new Mobile Machine Work Order has been submitted.

Machine: z

Asset #: z
Order Date: z
Date Promised: r
  z
Make/Model: r
  r
  r  r  zMobile Machine WO #r
  r
  r
  r	  r
  )$r
  r   r   rj  r   r   rg  r
  r  rC  r   ri   _rpm_register_submitted_partsr  r  r  _rpm_record_parts_usager  r  rX
  r  r  r  rF  r\  r  r-   r  r   r   r   r  r  r  r	  r	  )r
  part_numbers
part_names	part_qtyspart_amountsr0
  r  pnnmqtamr   manual_info
manual_errr
  r  r'  r
  r
  rv
  safe_machiner  r
  r
  s                           r1   mm_wor_submitr
  ; s    !'&/BMNPSSS "'++f"56G<<''8L%%m4J$$\2I<<''8LE3|$% N()C,=(=\!_""$2&'#j/&9Z]  "r%&Y%7Yq\!R()C,=(=\!_""$2rRLLB2LMN -e_gkkRXFYZ7<<##Ir288:((<BBD 	7<<++,=rBHHJ 	))/2>DDF	
 	',,**+;R@FFH 	W\\--.A2FLLN 	gll&&|R8>>@ 	w||r*002 	GLL$$Z4::< 	))/2>DDF 	((<BBD 	,,^< 	W\\--.A2FLLN 	gll&&|R8>>@ 	w||''r:@@B  	!" 	0GLL@#$ )GLL8||''r:@@B||''r:@@B ))/2>DDF ))/2>DDF%ll../CRHNNP$1D6 	?78#== 1i'++fBUB[Y[\KZ(#--$XW07*,=T=VG!D guTXXi5H.1;;v&,"11':	< oG<UCHNN4$N/
 ;;v$"D88GR(L#w||3((6*=d9o'"DO %T*I""$--.?@I66,DO<SbAGGIQQRUWZ[L$'&173-q1\NZ^_H("H#$$W-ID34 '--''5w{{67J7Pb
  -gc]%Y?PQY( )45 688$5r:; <"hh	BC D88L"56 7!
 #KL
	
 '{3Y{{6*0b55f=-gc]-YGXY'#'$3J Hg66r3   z/preventive-wor-submitc                  
   t        dt        d         st        d      dfS t        t        j                  d            } t
        j                  j                  d      }t
        j                  j                  d      }t
        j                  j                  d      }t
        j                  j                  d	      }g }t        t        |            D ]  }|t        |      k  r||   j                         nd
}|t        |      k  r||   j                         nd
}|t        |      k  r||   j                         nd
}	|t        |      k  r||   j                         nd
}
|s|s|	s|
s|j                  |||	|
d        | s t        |dt        j                  d            }i dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j                  d      dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         dt
        j                  j	                  dd
      j                         d|ddt
        j                  v ddt
        j                  v dt
        j                  j	                  dd
      j                         i}|d   st        d      dfS |d   st        d       dfS t        |d   t        j                  d      xs i       \  }}|rt        |      dfS |r||d!<   d|d"<   | rd#|d$<   | r
t               n	t               }||d%<   | sGt!        d&|z  ||j	                  d      t        j                  d      xs i j	                  d      '       t#        d| (      d)   }|j%                  d#d#*       t        j                  d      xs i }|j	                  dd
      }d+t
        j                  v |j	                  d,      xs |d   ||d-d.|d/<   t'        |      }t)        j*                         j-                  d0      }t/        j0                  d1d
|d         d2d3 j                         j3                  d4d5      }| rd6nd7 d5|d8d5| d5| d9}||z  }|j5                  |       |j7                  d:      }|j9                  t;        j<                  |d;<             t?        |t
        j@                  j                  d=      t        j                  d      xs i        | stC        jD                  tB        jF                  tI        |d|             tK        d>|d8d?|d    d@|d    dA|j	                  dd
       dB|j	                  dd
       dC|j	                  dd
      d2dD  dE|j	                  dd
       dF|j	                  ddG       dH| dI       tM        dJ      rHtO        dJdKt        j                  d      xs i j	                  d,      dL|d8dM|d    dN|d8d|dOP       t        ||j	                  d!      | Q      S )RzQAccept Preventative Maintenance work order form data, generate PDF, and store it.r   r'  r
  r  r  r
  r
  r
  r
  r7   r
  r  r  r  r3  r  r  r	  r  r  rB  r	  rD  r  r  r0
  r
  r
  r3
  r
  r`  r
  r  r
  Tr   r  r
  )r
  r		  r  r$  rM   r
  r9   Fr
  r  r
  r
  NrS  r\   r  TPMWOPMWOr  r   r  r   r   r
  z)New Preventative Maintenance Work Order #r
  zJA new Preventative Maintenance Work Order has been submitted.

Requestor: r
  r
  r
  rK   r
  r
  r
  r
  r
  r  r  zPreventive Maint. WO #r
  r
  r
  r	  )r  r  r
  )(r
  r   r   rj  r   r   rg  r
  r  rC  r   ri   r
  r  r  r  r
  r  r  r8
  r  r  r  rF  r\  r  r-   r  r   r   r   r  r  r=  r  r
  r  r  r	  r	  )r
  r
  r
  r
  r
  r0
  r  r
  r
  r
  r
  r   r
  r
  r
  r  r'  r
  r
  rv
  r  r  r
  r
  s                           r1   preventive_wor_submitr
  2< s    !wv?MNPSSS "'++f"56G<<''8L%%m4J$$\2I<<''8LE3|$% N()C,=(=\!_""$2&'#j/&9Z]  "r%&Y%7Yq\!R()C,=(=\!_""$2rRLLB2LMN -e\7;;vCVW',,**+;R@FFH!!'2.446 	!!'2.446 	7<<++,=rBHHJ	
 	GLL$$Z4::< 	GLL$$Z4::< 	GLL$$Z4::< 	W\\%%k26<<> 	w||''r:@@B 	gll**<8 	GLL,,-?DJJL 	gll&&|R8>>@ 	"7<<#3#34Mr#R#X#X#Z 	7<<##Ir288: 	  	0GLL@!" 	GLL8#$ 	w||''r:@@B%D*  !9:C??>?DD0k1BGKKPVDWD][]^KZ(#--$X$DW07*,=T=VG!DguTXXk5J;;v&,"11':	< l9%@HNN4$N/;;v$"D88GR(L#w||3((6*Dd3C.D'"DO -T2I""$--.?@I|R.>)?@"EKKMUUVY[^_I$'&173-q1YKW[\H("H#$$W-ID34 '--''5w{{67J7Pb
 **xt<	

 	!7}E$O_J`Iab/01 2XX/45 6*b12 3 HH]B7=> ?B/0 1##'88,Ey#Q"R S!
 #KL	
 '{3Y{{6*0b55f=0]4P`KaJbc'#'$0hG HTXXh-?QQr3   z/wor-historyc            
         t         j                  j                  dd      } t        |       }g }|d   df|d   dffD ]  \  }}|j	                         s|j                  d      D ]  }t        j                  |j                         j                        }t        |j                  |       }d}|r$	 t        j                  |j                               }|j#                  |j                  t%        |j                  | |      |j'                  d      |d         |j)                  d	 d
       t+        |      S # t        t         f$ r d}Y {w xY w)zFList new and in-progress Work Order Request PDFs (excludes completed).rT  r   r$  r   r+  Nr  )r9   re  r   rd   c                     | d   S r  rX   r  s    r1   r	  zwor_history.<locals>.<lambda>< s
    QvY r3   Tr
  r  )r   rh  r   r  r  r  r  r  r  r  r  r9   r   r   r   r   r   ri   r  r  r  r   )	r  r  r  r  rd   r  r	  r  r   s	            r1   wor_historyr
  < s?    ||6H(#DE#E{E2T-5H-4XY 	6!( 	A"33AFFH4E4EFE*1668<GD ::g&7&7&9:D LL+AFFHdC';< 	 	$ 
JJ&J5 #G,  D s   /#D::EEz/wor-previewc                      t         j                  j                  dd      } t        | t	        t        j                  d                  }|syt        t        |      d      S )z*Serve a Work Order PDF inline for preview.r  r7   r'  r  r  r  r   r   rh  r   r  r`  r   r   r*   r  ri  s     r1   wor_previewr
  < sR     ||+H "}W[[-@ACI$S^.?@@r3   z/wor-downloadc                      t         j                  j                  dd      } t        | t	        t        j                  d                  }|syt        t        |      d|       S )z"Download a Work Order Request PDF.r  r7   r'  r  r  Trn	  r
  r
  s     r1   wor_downloadr
  < sQ     ||+H!}W[[-@ACI$S^4xPPr3   z/wor-photo-uploadc                     t         j                  j                  d      xs dj                         } t         j                  j                  d      xs dj                         }t         j                  j                  d      xs dj                         }|t        vrd}t        | t        t        j                  d                  }|st        d	      d
fS t        |      \  }}}t         j                  j                  d      D cg c]  }|s|j                  s| }}|st        d	      dfS t        dz  dz  }t        |      }	|	j                  dd       t        j                  d      xs i }
t!        j"                         j%                  d      }g }|D ]8  }t&        j(                  j+                  |j                        d   j-                         }|j.                  j1                  dt&        j2                         |j.                  j5                         }|j.                  j1                  d       ||kD  r%t        d|j                   dt         d	      dfc S t7        |j                        xs d|z   }t!        j"                         j%                  d       d| }|j9                  t;        |	|z               |j=                  |       ; t?        | t        t        j                  d                  }i }|r$	 tA        jB                  |jE                               }tK        |      }|D ])  }|j=                  ||
j                  dd      |||d       + ||d<   |r'|jM                  tA        jN                  |d             n5|jQ                  d      jM                  tA        jN                  |d             |sEtS        d      r:tU        dd |
j                  d      tW        |       d!|  | |tW        |      d"#       t        dtW        |      tW        |      $      S c c}w # t@        jF                  tH        f$ r i }Y &w xY w)%a  Attach one or more photos to a work order (DVI-1063).

    Form fields: ``file`` (WO PDF filename), ``context`` in
    {attachment, rts_approval, rts_rejection}, optional ``caption``, and one or
    more image files under ``photos``. Images are stored in the sibling
    ``<stem>.photos/`` directory and recorded in the sidecar ``photos`` list.
    Gated by ``wor_approver_required`` so both admins (general attach) and
    department approvers (RtS approve/reject) can attach.
    r  r7   r  r  r   r'  r  Work order not foundr  r  r  zAt least one photo is requiredr`  r  TrM   r  r^   r   rd
  z' exceeds the z	 MB limitr  r  r  r9   r  r   r   r  r  photo_addedz photo(s) added to )r  r  r	  )rU  addedphoto_count),r   rg  r   r   WOR_PHOTO_CONTEXTSr  r`  r   r   _wor_locater  r
  r  r  r  r  r  r  r  r  rb   r   rE  r  r  r  r  r   r  r*   ri   r  r   r   r   r   r   r  r   r   r  r	  r	  rC  )r  r  r   r  r  _wor_is_testr  r  r  r  r'  r_   r  r  rd  r  r4  r  r  r  s                       r1   wor_photo_uploadr
  < s      (.B557H||	*:lAACG||	*0b779G(( }W[[-@ACH34c99$X.Aq,!--//9NQQ1::qNGN=>CC 4'$.I *JTD1;;v$"D					&	&':	;BE ggqzz*1-335
 	
a%xx}}	a)1QZZL$4#5Y"@ ABEF Fqzz*=w}"&&(112DEFavN	s:&'(V" #}W[[-@ACG	B	G--/0B R F 88FB/
 	 BxL4::b34 	W%00Bq1IJ 7D((6"5zl"5hZ@&U<	
 d#e*#f+FF} OD $$g. 	B	s$   P,P,P,#P1 1QQz/wor-photosc                     t         j                  j                  dd      } t        t	        j                  d            }t        | |      st        d      dfS t        | |      }i }|r$	 t        j                  |j                               }t        |      }t        |t        |            S # t        j                  t        f$ r i }Y >w xY w)	zCReturn the photo metadata list + count for a work order (DVI-1063).r  r7   r'  r  r
  r  r  )r  r  )r   rh  r   r`  r   r  r   r  r   r   r   r   r   r  rC  )r  r  r  r  r  s        r1   r
  r
  B= s     ||+H !V!45LXLA34c99"8,GG	B	G--/0B R F&F44 $$g. 	B	s   ,#B0 0CCz
/wor-photoc                     t         j                  j                  dd      } t         j                  j                  dd      }t        | t	        t        j                  d                  }|sy|rd|v sd|v sd	|v ryt        |      }||z  j                         }t        |      j                  t        |j                                     r|j                         syt         j                  j                  d
      r4t        |      }|r't        t        |      d      }d|j                  d<   |S t        t        |            S )zMServe one WO photo inline; ``?thumb=1`` returns a downscaled JPEG (DVI-1063).r  r7   r9   r'  r  r  r6   r  r  r4  ro  r   private, max-age=86400rv   )r   rh  r   r  r`  r   r  r,   r*   rw   r   r  r   rx   )r  r9   r  r  r  r4  r  s          r1   	wor_photor
  X= s	    ||+H<<FB'D }W[[-@ACH$3$;$$,$$,$ *JT!**,HM$$S););)=%>?##%$|| '1SZ,?D,DDLL)KS]##r3   z/wor-evh-pdfc                     t         j                  j                  dd      } t        |       }|sy|j                  dz   }|j
                  |z  }|j                         st        |       }i }|r$	 t        j                  |j                               }|j                  d      xs i j                  d      dk(  rL	 |j                  t        | |             ||d   d<   |r&|j                  t        j                   |d	
             |j                         syt+        t-        |      d|      S # t        j                  t        f$ r i }Y w xY w# t"        $ r# t$        j&                  j)                  d|        Y tw xY w)zServe the generated EVH form PDF for a completed/approved work order.

    Takes the WO PDF filename; the EVH snapshot lives alongside it as
    ``<stem>.evh.pdf`` (written by :func:`_generate_evh_pdf` on finalization).
    r  r7   r  .evh.pdfr  rd   r  r  r   r   z%EVH PDF lazy generation failed for %s)zEVH form not foundr  Frn	  )r   rh  r   r  r  r  r   r  r   r   r   r   r   r-   r
  r   r   ry   rm   rn   r  r   r*   )r  r  evh_nameevh_pathr  r  s         r1   wor_evh_pdfr
  s= sV    ||+H *H$}}z)H)H &x0ZZ 1 1 34 FF6N b%%h/:=X$$%6x%DE(06
9%&&tzz"Q'?@ (S]%xPP (('2   X

$$%LhWXs%   -#D+ 8AE +E	E	)E87E8z/wor-viewer-urlc                  :   t         j                  j                  dd      } t        |       }|st	        ddi      dfS 	 |j                  t        j                               }t               }| d| }t	        d|i      S # t        $ r t	        ddi      dfcY S w xY w)z4Return the Document Viewer URL for a work order PDF.r  r7   r  	not foundr  z/wor/rJ  )
r   rh  r   r  r   r{  r   r,   r   r  )r  ri  r  r  rJ  s        r1   wor_viewer_urlr
  = s     ||+H!(+I-.334##GOO$56 %&FHE#
CE3<  	  4-.334s   #A? ?BBz/pm-manual-lookupc                     t        t        j                  j                  dd            } | st	        ddi      S t	        d| d   | j                  d| d         | j                  dt        | d               | j                  dd      d	      S )
zLReturn any manual previously associated with an Equipment/Asset/System name.r  r7   foundFTr  r  rJ  )r
  r  r  rJ  r  )r  r   rh  r   r   r  )r   s    r1   pm_manual_lookupr
  = s     "',,"2"2;"CDE'((*%?E*4EFyy 253D EFYY{B/  r3   r~  c                 N    t        | t              xr | j                  d      dk(  S )zBTrue if a registry record is classified Mobile (DVI-1136 Phase 6).r  mobiler   r   r   r~  s    r1   _equipment_is_mobiler   = s#    c4 PSWW-C%D%PPr3   c            	          t               j                         D  ch c]9  } t        |       r,| j                  d      rt	        | j                  dd            ; c} S c c} w )z9Normalized names of registry equipment classified Mobile.r9   r7   )r  r  r   r   r  rx	  s    r1   _mobile_equipment_namesr  = sR     )*113:#A&155= !vr!23 : : :s   >Az/mm-eas-optionsc                  ,   g } t               j                         D ]X  }t        |      s|j                  d      s!| j	                  |d   |j                  d      xs dj                         d       Z | j                  d        t        d| i      S )zMobile equipment for the Mobile-Machine WO picker (DVI-1136 Phase 6).

    Returns objects {name, asset_no} so selecting one fills both the Machine
    and Asset # fields. Only registry records classified Mobile appear.r9   r  r7   )r9   r  c                 (    | d   j                         S Nr9   rs  )r"	  s    r1   r	  z mm_eas_options.<locals>.<lambda>= s    AfIOO- r3   r  options)r  r  r   r   ri   r   r  r   )optsr~  s     r1   mm_eas_optionsr  = s     D"$++- `$KKV377:;N;TRT:[:[:]^_` 	II-I.It$%%r3   z/pm-eas-optionsc                      t               } t        j                  j                  d      xs dj	                         dk(  r't               }| D cg c]  }t        |      |vs| } }t        d| i      S c c}w )a  Equipment/Asset/System names for the PM work order type-ahead (DVI-590).

    `mobility=stationary` (DVI-1136 Phase 6) drops names registered as Mobile
    machines so they route through the Mobile-Machine WO instead; free-text
    entry is unaffected (this only curates suggestions).mobilityr7   
stationaryr  )r  r   rh  r   r   r  r  r   )r  r
  r"	  s      r1   pm_eas_optionsr  = so     &'G$*113|C(*%O)=a)@)N1OOIw'(( Ps   A3 A3z/wor-eas-optionsc                      t               } t        j                  j                  d      xs dj	                         fdt        fd| j                         D        d       }t        d|i      S )a/  Equipment names from the registry for the maintenance WOR type-ahead (DVI-1014).

    `mobility=stationary` (DVI-1136 Phase 6) returns non-mobile equipment
    (stationary + unclassified); `mobility=mobile` returns only mobile. No
    param preserves the legacy full list. Free-text entry is unaffected.r
  r7   c                 H    dk(  rt        |       S dk(  rt        |        S y)Nr
  r  T)r   )r~  r
  s    r1   _keepzwor_eas_options.<locals>._keep= s/    x',,|#+C000r3   c              3   x   K   | ]1  }|j                  d       s |      s|j                  d d       3 yw)r9   r7   Nr  )r,  r  r  s     r1   r/  z"wor_eas_options.<locals>.<genexpr>= s-     Tqv5QR8vr	Ts   :::c                 "    | j                         S rT   rs  r  s    r1   r	  z!wor_eas_options.<locals>.<lambda>= s    aggi r3   r  r  )r  r   rh  r   r   r  r  r   )r  r  r  r
  s     @@r1   wor_eas_optionsr  = sd     #$H  ,299;H THOO$5TG Iw'((r3   z/wor-equipment-rtsc                  t   t         j                  j                  d      xs dj                         } | st	        d      S t        |       }t               }|j                  |      }|st	        d      S t	        d|j                  dd      |j                  dt        D ci c]  }|d c}            S c c}w )	zReturn RtS settings for a given equipment name (DVI-1014).

    Used by the WOR form to apply forced/default RtS state when equipment is
    matched to a registry record.
    r  r7   Fr
  Tr  r  )r
  r  r  )r   rh  r   r   r   r  r  rO  )r9   r)  r  r  rn  s        r1   wor_equipment_rtsr  = s     LL[)/R668DU##
t
$C"$H\\#FU##ZZ6

#4
6S1q$w6ST  7Ts   
B5z/pm-manual-urlc                     t         j                  j                  dd      xs dj                         } | rd| v sd| v sd| v rt	        ddi      dfS t
        | z  j                         st	        ddi      dfS t	        d	t        |       i      S )
z8Return the Document Viewer URL for a stored manual file.r  r7   r6   r  r  r  r
  r  rJ  )r   rh  r   r   r   r  r   r  r  s    r1   pm_manual_urlr  > s       ,299;Hsh$(*:dh>N-.33h&//1-.33E-h7899r3   z/wor-manual-infoc                     t        t        j                  j                  dd            } | st	        ddi      S 	 t        j                  | j                               }|j                  d      xs i }|j                  d      }|rt        |z  j                         st	        ddi      S t	        d||j                  d|      t        |      d	      S # t
        j                  t        f$ r t	        ddi      cY S w xY w)
zIReturn the manual attached to a given work order (from its JSON sidecar).r  r7   r
  Fr  r  Tr  )r
  r  r  rJ  )r  r   rh  r   r   r   r   r   r   r   r  r   r  )r  r   r  r4  s       r1   wor_manual_infor   > s     #7<<#3#3FB#?@G'(()zz'++-. XXh%2FZZ
#F/F2;;='((OV<!&)	     '* )'(()s   #C &C;:C;z/equipment-manuals/listc                  ,    t        dt               i      S )zNReturn all uploaded/attached equipment manuals for the Equipment Manuals tool.r   )r   r  rX   r3   r1   equipment_manuals_listr  7> s     I02344r3   qonly_filenamec                    | xs dj                         j                         }t        |      dk  rg S i }t               j	                         D ]  }|j                  d      }|s|||<    g }t               j                         D ]  \  }}|r||k7  rt        |z  j                         s'|j                  dd      xs d}|j                         }|j                  |      }	|	dk  rd|j                  |      }
d}|j                  dg       D ]D  }|j                  dd      |	cxk  r|j                  d	d      k  s/n 2|j                  d
d      } n t        d|	dz
        }t        t        |      |	t        |      z   dz         }||| j                  dd      j                         }|dkD  rd|z   }|t        |      k  r|dz   }|j                  |i       }|j                  ||j                  d      xs |j                  d|      |j                  dd      t!        |      ||
|d        |j#                  d d       |S )a[  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).r7   r   r  r&  r   r^   r  rq   r  r  r   r  r\   re
  r  r  )r  r  r  rJ  rO  match_countsnippetc                     | d   S )Nr   rX   rx	  s    r1   r	  z$_em_search_manuals.<locals>.<lambda>t> s    q/ r3   Tr
  )r   rE  rC  r  r  r   r	  r   r  r   rM  r  rC  rL  r  ri   r  r  )r  r  qlassocr   r  r  r&  tlfirstr  rO  r  rq   r  r!  r  s                    r1   _em_search_manualsr'  >> s5    'r		 	 	"B
2w{	 E$&--/ YYz"E"I
 G-/557 !	ER=0"$--/yy$*ZZ\197B' 	AuuWa E;AEE%O;uuXq)	 Aurz"#d)USW_r12uS/))$4::<19goGT?oGyyR "YY7X488OUW;X+r2%b) 
 	3!F LL/L>Nr3   z/equipment-manuals/searchc                      t         j                  j                  d      xs dj                         } t	        |       dk  rt        g |       S t        t        |       |       S )zCross-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).r  r7   r   )r  r#  )r   rh  r   r   rC  r   r'  r  s    r1   equipment_manuals_searchr*  x> sQ     
		#		$"++-A
1vzr++-a0::r3   z)/equipment-manuals/file/<stored_filename>c                     d| v sd| v sd| v rt        d       t        | z  }|j                         st        d       t        t	        |            S )a~  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.
    r6   r  r  r  )r   r  r   r   r*   )r  rb   s     r1   equipment_manuals_filer,  > sJ     o!8DO<Sc
_,D<<>c
SYr3   z*/equipment-manuals/thumb/<stored_filename>c                     d| v sd| v sd| v rt        d       t        |       }|st        d       t        t        |      d      }d|j                  d<   |S )	aq  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.
    r6   r  r  r  ro  r   r
  rv   )r   rW  r   r*   rx   )r  r4  r  s      r1   equipment_manuals_thumbr.  > sX     o!8DO<Sc
 1Ec
SZ,7D$<DLL!Kr3   z/equipment-manuals/uploadc            	         t         j                  j                  dd      xs dj                         } | st	        d      dfS t         j
                  j                  d      }|r|j                  st	        d      dfS t        j                  j                  |j                        d   j                         }|t        vr/d	j                  t        t                    }t	        d
|       dfS t        j                  dd       t!        |j                        xs d|z   }t#        j$                         j'                  d       d| }|j)                  t+        t        |z               t-        | ||j                  t/        j                  d      xs i        t1        ||j                         t	        d||j                  | t3        |      d      S )a  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).
    r  r7   +Equipment / Asset / System name is requiredr  r`  r  zA manual file is requiredr^   r  r  TrM   r  r  r'  rU  r  r  r  rJ  )r   rg  r   r   r   r  r  r  rb   r   rE  r  r  r  r  r  r   r  r  r  r  r*   r  r   r)  r  )r  r  r  r   r  r4  s         r1   equipment_manuals_uploadr2  > s    !!+r28b??AIJKSPP(#AAJJ893>>
''

1::
&q
)
/
/
1C
%%))F#678FwiPQSVVV$61::&:8c>D""$--o>?qGFFF3'()y&!**gkk&6I6ORP 

+!&)  r3   z/equipment-manuals/renamec            
         t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         }|rd|v sd|v sd|v rt	        d	
      dfS |st	        d
      dfS t
        |z  j                         st	        d
      dfS t        |      }|st	        d
      dfS t               }d\  }}|j                         D ]  \  }}|j                  d      |k(  s||}} n |rt        |      n||d}	t        j                  d      xs i }
|	j                  ||t        |      t        j                         j!                  d      |
j                  dd      d       |||k7  r|j#                  |d       |	||<   t%        |       t	        d|||	d         S )zEdit 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.
    Tr  r  r7   r  r6   r  r  r  r  r`  r0  zManual file not foundr  r  r  r  r'  r  r9   )r  r  rJ  r  r  NrJ  )rU  r  r  rJ  )r   r  r   r   r   r  r   r  r  r   r   r   r  r  r  r  r  r  r  )r  r  new_eqnew_keyr  old_keyr   r  r  r  r'  s              r1   equipment_manuals_renamer8  > s    4(.BD$*113Hhh{#)r002Fsh$(*:dh>N78#==JKSPPh&//145s::"6*GJKSPP!ENGU 155(UG  4;(X%VD;;v$"DKK!(+$((*334GHhhvr*  w'1		'4 E'NdXT%[QQr3   z/equipment/registryc                  z    t                t               } t        | j                         d       }t	        |      S )zKReturn all equipment registry records, auto-seeding from WO sidecars first.c                 B    | j                  dd      j                         S Nr9   r7   r   rE  rx	  s    r1   r	  z)equipment_registry_list.<locals>.<lambda>? s    aeeFB6G6M6M6O r3   r  )r  )r  r  r  r  r   )r  recordss     r1   equipment_registry_listr>  > s1     "$HX__&,OPGW%%r3   c            	         t        j                  d      xs i } | j                  d      xs dj                         }|st	        d      dfS t        |      }|st	        d      dfS t               }||v rt	        d|	      d
fS t        |      }|||<   t        |       t        d      r<t        ddt        j                  d      xs i j                  d      d| |d|i       t	        d|      S )z/Add a new equipment record manually (DVI-1012).Tr  r9   r7   zEquipment name is requiredr  r`  zEquipment already exists)r  r  r9  r  equipment_addedr'  zEquipment added: r	  r	  )r   r  r   r   r   r  r  r  r  r	  r	  r   )r  r9   r)  r  r  s        r1   equipment_registry_addrA  ? s     4(.BDHHV"))+D9:C??
t
$C9:C??"$H
h7#FKK#D)FHSM!";/*;;v&,"11&9'v.D>	
 d6**r3   z/equipment/registry/<asset_id>PATCHc                 6   t               }| |vrt        d      dfS t        j                  d      xs i }t	        ||          }d|v rt        |d         |d<   d|v rgt        |d   t              rT|j                  d      xs t        D ci c]  }|d c}}t        D ]  }||d   v st        |d   |         ||<     ||d<   d|v r|d   }|d	v r||d<   nt        |      |d<   d
|v r#t        |d
   xs d      j                         |d
<   d|v r#t        |d   xs d      j                         |d<   d|v rt        |d         |d<   d|v rt        |d         |d<   |j                  d      dk(  r1|j                  d      xs dj                         st        d      dfS ||| <   t        |       t        d      rt        ddt        j                  d      xs i j                  d      d|j                  d|        | |j                  d      |j                  d      |j                  d      |j                  d      |j                  d
      |j                  d      |j                  d      |j                  d      d       t        d|      S c c}w )z@Update RtS Approval settings for an equipment record (DVI-1012).zEquipment not foundr  r  Tr  r  r  r  )r  r
  r  r7   r  r  r  r
  z,An Asset # is required for mobile equipment.r`  r  equipment_updatedr'  r9   z Equipment RtS settings updated: )r9   r  r  r  r  r  r  r  r	  r	  )r  r   r   r  r   r   r   r   rO  r*   r   r  r	  r	  r   )r  r  r  r  rn  deptsrP  rQ  s           r1   equipment_registry_updaterF  $? s    #$Hx23S884(.BD(8$%F!%d>&:!;~D Z5F0G%N

,-N:1Na!T'1N 	BDt-.."4(9#:4#@Ad	B %* !%)***-0F)*-1#YF)*d"&)$/B*C*Ir&J&P&P&R"#T j!1!7R8>>@z4#'-=(>#? D i1y zz()X5vzz*?U?[Y[>b>b>dKLcQQHX!";/,;;v&,"11&96vzz&(7S6TU

6* &

> :#)::.?#@(.

3I(J%+ZZ0C%D"JJz2"(**-=">!::i0		
  d6**W 2Os   
Jz)/equipment/registry/<asset_id>/wo-historyc                 0    t        |       }t        |      S )zAReturn WO history rows for a given equipment asset_id (DVI-1012).)r  )r  r   )r  r  s     r1   equipment_registry_wo_historyrH  ^? s     $H-G7##r3   z/wor-attach-manualc                     t         j                  j                  d      xs dj                         } t	        |       }|st        d      dfS t         j                  j                  d      xs dj                         }t        |t        j                  d      xs i       \  }}|rt        |      dfS |st        d	      dfS 	 t        j                  |j                               }||d<   |j                  t        j                  |d             t        d|d   |j                  d      |j                  d            S # t        j                  t        f$ r t        d
      dfcY S w xY w)u  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.
    r  r7   r
  r  r  r  r'  r`  zNo manual providedz!Work order data could not be readr  r  r   r   Tr  r  rJ  )rU  r  r  rJ  )r   rg  r   r   r  r   r  r   r   r   r   r   r   r   r   )r  r  r  r
  r  r   s         r1   wor_attach_manualrJ  f? sJ      (.B557H"8,G34c99!!+.4";;=I))W[[5H5NBOK
S!3&&12C77Gzz'++-. !DNtzz$q12d[%<!,!A"u-/ /	   '* G@A3FFGs   #E 'E-,E-z
/wor-emailc            
         t        j                  d      xs i } | j                  dd      }| j                  dd      j                         }|st	        d      dfS |st	        d	      dfS t        |      }|st	        d
      dfS t        st	        d      dfS t        j                  t        t        t              }|j                  dg      }d|vr3|j                  d|j                  dd            }t	        d|       dfS |j                         }t        |      }|dk(  rd}	n|dk(  rd}	n
|dk(  rd}	nd}	|	 d| ddd d!d"|iigd#|d$t        j                   |      j#                         d%gd&d'd(}
t%        j&                  d)t         d*d+|d    d,d-|
d./      }|j(                  d0vrN|j+                         j                  di       j                  d1|j,                  d2d3       }t	        d4|       d5fS t/        d6      r@t1        d6d7t3        j                  d8      xs i j                  d9      d:| d;| |||d<=       t	        d>      S )?z7Email a Work Order Request PDF via Microsoft Graph API.Tr  r  r7   r  Invalid filenamer  r`  zEmail address is requiredr  r  4Email sender is not configured. Set AZURE_MAIL_FROM.r  r  r  r  r  r4  r  r2  Failed to acquire mail token: r   zICAST Mobile Machine Work Orderr   zICAST Safety Work Orderr   z)ICAST Preventative Maintenance Work OrderzICAST Maintenance Work Orderr
  r  z_Please find the attached Work Order PDF.

This email was sent from the ICAST Togen application.r  r  r  #microsoft.graph.fileAttachmentr  z@odata.typer9   r  contentBytesr  r  r  attachmentsr  r  r  r  r  r  r  r  r  r  r  NrK   Graph API error:   r  emailedr'  r9   WO z emailed to )tor  r	  rU  )r   r  r   r   r   r  r   r  r  r  r  r  r  r.   rn  r  	b64encoder  r  r  rg   r   r&  r	  r	  r   )r  r  to_emailri  r  r  r  r
  r  subject_prefixr  r  r	  s                r1   	wor_emailr]  ? s    4(.BDxx
B'Hxx$**,H/0#55893>>!(+I-.33STVYYY 33!3zSJ6667 7 9L \)2L4D4DWi4XY=cUCDcII$$&I	*H?":	X	2	\	!D7 ))xj9%L  )X!67
 $E$#4$*$4$4Y$?$F$F$H	
* #-J2 
1/1B)L&|N'C&DE.
 D z)"-11)TYYt_M09:C??";/;;v&,"11&9(<z:"9	
 dr3   z/wor-admin-listc            
      V   t               } t        j                  d      xs i }|j                  dd      }t        |      t	        |j                  d      xs |j                  d            }t
        j                  j                  d      dv xr t        |      }g }dD ]l  }t        ||	      }|j                         D ]H  \  }}	|	j                         s|	j                  d
      D ]  }
|
j                  j                  d      r t        j                  |
j!                         j"                        }|
j                  |j%                  d      |||
j!                         j"                  |d}| j                  |
j                        }|rI|j                  d      |j                  dd      |j                  dd      |j                  dd      d|d<   t'        |
j                  ||      }d}|r	 t)        j*                  |j-                               }|xs i j                  d      xs i }|j                  d      rd|j                  dd      d|d<   |xs i j                  dd      |d<   |xs i j                  dd      |d<   |xs i j                  d      }|r4t3        |      }|j                  d      |d<   ||d <   |j                  d!      |d"<   |j                  d#d      |d$<   |j                  d%g       |d&<   |j                  d'i       |d(<   t5        |      |d)<   |D cg c]	  }|v s| c}|d*<   t	        |j                  d+            |d,<   |j                  d-      }|sN|j                  d      d.k(  r:|
j7                  |
j8                  dz         }|j;                         r|j                  }||d-<   |j                  d/      |d0<   |t<        v r/t?        |xs i j                  d1            \  }}tA        |      |d2<   tC        |xs i       d3   |d4<   tA        tE        |xs i             |d5<   |xs i j                  d6      xs i }|j                  d7      s|j                  d8      rJ|j                  d7d      |j                  d8d      |j                  d9d      |j                  d:d      d;|d<<   tG        |
j                  ||      |d=<   |xs i j                  d>      |d?<   |jI                  |        K o |sL|sJ|D cg c]?  }|j                  d      d@k(  r)tK        fdA|j                  d&      xs g D              r|A }}|jM                  dB dC       |D ]  }
|
dD=  tO        |E      S # t.        t0        f$ r d}Y w xY wc c}w c c}w )Fa  List all WORs across new / in_progress / completed with status and type.

    Admins (wor_admin/togen_admin) get the full list. Approver-only callers
    (DVI-1024) get a server-side filtered list: only WOs awaiting RtS approval
    where one of their departments is required.
    r'  r  r7   r   r   rb  rc  r#  r  r+  r
  r  )r9   r   rd   r  r	  r
  r  next_runr  r~  T)r  r_  r  r~  	recurringr  Nr  r  )r~  r  r  r  submitter_namer  rd   r  r  r  r  r  evh_equipmentrk  r  r  r   r  evh_user_actionabler  evh_allow_parallelr  r  r  r  r0
  parts_countr   r  r
  _completionrq
  rr
  completed_bycompleted_atrq
  rr
  rg  rh  
completionre  r  r  r
  c              3   &   K   | ]  }|v  
 y wrT   rX   )r,  rn  _viewer_deptss     r1   r/  z!wor_admin_list.<locals>.<genexpr>Z@ s     Ym+Yr  c                     | d   S Nr	  rX   r  s    r1   r	  z wor_admin_list.<locals>.<lambda>[@ s
    QwZ r3   r
  r	  r
  )(r  r   r   rv  r   r   rh  r`  r  r   r  r  r9   r|  r  r  r  r  r  r  r   r   r   r   r   r  ro  	with_namer  r   RPM_PART_WOR_TYPES_rpm_normalize_partsrC  r  r  r  ri   r2  r  r   )r  _viewer_viewer_email_viewer_is_admin
_show_testr  r  r  
status_keyr  r  r	  r   r~  r  r  r  r  r  rn  evh_pdf_namer  
parts_normr  _comprB  rl  s                             @r1   wor_admin_listrz  ? s    ()Hkk&!'RGKK,M,];MGKK4RM8RS ,,""6*.HH -#G, EL Z$hZ8%)ZZ\ X	$!J	##%^^G, U$ 66??:.&778I8IJFF!NN+?@( (VVX..) ll166**-''2B*C$'GGJ$;$'GGJ$;#&779d#;	*E+& /qvvxjY#,'+zz'2C2C2E'F +0b55i@FBFzz),'+.4jj9I2.N+h 1=0B/G/GQS/TE+,/;/Ar.F.FGWY[.\E*+'-2226:C%:3%?
.1ggh.?l+8B4569gg>N6O2314b1Io.69gg>NPR6S2314b1Io.;WXb;c78CM7daQRVcQc7d346:377CS;T6U23 (+wwy'9+0AZ0O#$;;qvv
/B#CD#||~/3yy+7i(3677;V3W/0 11$8,:L"9Q9QRY9Z$[MJ+.z?E-( );<;M2(Nq(Qn%'*?<;M2+N'Om$%+00?E299/0EII>T4U,1II6H",M05		:PRT0U(-		."(E(-		."(E	+E,' "4AFFHl!Sg'3'9r&>&>}&Ml#U#kU$X	$Z$| J! [qEE,'+>>Yaee<P6Q6WUWYY  [ [ 
JJ'J6 gJI '0 ,'+,( 8eT[s%   6#V	.	V!
8V!
AV&	V	V	z/wor-admin-activityc            
         ddl m}  t        j                         j	                         }| | d      z
  }t        d      D ci c]  }| | |      z   j                  d      d  }}ddddd}dD ]6  }t        |      j                         D ]  \  }}|j                         s|j                  d	      D ]  }	|j                  |d      d
z   ||<   d}
|	j                  d      }|j                         r^	 t        j                  |j!                               xs i }|j                  d      xs |j                  d      xs dj#                         }
|
s<t        j(                  |	j+                         j,                        j                  d      }
|
|v s||
xx   d
z  cc<     9 t/        |j                               D cg c]
  \  }}||d }}}g }t1               j                         D ]  \  }}|j                  dd      s|j                  d      xs d}	 t        j2                  |d      j	                         }||z
  j6                  }|d
k  sh|j9                  ||||j                  dd      d        |j;                  d        t=        |||      S c c}w # t$        t&        f$ r d}
Y jw xY wc c}}w # t$        t4        f$ r Y w xY w)a(  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).
    r   r  r(
  r  r  r  rp  r#  r+  r^   r7   r  r   r3  )r   r  r~  Tr_  r  r   )r9   r_  
days_untilr  c                     | d   S )Nr|  rX   rx	  s    r1   r	  z$wor_admin_activity.<locals>.<lambda>@ s
    !L/ r3   r  )submissions_by_daystatus_countsrecurring_due_soon)r   r   r  r  r   r  r  r  r   r  r  r   r  r   r   r   r   r   r   r   fromtimestampr  r  r  r  r  r   r  ri   r  r   )r   r	  window_startr  
day_countsr  r  rv  r  r  dayr  r  rn  r  r~  r  r  r~  r_  nrr|  s                         r1   wor_admin_activityr  a@ s    # %%'E 9"--L r 
	q)	)33J?BJ 
 aaaPML )%3H%=%C%C%E 	)!J	##%^^G, ),9,=,=j!,Lq,Pj) --0??$!!ZZ(9(9(;<B!vvf~P8I1JPbWWY (66))++38J+? *$sOq(O#)	))0 -3:3C3C3E,F$(AqQ  1399; #wwy$'77:&,"	((:>CCEB 5j&&
?%% $(GGJ>	'    9:-#- o, '0 ! ! I& 		s1   #J#5AJ(K !$K(J=	<J=	KKz/wor-admin-toggle-notifyc                     t        j                  d      xs i } | j                  dd      }|rd|v sd|v rt        d      d	fS t	        |      }|st        d
      dfS 	 t        j                  |j                               xs i }|j                  d      xs i }d|d<   d|d<   ||d<   |j                  t        j                  |d             t        d      S # t        t        f$ r t        d      dfcY S w xY w)z_Admin disables per-WO notifications: sets _notify.enabled false and flags it as admin-disabled.Tr  r  r7   r  r6   rL  r  r`  r  r  Could not read work order datar  r  Fr~  r
  r   r   rY  )r   r  r   r   r  r   r   r   r   r   r   r   )r  r  r  r   r  s        r1   wor_admin_toggle_notifyr  @ s
    4(.BDxx
B'Htx'3(?/0#55"8,G-.33Dzz'++-.4" XXi &BFF9"&FDOtzz$q12d   D=>CCDs   !'C C>=C>c                      t         j                         r=	 t        j                  t         j	                               } t        | t              r| S 	 i S i S # t        t        f$ r Y i S w xY w)z5Return the {part_number: part_name} autofill catalog.)	RPM_KNOWN_PARTS_FILEr   r   r   r   r   r   r   r   rn  s    r1   _rpm_load_known_partsr  @ sh    ##%	

/99;<A!T" # I2I G$ 	I	s   8A A'&A'r  c                 X    t         j                  t        j                  | d             y r   )r  r   r   r   )r  s    r1   _rpm_save_known_partsr  @ r   r3   r  c                     | xs dj                         } |xs dj                         }| r|syt               }|j                  |       |k7  r||| <   t        |       yy)z?Remember a Part Number -> Part Name mapping (latest edit wins).r7   N)r   r  r   r  )r  r9   r  s      r1   _rpm_upsert_known_partr  @ s]    l!!#FJBD#%G{{6d"g& #r3   )assume_stocked_on_completemark_stocked_on_deliveredc                  .   t        t              } t        j                         r[	 t	        j
                  t        j                               }t        |t               r!t        D ]  }||v st        ||         | |<    | S | S # t        t        f$ r Y | S w xY wrT   )r   _RPM_SETTINGS_DEFAULTSRPM_SETTINGS_FILEr   r   r   r   r   r   r   r   )r  rn  r  s      r1   _rpm_load_settingsr  @ s    *+H  "	

,6689A!T"/ 1AAv&*1Q4j1
 O8O G$ 	O	s   AB *B BBc                 X    t         j                  t        j                  | d             y r   )r  r   r   r   rf  s    r1   _rpm_save_settingsr  A s      HQ!?@r3   )
all_adminsrK  specific)rm  r  r  r  rm  rO  r  r~  r   r   )approve_modeapprove_usersauto_approvalc                     g t               }}| xs g D ]W  }t        |xs d      j                         j                         }|s1||vs6|j	                  |       |j                  |       Y |S )zCLower-cased, de-duplicated, order-stable email list from raw input.r7   )r  r*   r   rE  r  ri   )r  r   r  r  rB  s        r1   _rpm_norm_emailsr  "A se    CECl RL &&($HHQKJJqM	
 Jr3   c                     dg ddg ddg ddt         D  ci c]  } | g  c} d}t        j                         rv	 t        j                  t        j                               }t        |t              r=|j                  d      t        v r|d   |d<   t        |j                  d            |d<   |j                  d	      }t        |t              rt        |j                  d
            |d	   d
<   t         D ]k  } |j                  |       }t        |t              s%|j                  d      }|t        v r||d	   |    d<   t        |j                  d            |d	   |    d<   m |j                  d      }t        |t              r+t         D ]"  } t        |j                  |             |d   | <   $ |S c c} w # t        t        f$ r i }Y iw xY w)zAReturn the RPM approval/auto-approval policy, defaults filled in.r  Frm  r  r  r  r  r  policy_notifyr  r  r  r~  rO  r  r  )rp  RPM_APPROVAL_FILEr   r   r   r   r   r   r   r   r   _RPM_APPROVE_MODESr  r   _RPM_AUTO_MODES)r5  polrn  r   blkrO  r
  s          r1   _rpm_load_approvalr  -A s    %&1B?#.<
 ,>>R"b&>C   "	

,6689A auu^$(::&'&7N##3AEE/4J#KC 55)D$%26txx	7J2KO$Y/, _B((2,C!#t,"wwv?2?CC04V<<LSWWU\M]<^O,R09_ 'B"d#, LB/?r
/KC(,LJ3 ?
 G$ 	A	s   
F2
'F7 7GGr  c                 X    t         j                  t        j                  | d             y r   )r  r   r   r   )r  s    r1   _rpm_save_approvalr  VA s      C!:;r3   c                    t        | t              sy| j                  d      ryt               }|j                  dd      }| j                  d      xs dj	                         j                         }|dk(  r:t               }||j                  |g       D cg c]  }|j                          c}v S |d	k(  r||j                  d
g       v S t        | j                  d            S c c}w )zWhether ``user`` (session dict) may approve parts for ``wor_type`` under
    the current Approval permission policy. Togen Admins always may.Fr   Tr  r  r  r7   rK  r  r  r   )r   r   r   r  r   rE  rC  r   )r'  r  r  rO  r  rK  rB  s          r1   _rpm_user_can_approver  ZA s     dD!xx

C77><0DXXg$"++-335Ey#%GKK",EFqFFFz444%&&	 Gs   C#c                    t        | t              syt               }|j                  d      xs i }|j                  d      sy|j                  |      xs i }|j                  dd      }| j                  d      xs dj	                         j                         }|dk(  r-t        | j                  d	      xs | j                  d
            S |dk(  r|t        |j                  d            v S t        | |      S )zWhether a part added/submitted by ``user`` for ``wor_type`` should be auto
    approved under the current Auto Approval policy.Fr  r~  rO  rm  r  r7   r  r   r   r  r  )	r   r   r  r   r   rE  r   r  r  )r'  r  r  r   rJ  rO  r  s          r1   _rpm_auto_approve_forr  mA s     dD!

C77?#)rD88I
((8

"C776;'DXXg$"++-335E|DHH[)DTXXm-DEEz()9::: x00r3   c           
          	 t        j                  | |t        ||      t        |||      |xs i j	                  d      xs d|xs i j	                  d      xs dd       y# t
        $ r Y yw xY w)zFire 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.r9   r7   r  )r  r
  wo_title	part_namepart_numberN)r=  
notify_rpmr  r  r   ry   )eventr  r  r   r  s        r1   _rpm_notify_partr  A sw    	   )(D9*8XtD*"))&17R JB++H5;)
 	  s   AA" "	A.-A.c                     t        | j                  d            ry|r|dk(  ry|r0t        | j                  d      xs d      j                         dk(  ryy)zA 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.stockedTr   tracking_statusr7   	deliveredF)r   r   r*   rE  )r  rv  assume_on_completemark_on_delivereds       r1   _rpm_effective_stockedr  A sR     DHHY jK7S*;!<!BCIIK{Zr3   c                    g dt               }}}| xs g D ]P  }t        |t              sd}t        |j	                  d      xs d      j                         }|r||v r&dt        j                         j                  dd  }d}|j                  |       d|vsd|vsd	|vsd
|vrd}d|vrd}|j                  |t        |j	                  d      xs d      j                         t        |j	                  d      xs d      j                         t        |j	                  d      xs d      j                         t        |j	                  d      xs d      j                         t        |j	                  dd            t        |j	                  d	d            t        |j	                  d
d            t        |j	                  d      xs d      j                         t        |j	                  d      xs d      j                         t        |j	                  d      xs d      j                         t        |j	                  d      xs d      j                         d       S ||fS )u   Return (normalized_parts, changed) — every part gets a stable id plus
    approved/ordered/stocked booleans, preserving number/name/qty/amount.FTr  r7   rpm_Nr-  r  rG  r  tracking_numberr  r9   r  r1
  rx  r  delivered_atr  r  r9   r  r1
  r  rG  r  r  rx  r  r  )r  r   r   r*   r   r   rW  rX  rY  r  ri   r   )r0
  
normalizedr  r  r  pids         r1   rq  rq  A s    !#E35Jkr !T"G!%%+#$**,cTk))#2./0CGq=Ja/9A3EZ[I[GA%G!%%//R0668f+,224quuU|)r*002!%%//R0668QUU:u56AEE)U34AEE)U34  #155):#;#ArBHHJ155+1r288:"155):#;#ArBHHJn 5 ;<BBD
 	< wr3   c                     t        |       \  }}t        |xr |xr t        ||            }|D ]  }|rd|d<   t        |d   |d           |S )u  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.Tr  r  r9   )rq  r   r  r  )r0
  r  r'  r  r  r   r  s          r1   r
  r
  A sc     )/MJKTK&;D(&KLD 7 AjMq{AfI67 r3   c                 h    |xs ddk(  r| j                  d      xs dS | j                  d      xs dS )u   The free-text equipment/machine name a WO's parts were used on — Mobile
    Machine WOs carry it as ``machine``, Preventive as ``equipment``.r7   r   r(  r  r  )r   r  s     r1   _rpm_usage_equipmentr  A s8     	B?*xx	"(b(88K &B&r3   c                     t        | xs d      }|rt        j                  |      }|r|S |xs dj                         }|rt        j                  |      r|S y)u  Best-effort resolve a WO's equipment reference to an asset_tag for the
    parts ledger (via the DVI-1472 P2 equipment-alias shim, else a direct
    asset-tag match on the MM asset #). Blank when nothing matches — usage is
    still recorded (against the WO), never blocked.r7   )r  r  asset_tag_for_equipmentr   	get_asset)r
  r)  rH  tagr  s        r1   _rpm_resolve_usage_assetr  A sY    
   4"5D2248JB%%'D&&t,r3   c                    |sy	 t        ||      }|D ]  }t        |t              s|j                  d      xs dj	                         }|s;t        |j                  d      xs d      j	                         }t        j                  ||j                  d      xs d|| |j                  d      xs d||      }	|	j                  d      s|	j                  d      xs i }
|	j                  d	      rd
n|	j                  d      rdnd}t        dd|d|
j                  d      z  | d||rd|z  ndd|
j                  d      d||
j                  d      || |||
j                  d      |	j                  d	      |	j                  d      d       t        |
d| z          y# t        $ r# t        j                  j                  d|        Y yw xY w)u  Record MM/PM WO part usage into the inventory ledger (D8-A): each part
    with a part number decrements catalog on_hand exactly once (idempotent per
    the part's stable RPM id, so a WO re-read/re-save never double-counts).
    Below-min/negative only FLAG. WO sidecars are never rewritten by this — the
    ledger is the audit trail. Best-effort: a store failure never breaks the WO
    flow.Nr  r7   r  r9   r  recordedr  	below_minu    — below minnegativeu    — NEGATIVEtogen-adminpart_wo_usagepart:%sz used part u    ×%s
 (on hand on_handr7  )part_idpart_nowo_ref	asset_tagr  r  r  r  r		  r  r
	  r	  zWO %s usagetriggered_byz&RPM part usage recording failed for %s)r  r   r   r   r   r*   r  record_wo_part_usager	  _parts_low_stock_alertry   rm   rn   r  )r  r0
  r
  r)  r		  r  r  r  r  r  r  ri  s               r1   r
  r
  A s    O,^\J	 	NAa&eeHo+224FaeeEl(b)//1C33f+S&!%%+:K5"C 77:&776?(bD(+(<$-0WWZ-@/b e.FsWs]$BHHY'/ $(88D>f"(y#'88I#6%(WW[%9$'GGJ$7	9
: #4mf6LM5	N6  O

EvNOs   F#F) ))GGc                     t        | |      }|sy	 t        j                  |j                               xs i }||fS # t        t
        f$ r Y yw xY w)zEResolve a WO sidecar and load its data; returns (sidecar_path, data).r  )r  r   r   r   r   r   )r  r  r  r   s       r1   _rpm_load_wor  B sZ    "8X6Gzz'++-.4" D=   s   '< AApersistc                     t        |j                  d            \  }}|r2|r0| .||d<   	 | j                  t        j                  |d             |S |S # t
        $ r Y |S w xY w)zGReturn normalized parts for a WO, persisting backfilled ids/flags once.r0
  r   r   )rq  r   r   r   r   r   )r  r   r  r0
  r  s        r1   _rpm_wo_partsr  )B sn    )$((7*;<NE77w2W	tzz$q9: L5L  	L	s   &A 	A! A!c                 Z    ||d<   | j                  t        j                  |d             y )Nr0
  r   r   r9	  )r  r   r0
  s      r1   _rpm_save_wo_partsr  5B s$    DMtzz$q12r3   c                     | j                  d      xs dj                         }|r/t        j                  |      }|r|d   | d<   |d   | d<   |d   | d<   | S )	zAnnotate an RPM part dict with its catalog stock (DVI-1472 P4 D8-A) so
    the RPM tool + WO Admin parts dialog surface on-hand + a low badge. No-op
    when the part carries no number or isn't in the catalog.r  r7   r  stock_on_handrK  	stock_lowmin_qty	stock_min)r   r   r  get_part_by_no)r  r  r(  s      r1   _rpm_attach_stockr  :B se     hhx &B--/F))&1$'	ND! #E
D #IDKr3   
approved_f	ordered_fc                     |dk(  r| j                  d      sy|dk(  r| j                  d      ry|dk(  r| j                  d      sy|dk(  r| j                  d      ryy)Nrf  r  FnorG  Tr  )r  r  r  s      r1   _rpm_part_matchesr  HB s]    U488J#7Tdhhz2E$((9"5DTXXi0r3   z
/rpm/partsc                     t         j                  j                  d      xs dj                         } t         j                  j                  d      xs dj                         }t         j                  j                  d      xs dj                         }t         j                  j                  d      xs dj                         dk(  }t	               }|d   }|d	   }| t
        v r| fnt
        }t        j                  d
      xs i }t
        D 	ci c]  }	|	t        ||	       }
}	g }|D ]  }t        |      j                         D ]g  \  }}|dk(  r|dk(  r|s|j                         s&|j                  d      D ]-  }|j                  d      }|j                         s&	 t        j                  |j!                               xs i }t'        ||      }|s]g }|D ]H  }t)        |||      st+        |      }t-        ||||      |d<   t/        |       |j1                  |       J |s|j1                  |j2                  ||t5        |j2                  ||      t7        |j2                  |      t9        |      ||
j                  |d      |j;                         j<                  d	       0 j  |j?                  d d       |D ]  }|d=  tA        |      S c c}	w # t"        t$        f$ r Y xw xY w)u?  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.rT  r  r  rG  r   r  rf  r  r  r'  r   r+  r  r  F)	r9   r  rd   re  r
  
part_countr0
  can_approver	  c                     | d   S rn  rX   )r   s    r1   r	  zrpm_parts.<locals>.<lambda>B s
    aj r3   Tr
  r	  )r   )!r   rh  r   r   r  rp  r   r  r  r   r  r  r  r   r   r   r   r   r   r  r  r   r  r  ri   r9   r  r  rC  r  r  r  r   )type_filterr  r  include_completedr  assumemark_deliveredrV  r'  r5  can_approve_by_typer   r  rv  r  r  r  r   r0
  shownr  ppr   s                          r1   	rpm_partsr  TB s    <<##F+4u;;=K,,"":.7%>>@J!!),5<<>I ))+6>$EEG5P!#H23F9:N)-??[NEWE;;v$"DI[\224T2>>\\F '%3H%=%C%C%E &	!J	[([(1B##%^^G, --0(::g&7&7&9:@bD &gt4 %A,Q
IF aB$:1j&R`$aByM%b)LL$% FF ((/$G 1!&&$ ?"%e*"#6#:#:8U#KVVX..
 
+&	'P KK($K7 gJ&!![ ]  #G, s   K#'K""K5	4K5	z/rpm/wo-partsc                     t         j                  j                  d      xs dj                         } t         j                  j                  d      xs dj                         xs d}t	        | |      \  }}|st        d      dfS t        ||      }|j                  d      xs |xs d}|D ]  }t        |        t        | |t        | ||      t        | |      |t        t        j                  d	      xs i |      
      S )zHParts associated with a single Work Order (used by the WO Admin dialog).r  r7   rT  Nr
  r  r  r
  r'  )r9   r  re  r
  r0
  r  )r   rh  r   r   r  r   r  r  r  r  r  r   )r  r  r  r   r0
  resolved_type_ps          r1   rpm_wo_partsr  B s       ,299;H  (.B557?4H 84MGT34c99'4(EHH[);X;M " =$?"8T2)'++f*=*C]S r3   z/rpm/manual-searchc                     t         j                  j                  d      xs dj                         } t         j                  j                  d      xs dj                         }t         j                  j                  d      xs dj                         xs d}t         j                  j                  d      xs dj                         }d}|rht	        ||      \  }}|rW|j                  d      xs i }|j                  d      }|r/t
        |z  j                         r||j                  d	      xs |d
}|dk(  r|r|d   nd}	t        |       dk\  rt        | |	      ng }
t        |
| ||      S )a  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.r  r7   r  rT  Nscoper+  r  r  r4  r   )r  )r  r#  r  	wo_manual)
r   rh  r   r   r  r  r   rC  r'  r   )r  r  r  r  r  _sidecarr   r  r  onlyr  s              r1   rpm_manual_searchr	  B s7    
		#		$"++-A  ,299;H  (.B557?4H\\g&.$557EI%h9$"(bAz"B+446)+aeeO>T>ZXZ[	%*d]y9Z tD;>q6Q; $7BG7!5INNr3   z/rpm/wo-parts/addc                     t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         xs d}| j                  d      xs dj                         }| j                  d      xs dj                         }| j                  d	      xs dj                         }| j                  d
      xs dj                         }t	        | j                  d            }|s|s|s|st        d      dfS t        ||      \  }}	|st        d      dfS t        ||	      }
|	j                  d      xs |xs d}t        t        j                  d      xs i |      }dt        j                         j                  dd  |||||d|ddddd}|
j                  |       t        ||	|
       t        ||       |t         v rft#        d|	j                  d      xs |z  |gt%        |	|      |	j                  d      t        j                  d      xs i j                  d             t'        |rt(        j*                  nt(        j,                  |||	|       t/        d      r\|rdnd}|rdnd }t1        d|t        j                  d      xs i j                  d      d!| d"|xs |xs d# d$| ||d%   |||d&'       t        d|(      S ))zDAssociate a new part with a Work Order and remember its Part Number.Tr  r  r7   rT  Nr  r9   r  r1
  r  zEnter at least one part detailr  r`  r
  r  r
  r'  r  r-  Fr  r
  r  r)  r  r
  r  part_approvedpart_requestedzauto-approved	requestedPart r>  r2   for WO r  )r  r  r9   r  r	  rU  r  )r   r  r   r   r   r   r  r  r  r   rW  rX  rY  ri   r  r  rp  r
  r  r  r=  EVENT_RPM_PART_APPROVEDEVENT_RPM_PART_AWAITINGr	  r	  )r  r  r  r  r9   r  r1
  r  r  r   r0
  r  auto_approvednew_part_action_labels                   r1   rpm_wo_parts_addr  B s    4(.BD$*113H &B--/74Hhhx &B--/FHHV"))+D88E? b
'
'
)Chhx &B--/F 488I&'GdcV=>CC 84MGT34c99'4(EHH[);X;M)'++f*=*C]SMTZZ\%%cr*+,$sf!e"UW	H 
LLwe,64( **txx.:(;hZ/mD.1;;v&,"11':		< 1>--22xx1
 #5)%2/8H$1{7;;v&,"11&9F82f&A&A	%B(8*U'~[hi	
 d**r3   z/rpm/part/updatec                  z   t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         xs d}| j                  d      xs dj                         t	        ||      \  }}|st        d	      d
fS t        ||      }t        fd|D        d      }|st        d	      d
fS dD ]3  }| j                  |      t        | |         j                         ||<   5 t        |||       t        |d   |d          t        d|      S )zEEdit a part's details in place; re-remembers the Part Number -> Name.Tr  r  r7   rT  Nr  r
  r  r  c              3   4   K   | ]  }|d    k(  s|  ywr	  rX   r,  r  r  s     r1   r/  z"rpm_part_update.<locals>.<genexpr>C      6qw#~16   Part not found)r  r9   r  r1
  r  rx  r  r9   r  )r   r  r   r   r  r   r  r  r*   r  r  )	r  r  r  r  r   r0
  r  r)  r  s	           @r1   rpm_part_updater  C s-    4(.BD$*113H &B--/74H88D>R
&
&
(C 84MGT34c99'4(E6e6=F-.33P 188C=$d3i...0F3K1 we,6(+VF^<d((r3   z/rpm/part/removec                     t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         xs d}| j                  d      xs dj                         }t	        ||      \  }}|st        d	      d
fS t        ||      }|D cg c]  }|d   |k7  s| }}t        |      t        |      k(  rt        d	      d
fS t        |||       t        d      S c c}w )z Remove a part from a Work Order.Tr  r  r7   rT  Nr  r
  r  r  r  rY  )	r   r  r   r   r  r   r  rC  r  )	r  r  r  r  r  r   r0
  r  	new_partss	            r1   rpm_part_remover!  C s     4(.BD$*113H &B--/74H88D>R
&
&
(C 84MGT34c99'4(E!4qQtW^4I4
9~U#-.33wi0d	 5s   7D	D	z/rpm/part/flagc                  r   t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         xs d}| j                  d      xs dj                         t	        ||      \  }}|st        d	      d
fS t        ||      }t        fd|D        d      }|st        d	      d
fS |j                  d      xs |xs d}t        |j                  d            }t        |j                  d            }	t        |j                  d            }
d| v rAt        | d         r3|s1t        t        j                  d      xs i |      st        d	      dfS d| v rt        | d         |d<   d| v rt        | d         |d<   d| v rt        | d         |d<   d| v r#t        | d   xs d      j                         |d<   d| v r#t        | d   xs d      j                         |d<   t        |||       |j                  d      r|st        t        j                  ||||       |j                  d      r|	st        t        j                   ||||       |j                  d      r|
st        t        j"                  ||||       t%        d      rq|j                  d      xs% |j                  d      xs |j                  dd      }|j                  d      rR|sPt'        ddt        j                  d      xs i j                  d      d| d| ||j                  d      |d       |j                  d      rb|	s`t'        ddt        j                  d      xs i j                  d      d| d| ||j                  d      |j                  d      |d        |j                  d      rR|
sPt'        dd!t        j                  d      xs i j                  d      d"| d| ||j                  d      |d       t        d|#      S )$zAMark a part approved/ordered/stocked (booleans sent in the body).Tr  r  r7   rT  Nr  r
  r  r  c              3   4   K   | ]  }|d    k(  s|  ywr	  rX   r  s     r1   r/  z rpm_part_flag.<locals>.<genexpr>9C r  r  r  r
  r  rG  r  r'  z,You do not have permission to approve parts.r  r  rx  r  r  r9   r  zPart approved: r  )r  r  r  r	  part_orderedzPart ordered: )r  r  trackingr  part_stockedzPart stocked: r  )r   r  r   r   r  r   r  r  r   r  r   r*   r  r  r=  r  EVENT_RPM_PART_ORDEREDEVENT_RPM_PART_STOCKEDr	  r	  )r  r  r  r  r   r0
  r  r  was_approvedwas_orderedwas_stocked_part_labelr  s               @r1   rpm_part_flagr-  -C s    4(.BD$*113H &B--/74H88D>R
&
&
(C 84MGT34c99'4(E6e6=F-.33HH[);X;M

:./Lvzz),-Kvzz),-KTd4
#34\$W[[%8%>BN!OPRUUUT!$z"23zD i1yD i1y D $'->(?(E2$F$L$L$N !DY 526<<>ywe,zz*l>>xY]_efzz)[==}hX\^dezz)[==}hX\^de"5)jj*Xfjj.@XFJJtUWDX::j!,{{6*0b55f=)+hxjI#&&**X2FTab ::i ~{{6*0b55f=(XhZH#&&**X2F$*JJ/@$A}^ ::i ~{{6*0b55f=(XhZH#&&**X2FTab d((r3   z/rpm/known-partc                      t         j                  j                  d      xs dj                         } | st	        d      S t	        t               j                  | d            S )zCAutofill lookup: return the remembered Part Name for a Part Number.r  r7   r  )r   rh  r   r   r   r  )r  s    r1   rpm_known_partr/  xC sP     llx(.B557FB-/33FB?@@r3   z/rpm/settingsc                  *    t        t                     S )z=Return the RPM policy settings (Settings tab -> Policy pane).rf  )r   r  rX   r3   r1   rpm_settings_getr1  C s     .011r3   c                      t        j                  d      xs i } t               }t        D ]  }|| v st	        | |         ||<    t        |       t        d|      S )z:Persist RPM policy settings. Only known keys are accepted.Tr  rU  r  )r   r  r  r  r   r  r   )r  r  r)  s      r1   rpm_settings_postr4  C s_     4(.BD!#H% ,$; cOHSM, x dX..r3   z/rpm/approval-policyc                  *    t        t                     S )z@Return the Approval permission + Auto Approval policy (DVI-614).policy)r   r  rX   r3   r1   rpm_approval_policy_getr8  C      ,.//r3   c                     t        j                  d      xs i } | j                  d      }t        |t              st        d      dfS |j                  d      t        v r|j                  d      ndt        |j                  d	            d
dg ddg ddt        D ci c]  }|g  c}d}|j                  d      }t        |t              rt        |j                  d            |d   d<   t        D ]l  }|j                  |      }t        |t              s%|j                  d      t        v r|d   |d   |   d<   t        |j                  d            |d   |   d<   n |j                  d      }t        |t              r+t        D ]"  }t        |j                  |            |d   |<   $ 	 t        |       t        dt                     S c c}w # t        $ r}t        d|       dfcY d}~S d}~ww xY w)zAPersist the Approval permission + Auto Approval policy (DVI-614).Tr  r7  zInvalid policy.r  r`  r  r  r  Frm  r  r  r  r  r~  rO  r  r  Failed to save: r  NrU  r7  )r   r  r   r   r   r   r  r  rp  r   r  r  r   r  )r  r  r5  rF  r   r  r
  rl  s           r1   rpm_approval_policy_postr=  C s    4(.BD
((8
Cc4 ./44 4777>3JN`3`/fr)#''/*BC%*4?"+M1<r(JL ,>>R"b&>E 77?#D$,0)1D,Eoy)$ 	YB((2,C#t$776?o59<VE/*2.v66FswwwGW6Xo&r*73	Y 
	!B"d$ 	FB)9"&&*)EE/"2&	F<5! d#5#788' ?"  </u56;;<s$   
G
1G 	G:G5/G:5G:)awaitingr  rG  r  r  c           
      6   | j                  t        j                        }i }t        D ]i  }t	        |t
              r|j                  |      nd}t	        |t
              r|ni }t        D ci c]  }|t        |j                  |             c}||<   k |S c c}w )zKReturn the {wor_type: {col: bool}} RPM notifications grid, all-off default.N)r   r=  RPM_NOTIFY_KEYrp  r   r   _RPM_NOTIFY_COLSr   )r  r4  rz  r5  r$  r6  s         r1   _rpm_notify_grid_from_settingsrB  C s    \\-667FD  I *64 8fjjndT*c7GHCcggcl++HRI K Is   *"Bz/rpm/notificationsc                      t        j                  d      xs i j                  dd      } t        t        t	        |                   S )u   Return the current user's RPM notifications grid (DVI-614). Available to
    all users — the grid is mirrored into User Settings > Notifications.r'  r  r7   )r=  )r   r   r   rB  r  r]  s    r1   rpm_notifications_getrD  C s<    
 [[ &B++GR8E!?@STY@Z![\\r3   c            
         t        j                  d      xs i } | j                  d      }t        |t              st        d      dfS t        j                  d      xs i j                  dd	      }t        |      }i }t        D ]d  }t        |j                  |      t              r|j                  |      ni }t        D ci c]  }|t        |j                  |             c}||<   f ||t        j                  <   |r||d<   	 t        ||       t        d|      S c c}w # t        $ r}t        d
|       dfcY d}~S d}~ww xY w)z<Persist the current user's RPM notifications grid (DVI-614).Tr  r=  zInvalid notifications grid.r  r`  r'  r  r7   r;  r  NrU  r=  )r   r  r   r   r   r   r   r  rp  rA  r   r=  r@  r  r   )	r  grid_inr  r  rz  r5  r$  r6  rl  s	            r1   rpm_notifications_postrH  C s5    4(.BDhh'Ggt$:;S@@[[ &B++GR8E"5)HD  I!+GKKOT!Bgkk"o7GHCcggcl++HRI .2H]))*!<E8, d$// I  </u56;;<s$   "D!D& &	E
/E?E
E
z/rpm/processingc                     t               } | d   }| d   }g g }}t        D ]]  }t        |      j                         D ]=  \  }}|dk(  s|j	                         s|j                  d      D ]  }|j                  d      }	|	j                         s&	 t        j                  |	j                               xs i }
t        |	|
      }|s]|D ]  }t        ||||      r|j                  ||t!        |j                  ||
      t#        |j                  |
      ||j%                         j&                  d}|j)                  d      s|j+                  |       |j)                  d      s|j+                  |         @ ` ||fD ]   }|j-                  d	 d
       |D ]  }|d=  " t/        ||      S # t        t        f$ r Y \w xY w)aL  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.r  r  r   r+  r  )r9   r  rd   re  r
  r  r	  r  rG  c                     | d   S rn  rX   )r|	  s    r1   r	  z rpm_processing.<locals>.<lambda>D s
    7 r3   Tr
  r	  )r
  ordered_not_stocked)r  rp  r  r   r  r  r  r   r   r   r   r   r   r  r  r9   r  r  r  r  r   ri   r  r   )r  r  r  r
  rK  r  rv  r  r  r  r   r0
  r  r	  lstr|	  s                   r1   rpm_processingrM  C s    "#H23F9:N-/*& 9%3H%=%C%C%E 	9!J	[(	0@0@0B^^G, 9--0(::g&7&7&9:@bD &gt4 9A-aV^T  !$,",!3AFFHd!K$5affd$C !!"!2!2D 55,)006uuY'+224899	99> "#67 +T: 	B7	 %6L_``3 #G, s   'F44G	G	z/wor-admin-statusc                  L   t        j                  d      xs i } | j                  dd      }| j                  dd      }|rd|v sd|v rt        d	      d
fS |dvrt        d	      d
fS t	        |      }|st        d	      dfS t        |      }| j                  d      xs dj                         }| j                  d      xs dj                         }|dk(  rSt        |      }i }|r$	 t        j                  |j                               }t        |      \  }	}
|	r|st        d	      d
fS |dk(  r1|dk(  r+t        |      }|r%	 t        j                  |j                               }ni }t        j                  di       }|j                  di       }|j                  d      s|j                  dd      |d<   |j                  d      s&t        j                          j#                  d      |d<   |j                  d      st%        t'                     |d<   ||d<   t)        ||      }|j+                  |       |r&|j-                  t        j.                  |d             |dk(  r1|dk(  r+t        |      }|r%	 t        j                  |j                               }ni }t        j                  di       }|j                  di       }|j                  d      s|j                  dd      |d<   |j                  d      s&t        j                          j#                  d      |d<   |j                  d      st%        t'                     |d<   ||d<   t1        ||      }|j+                  |       |r&|j-                  t        j.                  |d             |dk(  r1|d k(  r+t        |      }|r%	 t        j                  |j                               }ni }t        j                  di       }|j                  d!i       }|j                  d      s|j                  dd      |d<   |j                  d      s&t        j                          j#                  d      |d<   |j                  d      st%        t'                     |d<   ||d!<   t3        ||"      }|j+                  |       |r&|j-                  t        j.                  |d             |dk(  r|d#k(  rt        |      }|r%	 t        j                  |j                               }ni }|j                  d$      st%        t'                     |d$<   t5        |      }|j+                  |       |r&|j-                  t        j.                  |d             t        |      }t7        |      |   }|j9                  dd%       ||z  }|j;                         |j;                         k7  r(t=        j>                  t%        |      t%        |             d&}|rZ|jA                  d'      }|j;                         |j;                         k7  r(t=        j>                  t%        |      t%        |             tC        ||       |dk(  r|r|jE                         rr	 t        j                  |j                               }t        |      \  }}|r=|r:t        j                          j#                  d(      |j                  d)      xs dj                         tG        |j                  d)      xs dj                               |d*i d+d&d&||t        j                          j#                  d(      d,|d-<   |j-                  t        j.                  |d             |d-   }tI        |      }tK               }tM        |||      }|D ]I  }|j                  |      xs g }|stO        jP                  tN        jR                  tU        ||.             K |dk(  r|r|jE                         r|s|r	 t        j                  |j                               }||t        j                  d      xs i j                  dd      t        j                          j#                  d(      d/|d0<   |j-                  t        j.                  |d             tV        j                  |      }|rYi } |r4|jE                         r$	 t        j                  |j                               } tO        jP                  |tM        |||              tY        d1      rld2d3dd4dj                  ||      }!||d5}"|dk(  r|s|r
||"d<   ||"d<   t[        d1d6t        j                  d      xs i j                  d      d7| d8|! ||"9       t        d|:      S # t        j                  t        f$ r i }Y w xY w# t        j                  t        f$ r i }Y iw xY w# t        j                  t        f$ r i }Y Tw xY w# t        j                  t        f$ r i }Y ?w xY w# t        j                  t        f$ r i }Y ,w xY w# t        j                  t        f$ r i }Y w xY w# t        j                  t        f$ r i }Y Vw xY w# t        j                  t        f$ r i } Y w xY w);z7Change a WOR's status by moving it between directories.Tr  r  r7   rd   r  r6   rL  r  r`  rp  zInvalid statusr  r  rq
  rr
  r   z^Description of Work Performed is required for work orders with Return-to-Service requirements.r   r   r'  _maintenancer	  r9   r   r  r	  r   r   r   r   r   _safetyr   r   r<
  rM   Nr  r  r  Fr
  )r  r  r  rk  r  r  rd   r  r  rq
  rr
  ro
  r  evh_dept_emailsri  rf  r  reopenedacceptedr   )r  
new_statusstatus_changedrW  r\   r	  )rU  rd   ).r   r  r   r   r  rn  r   r  r   r   r   r   r   r  r   r  r  r  r*   r  r"
  r-   r   r   r8
  r]
  rX
  r  r  r,   r  r  r  r  r   r  r  rQ  r  r=  r  EVENT_WO_EVH_AWAITINGr   _WOR_STATUS_EVENTSr	  r	  )#r  r  rW  src_pathr  completion_wdcompletion_fu_pre_sidecar_pre_sd_pre_rtsr  sidecar_path	form_datar'  r   r
  r   cur_sidecarr  	dest_pathdest_sidecar_sd_rts_req
_req_depts	_init_evh_init_actionable_init_approvers	_init_ctx_d	_d_emails_csdr  r  _status_label_hist_details#                                      r1   wor_admin_statusrr  %D s	    4(.BDxx
B'H(B'Jtx'3(?/0#55II-.33 *H-.33)H XX017R>>@MXX45;BBDM[ +H5**\%;%;%=> )1!M "O PQTU U ]"x='@+H5 JJ|'='='?@	 I{{62&mmNB7}-)-&")=K&v&"0"4"4"6"?"?
"KK23/23J3L/MK+,$/	.!%i[I	Y'##DJJy$CD ]"x<'?+H5 JJ|'='='?@	 I{{62&mmNB7}-)-&")=K&v&"0"4"4"6"?"?
"KK23/23J3L/MK+,$/	.!0T	Y'##DJJy$CD ]"x8';+H5 JJ|'='='?@	 I{{62&y"-zz-($(HHVR$8F=!zz&!+//1:::FF6Nzz-.*-.E.G*HF&'%	),YvF	Y'##DJJy$CD ]"x?'B+H5 JJ|'='='?@	 I}}_-),-D-F)GIo&(3	Y'##DJJy$CD 'x0Kh'
3HNN4$N/8#IY..00CM3y>2 L ,,W5 L$8$8$::KKK(#l*;< 9- [ \l6J6J6L	**\3356C  2#6*
"0"4"4"6"?"?@S"T!ggk28b??A0#''+2F2L"1S1S1UV","'--1 %2(5'5'9'9';'D'DEX'YCK  ##DJJs1$=>FI4Y?13O*8XsCI& +//39r	!((%;;Y	B 	k!l|7K7K7M-	::l4467D !.$1$[[06B;;FBG*..099:MN	
] 	

4 :; "":.EL002"#zz,*@*@*BC 	U$6x<$XY";/j$;
 #j*
% 	 %-JG $-=/<L+,3@L/0);;v&,"11&9(1]O4	
 d:..s (('2  (('2 	8 (('2 	6 (('2 	6 (('2 	N $$g. 	C	T $$g. 	D	$ (('2 "!"s   *#f #f8 >#g 5#g< *#h :#i  #i" #j f54f58ggg98g9<hhh=<h= ii"j jj#"j#z/wor-evh-statusc                  v   t         j                  j                  dd      j                         } | rd| v sd| v rt	        d      dfS t        |       }|st	        dg g 	      S 	 t        j                  |j                               }|j                  d
      }|st	        dg g 	      S t        j                  d      xs i j                  dd      }t        |      }t        |      }|D cg c]	  }||v s| }}t	        ||||      S # t        j                  t        f$ r t	        dg g 	      cY S w xY wc c}w )uG  Return EVH state for a WO and which departments the current user can action.

    Response fields:
      evh       — the full _evh block from the sidecar (or null if none)
      actionable — list of dept slugs the current user can sign right now
      user_depts — all depts the current user is authorized to approve
    r  r7   r  r6   rL  r  r`  N)r  r  
user_deptsr  r'  r  )r  r  rt  all_actionable)r   rh  r   r   r   r  r   r   r   r   r   r   rv  r  )	r  r  r   r  rp  rt  currently_actionablern  user_actionables	            r1   wor_evh_statusrx  $E s2    ||+113Htx'3(?/0#55"8,G4B2>>?zz'++-. ((6
C4B2>>++f%+00"=J)*5J05"6JQ!z/qJOJs:"68 8   '* ?4B2>>? Ks   ##D	 *	D64D6	'D32D3z/wor-evh-actionc                     t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         }| j                  d      xs dj                         }| j                  d      xs dj                         }|rd|v sd	|v rt	        d
      dfS |t
        vrt	        d      dfS |dvrt	        d      dfS |dk(  r|st	        d      dfS t        j                  d      xs i }|j                  dd      }|j                  dd      }t        |      }||vrt	        d      dfS t        |      }	|	st	        d      dfS 	 t        j                  |	j                               }
|
j                  d      }|st	        d      dfS |j                  d      dk7  r!t	        d|j                  d       d       dfS t        |      }||vrt	        | d!      dfS t        j                          j#                  d"      }||||xs d#|d$}|d%k(  r||j%                  d&i       |<   t'        |      rd'|d<   ||d(<   	 t)        ||
      }t+        |      }|r=t-        |      j.                  d)z   }|j0                  |z  }|j3                  |       ||d*<   n,||d&   |<   d,|d<   |j                  d&i       |d-<   i |d&<   d.|d/<   ||
d<   |	j=                  t        j>                  |
d01             t+        |      }|rtA        |      nd2}tC        |||
      }|d%k(  r|j                  d      d'k(  r:tE        jF                  tD        jH                  tK        |tM        ||
      3             nt        |      }|D cg c]	  }||vs| }}tO               }|D ]I  }|j                  |      xs g }|stE        jF                  tD        jP                  tK        ||4             K n9tE        jF                  tD        jR                  tK        |tM        ||
      3             |dk(  rd#}t+        |      }|rtA        |      }|r|rtU        |      j                  d5      }|r|jW                  dd6       ||z  }|jY                         |jY                         k7  r(t[        j\                  t_        |      t_        |             |ja                  d7      }|	jY                         |jY                         k7  r(t[        j\                  t_        |	      t_        |             tc        ||       |
j                  d8d      } te        | tf              rd9| d:n
t_        |       }!d;d<d=d>d?j                  ||      }"ti        d@|! dA|" d dB|! dC|" dD| dE| dF|" dG| dH| dI       tk        dJ      rtm        dJdK||! dL|" dM|d#dN  |||dOP       ntk        dJ      rz|
j                  d8d      } te        | tf              rd9| d:n
t_        |       }!d;d<d=d>d?j                  ||      }"tm        dJdQ||! dR|" |||xs d#|j                  d      d'k(  dSP       t	        d|T      S # t        j                  t        f$ r t	        d      dfcY S w xY w# t4        $ r$ t6        j8                  j;                  d+|       Y w xY wc c}w )Uu  Record an approve or reject sign-off on an EVH department.

    Body: {filename, dept, action: "approve"|"reject", reason?}

    On approve: records sign-off; if all required depts approved, finalizes EVH
    (sets released_to_production_at, generates EVH PDF snapshot, status→"approved").

    On reject: records rejection, reopens WO to in_progress, clears ALL prior
    approvals so the next completion starts clean (board decision D2).
    Tr  r  r7   rP  r  rm  r  r6   rL  r  r`  zInvalid department)r  rejectz$action must be 'approve' or 'reject'rz  z Reason is required for rejectionr'  r  r9   z)Not authorized to approve this departmentr  zWork order sidecar not foundr  Could not read sidecarr  r  z No EVH record on this work orderrd   r
  z&EVH is not awaiting approval (status: r7  z is not currently actionabler  N)r  ru
  r  rm  rv
  r  r  r  r  r
  r  z EVH PDF generation failed for %srejectedr  Fr  r   r   r   )evh_recipientsrS  r   rM   r  r  r  r  r  rs
  r  rt
  r  u   RtS Approval Rejected — r:  zReturn-to-Service approval for z was rejected by z.

Rejected by: z <z>
Department: z	
Reason: z
Timestamp: z

The work order has been reopened to In Progress. All prior sign-offs have been cleared. A new completion will restart the RtS approval cycle.r  evh_rejectedz RtS rejected by r
  r   )rP  rm  r	  evh_approvedz RtS approved by )rP  r|  evh_completerU  r  )7r   r  r   r   r   rO  r   rv  r  r   r   r   r   r   r  r  r  r  r}  r  r
  r  r   r  r  r-   ry   rm   rn   r  r   r   rn  r  r=  r  EVENT_WO_EVH_FINALIZEDr   r  rQ  rY  EVENT_WO_EVH_REJECTEDr  r  r,   r  r  r*   r  r  r   r   r  r	  r	  )#r  r  rP  r  rm  r'  rp  r]  rt  r  r   r  rv  r_   sign_off_recordr
  r  rw  evh_pdf_path_evh_wor_path_evh_wor_type_evh_ctx_newly_actionablern  	_unlocked_approver_maprm  rn  r  r  rd  dest_scr!
  r
  
dept_labels#                                      r1   wor_evh_actionr  DE s    4(.BD$*113HHHV"))+Dhhx &B--/Fhhx &B--/Ftx'3(?/0#55:12C77**CDcII&?@#EE;;v$"D'2&J$I **5J:HI3NN"8,G;<cAA<zz'++-. ((6
C?@#EE
wwx//EcgghFWEXXYZ[]``` 15''v%ABCSHH					&	&':	;B$".DO 0?{B'-C &CM/1C+,	S-h=	,X6#'>#6#6#CL#+??\#AL ,,Y7%1C	N
 "1K"H%(WW["%=!"K %DLtzz$q12 &h/M7D$]3-M!(M4@H778
*  44X.Ec4.PQ
 !6c :$5WqBV9VWIW/1M )--b17R	!((%;;XyA 	//*A#t*LM	

 $X.'1H%h/33MBHtd;$x/	##%):):)<<KKHs9~>#//8??$(99KKGc'l;$Xy9-,*4VS*AT&&s6{%2, (Y@@CD$ 	 (
"ZLB-hZ7H U%;b 5%, 'h  W	X		
 '{3^#*$5j\vcr{mT $7 '{3XXmR0F.8.EfS\*3v;H)6l$,DDGCdO ^#*$5j\B $v~(+(9Z(GI d$$O   '* <56;;<J  S

$$%GRS4 Xs1   2#Y: *AZ' !	[+[:'Z$#Z$')[[z/wor-evh-parallelc                     t        j                  d      xs i } | j                  d      xs dj                         }|rd|v sd|v rt	        d      d	fS t        | j                  d
d            }t        j                  d      xs i j                  dd      }t        |      }t        |      }|st	        d      dfS 	 t        j                  |j                               }|j                  d      }|st	        d      d	fS |j                  d      dk7  rt	        d      d	fS |j                  d      xs g |j                  d      xs i }|j                         D 	
ch c]   \  }	}
|
xs i j                  d      dk(  s|	" }}	}
dv rd}nt        fdt         D        d      }|r||vrt	        d      d	fS ||vrt	        d      d fS ||d
<   ||d<   |j#                  t        j$                  |d!"             t	        d|#      S # t        j                  t        f$ r t	        d      dfcY S w xY wc c}
}	w )$a8  Toggle allow_parallel on an EVH after the gating department has approved.

    Only the gating approver (Maintenance, or the first required dept when
    Maintenance is not required) may set allow_parallel, and only after their
    own approval has been recorded.

    Body: {filename, allow_parallel: bool}
    Tr  r  r7   r  r6   rL  r  r`  r  Fr'  r  r
  r  r{  r  r  zNo EVH on this work orderrd   r
  zEVH is not awaiting approvalrk  r  r  r  r   c              3   ,   K   | ]  }|v s|  y wrT   rX   )r,  rn  r  s     r1   r/  z#wor_evh_parallel.<locals>.<genexpr>!F s     HQ!x-qH   	Nz&Gating department has not yet approvedz<Only the gating department approver may change parallel moder  r   r   r  )r   r  r   r   r   r   r   rv  r  r   r   r   r   r   r   r  r  r   r   )r  r  r  rp  rt  r  r   r  r  rn  r  r  r  r  s                @r1   wor_evh_parallelr  E s4    4(.BD$*113Htx'3(?/0#55$((#3U;<N++f%+00"=J)*5J"8,G34c99<zz'++-. ((6
C893>>
wwx//;<cAAww'(.BH$*I'oo/XdaAG==3Ji3WXHX  H"6H$OV8+EFKKZ[\^aaa*CDLtzz$q12d$$5   '* <56;;< Ys   #H % H=H='H:9H:z/wor-admin-datac                     t         j                  j                  dd      } t        |       }|r0	 t	        j
                  |j                               }t        |      S t        |       }|st        d      dfS 	 t        |      }t        |      S # t        j                  t        f$ r Y Nw xY w# t        $ r}t        d|       dfcY d	}~S d	}~ww xY w)
zFReturn JSON sidecar data for a WOR so the admin can populate the form.r  r7   r  Work order not found.r  r  zCould not extract form data: r  N)r   rh  r   r  r   r   r   r   r   r   r  r]  ry   )r  r  r   r  rl  s        r1   wor_admin_datar  .F s     ||+H"8,G	::g//12D%%
 !*H45s::I)(3D!! $$g. 		  I<SEBCSHHIs/   .B :B0 B-,B-0	C9C	CCz	/wor-editc            
         t         j                  j                  dd      j                         } | rd| v sd| v rt	        d      dfS t        |       }|st	        d      d	fS i d
t         j                  j                  d
d      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  d      dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         ddt         j                  v dt        D ci c]  }|d| t         j                  v  c}}|d
   st	        d      dfS |d   st	        d      dfS t        |       }i }|r6	 t        j                  |j                               }|j                  di       }d|d<   ||d<   t        ||xs d !      }|j                  |       |j!                  d"      }|j#                  t        j$                  |d#$             t'        d%      rLt)        d%d&t+        j                  d'      xs i j                  d(      d)|j                  d
|        | d*di+       t	        | ,      S c c}w # t        j                  t        f$ r Y w xY w)-zLAdmin-only: update an existing WOR with edited form data and regenerate PDF.edit_filenamer7   r  r6   rL  r  r`  Original work order not foundr  r  r  r  r3  r  r  r	  r  r  rB  r	  rD  r  r  r  r  r
  r
  r
  rO  r   r
  NrP  r  r   r   r  editedr'  r9   u   Maintenance WO edited — r  r	  r  )r   rg  r   r   r   r  r
  rO  r  r   r   r   r   r   r"
  r-   r  r   r   r	  r	  r   )	r  r  rP  r   ra  r   old_datar
  r
  s	            r1   wor_editr  FF s    LL$$_b9??AMDM1SM5I/0#55 /H<=sBB',,**+;R@FFH!!'2.446 	!!'2.446 	7<<++,=rBHHJ	
 	GLL$$Z4::< 	GLL$$Z4::< 	GLL$$Z4::< 	W\\%%k26<<> 	w||''r:@@B 	gll**<8 	GLL,,-?DJJL 	gll&&|R8>>@ 	"7<<#3#34Mr#R#X#X#Z 	7<<##Ir288: 	',,6  	"
 Ytf%55
!D,  !9:C??>?DD (6LK	zz,"8"8":;H",,~r:K &D&D!$K4G4HI# $$W-ID34";/;;v&,"11&90:JM1Z0[\.	
 M**Q
$ $$g. 		s   Q5Q Q54Q5z/safety-wor-editc                  @   t         j                  j                  dd      j                         } | rd| v sd| v rt	        d      dfS t        | d	      }|st	        d
      dfS t         j                  j                  dd      j                         t         j                  j                  dd      j                         t         j                  j                  dd      j                         t         j                  j                  dd      j                         t         j                  j                  dd      j                         t         j                  j                  dd      j                         t         j                  j                  dd      j                         t         j                  j                  dd      j                         t         j                  j                  dd      j                         t         j                  j                  d      t         j                  j                  dd      j                         t         j                  j                  dd      j                         t         j                  j                  dd      j                         t         j                  j                  dd      j                         d}|d   st	        d      dfS |d   st	        d      dfS t        | d	      }i }|r6	 t        j                  |j                               }|j                  di       }d|d<   ||d<   t        ||xs d       }|j                  |       |j                  d!      }|j!                  t        j"                  |d"#             t%        d$      rLt'        d$d%t)        j                  d&      xs i j                  d'      d(|j                  d|        | d)di*       t	        | +      S # t        j                  t        f$ r Y w xY w),zSAdmin-only: update an existing Safety WOR with edited form data and regenerate PDF.r  r7   r  r6   rL  r  r`  r   r  r  r  r  r  r  r3  r  r  r	  r  r  rB  r	  rD  r  r  r
  r
  r
  rQ  r
  NrR  r  r   r   r  r  r'  r9   u   Safety WO edited — r  r	  r  )r   rg  r   r   r   r  r
  r  r   r   r   r   r   r]
  r-   r  r   r   r	  r	  r   )r  r  r   ra  r   r  r
  r
  s           r1   safety_wor_editr  F sQ    LL$$_b9??AMDM1SM5I/0#55 BH<=sBB ",,**+;R@FFH!!'2.446!!'2.446"<<++,=rBHHJLL$$Z4::<LL$$Z4::<LL$$Z4::<\\%%k26<<>||''r:@@Bll**<8#LL,,-?DJJLll&&|R8>>@#*<<#3#34Mr#R#X#X#Z<<##Ir288:D"  !9:C??>?DD (ILF	zz,"8"8":;H\\)R0F !DDO(fnEI# $$W-ID34";/;;v&,"11&9+DHH5E},U+VW)	
 M**- $$g. 		s   5P PPz/mm-wor-editc            
         t         j                  j                  dd      j                         } | rd| v sd| v rt	        d      dfS t        | d	      }|st	        d
      dfS t         j                  j                  d      }t         j                  j                  d      }t         j                  j                  d      }t         j                  j                  d      }g }t        t        |            D ]  }|t        |      k  r||   j                         nd}|t        |      k  r||   j                         nd}	|t        |      k  r||   j                         nd}
|t        |      k  r||   j                         nd}|s|	s|
s|s|j                  ||	|
|d        i dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  d      dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         d |d!d!t         j                  v d"t         j                  v t         j                  j                  d#d      j                         t         j                  j                  d$d      j                         t         j                  j                  d%d      j                         t         j                  j                  d&d      j                         t         j                  j                  d'd      j                         dd(}|d   st	        d)      dfS t        | d	      }|rV	 t        j                  |j                               }|j                  d*      r|d*   |d*<   |j                  d+      r|d+   |d+<   t!        |d   t#        j                  d,      xs i       \  }}|rt	        |      dfS |r||d+<   t%        |      }|j'                  |       |j)                  d-      }|j+                  t        j,                  |d./             t/        d0      rLt1        d0d1t#        j                  d,      xs i j                  d2      d3|j                  d|        | d4di5       t	        | 6      S # t        j                  t        f$ r Y w xY w)7zEAdmin-only: update an existing Mobile Machine WOR and regenerate PDF.r  r7   r  r6   rL  r  r`  r   r  r  r  r
  r
  r
  r
  r
  r(  r)  r>
  r@
  rA
  r=
  rD
  rC
  rE
  rG
  rF
  rH
  rL
  rO
  rP
  r0
  r
  r
  r3
  rQ
  rR
  rS
  rT
  r
  r
  r<
  r  r'  r  r   r   r  r  r9   u   Mobile Machine WO edited — r  r	  r  )r   rg  r   r   r   r  r
  r  rC  ri   r  r   r   r   r   r   r  r   rX
  r-   r  r   r   r	  r	  )r  r  r
  r
  r
  r
  r0
  r  r
  r
  r
  r
  r   ra  r  r
  r
  r
  r
  s                      r1   mm_wor_editr  F s    LL$$_b9??AMDM1SM5I/0#55 IH<=sBB<<''8L%%m4J$$\2I<<''8LE3|$% N()C,=(=\!_""$2&'#j/&9Z]  "r%&Y%7Yq\!R()C,=(=\!_""$2rRLLB2LMN7<<##Ir288:((<BBD 	7<<++,=rBHHJ 	))/2>DDF	
 	',,**+;R@FFH 	W\\--.A2FLLN 	gll&&|R8>>@ 	w||r*002 	GLL$$Z4::< 	))/2>DDF 	((<BBD 	,,^< 	W\\--.A2FLLN 	gll&&|R8>>@ 	w||''r:@@B  	!" 	0GLL@#$ )GLL8||''r:@@B||''r:@@B ))/2>DDF ))/2>DDF%ll../CRHNNP$1D6 	?78#=='PL	zz,"8"8":;H||O,(0(A_%||H%!)(!3X 1i'++fBUB[Y[\KZ(#--$X$T*I#$$W-ID34";/;;v&,"11&93DHHY4V3WX0	
 M**1 $$g. 		s   	A[ [10[1z/preventive-wor-editc            
         t         j                  j                  dd      j                         } | rd| v sd| v rt	        d      dfS t        | d	      }|st	        d
      dfS t         j                  j                  d      }t         j                  j                  d      }t         j                  j                  d      }t         j                  j                  d      }g }t        t        |            D ]  }|t        |      k  r||   j                         nd}|t        |      k  r||   j                         nd}	|t        |      k  r||   j                         nd}
|t        |      k  r||   j                         nd}|s|	s|
s|s|j                  ||	|
|d        i dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  d      dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         dt         j                  j                  dd      j                         d|d d t         j                  v d!d!t         j                  v d"t         j                  j                  d"d      j                         i}|d   st	        d#      dfS |d   st	        d$      dfS t        | d	      }i }|rO	 t        j                  |j                               }|j                  d%i       }|j                  d&      r|d&   |d&<   t!        |d   t#        j                  d'      xs i       \  }}|rt	        |      dfS |r||d&<   d|d(<   ||d%<   t%        ||xs d)*      }|j'                  |       |j)                  d+      }|j+                  t        j,                  |d,-             t/        d.      rLt1        d.d/t#        j                  d'      xs i j                  d0      d1|j                  d|        | d2di3       t	        | |j                  d&      4      S # t        j                  t        f$ r Y 2w xY w)5zOAdmin-only: update an existing Preventative Maintenance WOR and regenerate PDF.r  r7   r  r6   rL  r  r`  r   r  r  r  r
  r
  r
  r
  r
  r  r  r  r3  r  r  r	  r  r  rB  r	  rD  r  r  r0
  r
  r
  r3
  r
  r
  rO  r  r'  r
  NrP  r  r   r   r  r  r9   u    Preventive Maint. WO edited — r  r	  )r  r  )r   rg  r   r   r   r  r
  r  rC  ri   r  r   r   r   r   r   r  r   r8
  r-   r  r   r   r	  r	  )r  r  r
  r
  r
  r
  r0
  r  r
  r
  r
  r
  r   ra  r   r  r
  r
  r
  r
  s                       r1   preventive_wor_editr  *G s    LL$$_b9??AMDM1SM5I/0#55 FH<=sBB<<''8L%%m4J$$\2I<<''8LE3|$% N()C,=(=\!_""$2&'#j/&9Z]  "r%&Y%7Yq\!R()C,=(=\!_""$2rRLLB2LMN',,**+;R@FFH!!'2.446 	!!'2.446 	7<<++,=rBHHJ	
 	GLL$$Z4::< 	GLL$$Z4::< 	GLL$$Z4::< 	W\\%%k26<<> 	w||''r:@@B 	gll**<8 	GLL,,-?DJJL 	gll&&|R8>>@ 	"7<<#3#34Mr#R#X#X#Z 	7<<##Ir288: 	  	0GLL@!" 	GLL8#$ 	w||''r:@@B%D*  !9:C??>?DD'MLK	zz,"8"8":;H",,~r:K||H%!)(!3X 1k1BGKKPVDWD][]^KZ(#--$X$D&D,T{?RdSI#$$W-ID34";/;;v&,"11&96txx@PR_7`6ab-	
 M$((82DEE7 $$g. 		s   <AX   X=<X=z/wor-make-recurringc                  Z   t        j                  d      xs i } | j                  dd      }| j                  d      }| j                  dd      }| j                  dd      }|rd|v sd	|v rt        d
      dfS 	 t	        |      }|dk  rt
        	 |s#t        j                         j                  d      }t        |      }|st        d      dfS t        |      }t        |      }|st        d      dfS 	 t        j                  |j                               }t%        ||      }	||||	||dt'        j                  di       j                  dd      t        j                         j                  d      d	}
|
|d<   	 |j)                  t        j*                  |d             t-               }|
||<   |j/                         D ci c]  \  }}|dk7  s|| c}}||   d<   	 t1        |       t        d|	|
       S # t        t
        f$ r t        d      dfcY S w xY w# t        j                   t"        f$ r t        d      dfcY S w xY w# t"        $ r}t        d|       dfcY d}~S d}~ww xY wc c}}w # t"        t
        t        f$ r}t        d|       dfcY d}~S d}~ww xY w)!zIMark a work order as recurring and register it in the recurring registry.Tr  r  r7   r  r  r  r  r6   rL  r  r`  r^   z+Frequency must be a positive number of daysr  r
  r  z!Work order sidecar data not foundr  r  r'  r9   r  )	r  r  r  r_  r  source_filenamer~  r	  r  
_recurringr   r   z!Failed to save recurring config: Ntemplate_data%Failed to update recurring registry: )rU  r_  r`  )r   r  r   r   r   r   r   r  r  r  r  rn  r  r   r   r   r   r   r  r   r   r   r  r   r  )r  r  r  r  r  r  r  r  rb  r_  recurring_configrl  r  r  r  s                  r1   wor_make_recurringr  G s    4(.BDxx
B'HXX./N,+Jxx
B'Htx'3(?/0#55Q^,A  #'')22:>
 *H34c99)H"8,G@A3FFDJJw0023	 j.9H ) #kk&"-11&"=$((*334GH
 /IlM4::i:; ()H)HX<EOO<M*cDAqQRVbQb1a4*cHX'Q * dX9IJJ_ z" QJKSPPQ    '* D=>CCD(  M@FGLLM
 +d Z+ QDSEJKSPPQsl   ;G> 9#H! ?&I I5I5%I; >HH!'I
I	I2I-'I2-I2;J*J%J*%J*z/wor-remove-recurringc                  P   t        j                  d      xs i } | j                  dd      }|rd|v sd|v rt        d      d	fS t	        |      }|r\	 t        j                  |j                               }|j                  d
d       |j                  t        j                  |d             t               }|j                  |d       	 t        |       t        d      S # t
        j                  t        f$ r Y Ow xY w# t        $ r}t        d|       dfcY d}~S d}~ww xY w)z*Remove recurring status from a work order.Tr  r  r7   r  r6   rL  r  r`  r  Nr   r   r  r  rY  )r   r  r   r   r  r   r   r   r  r   r   r   r   r  r  )r  r  r  rb  r  rl  s         r1   wor_remove_recurringr  G s    4(.BDxx
B'Htx'3(?/0#55"8,G	

7#4#4#67IMM,-tzz)A>? ()HLL4 Q * d $$g. 		  QDSEJKSPPQs1   AC" D "C>=C>	D%
D D% D%z/wor-recurring-runc                  0    t               } t        d|       S )z9Manually trigger processing of due recurring work orders.T)rU  r  )_process_recurring_work_ordersr   )r  s    r1   wor_recurring_runr  G s     -.GdG,,r3   c            	      \   ddl m}  t               }t        j                         j                  d      }g }d}t        |j                               D ]D  \  }}|j                  dd      s|j                  dd      }|r||k  rd|d<   d};|j                  d	d      }|r||kD  rU|j                  d
i       }	|	sj|j                  d|	j                  dd            }
|	j                         D ci c]  \  }}|j                  d      r|| }}}t               }|
dk(  r||d<   d|d<   ||d<   t        j                  dd       t        |      }t        j                  dd|j                  dd            dd j!                         j#                  dd      }t        j                         j                  d      }d|dd| d| d}t        |z  }|j%                  |       ||d<   |j'                  d      j)                  t+        j,                  |d             n&|
d k(  r||d<   d |d<   ||d<   t.        j                  dd       t1        |      }t        j                  dd|j                  dd            dd j!                         j#                  dd      }t        j                         j                  d      }d!|dd| d| d}t.        |z  }|j%                  |       ||d<   |j'                  d      j)                  t+        j,                  |d             n"|
d"k(  r||d<   d"|d<   ||d<   t2        j                  dd       t5        |      }t        j                  dd|j                  dd            dd j!                         j#                  dd      }t        j                         j                  d      }d#|dd| d| d}t2        |z  }|j%                  |       ||d<   |j'                  d      j)                  t+        j,                  |d             nt        j                         j                  d$      |d%<   d&|d<   ||d<   t6        j                  dd       t9        |      }t        j                  dd|j                  d'd            dd j!                         j#                  dd      }t        j                         j                  d      }d(|dd| d| d}t6        |z  }|j%                  |       ||d<   |j'                  d      j)                  t+        j,                  |d             |j;                  |       |j                  d)d*      }t=        |j                  d+|      ||,      |d	<   ||d-<   ||d.<   d}G |rt?        |       |S c c}}w )/zaCheck registry for due recurring WOs and create new instances. Returns list of created filenames.r   r  r  Fr~  Tr  r7   r_  r  r  r
  r   r  r3  r  rM   r
  r  	RecurringNrS  r\   r
  WOR_r  r   _recurring_sourcer  r   r   r   SWO_r   PMWO_z%Y-%m-%dT%H:%Mr>
  r   r(  MMWO_r  Z   r  )r  last_createdrp  ) r   r   r  r  r  r  r  r   r   rw   r  r{  r  r"
  rF  r\  r   r  r-   r  r   r   r   rl  r]
  rm  r8
  rk  rX
  ri   r  r  )r   r  r	  r  r  r  r   r  r_  templater  r  r  template_cleanr
  r
  r  rv
  new_filenamer
  r
  r  s                         r1   r  r  G s   "')H ))*5EGG"&x~~'7"8 PyyD)99Z,5($E)G99Z,8e+99_b199Zk=)QR+3>>+;U41a1<<PSCT!Q$UU)+}$05N,-*7N;',3N=)%%dT%B).9I|R1C1CDTVa1bcdgeghnnpxxy|  B  CI&**,556GHI!'#a	{!I;dKL*\9H  +2AN./  )44TZZWX5YZ!05N,-*2N;',3N=)   =0@I|R1C1CDTVa1bcdgeghnnpxxy|  B  CI&**,556GHI!'#a	{!I;dKL%4H  +2AN./  )44TZZWX5YZ%05N,-*6N;',3N=)$$TD$A4^DI|R1C1CDTVa1bcdgeghnnpxxy|  B  CI&**,556GHI"73-q1YKtLL)L8H  +2AN./  )44TZZWX5YZ0>0B0B0D0M0MN^0_N,-*9N;',3N=)!''t'D,^<I66,N4F4FyR]4^_`cacdjjlttuxz}~L&**,556GHI"73-q1\N$OL,|;H  +2AN./  )44TZZWX5YZ|$yy)2.*599\5+I4\abj ,n!jaPd  *NG Vs   0V(
V()r  r  r  r  r  r  r  r  r  r  r  zPath | Noner  c                    	 ddl m}  |||       }| j                  }t               j                  d       dt        |       }| dt        |j                         }|r| dt        |j                         nd}	|j                         }
t        |
|	|      }| | j                   dz  }|j                  |       |j                  d	
       y# t        $ r | dz  j                  d       Y yw xY w)zHBackground thread: build carrier sheet from scratch then merge QR codes.r   r  z	error.txtzgcreate_carrier_sheet not yet available in qr_merge.py. Waiting on Linux Automation Engineer (DVI-305).
Nr  r6   r  Tr  )r  r  r#  r   r9   r+   r8   r   r.   r4   r-   r/   )rd  r!   r#   r  r  r    r  r<   r=   r>   r  r@   r  s                r1   _run_create_carrierr  ZH s   1 (vZPL //K$&--c231U;5G4HIJQuY^^456H8F:,an11234D  ))+K$[-JL*//!22JKKKL)4(/  	k	!--@	
 	s   C C0/C0z/create-carrierc                     t         j                  j                  d      } t         j                  j                  d      }| r| j                  st	        d      dfS | j                  j                         j                  d      st	        d      dfS dD ]D  }t         j                  j                  |d	      j                         r2t	        d
| d      dfc S  t        D ci c]1  }|t         j                  j                  |d	      j                         3 }}t         j                  j                  dd	      j                         }|r(t        j                  dd|      dd j                         nt        t        j                               dd }t        |      }t!        |      rt	        |      S |j#                  dd       |t%        | j                        z  }| j'                  |       d}	|r^|j                  rR|j                  j                         j                  d      r)|t%        |j                        z  }	|j'                  |	       t)        j*                  t,        |||	|fd      }
|
j/                          t1        d      rt3        ddt5        j                  d      xs i j                  d      d|j                  dd	       d|j                  dd	       d|j                  dd	       d ||j                  d      |j                  d      |j                  d!      |j                  d      d"#       t	        |      S c c}w )$zSAccept shop/submittal PDFs + carrier field data, create carrier sheet from scratch.ru  rv  zShop Drawing PDF is requiredr  r`  r   z'shop' must be a PDF file)r  r  r  r  r7   rd
  z' is requiredjob_name[^\w\s\-\.]r  Nr   r  r	  TrM   r(  r  r0   r'  r9   u   Carrier sheet generated — r  z / r  r:  r  r7  r  )r	  projectvendor_coder  r	  )r   r  r   r  r   rE  r|  rg  r   _CARRIER_FIELDSrF  r\  r*   rW  rX  rc  rf  r  r   r  r  r  r  rq   r	  r	  r   )ru  rv  	req_fieldr  r  r  r^  rd  r!   r#   r   s              r1   create_carrierr  yH s    !!&)D!!+.IT]];<cAA== ))&1893>>T D	||	2.4461YK}!=>CCD ;JJQa!!!R(..00JFJ||
B/557H  	~sH-cr288:TZZ\*2A.  "&)Jz"f%%TD1_T]];;IIIi$(NY''I,>,>,D,D,F,O,OPV,W#oi6H6H&II~&")^V<	A
 GGI"#9:"K;;v&,"11&9.vzz/2/N.Os::nb12"VZZPR5S4TTUW "JJ7!::n5%zz*?@ &

> :		
  &!!Y Ks   #6Mr   c                     	 ddl m}  ||       }|j                         D ci c]  \  }}||v s|s|| c}}S # t        $ r-}t        j                  j                  d| |       i cY d}~S d}~ww xY wc c}}w )u+  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.
    r   )extract_title_block_fieldsz(title-block extraction failed for %s: %sN)r"  r  ry   rm   rn   ro   r   )r  r   r  suggestionsrl  r  r  s          r1   _suggest_fields_from_pdfr  H st    90: )..0GTQALQAqDGG  

ExQTU	 Hs*   < A5A5A5	A2"A-'A2-A2z/create-carrier/extractc                  (   t         j                  j                  d      xs t         j                  j                  d      } | r| j                  st	        d      dfS | j                  j                         j                  d      st	        d      dfS t        j                         5 }t        |      t        | j                        z  }| j                  t        |             t        |t              }ddd       t	        	      S # 1 sw Y   xY w)
u   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.r  ru  A PDF file is requiredr  r`  r   rV	  Nr  )r   r  r   r  r   rE  r|  r  TemporaryDirectoryr   r   r  r*   r  r  )r  tdrS  r  s       r1   create_carrier_extractr  H s     	&!>W]]%6%6v%>A!**56;;::&&v.12C77		$	$	& @"2h44	s3x)#?@ &!!	@ @s   (ADD)r  r  r  r  r  r  z/takeoff/extract-metadatac                     t        j                  d      xs i } | j                  dd      }t        j	                  |      rd|v rt        d      dfS t        |z  }|j                         st        d	      dfS t        |      }|j                  d
i       xs i }d}dD ]  }|j                         D ]  }|xs i j                  |      }|s||z  j                         }	t        |	      j                  t        |j                                     s`|	j                         sq|	j                  j                         dk(  s|	} n |s n |t        i       S t        t!        |t"                    S )zOCR 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.Tr  r  r7   r  r  r  r`  r  r  N)rx  ru  rv  r   r  )r   r  r   ra  rG  r   rb  r  rk  r  r,   r*   rw   r   r!  rE  r  _META_FIELDS)
r   r  r  ro  r  r  ftyper	  r9   r  s
             r1   takeoff_extract_metadatar  H sV    4(.BD((8R(Kk*dk.A-.33$F==?/0#55EIIgr"(bE#I1 LLN 	DJB##E*D$'')A1v  V^^%5!67AIIK(F2		  b!!29lKLLr3   r  r  c                 "   t         j                  |       rd| v sd|v ryt        | z  j                         }||z  j                         }t	        |      j                  t	        |      t        j                  z         sy|j                         sy|S )zResolve folder/rel under WATCH_DIR, rejecting traversal. Returns the file
    Path or None (shared by the DVI-641 project-file preview endpoints).r  N)	ra  rG  rb  r,   r*   rw   r  r  r   r,  s       r1   _takeoff_resolve_filer  I s~     k*dk.ATS[+%..0F#&&(Iy>$$S[266%9:r3   z/takeoff/extract-metadata-filec                  8   t        j                  d      xs i } t        | j                  dd      | j                  dd            }|t	        d      dfS |j
                  j                         d	k7  rt	        d
      dfS t	        t        |t                    S )u  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.Tr  r  r7   r  r  r  r`  r    OCR can only read PDF documents.r  )	r   r  r  r   r   r!  rE  r  r  )r   ri  s     r1   takeoff_extract_metadata_filer  I s     4(.BD%dhhx&<dhhvr>RSI^,c116)?@#EE29lKLLr3   z/takeoff/pdf-pagesc                     t        t        j                  j                  dd      t        j                  j                  dd            } | t	        d      dfS | j
                  j                         dk7  rt	        d	      dfS 	 d
dl}|j                  t        |             }	 |j                  }|j                          	 t	        |      S # |j                          w xY w# t        $ r}t	        d|       dfcY d}~S d}~ww xY w)u   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).r  r7   r  Nr  r  r`  r   rU  r   zCould not read PDF: r  )r  )r  r   rh  r   r   r!  rE  r=  r?  r*   r@  r[  ry   )ri  r=  r   r  rl  s        r1   takeoff_pdf_pagesr  #I s     &gll&6&6x&D&-ll&6&6vr&BDI^,c116)[)3..@iiI'	NNEIIK  IIK @3C59:C??@s6   C" $C 0C" CC" "	D+D;DDz/takeoff/pdf-pagec                     t        t        j                  j                  dd      t        j                  j                  dd            } | | j                  j                         dk7  ry	 t        t        j                  j                  dd            }d
dl}	 d
dl	}|j                  t        |             }	 |d
k  s||j                  k\  r	 |j                          y|j                  |      }t        dt!        |j"                  j$                  d      z  d      }|d
k  rd}|j'                  |j)                  ||            }|j+                  d      }|j                          	 t/         |j0                  |      dd|dz    d      S # t        $ r Y y	w xY w# |j                          w xY w# t,        $ r}	d|	 dfcY d}	~	S d}	~	ww xY w)zWRender one page of a project PDF as a PNG sized for fit-to-width preview
    (DVI-641).r  r7   r  Nr   r  rO  rF   Invalid pager`  r   zPage out of ranger       @@r^         @      ?r7  rq  Could not render page: r  rn  zpage-r   r!  r+  )r  r   rh  r   r!  rE  r   r   r>  r=  r?  r*   r@  r[  rA  rL  rC  rD  rE  rG  rH  rK  ry   r   rJ  )
ri  page_nor>  r=  r   rO  rP  rQ  rq  rl  s
             r1   takeoff_pdf_pager  ;I s   
 &gll&6&6x&D&-ll&6&6vr&BDII,,224>"#gll&&vs34 4iiI'
	{g7/ IIK ==)DvDIIOOQ 77=Dqy//T4)@/AC++e$CIIK ZRZZ_{%*7Q;-t#<> >'  #"#  IIK 4(.334sU   *)F F/ 7F F/ A8F F/ 	FFF,,F/ /	G	8G>G	G	r  z/takeoff/extract-regionc                     t        j                  d      xs i } t        | j                  dd      | j                  dd            }|t	        d      d	fS |j
                  j                         d
k7  rt	        d      d	fS 	 t        | j                  dd            }| j                  d      xs i }	 t        |j                  d            t        |j                  d            }}t        |j                  d            t        |j                  d            }}t        t        dt        d|            t        dt        d|            f      \  }}	t        t        dt        d|            t        dt        d|            f      \  }
}|	|z
  dk  s||
z
  dk  rt	        d      d	fS | j                  d      xs dj                         }ddlm} 	  |||||
|	|ft#        |            }t-        d      rut/        ddt1        j                  d      xs i j                  d       d!| j                  dd       d"| | j                  dd      | j                  dd      ||xs dd#$       |r'|t2        vrt	        d%      d	fS t	        ||xs d&      S |xs i j5                         D ci c]  \  }}|t2        v r|r|| }}}t	        |'      S # t        t        f$ r t	        d      d	fcY S w xY w# t        t        f$ r t	        d      d	fcY S w xY w# t$        $ r2}t&        j(                  j+                  d||       |rdni }Y d}~bd}~ww xY wc c}}w )(u~  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.Tr  r  r7   r  Nr  r  r`  r   r  rO  r   r  rD  r
  r
  x1y1Invalid region        r  {Gzt?*   Selection too small — draw a larger box.fieldextract_regionr   zregion OCR failed for %s: %sr  z
region-ocrr'  r9   zRegion OCR: z p)r  rO  r  r	  zUnknown field)r  r&  r  )r   r  r  r   r   r!  rE  r   r   r   floatr  rC  rL  r   r"  r  r   ry   rm   rn   ro   r	  r	  r   _META_REGION_FIELDSr   )r   ri  rO  rD  r
  r
  r  r  fx0fx1fy0fy1r  r  r>  rl  r  r  r  s                      r1   takeoff_extract_regionr  cI s-    4(.BD%dhhx&<dhhvr>RSI^,c116)?@#EE2488FA&' 88F!rD4txx~&dhhtn(=Btxx~&dhhtn(=B
 s3C-s3C/EFGHCs3C-s3C/EFGHCc	UsSyE1IJCOOXXg$"++-E)%	4#sC1E%)%[2 ##34l;;v&,"11&9"488FB#7"84&A$"hhx4d"]d,	
 ++1366U266 &"335 1tq!))a d 1F 1&!!O z" 2^,c112 z" 4-.334  %

99cJ"%"1sC   K 4A(K6 L -MK32K36LL	M"'MMc                     | rd| v sd| v ryt         | z  j                         }t        |      j                  t        t         j                                     sy|S )z8Resolve a job folder under OCR_DIR, rejecting traversal.r6   r  N)r   r,   r*   rw   )r^  r  s     r1   _ocr_job_dirr  I sM    SF]dfn((*Gw<""3w'8#9:Nr3   c                 V    | dz  j                  t        j                  |d             y r7	  r9	  r:	  s     r1   _ocr_write_statusr  I r;	  r3   c                     | dz  }|j                         sddiS 	 t        j                  |j                               S # t        j                  t
        f$ r ddicY S w xY w)Nr8	  rd   r  )r  r   r   r   r   r   r3	  s     r1   _ocr_read_statusr  I s_    -A88:)$$%zz!++-((  '* %)$$%s   "> AAc                  V   t         j                         syt        j                         t        dz  z
  } t         j	                         D ]P  }	 |j                         r=|j                         j                  | k  r t        j                  t        |      d       R y# t        $ r Y _w xY w)z3Remove OCR job folders older than OCR_JOB_TTL_DAYS.NQ TrO	  )r   r  rU   OCR_JOB_TTL_DAYSrY  r  r  r  r:  r*   r   )cutoffchilds     r1   _prune_ocr_jobsr  I s    >>YY[+e33F" 	||~%**,"7"7&"@c%j=  		s   AB	B('B(r		  	orig_namein_pathr
  rO  c          
         	 ddl m}m} 	  ||||      }t        |j                        }
t        | d|j
                  |
d       t        d	      r.t        d	d
|d|xs |j
                   d|
 d|xs d|
|d       yy# |$ r-}	t        | dt	        |	      |j
                  d       Y d}	~	yd}	~	ww xY w# t        $ r-}	t        | dt	        |	      |j
                  d       Y d}	~	yd}	~	ww xY w)z>Background thread: OCR the uploaded PDF into a searchable PDF.r   )r  r  r  r  rd   r  r  Nr  rd   r  r  r  zocr-runzOCR: r:  p)r7   r  rO  r	  )r"  r  r  r  r*   r9   rC  r  r	  r	  ry   )r  r  r
  rO  r		  r  r  r  r>  rl  r  s              r1   _run_ocr_jobr  I s   @<	WhT:F
 FLL!' $
 	
 'z2I	 :X]];2eWBGO!&5 3 $ 	g'CH4<MM(C D	$  @'gC08$? 	@ 	@@s@   B4 A? A'B4 ?B1#B,'B4 ,B11B4 4	C*=#C%%C*z/ocr/submitc                  z   	 t                t        j                  j                  d      } | r| j                  st        d      dfS | j                  j                         j                  d      st        d      dfS t        j                  j                  dd      }|d	vrd}	 t        j                  d
d
       t        t        j                               dd }t        |z  }|j                  d
d
       t        j                          j#                  d      }t%        | j                        xs d}| d| }||z  }| j'                  |       t)        |      j*                   d}	||	z  }
t-        |d|	d       t/        j                  d      xs i j                  d      }t1        j2                  t4        |||
|f|| j                  dd
      }|j7                          t        |      S # t        $ r}t        d|       dfcY d}~S d}~ww xY w# t8        $ r}t        d|       dfcY d}~S d}~ww xY w)z3Accept a PDF upload and start a background OCR job.r6  r  r  r`  r   rV	  rO  	skip-textr  zredo-ocrz	force-ocrTrM   OCR storage unavailable: r  Nr  r
  document.pdfr  _searchable.pdfr  rd   r  r'  r9   r  r  rh  r  r  r	  Unexpected error: )r  r   r  r   r  r   rE  r|  rg  r   r  r   r*   rW  rX  r  r  r  r   r  r   r  r  r   r  r  r  rq   ry   )r  rO  rl  r^  r  rv
  r  in_namer  out_namer
  
_ocr_actorr   s                r1   
ocr_submitr  I s   *>MMe$

!9:C??zz!**62!56;;||4==D	IMM$M6 TZZ\"2A&F"dT2"&&(112CD	qzz*<nKq'G#	w4joo&o6X%'iX#NOkk&)/R44V<
7Hd3'ajjA	
 	
	f%%5  	I#<SE!BCSHH	I6  >1#78#==>s[   AH 6H >&H %G/ <D2H /	H8HH	H HH 	H:H5/H:5H:z/ocr/status/<job_id>c                     t        |       }|r|j                         st        dd      dfS t        |      }t        |j	                  dd      |j	                  dd      |j	                  dd      	      S )
Nr  Job not foundr  r  rd   r  r  r7   )rd   r  r  )r  r  r   r  r   )r^  r  rd   s      r1   
ocr_statusr  J sl     6"G'..*g_=sBBg&F&**Xy9"JJz26GR02 2r3   z/ocr/download/<job_id>c                    t        |       }|r|j                         st        d       t        |      }|j	                  d      dk7  rt        d       ||j	                  dd      z  j                         }t        |      j                  t        |j                                     r|j                         st        d       t        t        |      dd|j                        S )	Nr  rd   r  r  r7   r  Tr!  r  r+  r  r  r   r  r   r,   r*   rw   r  r   r9   r^  r  rd   r
  s       r1   ocr_downloadr   "J s     6"G'..*c
g&Fzz(v%c
&**Z44==?Hx=##C(9$:;8??CTc
S]->#'x}}F Fr3   z/ocr/backendc                  @    ddl m} m} t         |        |              S )z@Return the selected OCR backend and the registered backend list.r   list_backendsload_backend)backendbackends)r"  r#  r$  r   r"  s     r1   ocr_backend_getr'  2J s     7<>MODDr3   c                      ddl m} m} t        j                  d      xs i }|j                  d      xs dj                         }|| vrt        d      d	fS  ||       t        d|
      S )z Set the OCR backend. Admin only.r   )OCR_BACKENDSsave_backendTr  r%  r7   Invalid OCR backend selected.r  r`  )rU  r%  )r"  r)  r*  r   r  r   r   r   )r)  r*  r  r%  s       r1   ocr_backend_postr,  :J sf     64(.BDxx	"(b//1Gl"<=sBBdG,,r3   c          
         	 ddl m}m} 	  ||||      }|j                  d      }
|
j                         s"	 |
j                  |j                  xs dd	
       t        |j                        }t	        | d|j                  |d       t        d      r.t        dd|d|xs |j                   d| d|xs d||d       yy# |t        f$ r-}	t	        | dt        |	      |j                  d       Y d}	~	yd}	~	ww xY w# t        $ r Y w xY w# t         $ r-}	t	        | dt        |	      |j                  d       Y d}	~	yd}	~	ww xY w)zBackground thread: OCR an existing project file into a searchable PDF
    and ensure a text sidecar exists for in-document search.r   )ocr_filer  r  r  r  Nr  r7   r  r  r  r  r  zocr-projectzOCR (project): r:  r	  r
  r	  )r"  r.  r  r   r  r*   r9   r  r  r   r&  r   rC  r  r	  r	  ry   )r  r  r
  rO  r		  r  r.  r  r>  rl  r  r  s               r1   _run_ocr_project_jobr/  QJ sR   @=	gxd;F &&v.~~""6;;#4"w"G FLL!'f(---2$4 	5&z2M))*Dx}})ERwbQO!&5 3 %j1 	g'CH4<MM(C D	    @'gC08$? 	@ 	@@sc   D C !D !C= A'D C:#C50D 5C::D =	D	D D		D 	E#D==Ez/ocr/process-projectc                  \   	 ddl m}  t                t        j                  d      xs i }|j                  d      xs dj                         }|rt        |      nd}||j                         st        d	      d
fS  | |j                        st        d	      d
fS |j                  dd      }|dvrd}	 t        j                  dd       t        t        j                                dd }t        |z  }|j                  dd       t#        |j                        xs d}||z  }	t%        j&                  t        |      t        |	             t)        |j                        j*                   d}
||
z  }t-        |d|
d       t/        j
                  d      xs i j                  d      }t1        j2                  t4        ||	||f||j                  dd      j7                          t        |j8                  j;                  t<        j?                                     }|dk(  rd}t        |t)        |j                        j*                   d||j                        S # t        $ r}t        d| 	      dfcY d}~S d}~ww xY w# t@        $ r}t        d| 	      dfcY d}~S d}~ww xY w)z?Start an OCR job on a file already in the shared Projects tree.r   )is_processableTr  rb   r7   Nz)File not found in the Projects directory.r  r`  z'This file type cannot be OCR-processed.rO  r  r  rM   r  r  r  documentr  r  r  r'  r9   r  r  r  z-ocr_processed)r^  suggested_name
source_dirsource_namer  )!r"  r1  r  r   r  r   r   _fb_safe_pathr   r   r9   r   r  r   r*   rW  rX  r   r  copy2r   r  r  r   r  r  r/  rq   r  r{  _FB_ROOTr,   ry   )r1  r  r  r$  rO  rl  r^  r  r  r  r  r
  _ocr_proj_actorr  s                 r1   ocr_process_projectr:  uJ s`   .>-t,2xx%2,,.$'mC T;ckkm!LMsRRchh'!JKSPPxx,==D	IMM$M6 TZZ\"2A&F"dT2!#((+9zG#SXs7|,388n))*/:X%'iX#NO";;v.4"99&A 4&4@*9 Q $	& ',egcjj,,X-=-=-?@Ac>Gf)-chh)<)<(=^&L")#&88- 	-1  	I#<SE!BCSHH	I8  >1#78#==>s[   A;J >J J 7I  FJ  	J)I?9J:J ?JJ 	J+J& J+&J+z/ocr/view/<job_id>c                    t        |       }|r|j                         st        d       t        |      }|j	                  d      dk7  rt        d       ||j	                  dd      z  j                         }t        |      j                  t        |j                                     r|j                         st        d       t        t        |      dd|j                        S )	zFServe a finished job's searchable PDF inline (for the preview iframe).r  rd   r  r  r7   r  Fr  r  r  s       r1   ocr_viewr<  J s     6"G'..*c
g&Fzz(v%c
&**Z44==?Hx=##C(9$:;8??CTc
S]->#(G Gr3   z/ocr/search/<job_id>c                    t        |       }|r|j                         st        d      dfS t        j                  j                  d      xs dj                         }|st        dg d      S t        |      }|j                  dd      }|r||z  j                  d	      nd
}|r|j                         st        dg d      S 	 |j                  dd      }|j                         }g }d}	t        |j                  d            D ]  \  }
}|j                         }|j                  |      }|s*|	|z  }	|j!                  |      }t#        d|dz
        }|||t%        |      z   dz    j'                  dd      j                         }|j)                  |
dz   ||d        t        |	||r|d   d         S d      S # t        $ r t        dg d      cY S w xY w)zSearch 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.r  r  r  r  r7   r   )r  r  
first_pager  r  Nr  r  )r  errorsr  rS  r0  r\   r^   )rO  r  r!  rO  )r  r  r   r   rh  r   r   r  r  r  r   r   rE  r  r  r  rM  rC  rC  r  ri   )r^  r  r  rd   r  r  r&  r#  r  totalr  ptextrK  r  r  rq   r!  s                    r1   
ocr_searchrB  J s   
 6"G'..*_-s22			#		$"++-AQq99g&Fzz*b)H:Bw!..v6G'..*Qq99:  ') D 
BGEdjj./ L5kkmIIbMQJE((2,C38$EE#A,"34<<T3GMMOGNNAEA'JKL 5<wqz&1F FBCF F  :Qq99:s   F< <GGz/ocr/save-projectc            	         	 t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         }| j                  d      xs dj                         }t	        | j                  d            }t        |      }|r|j                         st        d	      d
fS t        |      }|j                  d      dk7  rt        d	      dfS ||j                  dd      z  j                         }t        |      j                  t        |j                                     r|j                         st        d	      d
fS |rd|v sd|v s|dv rt        d	      dfS |j                         j                  d      s|dz  }|r| d| n|}t        |      }	|	t        d	      dfS |	j                   t"        j                         k7  r(|	j                   j                         st        d	      dfS |	j                         r|st        d|      dfS t%        j&                  t        |      t        |	             t        dt        |	j)                  t"        j                                     |      S # t*        $ r}
t        d|
 	      dfcY d}
~
S d}
~
ww xY w)zSave 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.Tr  r^  r7   r  r  r5  zOCR job not found.r  r  rd   r  zOCR is not finished yet.r`  zProcessed file is unavailable.r6   r  )r  r  Invalid filename.r   NzInvalid destination path.z%Destination directory does not exist.)needs_confirmr  rK   )rU  rb   r  r  r  )r   r  r   r   r   r  r  r   r  r,   r*   rw   r  rE  r|  r6  r  r8  r  r7  r{  ry   )r  r^  r  r  r5  r  rd   r	  r  r  rl  s              r1   ocr_save_projectrF  J sc   #>t,2((8$*113HHUO)r002HHZ(.B557+./	v&gnn.!56;;!'*::h6)!;<cAAVZZ
B77@@B7|&&s7??+<'=>gnnFV!ABCGG3(?dh.>(kBY!45s::~~((0H*2
!H:&S!<!<=sBB;;(**,,T[[5G5G5I!HI3NN;;=A3FFS\3t9-$S)9)9(:J:J:L)M%N (* 	* >1#78#==>sL   CJ 
,J 7A1J )J A	J AJ  J 9AJ 	J="J82J=8J=z/uploadc                     t         j                  j                  d      } t         j                  j                  d      }t         j                  j                  d      }t         j                  j                  d      }t         j                  j                  dd      j	                         }| r(| j
                  r|r|j
                  r|r|j
                  st        d      d	fS d| fd|fd|ffD ]B  \  }}|j
                  j                         j                  d
      r0t        d| d      d	fc S  |rC|j
                  r7|j
                  j                         j                  d      st        d      d	fS |r(t        j                  dd|      d d j	                         nt        t        j                               d d }t        |      }t        |      rt        |      S |j!                  dd       | j#                  |t%        | j
                        z         |j#                  |t%        |j
                        z         |j#                  |t%        |j
                        z         |r3|j
                  r'|j#                  |t%        |j
                        z         t'        j(                  t*        |fd      }	|	j-                          t        |      S )Nru  rv  rx  r  r  r7   z.shop, submittal, and carrier PDFs are requiredr  r`  r   rd
  z' must be a PDF filer  zcsv must be a .csv filer  r  r   r  r	  TrM   r(  )r   r  r   rg  r   r  r   rE  r|  rF  r\  r*   rW  rX  rc  rf  r  r  r   r  r  r  rq   )
ru  rv  rx  rQ	  r  r   r  r^  rd  r   s
             r1   uploadrH  K sP    !!&)D!!+.I!!),G!!%(H  R0668HT]]yY5G5GG,,MNPSSSd^k9%=	7?ST Gqzz!**621UG+?!@A3FFG H%%h.?.?.E.E.G.P.PQW.X67<<CKRVVNC23B7==?QTUYU_U_UaQbcedeQfF!&)Jz"f%%TD1IIj?4==99:NN:	0B0B CCDLLog.>.>??@H%%j?83D3D#EEF 1dSAGGI&!!r3   z/status/<path:job_id>c                     t        |       }|j                         st        d       t        |      }|rt	        d|j
                        S t	        d      S )Nr  r  r  r  )rd   )rc  r  r   rf  r   r9   r^  rd  r0   s      r1   rd   rd   7K sJ     "&)Jc

+Ify~~>>)$$r3   z/preview/<path:job_id>c                 b    t        |       }t        |      }|st        d       t        |d      S )Nr  r  r   )rc  rf  r   r   rJ  s      r1   previewrL  CK s0     "&)J
+Ic
Y):;;r3   z/download/<path:job_id>c                 x    t        |       }t        |      }|st        d       t        |d|j                        S )Nr  Trn	  )rc  rf  r   r   r9   rJ  s      r1   download_pdfrN  MK s5     "&)J
+Ic
Yd)..QQr3   z/single-carrier-viewer-urlc                  t   t         j                  j                  dd      } | st        ddi      dfS t	        |       }t        |      }|st        ddi      dfS t               }| dt        |j                         d	t        |j                         }t        ||j                   d	|j                   d
      S )z=Return the Document Viewer URL for a generated carrier sheet.r^  r7   r  zmissing job_idr`  r
  r  r  r6   )rJ  r  )	r   rh  r   r   rc  rf  r  r   r9   )r^  rd  r0   r  rJ  s        r1   single_carrier_viewer_urlrP  WK s     \\h+F!123S88!&)J
+I-.33$&FHHU:??34AeINN6K5L
MC3*//):!INN;K'LMNNr3   z/reprint-list-projectsc                  F   g } t         j                         rp	 t        t         j                         d d      D ]J  }|j                         s|j                  j                  d      r0| j                  |j                         L 	 t        d| i      S # t        $ r Y w xY w)zFReturn project folder names under SHARED_DIR, sorted newest-to-oldest.c                 6    | j                         j                  S rT   r  r  rB  s    r1   r	  z'reprint_list_projects.<locals>.<lambda>vK s    affh// r3   Tr
  r  r  )	r
  r  r  rY  r9   rw   ri   r   r   r  r   s     r1   reprint_list_projectsrV  mK s     G		""$/ /
 <<>%***?*?*DNN5::./ Iw'((  		s   4B B )B 	B B z/reprint-list-carriersc                     t        j                  d      xs i } | j                  dd      j                         }|rt        j                  |      rd|v rt        ddi      dfS t        |z  j                         }t        |      j                  t        t        j                                     r|j                         st        dd	i      d
fS |}|j                  d      }t        |      dk\  r|d   j                         }n!t        |      dk(  r|d   j                         }|dz  }|j                         r<	 t        j                   |j#                  d            }|j                  d      r|d   }t)        |j+                  d            }g }t-               }	d}
|j/                         D ]4  }|j0                  j3                         dk(  s!|j                         s2|}
 n |
r	 t5        |
|||	      }|s?|dz  }|j                         r)	 t        j                   |j#                  d            }t;        |j                  dg             D ]  \  }}|j                  di       j                  t        |      i       }|j                  d      }|sE||z  }|j                         s[t        |j=                  t                    }||	v r~|	j?                  |       |j                  d      }|j                  d      }|jA                  ||tC        ||t              tC        ||t              d        	 |stE        |d       D ]  }|jF                  j3                         }t        |j=                  t                    }||	v r@|jI                  d      s
d|v sVd |v s[|	j?                  |       |jA                  |jF                  |ddd        t        |||d!      S # t        j$                  t&        f$ r Y w xY w# t&        t6        j8                  f$ r Y +w xY w# t        j$                  t&        f$ r Y 	w xY w)"zAList carrier sheet PDFs in a project folder, ordered by CSV/JSON.Tr  r  r7   r  r  r  r`  r  r  r*  rP   r^   r   r  r  r  r  r+  Nr  rh  r   r  rx  ru  rv  r9   r    r!   r#   c                 6    | j                   j                         S rT   )r9   rE  r  s    r1   r	  z'reprint_list_carriers.<locals>.<lambda>K s    !&&,,. r3   r  rw  r0   )r  r  carriers)%r   r  r   r   ra  rG  r   r
  r,   r*   rw   r  r  rC  r   r   r   r   r   r   r  rz  r  rY  r!  rE  _reprint_carriers_from_csvr  Errorr  r{  r  ri   _reprint_relr  r  r|  )r   r  r  r  r0
  r  r  all_pdfsr[  r  r  r  
state_pathro  r  r  fmr  cpr  r  sub_namer6  rt  s                           r1   reprint_list_carriersrd  K s    4(.BD((="-335Kl00=AT!678#==+446K{&&s:+=+=+?'@AI[I[I]!345s:: He$E
5zQ8>>#	Uq8>>#55I	::i1171CDDxx'/ K%%g./HHUD H  " 88>>v%!))+H
 	1(HkSWXH
  </


:#7#7#7#IJ$-eii.D$E LAy7B/33CFB?B#%66)#4L' $|3B::< bnnZ89Cd{ HHSM "vI!vvk2HOO )(+%1+y*%U*6{Hj*Y	% 0 ((@A 	C)Jcooj12Cd{"";/Z'K:,EHH$'!%&*	! 	  "  G $$g. 		" # 		: (('2 s7   <;O& )P D'P& &PPP#"P#&QQsharedc                 j    |sy | |z  }|j                         rt        |j                  |            S d S rT   )r   r*   r{  )r  r  re  r  s       r1   r^  r^  K s3    A)*3q}}V$%>$>r3   r_  
shared_dirr  r!  c                    | r_t        | j                  d      j                  dd      d         }|D ]-  j                  |k(  st	        j                  |            c S  |D cg c]1  }|j                         s|j                         j                         3 }}|ra|D ]\  t        fd|D              sj                  j                         j                  |      sBt	        j                  |            c S  yc c}w )zHLocate a PDF by URL-extracted filename or by identifier+suffix matching.r6   r^   r  c              3   V   K   | ]   }|j                   j                         v  " y wrT   )r  rE  )r,  r  r6  s     r1   r/  z$_reprint_find_pdf.<locals>.<genexpr>K s!     9Q1((9s   &)N)r	   r8   rsplitr9   r*   r{  r   rE  r  r  r|  )	rJ  r_  rg  r  r!  r  r  identsr6  s	           @r1   _reprint_find_pdfrl  K s     3::c?11#q9"=> 	8Cxx8#3??:677	8 *5BA	aggiooBFB 	8C9&99chhnn>N>W>WX^>_3??:677	8  Cs   'D= Dr  r  c                 B   g }t        | dd      5 }t        j                  |      }t        |g       }|D cg c]   }|j	                         j                         " }	}i t        |	      D ]  \  }
}d|v rj                  d|
       d|v rj                  d|
       4|dv rj                  d	|
       Kd
|v rj                  d|
       bd|v rj                  d|
       yd|v rj                  d|
       d|v sd|v sj                  d|
        d	vr t        |      dkD  rj                  d	d       dvr t        |      dkD  rj                  dd       dvr t        |      dkD  rj                  dd       dvr t        |      dkD  rj                  dd       dt        t           dt        dt        ffd}|D ]  } ||d	      } ||d      } ||d      } ||d      }|s|s,|xs
 |xs |xs |}|||g}t         ||d      |t        |d      }|r||v r`|j                  |       |j                  ||t         ||d      |t        |d      t         ||d      |t        |d      d        	 ddd       |S c c}w # 1 sw Y   |S xY w)z<Parse a project CSV and return carrier entries in row order.r7   r  r  tranidr   	structurestruct)itemline_itemr	  r	  zpart referenceprefzqr labelcurldrawingimagesurlrv  r   burlr   r  ry  r   r  r)  r$   c                 p    j                  |      }|!|t        |       k  r| |   j                         S dS r  )r   rC  r   )r  r)  r  r6  s      r1   _cellz)_reprint_carriers_from_csv.<locals>._cell)L s4    ''#,C'*3S>3s8>>#QrQr3   rw  ru  rX  N)r?  r  rO  r  r   rE  r  r}  rC  r  r*   rl  r
  r  ri   )r  r_  r  r  r[  r  rO  headerr}
  header_lowerr  rx  r  r	  rp  r   rr  displayidscarrier_filer6  s                       @r1   r\  r\  L s    H	h[	9 9RBfb!39:a	)::l+ 	*DAq1}ua(!x+//vq)!Q&vq)qvq)1$vq)!fkvq)	*" VqNN61%Vr!1NN62&VqNN61%Vr!1NN62&	RtCy 	Rs 	Rs 	R  	Cf%D3)FU#Cf%D3f33Gd#C,U3-?:WZ\ghL<4#7HH\"OO ,.uS&/A8ZY\^de"3E#v4FR\^acn"o	 !	I9v Oq ;9v Os*   &J%JB#JJE>JJJ	sent_namekeywordexcludec                 >   |r$| t        |      z  }|j                         r||vr|S t        | j                               D ][  }|j                         r!|j                  j                         dk7  s||v r5|j                  j                         }d|v rT||v sY|c S  y)z]Resolve a shop/submittal PDF in the job folder: client-sent filename first, keyword fallback.r   r0   N)r   r   r  rY  r!  rE  r9   )rd  r~  r  r  r  r  r9   s          r1   _scs_find_job_pdfr  LL s    OI66<<>d'1KJ&&() yy{ahhnn.&8ALvv||~$d?H r3   /scs-save-to-projectc            
         t        j                  d      xs i } | j                  d      xs dj                         }|rt        j                  |      rd|v rt        ddi      dfS t        |z  j                         }t        |      j                  t        t        j                               t        j                  z         st        ddi      dfS |j                         st        dd	i      d
fS | j                  d      xs dj                         }|st        ddi      dfS t        | j                  dd            }t               j!                  d      }| dt#        |d       }d}d}| j                  d      xs dj                         }	|	rd|	v rt        ddi      dfS t        |	z  j                         }
t        |
      j                  t        t        j                               t        j                  z         r|
j%                         st        ddi      d
fS |
j&                  |k(  rg n|
g}| j                  d      xs dj                         }| j                  d      xs dj                         }|rd|vr| dt#        |d       }|rd|vr| dt#        |d       }n| j                  d      xs dj                         }|st        ddi      dfS t)        |      }t+        |      }
|
st        ddi      d
fS t-        || j                  d      xs dd|
h      }t-        || j                  d      xs dd|
h|r|hn	t/               z        }|
g||fD cg c]  }|s|	 c}z   }|r| dt#        |j0                  d       }|r| dt#        |j0                  d       }| dt#        |
j0                  d       }g }|D ]R  }t3        j4                  t        |      t        ||j0                  z               |j7                  |j0                         T d}d}|dz  }|j%                         rQ	 t9        j:                  |j=                  d             }|j                  d!      }|r||z  j%                         r||z  }|GtC        d" |jE                         D              }tG        |      d#k(  r|d$   }ntG        |      d#kD  rd%}nd&}|rt        d||d'      S tI        |dd()      5 }tK        tM        jN                  |            }ddd       |jQ                         }tS              D  cg c]>  \  }} |d$kD  r4tG        |       d*k\  r&| d+   j                         jQ                         |k(  r|@ }!}} d}"|!rN|!D ]H  }||   } tG        |       d,k  r | j7                  d       tG        |       d,k  r ||| d-<   ||| d.<   || d/<   J n|st        d||j0                  dd0      S dgd,z  }#| j                  d1      xs dj                         |#d#<   ||#d+<   | j                  d2      xs dj                         |#d3<   | j                  d4      xs dj                         |#d5<   | j                  d6      xs dj                         |#d7<   |||#d-<   |||#d.<   ||#d/<   |j7                  |#       d}"tI        |d8dd)      5 }tM        jT                  |      jW                  |       ddd       t        d||j0                  d|"tG        |!      |d9      S c c}w # t8        j>                  t@        f$ r Y w xY w# 1 sw Y   ,xY wc c} }w # 1 sw Y   gxY w):zXCopy an SCS carrier sheet (+ drawings) into a project folder and update its CSV records.Tr  r  r7   r  r  r  r`  Project folder not foundr  r  zProduct Code is required
append_rowFr6   r  Nr    zInvalid carrier pathzCarrier sheet not foundr!   r#   r^  zMissing job_idz No generated carrier sheet foundshop_filenameru  submittal_filenamerv  rh  r  r  r  c              3   ~   K   | ]5  }|j                         s|j                  j                         d k(  s2| 7 ywr  Nr   r!  rE  r  s     r1   r/  z&scs_save_to_project.<locals>.<genexpr>L s+     hAQYY[QXX^^M]agMgah   ===r^   r   8   Multiple CSV files found — project records not updatedu,   No CSV found — project records not updated)rU  saved_filescsv_warningr  r  rP   r   r-  ry  r   r  )rU  r  csv_name	csv_matchr  r  r   r  r   r  r   r  )rU  r  r  r  appendedupdated_rowsr  ),r   r  r   r   ra  rG  r   r
  r,   r*   rw   r  r  r  r   r+   r8   r   r   r  rc  rf  r  r  r9   r  r7  ri   r   r   r   r   r   r  rY  rC  r?  r  r  rO  rE  r  r  r  )$r   r  project_folderr  r  r  r<   r=   r>   carrier_relcarrier_srccopy_sourcesshop_relsubmittal_relr^  rd  shop_srcsubmittal_srcr  r  r  r$  r  r  r`  ro  r9   csvsr  rn  
code_lowerr  r  r  r  new_rows$                                       r1   scs_save_to_projectr  ]L sb    4(.BD88M*0b779Kl00=AT!678#== ;.779N~))#j.@.@.B*Cbff*LM!678#==  "!;<=sBBHH^,299;L!;<=sBBdhh|U34J!((-H:Qu[r:;<J "H"&M88N+1r88:K;G%;<=sBB!K/88:;**3z/A/A/C+Drvv+MNVaViViVkG%>?@#EE(//>Ar}HH[)/R668"239r@@BH,"1U8#%>$?@HT6'j%C*H)IJM((8$*113G%567<<%f-
%j1G%GHI3NN$Z/1J1PbRX[fZgh)!56<"kM8hZ?
 $}Hm3L'RaPQ'RR$QuX]]'D&EFH)l!E-2D2D2,N+OPML%(8(8r"B!CDK K %SXs>CHH#<=>388$%
 HK,.J	JJz33W3EFE99^,D$.779)D0 h!7!7!9hht9>AwHY]TKHKd;{[\\ 
h[	9 $RCJJrN#$ ##%J%dOS7s3x1}Q)=)=)?:)M 	G 
 H 	"Cs)Cc(R-

2 c(R-#!A('B!CG	" & 	
  	 $)hh~.4";;=
!
hhx(.B557
hh/06B==?
hhz*0b779
!GAJ$'GBK!G	hR'	: 'b

2  &' "MMG$  k (S0 $$g. 		$ $N' 's>   +\#3\#A\( ?]A]%](]]]]$z/home/manager/rclone.logz/home/manager/rclone_resync.shz$/home/manager/rclone_force_resync.shz/sync-admin/logc            	      `   t         j                         st        dt        t               z         dfS 	 t        j
                  j                  dt        t              } t        dt        | d            } t        t         d      5 }|j                  d	d
       |j                         }t        |d      }|j                  t        d	||z
               |j                         }d d d        j                  dd      }|j!                         }||  d  }d}d}	d}
|rD|d   }d|v rd|v rd}|D ]0  }d|v rd}	t#        j$                  d|t"        j&                        s/d}
2 t        |||	|
t)        |            S # 1 sw Y   xY w# t*        $ r!}t        t        |            dfcY d }~S d }~ww xY w)NzLog file not found at r  r  rP  rT  r^   r  rbr   r      r  r  r?  Fr  zBisync abortedzMust run --resync to recoverTz(failed to bisync: prior lock file found:z\b(?:error|failed|aborted)\b)rP  needs_resyncneeds_lock_fix
has_errorstotal_lines)RCLONE_LOG_PATHr   r   r*   r   rh  r   SYNC_ADMIN_DEFAULT_LINESr   rC  rL  r?  r  r  readr  r  rF  r  
IGNORECASErC  ry   )lines_requestedr  rd  
chunk_sizer   r&  	all_linesr&  r  r  r  	last_liners   rl  s                 r1   sync_admin_logr  L s    ""$5O8LLMsRR&,!,,**74LSV*Wa_c!:;/4( 	AFF1aL668DT5)JFF3q$+,-&&(C	 zz')z4OO%	/)*+
RI9,1OS\1\# &=E%)N99<dBMMR!%J	& %)!I
 	
5	 	B  ,SX&++,s>   AF AE7!A8F F 7F <F 	F-F("F-(F-z/sync-admin/resyncc                     t         j                  j                  t              st	        dt        z         dfS 	 t        j                  dt        gt
        j                  t
        j                  d       t        ddt        j                  d	      xs i j                  d
      d       t	        dd      S # t        $ r!} t	        t        |             dfcY d } ~ S d } ~ ww xY w)NzResync script not found at r  r  	/bin/bashTrb  rc  start_new_sessionr  resyncr'  r9   zResync initiatedr		  r
	  zResync script launched.rU  r  r  )r  rb   isfileRCLONE_RESYNC_SCRIPTr   r9  PopenDEVNULLr	  r   r   ry   r*   rl  s    r1   sync_admin_resyncr  .M s     77>>./:=QQRTWWW,./%%%%"		
 	(;;v&,"11&9&	

 $(ABB ,SX&++,   A<B7 7	C! CC!C!z/sync-admin/force-resyncc                     t         j                  j                  t              st	        dt        z         dfS 	 t        j                  dt        gt
        j                  t
        j                  d       t        ddt        j                  d	      xs i j                  d
      d       t	        dd      S # t        $ r!} t	        t        |             dfcY d} ~ S d} ~ ww xY w)z@Stop running rclone, remove the lock file, and run a fresh sync.z!Force-resync script not found at r  r  r  Tr  r  zforce-resyncr'  r9   z%Force resync initiated (lock cleared)r  z]Force-resync script launched. rclone will be stopped, lock cleared, and a fresh sync started.r  r  N)r  rb   r  RCLONE_FORCE_RESYNC_SCRIPTr   r9  r  r  r	  r   r   ry   r*   r  s    r1   sync_admin_force_resyncr  EM s     77>>45@C]]^`ccc,45%%%%"		
 	.;;v&,"11&9;	

 $  )H  I  	I ,SX&++,r  z/sync-admin/log-tailc                  *   t         j                         st        dd      dfS 	 t        t         d      5 } | j	                  dd       | j                         }t        |d      }| j	                  t        d||z
               | j                         }d	d	d	       j                  d
d      }|j                         dd	 }t        dj                  |            S # 1 sw Y   JxY w# t        $ r"}t        t        |      d      dfcY d	}~S d	}~ww xY w)zNReturn the last 50 lines of the rclone log for support request pre-population.zLog file not found.r7   )r  r&  r  r  r   r   r  Nr  r  r  r0  r%  r  )r  r   r   r?  r  r  rL  rC  r  r  r  r  ry   r*   )r  rd  r  r   r&  rP  rl  s          r1   sync_admin_log_tailr  ]M s     ""$2<cAA5/4( 	AFF1aL668DT5)JFF3q$+,-&&(C	 zz')z4!#$'DIIe,--	 	  5SXB/445s6   C' ACAC' C$ C' '	D0DDDzandycrawford/Togenr  )r   r_   r  c                    ddl }|j                         }t        5  t        j                  d      }t        j                  dd      }ddd       r| s|z
  t        k  r|dfS 	 ddi}t
        j                  j                  d      }|rd	| |d
<   t        j                  dt         d|d      }|j                  dk7  r|r|dfS dd|j                   fS |j                         }|d   dd |d   d   d   |d   d   j                  d      d   d}	t        5  |	t        d<   |t        d<   ddd       |	dfS # 1 sw Y   xY w# 1 sw Y   xY w# t        $ r}
|r	|dfcY d}
~
S dd|
 fcY d}
~
S d}
~
ww xY w)u^  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.
    r   Nr   r_   r  r  zapplication/vnd.github.v3+jsonGITHUB_TOKENr  r  zhttps://api.github.com/repos/z/commits/mainr  r;  rK   zGitHub API returned shar-  rZ  authorr   r  r0  hashr   r  zGitHub version check failed: )rU   _GH_VERSION_LOCK_GH_VERSION_CACHEr   _GH_VERSION_TTLr  environr  _GITHUB_REPOrg   r   r  ry   )force_tr  cached	cached_ts
gh_headersgh_tokengh_respgh_dataremote_inforl  s              r1   _fetch_github_latestr  M s    
'')C	 5"&&v.%))$4	5 eyO Ct|; @A
::>>.1,3H:*>J' $$+L>G

 #%t|#/0C0C/DEEE,,.EN3B'H%h/7x(399$?B

  	*(3f%&)d#	* D  ;5 54	* 	*  ;4<4SE:::;s[   ,D9$A!E E AE E-E 9EE
E 	E6E1E6%E1+E61E6z/togen-admin/versionc                     t         dz  } 	 | j                         st        d      dfS t        j                  | j                               }|d   dd |d   |j                  d	d
      d}t               \  }}|rt        |      dfS |d   |d   k7  }t        |||      S # t        $ r}t        d|       dfcY d}~S d}~ww xY w)zDReturn deployed commit info and latest GitHub commit for comparison.VERSIONz*VERSION file not found. Redeploy required.r  r  r  Nr-  r   r  r7   r  zLocal version check failed: rU  )r?  remoteupdate_available)	_TOGEN_APP_DIRr   r   r   r   r   r   ry   r  )version_filever
local_inforl  r  gh_errr  s          r1   togen_admin_versionr  M s     "I-L
H##%!MNPSSSjj//12K$Kwwy"-

 /0KV$c))!&)[-@@)   H;C5ABCGGHs#   B! AB! !	C*C :C Cz/togen-admin/updatec                     t        d      } | j                         st        d      dfS 	 t        j                  dt        |       gt        j                  t        j                  d       t        dd	t        j                  d
      xs i j                  d      d       t        dd      S # t        $ r!}t        t        |            dfcY d}~S d}~ww xY w)zLTrigger a git pull and service restart (wired by Linux Automation Engineer)./opt/togen/update.shzDUpdate script not yet configured. Contact your system administrator.r  i  r  Tr  r  zupdate-appliedr'  r9   z5Immediate update applied (git pull + service restart)r  z9Update started. The application will restart momentarily.r  r  N)r   r   r   r9  r  r*   r  r	  r   r   ry   )update_scriptrl  s     r1   togen_admin_updater  M s     /0M  "cdfiii,#m,-%%%%"		
 	+;;v&,"11&9K	

 $(cdd ,SX&++,s   BB- -	C6CCCzupdate_schedule.jsonzhelp@voyagetech.comc                      	 t         j                         r't        j                  t         j	                               S 	 dddddS # t
        $ r" t        j                  j                  d       Y 1w xY w)zDReturn the persisted scheduled-update record, or a disabled default.zFailed to read update scheduleFr7   rs  r~  versionscheduled_forrd   )	UPDATE_SCHEDULE_FILEr   r   r   r   ry   rm   rn   r  rX   r3   r1   _load_update_scheduler  M sf    ?'')::2<<>?? * bFSS  ?

=>?s   :A (A0/A0c                     	 t         j                  t        j                  | d             y # t        $ r" t
        j                  j                  d       Y y w xY w)Nr   r   zFailed to write update schedule)r  r   r   r   r   rm   rn   r  r  s    r1   _save_update_scheduler  M r  r  c                     	 ddl m}   | d      }t        j                  |      }|j	                  dddd      }||k  r|t        d      z  }|j                  t        j                        j	                  d	      S # t        $ rR t        j                         j	                  dddd      }|t        j                         k  r|t        d      z  }|cY S w xY w)
zFReturn the next 02:00 America/Chicago instant as a naive UTC datetime.r   r  r|  r   r  r^   r  Ntzinfo)r  r  r  r  r  r  r  _dt_timezoner  ry   r  )r  centralnow_cr  s       r1   _next_2am_central_utcr  N s    %,-""7+AaqIU?o1--F  !1!12::$:GG &&(00a!YZ0[^**,,o1--Fs   A:A= =ACCz/togen-admin/scheduled-updatec                  *    t        t                     S )z4Return the current scheduled-update state (DVI-768).schedule)r   r  rX   r3   r1    togen_admin_scheduled_update_getr  N s     1344r3   c            	         t        j                  d      xs i } t        | j                  d            }|s%t	        ddddd       t        dt                     S t        | j                  d	d            j                         }|st        d
      dfS t               j                  d      }t        j                  di       }d||d|j                  d      xs |j                  d      xs dt        j                         j                  d      d}t	        |       t        ddt        j                  d      xs i j                  d      d| d|        t        d|      S )a3  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.
    Tr  r~  Fr7   rs  r  rU  r  r  z.Version is required when scheduling an update.r  r`  r  r'  r  r  r9   )r~  r  r  rd   r	  r  r  zupdate-scheduledzUpdate vz scheduled for r  )r   r  r   r   r  r   r  r*   r   r  r  r   r  r  r	  )r  r~  r  r  r'  r  s         r1   !togen_admin_scheduled_update_postr  N sG    4(.BD488I&'G%BQS_efg$)>)@AA$((9b)*002GMNPSSS)+445IJM;;vr"D&xx(BDHHV,<B$++-667KLE % ){{6"(b--f57)?=/B
 dU++r3   c                    | j                  dd      }| j                  dd      }| j                  dd      }| j                  dd      }|r=d|j                  dd       d|j                  d	d       d
|j                  dd       d}n
d|xs d d}d| d| d| d| d| d}	 t        j                  t        d|       y# t        $ r" t
        j                  j                  d       Y yw xY w)zHEmail help@voyagetech.com that a scheduled update was applied (DVI-768).r  r7   r  r  r	  z#<li><strong>Commit:</strong> <code>r  z,</code></li><li><strong>Committed:</strong> r   z#</li><li><strong>Message:</strong> r  </li>z'<li>GitHub commit details unavailable: zunknown errorz<h2>Togen Scheduled Update Applied</h2><p>A scheduled application update has been applied per schedule.</p><ul><li><strong>Scheduled for:</strong> z,</li><li><strong>Applied at (UTC):</strong> z(</li><li><strong>Scheduled by:</strong> z2</li><li><strong>Version shown to users:</strong> z0</li></ul><h3>GitHub update information</h3><ul></ul>zTogen scheduled update appliedz%Failed to send scheduled-update emailN)r   r=  send_html_email_SCHEDULED_UPDATE_NOTIFY_EMAILry   rm   rn   r  )	r  r  r  r  r  r  r	  gh_blockr  s	            r1   _send_scheduled_update_emailr  AN s0   ii	2&GIIor2M<,J<,J1&**VR2H1I J//5zz&"/E.F G--3ZZ	2-F,GuN 	 =V=V<WW\]	/ 0=o >22< >..8\ :88?y A j		 	F%%*,	

  F

DEFs   %C (C,+C,c                  .   t         5  t               } | j                  d      r| j                  d      dk7  r
	 ddd       y| j                  d      }|s
	 ddd       yt        j                         j                  d      }||k  r
	 ddd       yt        d      }|j                         s)t        j                  j                  d       	 ddd       y| j                  d	d
      j                         }t        d      \  }}|r	 t        j                  |dz          d| d<   d| d<   || d<   t#        |        t%        | ||       	 t'        j(                  dt+        |      gt&        j,                  t&        j,                  d       t        j                  j/                  d|       ddd       y# t        $ r" t        j                  j!                  d       Y w xY w# t0        $ r" t        j                  j!                  d       Y aw xY w# 1 sw Y   yxY w)zBIf a scheduled update is due, set the public version and apply it.r~  rd   r  Nr  r  r  z;Scheduled update due but update.sh missing; leaving pendingr  r7   Tr  r0  z0Scheduled update: failed to write public versionappliedFr  r  r  z&Scheduled update launched (version=%s)z,Scheduled update: failed to launch update.sh)_UPDATE_SCHEDULE_LOCKr  r   r  r  r  r   r   rm   rn   ro   r   r  r   r   r   r  r  r  r9  r  r*   r  rl   ry   )r  r  now_strr  r  r  r  s          r1   _maybe_run_scheduled_updater  eN s   	 ,Q%'yy#uyy':i'G,Q ,Q 		/2,Q ,Q !'')223GH]",Q ,Q 34$$&JJ\],Q ,Q  ))Ir*002-D9Y#..w~> $h i%le$$UFF;		Qc-01!))!))"&	 JJOODgNU,Q ,Q,  Y

$$%WXY*  	QJJ  !OP	QW,Q ,Qsf   1HH)H;H1HF/'HA G/(GHGH(HHHHHz/togen-admin/tool-visibilityc                  *    t        t                     S )zCReturn the current enabled/disabled state for each manageable tool.)tools)r   r  rX   r3   r1   togen_admin_tool_visibility_getr  N s     .011r3   c                  n   t        j                  d      xs i } | j                  di       }t               }t        D ]  }||v st        ||         ||<    	 t        j                  t        j                  |d             t        d|
      S # t        $ r}t        d|       dfcY d	}~S d	}~ww xY w)zCSave tool visibility settings. Body: {tools: {tool_id: bool, ...}}.Tr  r  r   r   Failed to save settings: r  r  N)rU  r  )r   r  r   r  r  r   r1  r   r   r   r   r   r  incoming
visibilityr   rl  s        r1    togen_admin_tool_visibility_postr  N s     4(.BDxx$H&(J 2(?"8C=1JsO2E''

:a(HI d*--  E8>?DDE   *B 	B4B/)B4/B4z/togen-admin/toolbar-visibilityc                  *    t        t                     S )zEReturn Admins/Users enable state for each top-bar function (DVI-616).)toolbars)r   r  rX   r3   r1   "togen_admin_toolbar_visibility_getr  N      4677r3   c            	         t        j                  d      xs i } | j                  di       }t               }t        D ]j  }|j                  |      }t        |t              s%t        |j                  d||   d               t        |j                  d||   d               d||<   l 	 t        j                  t        j                  |d             t        d|      S # t        $ r}t        d	| 
      dfcY d}~S d}~ww xY w)zMSave toolbar visibility. Body: {toolbars: {id: {admins: bool, users: bool}}}.Tr  r  r  r  r  r   r   r  r  r  N)rU  r  )r   r  r   r  r  r   r   r   r  r   r   r   r   r   )r  r  r  r   r  rl  s         r1   #togen_admin_toolbar_visibility_postr  N s     4(.BDxx
B'H)+J ll3c4 swwxC1JKLcgggz#w/GHIJsOE**4::j+KL dZ00  E8>?DDEs   +*C" "	D+D;DDz/togen-admin/tool-orderc                  *    t        t                     S )z&Return the current tool display order.r  )r   r   rX   r3   r1   togen_admin_tool_order_getr  N      )+,,r3   c                     t        j                  d      xs i } | j                  dg       }t        |t              st        d      dfS t               g ddddf\  }}}}}}|D ]  }||v rt        |      r|t        k\  r|dz  }nZt        |      r|t        k\  r8|dz  }n?t        |      r|t        k\  rS|dz  }n$t        |      r|t        k\  rn|dz  }n	|t        vr}|j!                  |       |j#                  |        t        D ])  }||vs|j!                  |       |j#                  |       + d	| v r| d	   n	t%               }	t'        |	|      }
d
| v r| d
   n	t)               }t+        ||      }	 t,        j/                  t1        j2                  |d             t5        |
       t7        |       t;        |       t        d||
|      S # t8        $ r}t        d|       dfcY d}~S d}~ww xY w)u  Save tool display order. Body: {order: [tool_id | sep_id, ...],
    separators?: {sep_id: {label, justify, collapsed}}}.

    Beyond the admin-managed tools, the order may carry home-grid separator
    tokens (DVI-1337) — the legacy 'separator' marker or unique 'sep_<hex>'
    tokens — capped at 10 per list. An optional `separators` map carries their
    admin-default properties (edited in P2); it is sanitized against the saved
    order so orphaned entries are dropped.
    Tr  r  order must be a listr  r`  r   r^   r  r   r   r   Failed to save order: r  N)rU  r  r  r   )r   r  r   r   r  r   r  r   r  r4  r  r7  r  r:  r  r  r  ri   r   r   ry  rv  r  r   r   r   r   r{  r   _apply_dashref_org_share)r  r  r  validr  r  r  r  r   
sep_sourcer  item_sourcer   rl  s                 r1   togen_admin_tool_order_postr'  N s    4(.BDxx$Hh%34c99DGE2qRSUVXYDYAD%I{M $;s#44NI$33NI%c*;;1K#C( ;;QM%S-.  d?HHSMLL (4t';l#AVAXJ*:u=J#*d?$w-8H8JK e4EB""4::eA#>?j) U#d%JeLL  B5cU;<cAABs   6A G 	G4G/)G4/G4z/togen-admin/tool-overridesc                  *    t        t                     S )z=Return per-tool display name/description overrides (DVI-588).)r  )r   r  rX   r3   r1   togen_admin_tool_overrides_getr)  O s     1344r3   c                  ~   t        j                  d      xs i } t        | j                  dd            j	                         }|t
        vrt        d      dfS t        | j                  dd            j	                         }t        | j                  d	d            j	                         }t               }i }|r||d<   |r||d	<   |r|||<   n|j                  |d
       	 t        j                  t        j                  |d             t        d|      S # t        $ r}t        d|       dfcY d
}~S d
}~ww xY w)zSave 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.
    Tr  r  r7   zUnknown toolr  r`  r9   r  Nr   r   zFailed to save overrides: r  )rU  r  )r   r  r*   r   r   r  r   r  r  r  r   r   r   r   )r  r   r9   r  r  r   rl  s          r1   togen_admin_tool_overrides_postr+  O s%    4(.BD
dhhy"%
&
,
,
.C
-^,c11txx#$**,Dtxxr*+113D$&IEf#m	#c4 F&&tzz)A'FG di00  F9#?@#EEFs   !*D 	D<!D71D<7D<image/x-icon)icorq  rr  rs  ru  rv  rt  r-  i   r$  c                     ddl }|j                  | j                  dd            j                         dd }t        j                  d|z   dz         D ]  }|j                         s|c S  y)z3Return the cached favicon file for a host, or None.r   Nr  ignorerx  fav_.*)r  sha1r  r  TOOL_ICONS_DIRr  r   )r$  r  r}
  r  s       r1   _favicon_cache_pathr4  QO sd    T[[(34>>@"EA!!&1*t"34 ::<I r3   content_typec                     | xs dj                  d      d   j                         j                         }t        j	                         D ]  \  }}||k(  s|c S  y)z?Map a response Content-Type to a known icon extension, or None.r7   ;r   N)r  r   rE  _TOOL_ICON_EXT_MIMESr   )r5  ctr  mimes       r1   _tool_icon_content_extr;  [O s[    

"	#	#C	(	+	1	1	3	9	9	;B)//1 	T2:J r3   c           	      L   t        |       }|j                  sy|j                   d|j                   }ddi}g }	 t        j                  |d|      }|j
                  r|j                  rt        j                  d|j                  dd	 t        j                        D ]  }|j                  d
      }t        j                  d|t        j                        s:t        j                  d|t        j                        }|sb|j                  t        |dz   |j                  d                    |j                  |dz          t               }	|D ]  }
|
|	v r	|	j!                  |
       	 t        j                  |
d|      }|j
                  r(|j"                  rt%        |j"                        t&        kD  rht)        |j*                  j	                  dd            }|?|
j-                  dd      d   j/                  d      d
   j1                         }|t2        v r|}||dk7  r<|j"                  dd j5                         j1                         j7                  d      r|j"                  |fc S  y# t        j                  $ r Y Zw xY w# t        j                  $ r Y Tw xY w)zBest-effort server-side favicon fetch. Returns (bytes, ext) or (None, None).
    Tries the page's declared <link rel="...icon..."> then /favicon.ico.r  r>  z
User-Agentz#Mozilla/5.0 (Togen favicon fetcher)r   )r7  rx   z<link[^>]+>N@ r   zrel=["\'][^"\']*iconzhref=["\']([^"\']+)["\']r6   r^   z/favicon.icor  r7   r  r  r]   rv  r  s	   <!doctype)r
   rI  rH  r  r   rU  r&  rF  finditerr	  rb  r  ri   r   RequestExceptionr  r  r  rC  _TOOL_ICON_MAX_BYTESr;  rx   rj  r  rE  r8  rV  rw   )rJ  rL  originrx   
candidatesr  r  r  hrefr  r  icr  r&  s                 r1   _fetch_faviconrE  dO sD    c]F==c&--1FBCGJ
vq'B44AFF[["$$G Pggaj994c244@99%@#rttLD"))'&3,

1*NOP f~-.5D 9	##Aq'BB uuBJJ#bjj/<P*P$RZZ^^NB%GH;88C#B'--c215;;=D++;%<BJJsO224::<GGUzz3)* 3 **  .. 		s0   BI2 'I2 ?/I2 )J2J	J	J#"J#c                 p   ddl }t        |       j                  }|syt        |      }||S t	        |       \  }}|sy	 t
        j                  dd       |j                  |j                  dd            j                         dd }t
        d| d	| z  }|j                  |       |S # t        $ r Y yw xY w)
zReturn the on-disk cached favicon for a URL's host, fetching+caching on a
    miss. None when the site has no fetchable favicon.r   NTrM   r  r/  rx  r0  r  )r  r
   rI  r4  rE  r3  r  r2  r  r  r-   r   )rJ  r  r$  r  blobr  r}
  r  s           r1   _favicon_cache_get_or_fetchrH  O s     C=D &Fs#ID#TD9LLWh78BBDSbIQCq..
t I  s   A)B) )	B54B5z/tool-icon/faviconc                     t        t        j                  j                  dd            } | syt	        |       }|yt
        j                  |j                  j                  d      j                         d      }t        t        |      |d      S )	zFetch + cache + serve a website's favicon same-origin (DVI-1344 D1).
    404 on failure so the tile falls back client-side to a globe icon.r  r7   )zBad urlr`  	Not foundr  r  r,  r  r!  max_age)rM  r   rh  r   rH  r8  r!  rV  rE  r   r*   )rJ  r  r:  s      r1   tool_icon_faviconrN  O st    
 !!1!1#r!:
;C	$S	)B	z##BII$4$4S$9$?$?$A>RDSWtU;;r3   z/tool-icon/uploadc                  `   t         j                  j                  d      } | | j                  st	        d      dfS d| j                  v r-| j                  j                  dd      d   j                         nd	}|t        vrt	        d
      dfS | j                  t        dz         }t        |      t        kD  rt	        d      dfS t        j                  dd       dt        j                         j                  dd z   }t        | d| z  j!                  |       t	        d|d|z         S )zStore one uploaded custom web-tile icon and return its id/url. The caller
    stages the id into the item props; the item is saved via the tool-order /
    user-settings routes (config carries the id only; bytes live on disk).rW  NNo file uploadedr  r`  r  r^   r  r7   z8Unsupported image type. Use PNG, JPEG, GIF, WebP or SVG.zIcon is too large (512 KB max).TrM   icon_r$
  z/tool-icon/)rU  r  rJ  )r   r  r   r  r   rj  rE  _TOOL_ICON_UPLOAD_EXT_MIMESr  r@  rC  r3  r  rW  rX  rY  r-   )r  r  rG  icon_ids       r1   tool_icon_uploadrT  O s    ==V$D|4==/0#5569T]]6J$--

sA
&r
*
0
0
2PRC
--WXZ]]]99)A-.D
4y''>?DD5

(("--G	3%((55d;dwMG,CDDr3   rS  c                     t        j                  dd| xs d      }|r|j                  d      syt        j	                  |dz         D ]  }|j                         s|c S  y)z|The on-disk file for a custom icon id, or None. The id is scrubbed to a
    safe token so it can never escape the store dir.rV  r7   rQ  Nr1  )rF  r\  rw   r3  r  r   )rS  r  r  s      r1   _tool_icon_pathrV  O s]     66"B26Dtw/!!$+. ::<I r3   z/tool-icon/<icon_id>c                     t        |       }|yt        j                  |j                  j	                  d      j                         d      }t        t        |      |d      S )z.Serve a custom web-tile icon by id (DVI-1344).rJ  r  r  r  rL  )rV  r8  r   r!  rV  rE  r   r*   )rS  r  r:  s      r1   tool_icon_serverX  O sW     
	!B	z##BII$4$4S$9$?$?$A$>@DSWtU;;r3   z /togen-admin/wor-type-visibilityc                  *    t        t                     S )z;Return the enabled/disabled state for each Work Order type.)rV  )r   r  rX   r3   r1   #togen_admin_wor_type_visibility_getrZ  O s     2455r3   c                  n   t        j                  d      xs i } | j                  di       }t               }t        D ]  }||v st        ||         ||<    	 t        j                  t        j                  |d             t        d|
      S # t        $ r}t        d|       dfcY d	}~S d	}~ww xY w)zFSave Work Order type visibility. Body: {types: {wor_type: bool, ...}}.Tr  rV  r   r   r  r  r  N)rU  rV  )r   r  r   r  r  r   r  r   r   r   r   r   r  s        r1   $togen_admin_wor_type_visibility_postr\  O s     4(.BDxx$H*,J 2(?"8C=1JsO2E ++DJJz!,LM d*--  E8>?DDEr  z/togen-admin/wor-type-orderc                  *    t        t                     S )z1Return the current Work Order type display order.r  )r   r  rX   r3   r1   togen_admin_wor_type_order_getr^  P s     -/00r3   c                  >   t        j                  d      xs i } | j                  dg       }t        |t              st        d      dfS t               g }}|D ]2  }|t        v s||vs|j                  |       |j                  |       4 t        D ])  }||vs|j                  |       |j                  |       + 	 t        j                  t        j                  |d             t        d|      S # t        $ r}t        d	|       d
fcY d}~S d}~ww xY w)zCSave Work Order type display order. Body: {order: [wor_type, ...]}.Tr  r  r!  r  r`  r   r   r"  r  NrU  r  )r   r  r   r   r  r   r  r  r  ri   r  r   r   r   r   r  r  r  r$  r   rl  s         r1   togen_admin_wor_type_order_postrb  	P s    4(.BDxx$Hh%34c99%%D -CtOHHSMLL  d?HHSMLLB&&tzz%'BC d%((  B5cU;<cAAB   *C8 8	DDDDz/togen-admin/subtool-visibilityc                  *    t        t                     S )zCReturn the admin-default enable/disable state per surface sub-tool.)surfaces)r   r  rX   r3   r1   "togen_admin_subtool_visibility_getrf  'P r  r3   c                     t        j                  d      xs i } | j                  di       }t               }t	        |t
              rQ|j                         D ]>  \  }}||v st	        |t
              s||   D ]  }||v st        ||         ||   |<    @ 	 t        |       t        d|      S # t        $ r}t        d|       dfcY d}~S d}~ww xY w)	zJSave sub-tool visibility. Body: {surfaces: {surface_id: {subtool: bool}}}.Tr  re  r  r  r  N)rU  re  )r   r  r   r  r   r   r   r   r  r   r   )r  r  r  r   r  r  rl  s          r1   #togen_admin_subtool_visibility_postrh  .P s     4(.BDxx
B'H)+J(D!!) 	?ICj Zd%;%c? ?Cd{/3DI
3,?	?
E , dZ00  E8>?DDEs   B1 1	C:C
CCz/togen-admin/bot-visibilityc                  *    t        t                     S )z/Return the enabled/disabled state for each bot.)r  )r   r  rX   r3   r1   togen_admin_bot_visibility_getrj  GP s     ,.//r3   c                  n   t        j                  d      xs i } | j                  di       }t               }t        D ]  }||v st        ||         ||<    	 t        j                  t        j                  |d             t        d|
      S # t        $ r}t        d|       dfcY d	}~S d	}~ww xY w)z7Save bot visibility. Body: {bots: {bot_id: bool, ...}}.Tr  r  r   r   r  r  r  N)rU  r  )r   r  r   r  r   r   r  r   r   r   r   r   r  s        r1   togen_admin_bot_visibility_postrl  NP s     4(.BDxx#H%'J 2(?"8C=1JsO2E&&tzz*Q'GH d,,  E8>?DDEr  z/togen-admin/bot-orderc                  *    t        t                     S )z%Return the current bot display order.r  )r   r  rX   r3   r1   togen_admin_bot_order_getrn  _P s     *++r3   c                  >   t        j                  d      xs i } | j                  dg       }t        |t              st        d      dfS t               g }}|D ]2  }|t        v s||vs|j                  |       |j                  |       4 t        D ])  }||vs|j                  |       |j                  |       + 	 t        j                  t        j                  |d             t        d|      S # t        $ r}t        d	|       d
fcY d}~S d}~ww xY w)z5Save bot display order. Body: {order: [bot_id, ...]}.Tr  r  r!  r  r`  r   r   r"  r  Nr`  )r   r  r   r   r  r   r  r   r  ri   r  r   r   r   r   ra  s         r1   togen_admin_bot_order_postrp  fP s    4(.BDxx$Hh%34c99%%D (?s$HHSMLL  d?HHSMLLB!!$**U1"=> d%((  B5cU;<cAABrc  z/user-settingsc                     t         d   j                  dd      } t        |       }|j                  d      }t        |t              rsi }|j                         D ]Y  \  }}t        |t              r?|j                  d      r.|j                         D ci c]  \  }}|dk7  s|| }}}d|d<   |||<   [ ||d<   t        |      S c c}}w )	a  Return the current user's personal settings.

    Per-service credentials (services.<id>.password, DVI-1226) are write-only:
    they are managed via their own routes (e.g. /idencia/user-connection) and
    redacted here so no JSON surface ever echoes a stored secret.r'  r  r7   servicespasswordThas_passwordrf  )r   r   r>  r   r   r   r   )r  r  rr  redactedr   svcr  r  s           r1   user_settings_getrw  P s     FO,E'.H||J'H(D! ( 	 HC#t$)<(+		G1qJq!tGG&*N#HSM		 
  (H%%	 Hs   C Cc            	         t         d   j                  dd      } t        j                  d      xs i }t	        |       }d|v rb|d   }t        |t              rM|r9t        t              dgz   }|D ci c]  }||v s|t        ||          c}|d<   n|j                  dd       d	|v r|d	   }t        |t              r|rt        t              dhz  }t               g d
d
d
d
f\  }}	}
}}}|D ]  }||v rt        |      r|
t        k\  r|
dz  }
nVt        |      r|t        k\  r8|dz  }n;t!        |      r|t"        k\  rS|dz  }n t%        |      r|t&        k\  rn|dz  }n||vry|j)                  |       |	j+                  |        t        D ])  }||vs|j)                  |       |	j+                  |       + |	|d	<   |j                  d      }t        |t              r&t-        ||	      }|r||d<   n|j                  dd       |j                  d      }t        |t              r]t/        ||	      }|r||d<   nI|j                  dd       n6|j                  d	d       |j                  dd       |j                  dd       d|v rX|d   }t        |t              rC|j                  d	      xs
 t1               }t-        ||      }|r||d<   n|j                  dd       d|v rX|d   }t        |t              rC|j                  d	      xs
 t1               }t/        ||      }|r||d<   n|j                  dd       d|v rS|d   }t        |t              r>|r*t2        D ci c]  }||v s|t        ||          c}|d<   n|j                  dd       d|v rr|d   }t        |t              r]|rIt               g }	}|D ]2  }|t2        v s||vs|j)                  |       |	j+                  |       4 |	|d<   n|j                  dd       d|v rS|d   }t        |t              r>|r*t4        D ci c]  }||v s|t        ||          c}|d<   n|j                  dd       d|v rr|d   }t        |t              r]|rIt               g }	}|D ]2  }|t4        v s||vs|j)                  |       |	j+                  |       4 |	|d<   n|j                  dd       d|v r|d   }t        |t              ri }|j7                         D ]w  \  }}|t8        v st        |t              s t;        t8        |         s3t8        |   d   D ch c]  }|d   	 }}|D ci c]  }||v s|t        ||          }}|ss|||<   y |r||d<   n|j                  dd       d|v r*|d   dv r#|d   dk(  r|j                  dd       n|d   |d<   d|v r:|d   }t        |t              r%t=        |      } | r| |d<   n|j                  dd       d|v rM|d   }!t        |!t              r8t        |!j                  d            t        |!j                  d            d|d<   d|v r8|d   }"|"dv r|j                  dd       n|"t>        v r|"|d<   ntA        d !      d"fS d#D ]E  }#|#|v s||#   }"|"dv r|j                  |#d       $tC        |"      }$|$tA        d$!      d"fc S |$||#<   G d%|v r<|d%   }"|"d&v r|j                  d%d       n tE        |"      }%|%tA        d'!      d"fS |%|d%<   d(|v r!|d(   }"|"tF        v r|"|d(<   ntA        d)!      d"fS d*|v r<|d*   }"|"d&v r|j                  d*d       n tI        |"      }&|&tA        d+!      d"fS |&|d*<   	 tK        | |       t        |      }(tO        |      |(d<   tA        d|(.      S c c}w c c}w c c}w c c}w c c}w # tL        $ r}'tA        d,|' !      d-fcY d}'~'S d}'~'ww xY w)/z*Save the current user's personal settings.r'  r  r7   Tr  ro  r  Nr}  r   r^   rp  r~  rt  ru  rv  rw  r  r  r  theme)darklightsystemr|  r=  r*  scada_notifications
stale_dataout_of_range)r~  r  bg_mode_overrider  Invalid background mode.r  r`  r]  .Opacity must be an integer between 40 and 100.ri  )Nr7   r.  (Tile columns must be 3-9 or "unlimited".	tile_viewzInvalid tile view.rj  1Side padding must be an integer between 0 and 40.r  r  r3  )(r   r   r   r  r  r   r   r  r  r   r  r  r   r  r4  r  r7  r  r:  r  r  ri   r   rv  r   r  r   r   r  r  r;  USER_BG_MODESr   r  r  TILE_VIEW_MODESr  r  r   r7  ))r  r  r  tvallowed_visr   r  base_allowedr  r$  r  r  r  r  existing_sepsprunedexisting_itemspruned_itemsr_   r   rF  tiwtvbvsvr   r  r  knownr  keptrz  r+  snrQ  r)  rc  ri  rj  rl  r  s)                                            r1   user_settings_postr  P s    FO,E4(.BD"5)HD #$b$ #=1]OC2=/+.Cbg&/*+ .5t%h%
  #=1]OCPSPUWY[\^_abdePeMeY	;# &Cd{ *3/$(@@$!Q	+C0$(??$!Q	1#6&*GG$#q(/4(,GG$%*L0 HHSMLL%-&. ) *C$S)* */& (-> ?mT26}eLF6<!23 %6=!)l!;nd3#7#NL#1=. \48 \40.5\40
 D #$b$ \2H6F6HI-b)<E.3*+.5
 t,b$ \2H6F6HI(Y7E).&\40 $()c4 3@3,/C3JCc#h'3./ 2D94()h%!eRe# *Cm+4S)* .3)*-t44"#b$2:.+.cRiCbg&.)* -t4d$h%!eRe# *Ch3d?S)* ).%[$/
 t#&'b$EXXZ *	T>)jt.D7s8KL /=S.A).LMQtWMEM<@QSC5LCd3i0QDQ%)c
* 16-.148$=77G}(Wd+$(M!$O$dD!.t4B/1+,/6 $'(b$"266,#78 $RVVN%; </H*+ T!%&*LL+T2M!+.H'(!;<cAA 7 	($;s)Cj S$',S1?")YZ\___ '	( >"''LL./4L#%OPRUUU'3H^$d;/!$'H[!!56;; d"&'''LL,d3 9# > (%XY[^^^,=H()EE8, >D<XFDdT**y/J3..> NQx  E8>?DDEsT   1	^4;^4#	^9-^90	^>:^>_	_"__ 	_1_,&_1,_1z/user-settings/custom-bgc                      t         d   j                  dd      } t        |       }|st        d       t        j                  |j
                  j                  d      j                         d      }t        ||      S )zIServe the current user's uploaded custom background image; 404 if absent.r'  r  r7   r  r  r  r   )	r   r   r"  r   r!  r!  rV  rE  r   )r  r  r:  s      r1   user_settings_custom_bg_getr  Q sf     FO,E		Bc
""299#3#3C#8#>#>#@B\]DR$''r3   c                  B   t         d   j                  dd      } t        j                  j                  d      }||j                  st        d      dfS d	|j                  v r-|j                  j                  d	d
      d   j                         nd}|t        vrt        d      dfS |j                  j                  dt        j                         |j                  j                         }|j                  j                  d       |t        kD  rt        d      dfS t        j!                  dd       t#        |       }|r=|j$                  j'                  d	      j                         |k7  r	 |j)                          |j-                  t/        t1        | |                   t3        |       }||d<   d|d<   	 t5        | |       t7        |      }t9        |      |d<   t        d|      S # t*        $ r Y ww xY w# t*        $ r}t        d|       dfcY d}~S d}~ww xY w)a  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.
    r'  r  r7   
backgroundNzNo file provided.r  r`  r  r^   r  z8Unsupported image type. Use PNG, JPG, WEBP, GIF, or SVG.r   zImage too large (max 8 MB).TrM   custom_bg_extrY  r  r  r  r=  r3  )r   r   r   r  r  r   rj  rE  r!  r  r  r  r  r  CUSTOM_BG_MAX_BYTESr  r  r"  r!  rV  r/   r   r  r*   r  r  r  r   r7  )r  r  r  rd  oldr  rl  r  s           r1   user_settings_custom_bg_postr  Q s    FO,E,'Ay

0136636!**3D!**

C
#B
'
-
-
/"C
%%WXZ]]]HHMM!R[[!88==?DHHMM!!!:;S@@t4 %
 C
szz  %++-4	JJL FF3uc*+,"5)H #H_#+H EE8, >D<XFDdT**  		  E8>?DDEs0   /G+ 9G: +	G76G7:	HHHHc                     t         d   j                  dd      } t        |       }|r	 |j                          t        |       }|j                  dd       |j                  d      dk(  r|j                  dd       	 t        | |       t        |      }t        |      |d<   t        d|      S # t        $ r Y w xY w# t        $ r}t        d| 	      d
fcY d}~S d}~ww xY w)zLRemove the current user's custom background and clear the "custom" override.r'  r  r7   r  Nr  rY  r  r  r  r=  Tr3  )r   r   r"  r/   r   r  r  r  r   r   r7  )r  r  r  rl  r  s        r1   user_settings_custom_bg_deleter  Q s     FO,E		B		IIK #5)HLL$'||&'83'.EE8, >D<XFDdT**  		  E8>?DDEs/   B. <B= .	B:9B:=	C!CC!C!z/notifications-settingsc                     t         d   j                  d      st        d      dfS t         d   j                  dd      } t        j                  d	      xs i }|j                  d
      }t        |t              st        d      dfS t        |       }t        |      }|r||d<   | r| |d<   n|j                  dd       	 t        | |       t        dt        |            S # t        $ r}t        d|       dfcY d}~S d}~ww xY w)zPersist 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.
    r'  r   r  r  r  r  r7   Tr  r=  zMissing 'notifications' gridr`  r*  Nr  r  rF  )r   r   r   r   r  r   r   r  r;  r  r  r   r7  )r  r  rz  r  r+  rl  s         r1   notifications_settings_postr  Q s    6?{+YZ\___FO,E4(.BD88O$DdD!;<cAA"5)H	&t	,B	')#$  %HW'.EE8, d*J8*TUU  E8>?DDEs   C# #	D,D<DDz/wor-primaryc                  *    t        t                     S )z>Return the Primary recipient lists keyed by WO type (DVI-405).)rK  )r   rC  rX   r3   r1   wor_primary_getr  R      ,.//r3   c                  n   t        j                  d      xs i } | j                  dd      }| j                  dd      }| j                  d      xs dj                         }|t        vrt        d      d	fS |d
vrt        d      d	fS |j                         }|dk(  r(d|vsd|j                  d      d   vrt        d      d	fS t               }|j                  |g       }|dk(  r||vr'|j                  |       n|D cg c]
  }||k7  s	| }}|||<   	 t        |       t        d      rGt        dd| t        j                  d      xs i j                  d      d| d| d| ||||d       t        dt                     S c c}w # t        $ r}t        d|       dfcY d}~S d}~ww xY w)z@Add or remove a Primary recipient email for a WO type (DVI-405).Tr  r  r7   r  r  zInvalid work order type.r  r`  r  r  Invalid action.r  r  r  r  "A valid email address is required.r;  r  Nr  primary_r'  r9   zPrimary recipient r\   ed for )r  r  r  r	  )rU  rK  )r   r  r   r   r0  r   rE  r  rC  ri   rI  r   r	  r	  r   )	r  r  r  r  rH  rK  rA  rB  rl  s	            r1   wor_primary_postr  %R s    4(.BDxx
B'HXXh#FXXg$"++-E))78#==&&./44;;=DCtOs$**S/":M/MABCGG!G[[2&FvMM$#1qDy!11GH<'" #;/8F8,;;v&,"11&9(axwxjI (46J	
 d$5$788 2
  </u56;;<*   
FFF 	F4F/)F4/F4z/wor-approversc                  *    t        t                     S )zHReturn the Authorized Approver lists keyed by RtS department (DVI-1013).rm  )r   rQ  rX   r3   r1   wor_approvers_getr  OR s     0233r3   c                  n   t        j                  d      xs i } | j                  dd      }| j                  dd      }| j                  d      xs dj                         }|t        vrt        d      d	fS |d
vrt        d      d	fS |j                         }|dk(  r(d|vsd|j                  d      d   vrt        d      d	fS t               }|j                  |g       }|dk(  r||vr'|j                  |       n|D cg c]
  }||k7  s	| }}|||<   	 t        |       t        d      rGt        dd| t        j                  d      xs i j                  d      d| d| d| ||||d       t        dt                     S c c}w # t        $ r}t        d|       dfcY d}~S d}~ww xY w)zAAdd or remove an approver email for an RtS department (DVI-1013).Tr  rP  r7   r  r  zInvalid department.r  r`  r  r  r  r  r  r  r  r;  r  Nr  	approver_r'  r9   zRtS approver r\   r  )rP  r  r  r	  rU  rm  )r   r  r   r   rO  r   rE  r  rQ  ri   rS  r   r	  r	  r   )	r  rP  r  r  rH  rm  rA  rB  rl  s	            r1   wor_approvers_postr  VR s    4(.BD88FBDXXh#FXXg$"++-E:23S88&&./44;;=DCtOs$**S/":M/MABCGG#%I]]4$FvMM$#1qDy!11IdO<I& #;/9VH-;;v&,"11&9#D66('$@ 46B	
 d&9&;<< 2
  </u56;;<r  z/wor-testersc                  *    t        t                     S )zLReturn the Authorized Testers email list (DVI-1082, Togen Admin -> Testing).)testers)r   rW  rX   r3   r1   wor_testers_getr  R r  r3   c            
         t        j                  d      xs i } | j                  dd      }| j                  d      xs dj                         }|dvrt	        d      d	fS |j                         }|d
k(  r(d|vsd|j                  d      d   vrt	        d      d	fS t               }|d
k(  r||vr'|j                  |       n|D cg c]
  }||k7  s	| }}	 t        |      }t        dd| t        j                  d      xs i j                  d      d| d| d|||d       t	        d|      S c c}w # t        $ r}t	        d|       dfcY d}~S d}~ww xY w)a  Add or remove an Authorized Tester email (DVI-1082).

    Authorized Testers may create and view test WOs without being Togen Admins.
    Managing this list is Togen-admin-only. Convert-to-test and delete remain
    Togen-admin-only and are NOT delegated to testers (Phase 2).
    Tr  r  r7   r  r  r  r  r`  r  r  r  r  r  r;  r  Nr  tester_r'  r9   zAuthorized Tester r\   edr  r  r	  )rU  r  )r   r  r   r   r   rE  r  rW  ri   r[  r   r	  r   )r  r  r  rH  r  rB  rl  s          r1   wor_testers_postr  R st    4(.BDXXh#FXXg$"++-E&&./44;;=DCtOs$**S/":M/MABCGG!GwNN4 %3d133<#G, ){{6"(b--f5$TF!F8260 dG,, 4  </u56;;<*   
D6D6D; ;	EEEE>   r   r   r   rb   c                    | j                         }|j                  }d|v }|r3|j                  d      }|dz   t        |      k  r||dz      nd}|dvrd}nt	        |       }|j
                  j                  }|t        vrd}|||fS )zReturn (wor_type, status, is_test) for a resolved WO PDF path.

    ``status`` is the lifecycle subdir name (or "new" for the type root), and
    ``is_test`` is True when the path lives under the segregated ``_test`` tree.
    r   r^   r   r#  r$  )r,   r0
  r  rC  rn  r  r9   _WOR_STATUS_SUBDIRS)rb   resolvedr0
  r
  r  r  rd   s          r1   r
  r
  R s     ||~HNNEGKK #$q53u:#55Q<=SS$H#D)__!!F((VW$$r3   c                    | g}|xs i j                  d      }t        |t              s6t        j                  d| xs d      }|rt        |j                  d            }t        |t              r|j                  d|d       |S )zReturn the tool_history ref shapes for a WO (filename + "WO#NNNNN").

    Mirrors _collect_wor_audit: ``created`` events key on ``WO#NNNNN`` while
    status/EVH/edit events key on the filename, so both must be quarantined.
    r  r  r7   r^   r
  r  )r   r   r   rF  r  rb  ri   )r  r  refsr!
  r  s        r1   _wor_history_refsr  R sv     :D b%%m4Ffc"IInhn"5_F&#c&&'Kr3   quarantine_keyr  rm  c                    |syt               }	 dj                  dt        |      z        }|j                  d|z   dz   |      j	                         }t        j                         j                  d      }|D ]7  }|j                  d|d   |d	   |d
   |d   |d   |d   |d   |d   ||| f       9 |r|j                  d|z   dz   |       |j                          t        |      |j                          S # |j                          w xY w)a  Move a WO's ``wor-admin`` history rows out of the live table (DVI-1084).

    Copies matching rows into ``tool_history_quarantine`` (tagged with
    ``quarantine_key`` = the WO filename, so restore can target them) then
    DELETEs them from ``tool_history``. Live audit surfaces (WO Admin, Auditor
    History, Search, exports) read only ``tool_history``, so the WO disappears
    from them by construction. Returns the number of rows quarantined.
    r   ,r]   zvSELECT id, tool, action, actor, summary, ref, detail, created_at FROM tool_history WHERE tool='wor-admin' AND ref IN (r7  r  zINSERT INTO tool_history_quarantine (orig_id, tool, action, actor, summary, ref, detail, created_at,  quarantined_at, quarantine_reason, quarantine_key) VALUES (?,?,?,?,?,?,?,?,?,?,?)r  r  r  r		  r
	  r  r	  r  z<DELETE FROM tool_history WHERE tool='wor-admin' AND ref IN ()
_history_dbr  rC  rL  fetchallr  r  r  rZ  r[  )r  r  rm  rO  placeholdersrn  r
  r  s           r1   _quarantine_wor_historyr  R s)    =Dxxc$i0||E ! 	

 (* 	 ""$--.BC 	ALL2 4!F)Qx[!G*a	l51X;,V^U	 LLN!$%
 	4y



s   CC6 6Dc                 n   t               }	 |j                  d| f      j                         }|D ]0  }|j                  d|d   |d   |d   |d   |d   |d   |d	   f       2 |r|j                  d
| f       |j                          t	        |      |j                          S # |j                          w xY w)zIMove a WO's quarantined history rows back into the live table (DVI-1084).zpSELECT tool, action, actor, summary, ref, detail, created_at FROM tool_history_quarantine WHERE quarantine_key=?zgINSERT INTO tool_history (tool, action, actor, summary, ref, detail, created_at) VALUES (?,?,?,?,?,?,?)r  r  r		  r
	  r  r	  r  z:DELETE FROM tool_history_quarantine WHERE quarantine_key=?)r  rL  r  rZ  rC  r[  )r  rO  rn  r  s       r1   _restore_wor_historyr  	S s    =D||C
 (*	 	
  	ALL* 6AhK7Qy\51X;,9		 LLL! 	4y



s   BB" "B4c                    |j                  dd       g }|| j                  z  }| j                         |j                         k7  r(t        j                  t        |       t        |             |j                  | j                         | j                  d      }|j                         ru|j                  d      }|j                         |j                         k7  r(t        j                  t        |      t        |             |j                  |j                         | j                  | j                  dz   z  }|j                         rs||j                  z  }|j                         |j                         k7  r(t        j                  t        |      t        |             |j                  |j                         t        |       }|j                         r*t        | |       |j                  |j                  dz          ||fS )zRelocate a WO's full bundle (PDF + sidecar + .evh.pdf + .photos/) to dest_dir.

    Filename is preserved, so the sibling ``<stem>.*`` companions follow. Returns
    (moved_basenames, dest_pdf_path).
    TrM   r  r
  r6   )r  r9   r,   r  r  r*   ri   r  r   r  r  r  r  r  )	r	  r  movedr
  src_sidecarre  src_evhdest_evh
src_photoss	            r1   _move_wor_bundler  %S s    NN4$N/E',,&HH,,..CL#h-0	LL%%g.K++G4 L$8$8$::KKK(#l*;<[%%&nnz 9:Ggll*?? 0 0 22KKGc(m4W\\" )JWh/Z__s*+(?r3   r)  c                 T   | j                  d      }i }|j                         r$	 t        j                  |j	                               }||j                  |d       n|||<   |j                  t        j                  |d             y# t        j
                  t        f$ r i }Y ^w xY w)z@Set (or, when value is None, drop) a key on a WO's JSON sidecar.r  Nr   r   )
r  r   r   r   r   r   r   r  r   r   )r  r)  r  r  r  s        r1   _wor_set_sidecar_flagr  GS s    ""7+G	B	G--/0B }
sD3tzz"Q/0 $$g. 	B	s   #B	 	B'&B'r   	prev_hashc                     ddl }| D ci c]  }|dk7  s	|| |    }}t        j                  |dd      }|j                  ||z   j	                  d            j                         S c c}w )zLsha256 over the canonical JSON of an entry (minus its own hash) + prev_hash.r   N
entry_hashT)r  rS  )	sort_keysr  r  )r  r   r   r  r  r  )r   r  r  r  r  	canonicals         r1   _wor_deletion_entry_hashr  WS sf    !&<A!|*;AuQxK<D<

44JGI>>9y088ABLLNN =s
   
A'A'c                     t         j                         syd\  } }	 t         j                  dd      5 }|D ]  }|j                         }|s| dz  } |} 	 ddd       |s| dfS 	 | t        j                  |      j                  dd      fS # 1 sw Y   7xY w# t        $ r Y yw xY w# t
        j                  $ r | dfcY S w xY w)	z;Return (entry_count, last_entry_hash) for the deletion log.)r   r7   r  r  r  r^   Nr7   r  )	WOR_DELETION_LOG_FILEr   r?  r   r   r   r   r   r   )r  lastr  rs   s       r1   _wor_deletion_log_scanr  _S s     ((*KE4"''g'> 	 "  zz|QJED	 	  bydjj&**<<<<	  	     bys@   B B	BB %&B' BB 	B$#B$'C Cc                    t         j                  dd       t               \  }}t        |       } |dz   | d<   || d<   t	        | |      | d<   t
        j                  dd	      5 }|j                  t        j                  |       d
z          ddd       	 t        j                  t
        d       | S # 1 sw Y   &xY w# t        $ r Y | S w xY w)uy  Append one hash-chained record to the tamper-evident deletion log (DVI-1084).

    ``entry_hash`` chains each record to its predecessor's hash, so editing or
    removing any past entry breaks every subsequent link — detectable via
    ``_wor_deletion_log_verify``. The file is written 0600 as defense-in-depth;
    we cannot *prevent* a root edit, only make it evident.
    TrM   r^   seqr  r  ar  r  r0  Ni  )r   r  r  r   r  r  r?  r  r   r   r  r  r   )r   r  r  r  s       r1   _wor_deletion_log_appendr  uS s     MM$M.-/E9KE19E%L"E+25)DE,		#	#C'	#	: +b
E"T)*+
&. L+ +  Ls   #(B0B< 0B9<	C	C	c                     g } t         j                         s| S 	 t         j                  dd      5 }|D ]:  }|j                         }|s	 | j	                  t        j                  |             < 	 ddd       | S # t
        j                  $ r | j	                  d|i       Y pw xY w# 1 sw Y   | S xY w# t        $ r Y | S w xY w)z6Return all parsed deletion-log entries (oldest first).r  r  r  _unparseableN)	r  r   r?  r   ri   r   r   r   r   )r   r  rs   s      r1   _wor_deletion_log_entriesr  S s    
C ((*
"''g'> 	7" 7zz|7JJtzz$/07	7 J	 ++ 7JJ567	7 J  JsR   B5 B($A</B(2B5 <&B%"B($B%%B((B2-B5 2B5 5	CCc                     t         j                         sdddddS d} d}d}	 t         j                  dd	      5 }|D ]  }|j                         }|s|d
z  }	 t	        j
                  |      }|j                  dd      | k7  r|j                  d|      } nIt        ||       }||j                  dd      k7  r|j                  d|      } n|j                  dd      }  ddd       |du }||||rddS d| ddS # t        j                  $ r |}Y  5w xY w# 1 sw Y   :xY w# t        $ r}d|dt        |      dcY d}~S d}~ww xY w)zIRecompute the deletion-log hash chain; report OK or the first broken seq.Tr   NzNo deletion log yet.)rU  entries
broken_seqr  r7   r  r  r  r^   r  r  r  Fu'   Chain intact — no tampering detected.zChain broken at entry #r  )r  r   r?  r   r   r   r   r   r  r   r*   )	r  r  brokenr  rs   r   
recomputedrl  rU  s	            r1   _wor_deletion_log_verifyr  S s    ((*q13 	3IEFX"''g'> 	8" 8zz|
 JJt,E 99["-:"YYue4F5eYG
<!<<"YYue4F!IIlB7	#8	8* 
4Bf AG G$;F81"EG G ++ "F	8 	8&  XTcRUhWWXsY   D% DC=.A2D D% =DDDDD"D% %	E.E=EEc                      t        j                  d      xs i j                  d      xs, t        j                  d      xs i j                  d      xs dS )Nr'  r9   r  r7   r   r   rX   r3   r1   _wor_admin_actorr  S sF    KK%2**62dw{{67J7Pb6U6UV]6^dbddr3   z/wor-convert-testc                     t        j                  d      xs i } | j                  d      xs dj                         }t	        |d      }|st        d      d	fS t        |      \  }}}|rt        d
      dfS |j                  d      }i }|j                         r$	 t        j                  |j                               }t        ||      }t        |d      |   }	t!        ||	      \  }
}t#        |dd       t%        ||d      }t'        t)        j*                         j-                  d      t/               d||j                  d      ||| j                  d      xs dj                         |
t1        |	      |d      }t3        ddt/               d| d||d       t        d||d         S # t        j                  t        f$ r i }Y w xY w)a  Convert a production WO into a segregated test WO (DVI-1084, admin-only).

    Moves the full bundle into the ``_test`` tree, flags the sidecar ``_test``,
    and quarantines the WO's history rows so it leaves every prod audit surface.
    Reversible via ``/wor-convert-prod``.
    Tr  r  r7   Fr  z,Work order not found in the production tree.r  r  z%This work order is already a test WO.r`  r  r  r   rL  r  r  rm  r_   r		  r  wo_filename	wo_numberwo_typeoriginal_statusrm  files_movedquarantine_pathhistory_rows_quarantinedr  wor_convert_testz
Converted z to a test WO)r  history_rowsr		  r
	  r	  r  )rU  r  r  )r   r  r   r   r  r   r
  r  r   r   r   r   r   r   r  r  r  r  r  r  r  r  r  r  r*   r	  )r  r  r$  r  rd   r
  r  r  r  r  r  r
  n_histr   s                 r1   r  r  S s    4(.BD$*113H
H5
9CKLcQQ +C 0HfgDEsJJoog&G	B	G--/0B Xr*DhT26:H&sH5OE8(GT2$XtY?F$##%../CD!#VVM*!88H%+224x=$*& E ="4<L<N'z?'/HJ dVuNN; $$g. 	B	s   #F/ /GGz/wor-convert-prodc                     t        j                  d      xs i } | j                  d      xs dj                         }t	        |d      }|st        d      dfS t        |      \  }}}|st        d	      d
fS t        |d      |   }t        ||      \  }}t        |dd       t        |      }	t        t        j                         j                  d      t               d|d||| j                  d      xs dj                         |t!        |      |	d      }
t#        ddt               d| d||	d       t        d|	|
d         S )zCConvert a test WO back into a production WO (DVI-1084, admin-only).Tr  r  r7   r  r  r  r  z!This work order is not a test WO.r`  Fr  r   Nr  convert_backrm  r  r  wor_convert_prodzConverted test WO z back to productionr  history_rows_restoredr  r  rU  r  r  )r   r  r   r   r  r   r
  r  r  r  r  r  r  r  r  r  r*   r	  )r  r  r$  r  rd   r
  r  r  r
  r  r   s              r1   r  r  S sT    4(.BD$*113H
H4
8C45s:: +C 0Hfg@A3FFhU3F;H&sH5OE8(GT2!(+F$##%../CD!# !88H%+224x=$*& E ="4<L<N/z9LM'/&QS d&eElKKr3   z/wor-deletec                     t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         }|st	        d      dfS t        |d	      }|st	        d
      dfS t        |      \  }}}|j                  d      }i }|j                         r$	 t        j                  |j                               }t        ||      }	t        j                          j#                  d      }
t$        |
 dt'        |      j(                   z  }t+        ||      \  }}|rdnt-        ||	d      }||||||j                  d      |t/               t        j                          j#                  d      ||d}|dz  j1                  t        j2                  |d             t5        |d   |d   d||j                  d      ||||t7        |      |d      }t9        dd|d   d| ||||d   d       |st9        d d!|d   d"| |d!||d   d#$       t	        d|j:                  ||d   %      S # t        j                  t        f$ r i }Y w xY w)&a  Soft-delete a WO: quarantine its bundle + history, log the erasure (DVI-1084).

    Board decision: no irreversible hard delete. The bundle is moved to
    ``_deleted/<ts>_<file>/`` (outside every scanner) and the WO's history rows
    are quarantined, so it vanishes from every audit surface while the tamper-
    evident deletion log retains the compliance trail. A ``reason`` is mandatory.
    Tr  r  r7   rm  zA deletion reason is required.r  r`  r  r  r  r  r  r  r   deleter  r  )r  r  r  r
  r  r  rm  
deleted_by
deleted_atr  r  _restore.jsonr   r   r  r  r  r  wor_deletedzSoft-deleted work order r  )r  rm  r  r  r  r  deletedu   Deleted (soft) — _status_changerm  r  r	  )rU  
quarantiner  r  )r   r  r   r   r   r  r
  r  r   r   r   r   r   r   r  r  r  r  WOR_DELETED_ROOTr   r  r  r  r  r   r   r  r*   r	  r9   )r  r  rm  r$  r  rd   r
  r  r  r  r_   quarantine_dirr  r  r  manifestr   s                    r1   
wor_deleter  &T s    4(.BD$*113Hhhx &B--/F=>CC
H4
8C45s:: +C 0Hfgoog&G	B	G--/0B Xr*D				 	)	)/	:B%2$aX0C0C/D(EEN^4HE1Q6xxPF!"VVM*&($++-667KL$*H o%11$**Xa2PQ$|$,'VVM*!~.$*& E =-x7M5hZ@'/6+1%,HI {IXl5K!4XJ?X1:f&+El4	5 d~':':,2eF Fe $$g. 	B	s   #I I10I1z/wor-deleted-listc                     g } t         j                         rt        t         j                         d      D ]z  }|j                         s|dz  }d|j                  i}|j                         r3	 |j                  t        j                  |j                                      | j                  |       | t        |       S # t        j                  t        f$ r Y :w xY w)zEList quarantined (soft-deleted) WOs available for restore (DVI-1084).Tr	  r  r  r  )r  r  r  rY  r9   r   r  r   r   r   r   r   ri   r   )r   rn  mfrl   s       r1   wor_deleted_listr  tT s     E (002DA 
	A88:_$B166?Dzz|KK

2<<> :; LL
	  ,,g6 s   .2B??CCz/wor-restorec                  $   t        j                  d      xs i } | j                  d      xs dj                         }|rd|v sd|v rt	        d      d	fS t
        |z  j                         }t        |      j                  t        t
        j                                     r|j                         st	        d
      dfS |dz  }|j                         st	        d      d	fS 	 t        j                  |j                               }|j                  d      xs d}||z  }|j                         st	        d      d	fS |j                  d      xs d}|j                  d      xs d}t!        |j                  d            }	t#        ||	      |   }
t%        ||
      \  }}|	rdnt'        |j                  d      xs |      }	 |j)                  d       |j+                          t-        t/        j0                         j3                  d      t5               d||j                  d      ||| j                  d      xs dj                         |t        |
      |d      }t7        dd t5               d!| ||d"#       |	sDt7        d$d%t5               d&| |d%| j                  d      xs dj                         |d'   d()       t	        d||d'   *      S # t        j                  t        f$ r t	        d      d	fcY S w xY w# t        $ r Y 'w xY w)+zHRestore a soft-deleted WO from quarantine back to its origin (DVI-1084).Tr  r  r7   r6   r  zInvalid quarantine reference.r  r`  z!Quarantined work order not found.r  r  z7Restore manifest missing; cannot restore automatically.zRestore manifest is unreadable.r  z&Quarantined work order PDF is missing.r  r   r  r$  r
  r  r   r  r  r  restorer  rm  r  r  wor_restoredz!Restored soft-deleted work order r   r  r  restoredu   Restored from deletion — r  r
  r	  r  )r   r  r   r   r   r  r,   r*   rw   r  r   r   r   r   r   r   r   r  r  r  r/   rmdirr  r  r  r  r  r	  )r  dir_namer  r  r  r  r	  r  rd   r
  r  r  r  r  r   s                  r1   wor_restorer  T s    4(.BD%2,,.Hsh$(*:<=sBB&1::<N~))#.>.F.F.H*IJ!((*@A3FF	/	)B::<VWY\\\E::blln- ||J'-2Hx'G??EFKK||J'8=H\\+,5F8<<	*+GhW5f=H2HE1Q3%&2( 4F
		T	" %##%../CD!#\\+.!88H%+224x=$*& E =.8H8J>xjI'/&QS
 {J6F6H!<XJGX1;*.((8*<*B)I)I)K&+El4	5
 d&eElKKW   '* E>?DDE"  s$   $#K "L 'K?>K?	LLz/wor-deletion-logc                  `    t               } | j                          t        | t                     S )zGServe the tamper-evident deletion log + chain-verify result (DVI-1084).)r  verify)r  r  r   r  r  s    r1   wor_deletion_log_getr  T s'     ()GOO7+C+EFFr3   z/wor-deletion-log/verifyc                  (    t        di t               S )zFRecompute the deletion-log hash chain and report integrity (DVI-1084).rX   )r   r  rX   r3   r1   wor_deletion_log_verify_router   T s     0-/00r3   z/wor-policyc                  *    t        t                     S )z8Return the Policy-enforced notifications grid (DVI-597).r6  )r   r  rX   r3   r1   wor_policy_getr"  T s     /122r3   c                     t        j                  d      xs i } | j                  d      }t        |t              st        d      dfS 	 t        |       t        d
      r5t        d
dt        j                  d      xs i j                  d      d       t        dt                     S # t        $ r}t        d|       dfcY d	}~S d	}~ww xY w)z9Persist the Policy-enforced notifications grid (DVI-597).Tr  r7  zInvalid policy grid.r  r`  r;  r  Nr  policy_updatedr'  r9   z*Policy-enforced notifications grid updatedr  r<  )r   r  r   r   r   r   r  r   r	  r	  r   r  )r  rz  rl  s      r1   wor_policy_postr%  T s     4(.BD88HDdD!34c99<d# #;/);;v&,"11&9@	

 d#8#:;;  </u56;;<s   B+ +	C4C
C
Cz/support-request-submitc                     t        j                  d      xs i } | j                  dd      j                         }| j                  dd      j                         }| j                  dd      j                         }| j                  dd      j                         }| j                  dd      j                         }| j                  d	d      j                         }| j                  d
d      j                         xs d}|st	        d      dfS t        j                  di       }|s|j                  dd      }|s|j                  dd      }	 t        j                  dd       t        j                         j                  d      }
t        j                  dd|      dd j                         j                  dd      }d|
 d| d}t        |z  }||||xs" t        j                         j                         |||t        j                         j                         d}	 |j!                  t#        j$                  |d             d }d}t&        r	 t)        j*                  t,        t.        t0        !      }|j3                  d"g#      }d$|v rd%| d&| d'| d(| d)| d*| d+| }d,| d-| d.|d/d0d1t&        iid0d1|iigd0d1|iigd2d3d4}t5        j6                  d5t&         d6d7|d$    d8d9|d:;      }|j8                  d<v rd}nE|j#                         j                  d=i       j                  d>|j:                  dd?       }d@| }ndA}||dB}|r||dC<   t	        |      S # t        $ r}	t	        d|	       dfcY d}	~	S d}	~	ww xY w# t        $ r}	t	        d|	       dfcY d}	~	S d}	~	ww xY w# t<        $ r}	t?        |	      }Y d}	~	~d}	~	ww xY w)Dz9Accept a support request, store it as JSON, and email it.Tr  r9   r7   r  r  r   active_tool	app_statesend_to'icast.voyagetech.com@email.autotask.netIssue description is required.r  r`  r'  r  rM   z)Cannot create support request directory: r  Nr
  r
  rS  r\   r  SR_r  )r9   r  r  r   r'  app_state_jsonr)  submitted_atr   r   z Failed to save support request: Fr  r  r  r  zSupport Request from r:  z)
Date/Time: z
Active Tool: 

Issue Description:
z

Application State:


Reference: u   Togen Support Request — r
  r  r  r  r  r  r  fromreplyTor  r  r  r  r  r  r  r  r  r  r  r  r  rK   rT  Failed to acquire mail tokenr  
email_sentemail_error) r   r  r   r   r   r   r   r  r   r  r  r  rF  r\  r  r  r   r   r   r   r  r  r  r  r  r  r  r  rg   r&  ry   r*   )r  r9   r  r  sr_datetimer'  r(  r)  r'  rl  rv
  r  r  r
  r  r6  r7  r  r  
email_bodyr  r  r	  r>  s                           r1   support_request_submitr:  T s2    4(.BD88FB%%'DHHWb!'')E((="-335K((:r*002K((="-335Kb)//1Ihhy"%++-Z1ZG=>CC;;vr"D"%xx	*U$6 ""$--.?@I|R.s399;CCCMIYKq51HX%H "C>#5#5#7#A#A#C"#&**,668	FLDJJva89
 JK3	#;;);z[J%>>>? ? AL -+D6E7 ;""- /$$/= 1++6- 8++4+ 6""*-  &@vU;-#X+1'1!
 +Y,H! ,i-?@$ ,i-AB)   (/#
& &**=o=NiX+2<3O2P)Q(: $ ##z1!%J!YY[__Wb9==iSWTWYF$5fX">K< #*=F +}6?g  UHNOQTTTU(  L?uEFKKLr  	#c(K	#sU   M" "&N	 C8N0 "	N+N;NN		N-N("N-(N-0	O9O		Oz/feature-request-submitc            
         t        j                  d      xs i } | j                  dd      j                         }| j                  dd      j                         }| j                  dd      j                         }| j                  dd      j                         }| j                  dd      j                         xs d	}|st	        d
      dfS t        j                  di       }|s|j                  dd      }|s|j                  dd      }	 t        j                  dd       t        j                         j                  d      }t        j                  dd|      dd j                         j                  dd      }	d| d|	 d}
t        |
z  }|||||t        j                         j                         d}	 |j!                  t#        j$                  |d             d}d}t&        r	 t)        j*                  t,        t.        t0              }|j3                  d g!      }d"|v rd#| d$| d%| d&| d'|
 
}d(| d)|d*d+d,t&        iid+d,|iigd+d,|iigd-d.d/}t5        j6                  d0t&         d1d2|d"    d3d4|d56      }|j8                  d7v rd}nE|j#                         j                  d8i       j                  d9|j:                  dd:       }d;| }nd<}|
|d=}|r||d><   t	        |      S # t        $ r}t	        d|       dfcY d}~S d}~ww xY w# t        $ r}t	        d|       dfcY d}~S d}~ww xY w# t<        $ r}t?        |      }Y d}~~d}~ww xY w)?z9Accept a feature request, store it as JSON, and email it.Tr  r9   r7   r  r  beneficiariesr)  r*  z Feature description is required.r  r`  r'  r  rM   z)Cannot create feature request directory: r  Nr
  r
  rS  r\   r  FR_r  )r9   r  r  r<  r)  r.  r   r   z Failed to save feature request: Fr  r  r  r  zFeature Request from r:  z)

Feature Description:
z,

Who may use or benefit from this feature:
r0  u   Togen Feature Request — r  r  r  r  r1  r  r  r  r  r  r  r  r  r  r  r  r  rK   rT  r4  r5  r7  ) r   r  r   r   r   r   r   r  r   r  r  r  rF  r\  r  r  r   r   r   r   r  r  r  r  r  r  r  r  rg   r&  ry   r*   )r  r9   r  r  r<  r)  r'  rl  rv
  r  r  r
  r  r6  r7  r  r  r9  r  r  r	  r>  s                         r1   feature_request_submitr>  gU s    4(.BD88FB%%'DHHWb!'')E((="-335KHH_b1779Mhhy"%++-Z1ZG?@#EE;;vr"D"%xx	*U$6 ""$--.?@I|R.s399;CCCMIYKq51HX%H "&&**,668FLDJJva89
 JK1	#;;);z[J%>>>? ? AL -+D6E7 ;--8M :BBO Q""*-  &@v#F+1'1!
 +Y,H! ,i-?@$ ,i-AB)   (/#
& &**=o=NiX+2<3O2P)Q(: $ ##z1!%J!YY[__Wb9==iSWTWYF$5fX">K< #*=F +}6?_  UHNOQTTTU$  L?uEFKKLn  	#c(K	#sU   K3 <&L -C/M 3	L<LLL	L>#L93L>9L>	M
MMz/it-help-desk-submitc            
      z   t        dt        d         st        d      dfS t        j                  d      xs i } | j                  dd	      j                         }| j                  d
d	      j                         }| j                  dd	      j                         }| j                  dg       }t        |t              sg }|D cg c]5  }t        |      j                         st        |      j                         7 }}|st        d      dfS t        j
                  di       }|s|j                  d
d	      }|s|j                  dd      }d}	 t        j                  dd       t        j                         j                  d      }	t!        j"                  dd	|      dd j                         j%                  dd      }
d|	 d|
 d}t        |z  }|||||t        j                         j'                         d}	 |j)                  t+        j,                  |d             |rd j/                  |      nd!}d"}d	}|xs t0        }t0        r|r	 t3        j4                  t6        t8        t:        #      }|j=                  d$g%      }d&|v rd'| d(| d)| d*| d+| 
}d,| d-|d.d/d0|iid/d0|iigd1d2d3}t?        j@                  d4| d5d6|d&    d7d8|d9:      }|jB                  d;v rd}nE|j+                         j                  d<i       j                  d=|jD                  dd>       }d?| }nd@}||dA}|r||dB<   t        |      S c c}w # t        $ r}t        d|       dfcY d}~S d}~ww xY w# t        $ r}t        d|       dfcY d}~S d}~ww xY w# tF        $ r}t        |      }Y d}~d}~ww xY w)CzgAccept an IT Help Desk ticket, store it, and email icast.voyagetech.com@email.autotask.net as the user.r  r'  r
  r  r  Tr  r9   r7   r  r  relatedr+  r`  r  r*  rM   z!Cannot create request directory: r  Nr
  r
  rS  r\   r  IT_r  )r9   r  r  r@  r)  r.  r   r   zFailed to save request: r  z(none selected)Fr  r  r  r  zIT Help Desk ticket from r:  z)
Issue Related To: r/  r0  u   IT Help Desk Request — r  r  r  r  )r  r  r2  r  r  r  r  r  r  r  r  r  r  r  r  r  rK   rT  r4  r5  r7  )$r
  r   r   r   r  r   r   r   r  r*   r   r  r   r  r  r  rF  r\  r  r  r   r   r   r  r   r  r  r  r  r  r  r  r  rg   r&  ry   )r  r9   r  r  r@  r  r'  r)  rl  rv
  r  r  r
  r  related_liner6  r7  send_asr  r  r9  r  r  r	  r>  s                            r1   it_help_desk_submitrD  U s    !wv?MNPSSS4(.BD88FB%%'DHHWb!'')E((="-335Khhy"%Ggt$'.A!#a&,,.s1v||~AGA=>CC;;vr"D"%xx	*7GM$6 ""$--.?@I|R.s399;CCCMIYKq51HX%H "&**,668FDDJJva89 *1499W%6GL
 JK&G7.	#;;);z[J%>>>? ? AL -/vRw ?))5 7++6- 8""*-  &?tf#E+1'1!
 +Y,@! ,i-AB)  (/
  &**=gYiP+2<3O2P)Q(: $ ##z1!%J!YY[__Wb9==iSWTWYF$5fX">K< #*=F +}6?A B  M@FGLLM$  D7u=>CCDr  	#c(K	#sa   M	#M	M ?&M5 C!N 	M2M-'M2-M25	N>NNN	N:%N55N:)tranIditemLine_custom:StructureitemLine_itemitemLine_quantityitemLine_salesPriceitemLine_weight	Item_SizePart ReferenceitemLine_descriptionz,itemLine_custom:Stack-It PART - DrawingImagez&itemLine_custom:Submittal Drawing linkzitemLine_custom:QR Labelc                     t        j                         j                  d      } d|  }d}t        t        fD ]  }|j                         s|j                         D ]  }|j                         s|j                  }|j                  |      }|dk(  r7||t        |      z   d }d}|D ]  }	|	j                         r||	z  } n |skt        |t        |            }  | |dz   dS )	zFCompute the next project number for today: T + YYYYMMDD + 2-digit seq.%Y%m%dTr   r  Nr7   r^   02d)r  r  r  r
  r   r  rY  r9   rM  rC  r	  rC  r   )
r	  r  max_seq
search_dirr   r9   r  r&  digitschs
             r1   _sp_next_project_numberrV  UV s     ))(3E[FG!#56 4
  "'') 	4E<<>::D))F#Cbyc&k)*+DF ::<bLF	
 gs6{3	44& Xgk#&''r3   z/project-builder-next-numberc                  ,    t        dt               i      S )Nr  )r   rV  rX   r3   r1   project_builder_next_numberrX  pV s     H57899r3   z/project-builder-checkc                  2   t        j                  d      xs i } | j                  d| j                  dd            j                         }|rt        j                  |      rd|v rt        ddi      d	fS t        |z  j                         }t        d
|i      S )NTr  r  r  r7   r  r  r  r`  r  )	r   r  r   r   ra  rG  r   r
  r  )r   r9   r  s      r1   project_builder_checkrZ  vV s     4(.BD88M488NB#?@FFHD|))$/44<!678#==4'')FHf%&&r3   c                 |    t        j                  ddt        |       j                               j                  d      S )z=Sanitize a project number / tranId for use in a CSV filename.[^A-Za-z0-9._-]+rc
  z-._rF  r\  r*   r   r  s    r1   _sp_filename_partr_  V s.    66%sCJ,<,<,>?EEeLLr3   c                     d}|D ]P  }t        |t              s|st        |d         j                         s4t        |d         j                         } n t	        |       }t	        |      }|r
|r| d| dS | dS )zECSV name convention: <Project Number>-<Transaction ID>.csv (DVI-323).r7   r   rc
  r  )r   r  r*   r   r_  )project_numberrn  r  tran_idr  num_part	tran_parts          r1   _sp_csv_filenamere  V s    G c4 SSQ[->->-@#a&k'')G !0H!'*II1YKt,,]$r3   z/project-builder-submitc                  \
   t        j                  d      xs i } | j                  dd      j                         }| j                  dd      j                         }| j                  dd      j                         }| j                  dd      j                         }| j                  dd      j                         }| j                  d	g       }| j                  d
g       }| j                  dd      j                         }| j                  dd      j                         }	|rt        j                  |      rd|v rt        ddi      dfS |r(t        j                  |      rd|v rt        ddi      dfS |	r(t        j                  |	      rd|	v rt        ddi      dfS |st        ddi      dfS |st        ddi      dfS |xs
 t               }|D 
cg c]1  }
t        |
t              st        |
      t        t              k(  s0|
3 }}
|st        ddi      dfS t        |||      }|rt        |||||||||	      S |	rt        |	||||||||	      S 	 t        j!                  dd       t        |z  }|j!                  dd       ||z  }|||||t%        j&                         j)                         |d}|dz  }	 t+        |ddd      5 }t-        j.                  |      }|j1                  t               |j3                  |       d d d        t+        |dd      5 }t5        j6                  ||d       d d d        t9        |       t:        |z  }|j=                         rt?        j@                  tC        |             t:        j!                  dd       t?        jD                  tC        |      tC        |             	 t?        j@                  tC        |      d       tI        d!      rJtK        d!d"tM        j                  d#      xs i j                  d$      d%| d&| d'||t        |      d()       t        ||d*      S c c}
w # t"        $ r t        ddi      dfcY S w xY w# 1 sw Y   axY w# 1 sw Y   ?xY w# t"        $ r<}t        dd| i      dfcY d }~t?        j@                  tC        |      d       S d }~wtF        $ r<}t        dd | i      dfcY d }~t?        j@                  tC        |      d       S d }~ww xY w# t?        j@                  tC        |      d       w xY w)+NTr  r  r7   r  r  r  ra  productsrn  editing_folderimport_source_folderr  r  r  r`  zProject name is requiredzNo product rows providedrM   5Server cannot write to temp directory. Contact admin.r  )r  r  r  ra  r  r  rg  r  r  r  r  r  r   r   Permission denied: rO	  File system error: r  r  r'  r9   zProject created: r:  r7  ra  rn  r	  r  r  )'r   r  r   r   ra  rG  r   rV  r   r  rC  _SP_CSV_HEADERSre  _sp_update_existing_project_sp_import_projectr   r  PermissionErrorr  r  r  r?  r  r  writerowr  r   r  _pb_move_temp_uploadsr
  r  r  r:  r*   r  r   r	  r	  r   )r   r  r  r  r  ra  rg  rn  rh  ri  r  
clean_rowsr  
tmp_folderr  r  r  r  r  r  rl  s                        r1   project_builder_submitrw  V s    4(.BD88NB/557LHH_b1779M((#8"=CCE((="-335KXX.399;Nxx
B'H88FBDXX.399;N88$:B?EEGl00=AT!678#==|11.AT^E[!678#==\%7%78L%MQUYmQm!678#==!;<=sBB!;<=sBB#@'>'@N!%d#C)>3s8sSbOcCc#dJd!;<=sBB#NJLL*Kz<)<nh
 	

 ! +|Z<)<nh
 	

`   = $k1JTD1L(H '$2("$((*446H 44I;(Cg> 	)"ZZ^FOOO,Z(	)
 )S73 	.rIIh1-	. 	j)K';;=MM#d)$5JT3 	c*oT:"#45y;;v&,"11&9'~R}AF&4c*oN	
 k,?@@K e&  `!XYZ\___`(	) 	)
	. 	.  D#6se!<=>CC 	c*oT:  D#6se!<=>CCc*oT:D 	c*oT:s   Q'QQQ :Q> 	<Q$Q> Q14B	Q> Q! Q!$Q.)Q> 1Q;6Q> >	TR>TT	 >T
TTT	 TT	 	"T+c           
        
 g 
| D ]  }	 t        |dd      5 }t        j                  |      }t        |d       |D ]l  t	              dk  rd   d   d   g}t        d	 |D              s3
j                  t        fd
dD              t        fddD              |dd       n 	 ddd        
fd}g }|D ]B  t        fddD              } |d|      }|r|\  d<   d<   d<   2|j                         D |D ]1  t        fddD              }	 |d|	      }|s#|\  d<   d<   d<   3 y# 1 sw Y   xY w# t        t        j                  f$ r Y ]w xY w)aF  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).r7   r  r  Nr-  ry  r   r  c              3   <   K   | ]  }|j                           y wrT   r   )r,  r  s     r1   r/  z+_sp_preserve_url_columns.<locals>.<genexpr>W s     7Qqwwy7   c              3   L   K   | ]  }|   xs d j                           ywr7   Nrz  r,  r  old_rows     r1   r/  z+_sp_preserve_url_columns.<locals>.<genexpr>W s#     $VAgaj&6B%=%=%?$V   !$r   r^   r   r   c              3   L   K   | ]  }|   xs d j                           ywr}  rz  r~  s     r1   r/  z+_sp_preserve_url_columns.<locals>.<genexpr>	W s#     #O1WQZ%52$<$<$>#Or  )r^   r   F)r)  fburlsusedc                 H    D ]  }|d   r	||    |k(  sd|d<   |d   c S  y )Nr  Tr  rX   )r  r  r   r  s      r1   _takez'_sp_preserve_url_columns.<locals>._takeW s<     	%E=U5\U%: $fV}$	% r3   c              3   ^   K   | ]$  }t        |   xs d       j                          & ywr}  r*   r   r,  r  r  s     r1   r/  z+_sp_preserve_url_columns.<locals>.<genexpr>W s'     D!CA"%++-D   *-r)  c              3   ^   K   | ]$  }t        |   xs d       j                          & ywr}  r  r  s     r1   r/  z+_sp_preserve_url_columns.<locals>.<genexpr> W s'     =3s1v|$**,=r  r  )
r?  r  rO  r  rC  r2  ri   r	  r   r]  )source_csvsru  old_csvr  rO  r  r  	unmatchedr)  r  r  r  r  s             @@@r1   _sp_preserve_url_columnsr  V s   
 G 	grK@ BBVT"% G7|b( #AJWR[AD7$77 NN$$V$VV##O#OO $ %	$ ( I "D|DDUC '+$CFCGSWS!"  ,=f==T2'+$CFCGSW	,C  # 		s)   E	BD=.E	=E	E		E&%E&c	                    t         | z  }	|	j                         st        ddi      dfS t         |z  }
|
j                         rt        dd| di      dfS t	        d |	j                         D              }t        ||       	 t        j                  dd	       t        |z  }t        j                  t        |      d       |j                  dd	       	 |	j                         D ]  }|j                         rc|j                  j                         dk(  s|j                   dk(  r@t        j"                  t        |      t        ||j                   z               v|j                         st        j$                  t        |      t        ||j                   z                t'        ||z  ddd      5 }t)        j*                  |      }|j-                  t.               |j1                  |       ddd       |||||t3        j4                         j7                         | |d}t'        |dz  dd      5 }t9        j:                  ||d       ddd       t=        |       |
j                         r3t        dd| di      dft        j                  t        |      d       S t         j                  dd	       t        j$                  t        |      t        |
             	 t        j                  t        |      d       tA        d      rMtC        ddtE        jF                  d      xs i jG                  d      d| d| d |  ||tI        |      | d!"       t        ||d#      S # t        $ r t        dd
i      dfcY S w xY w# 1 sw Y   xY w# 1 sw Y   IxY w# t        $ r<}t        dd| i      dfcY d}~t        j                  t        |      d       S d}~wt>        $ r<}t        dd| i      dfcY d}~t        j                  t        |      d       S d}~ww xY w# t        j                  t        |      d       w xY w)$uH  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).r  zSource project folder not foundr  zA project folder named "r?  r9  c              3   ~   K   | ]5  }|j                         s|j                  j                         d k(  s2| 7 ywr  r  r  s     r1   r/  z%_sp_import_project.<locals>.<genexpr>4W s+     `qQYY[QXX^^EUY_E_`r  TrM   rj  r  rO	  r  r  r  r7   r  r  N)r  r  r  ra  r  r  imported_fromrg  r  r   r   rk  rl  r  importedr'  r9   zProject imported: r:  z) from )ra  rn  r	  r	  rn  )%r
  r  r   r  r  rY  r  r   r  rr  r  r:  r*   r   r!  rE  r9   r7  r  r?  r  r  rs  ro  r  r  r  r  r   r  rt  r   r	  r	  r   r   rC  )source_folderr  r  ru  r  r  r  ra  rg  r$  r  r  rv  r	  r  r  r  rl  s                     r1   rq  rq  &W s    }
$C::<!BCDcII#D{{}#;K=HY!Z[\^aaa`CKKM``K[*5`   = $k1J
MM#j/6TD1&;KKM 	HD||~;;$$&&0DIIAX4XSYJ,B(CDD	3zDII/E+FG	H *|+S"wO 	)SUZZ^FOOO,Z(	) +(#6,&(,,.88:* 	
 *66gN 	.RTIIh1-	. 	j);;=G'?}L]%^_`bee 	c*oT: 	5JT3 	c*oT:"#45z;;v&,"11&9(bW]O\&4c*o+-	
 k,?@@q  `!XYZ\___` 	) 	)	. 	.  D#6se!<=>CC 	c*oT:  D#6se!<=>CCc*oT:D 	c*oT:s   M+ BN# -A	N# 6<N	2AN# 6N5N# %?N# +NN	NN# N N# #	P+,O#=P+>P. #P+/P& P+P. &P++P. ."Qc	                    t         | z  }	|	j                         st        ddi      dfS t         |z  }
	 | |k7  rV|
j                         rt	        j
                  t        |
             t	        j                  t        |	      t        |
             t        d |
j                         D              }t        ||       t        |
|z  ddd      5 }t        j                  |      }|j                  t               |j!                  |       d	d	d	       |D ]"  }|j"                  |k7  s|j%                          $ |
d
z  }|j'                         rw	 t)        j*                  |j-                  d            }t/        |t0              rA|j3                  d      |k7  r-||d<   |j5                  t)        j6                  |d      d       |
dz  }i }|j'                         r?	 t        |dd      5 }t)        j<                  |      }d	d	d	       t/        t0              r|}|j?                  ||||||d       |jA                  dtC        jD                         jG                                tC        jD                         jG                         |d<   t        |dd      5 }t)        jH                  ||d       d	d	d	       tK        |
       tO        d      rJtQ        ddtS        j2                  d      xs i j3                  d      d| d| d||tU        |      d       t        ||d       S # 1 sw Y   xY w# t(        j8                  t:        f$ r Y w xY w# 1 sw Y   UxY w# t(        j8                  t:        f$ r Y bw xY w# 1 sw Y   xY w# tL        $ r}t        dd| i      dfcY d	}~S d	}~wt:        $ r}t        dd| i      dfcY d	}~S d	}~ww xY w)!zUpdate 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).r  r  r  c              3   ~   K   | ]5  }|j                         s|j                  j                         d k(  s2| 7 ywr  r  r  s     r1   r/  z._sp_update_existing_project.<locals>.<genexpr>W s+     gQAIIKAHHNNL\`fLfqgr  r  r7   r  r  Nrh  r  r  r   r   r  r  )r  r  r  ra  r  rg  r  r  rk  r  rl  r  r  r'  r9   zProject edited: r:  r7  rm  r	  rn  )+r
  r  r   r  r  r:  r*   r  r  rY  r  r?  r  r  rs  ro  r  r9   r/   r   r   r   r   r   r   r   r   r   r   r   loadr  r}  r  r  r  r  rt  rr  r	  r	  r   rC  )rh  r  r  ru  r  r  r  ra  rg  
src_folderr  existing_csvsr  r  r  r`  ro  r  r  existing_metarl  s                        r1   rp  rp  tW s    n,J!;<=sBB#D8D[({{}c$i(KKJT3g$,,.gg 
;$%sBI 	)RZZ^FOOO,Z(	)
 % 	!G|||+ 	! L(


:#7#7#7#IJeT*uyy/HL/X,8E.)))$**U1*EPW)X 22	)S7; 2r$(IIbMM2mT2,H 	*(#6,& 
 	 	L.*<*<*>*H*H*JK!/!3!3!5!?!?!A)S73 	.rIIh1-	. 	d# ##45x;;v&,"11&9&|nB{m1E&4c*oN	
 k,?@@q	) 	)  (('2 2 2 (('2 	. 	.  D#6se!<=>CC D#6se!<=>CCDs   BN <L%N %'N A6L2 N M )M?M A=N M?/N %L/*N 2MN MN MM M<8N ;M<<N ?NN 	ON+%O+O7OOOz/project-builder-list-projectsc                  L   g } t         j                         rt        t         j                         d d      D ]^  }|j                         s|j                  j                  d      r0| j                  |j                  |dz  j                         d       ` t        d| i      S )Nc                 6    | j                         j                  S rT   rS  rT  s    r1   r	  z/project_builder_list_projects.<locals>.<lambda>W s    @Q@Q r3   Tr
  r  r  r9   has_metar  )	r
  r  r  rY  r9   rw   ri   r   r   rU  s     r1   project_builder_list_projectsr  W s     GJ..06Q[_` 	E||~ejj&;&;C&@!JJ!&)@!@ I I K  	 Iw'((r3   )ru  rv  )r   r   r   r   .gif.tif.tiffr   r   z.xlsrN	  z.dwgz.dxfc                 ,     t         fddD              S )u   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.c              3   d   K   | ]'  }t              |kD  r|   j                         nd  ) ywr}  r  r  s     r1   r/  z_sp_row_key.<locals>.<genexpr>W s*     PaCHqL#a&,,.b8Ps   -0r  r	  )r  s   `r1   _sp_row_keyr  W s     P<PPPr3   c           	         d }|D cg c]/  } ||      |j                  dd      |j                  dd      dd1 }}t        |       D ]n  \  }}t        |t              sd} ||      }|D ]  }	|	d   r	|	d	   |k(  s|	} n ||t	        |      k  r||   d   s||   }|Zd
|d<   |d   |d<   |d   |d<   p yc c}w )a  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).c                 ,     t         fddD              S )Nc              3   x   K   | ]1  }t        j                  |d       xs d       j                          3 ywr}  r*   r   r   )r,  r  rn  s     r1   r/  z8_sp_overlay_drawing_urls.<locals>.key.<locals>.<genexpr>W s6      
 a"#))+
s   7:rE  ro  r	  partRefr  r  s   `r1   r)  z%_sp_overlay_drawing_urls.<locals>.keyW s     
?
 
 	
r3   shopUrlr7   submittalUrlF)r)  r  r  r  Nr  r)  T)r   r  r   r   rC  )
rg  csv_rowsr)  r  poolr  prodrG  pkr   s
             r1   _sp_overlay_drawing_urlsr  W s   

 
 	 1v55B'nb1	 D  X& 94$%Y 	E=U5\R%7	 =QT]476?GE E&M#I.DO#(#8D 9s   4B;c                    | dz  }|j                         rS	 t        j                  |j                  d            }|j	                  d      }|r| |z  j                         r| |z  dfS t        d | j                         D              }t        |      dk(  r|d   dfS t        |      dkD  ry	y
# t        j
                  t        f$ r Y `w xY w)u   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.rh  r  r  r  Nc              3   ~   K   | ]5  }|j                         s|j                  j                         d k(  s2| 7 ywr  r  r  s     r1   r/  z!_sp_locate_csv.<locals>.<genexpr>X s+     dIY]cIc!dr  r^   r   )Nr  )NzNo CSV found for this project)
r   r   r   r   r   r   r   r  rY  rC  )r  r`  ro  r9   r  s        r1   _sp_locate_csvr  X s      ,.J	JJz33W3EFE99^,D$.779%,d22 d^335ddD
4yA~Aw}
4y1}O0 $$g. 		s   AB. .C
	C
c                     | dd }t        |      }t        |t              r,d|cxk  rt        |      k  rn n|rt	        ||         |k(  r|S |r%t        |      D ]  \  }}t	        |      |k(  s|c S  y)zIndex (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.r^   Nr   )r2  r   r   rC  r  r  )rn  	row_indexr)  r   has_keyr  r  s          r1   _sp_match_data_rowr  X s{     8D#hG)S!a9&@s4y&@+d9o6#=dO 	DAq1~$	 r3   c                    | dz  }|j                         sy	 t        j                  |j                  d            }|j                  d      }t        |t              rd|cxk  rt        |      k  sy y||   }t        |t              sy|||dk(  rdnd	<   	 |j                  t        j                  |d
      d       y# t        j                  t
        f$ r Y yw xY w# t
        $ r Y yw xY w)zBest-effort: reflect a drawing add/clear on the matching products entry in
    project_metadata.json so metadata stays consistent with the CSV.r  Nr  r  rg  r   ru  r  r  r   r   )r   r   r   r   r   r   r   r   r  rC  r   r   r   )r  
data_indexr  rJ  r  r  rg  r  s           r1   _sp_update_meta_productr  )X s     !88Izz)--w-?@ xx
#Hh%a:.MH.M /NJDdD!ADDi6)~>TZZQ7'J   '*   s#   %C (C  CC 	C,+C,z/project-builder-upload-drawingc                     dt         j                  vrt        ddi      dfS t         j                  d   } | j                  st        ddi      dfS t        j
                  j                  | j                        d   j                         }|t        vrt        ddi      dfS t         j                  j                  d      xs dj                         }t         j                  j                  d	      xs dj                         }|t        vrt        dd
i      dfS |rt        j                  |      rd|v rt        ddi      dfS t        |z  j!                         }t#        |      j%                  t#        t        j!                               t        j&                  z         r|j)                         st        ddi      dfS t+        |      \  }}|t        d|i      dfS t-        | j                        }|st        ddi      dfS t         j                  j                  dt.              }t1        d dD              }	t3        |dd      5 }
t5        t7        j8                  |
            }ddd       t;        ||	      }|t        ddi      dfS | j=                  t#        ||z               t        |   }t?               jA                  d      }| dtC        |d       dtC        |d       }||dz      }tE        |      dk  r |jG                  d       tE        |      dk  r |||<   t3        |ddd      5 }
t7        jH                  |
      jK                  |       ddd       tM        ||||       tO        d      rCtQ        ddtS        j                  d       xs i j                  d!      d"| d#| d$| |||d%&       t        d'||d(      S # 1 sw Y   ixY w# 1 sw Y   xY w))a  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).r  r  r  r`  r^   zUnsupported file typer  r7   r  r  r  r  r  r  NzInvalid file namer  r  c              3   ~   K   | ]5  }t         j                  j                  |      xs d j                          7 ywr}  )r   rg  r   r   r,  r  s     r1   r/  z1project_builder_upload_drawing.<locals>.<genexpr>dX s-     h!!!$*113hs   ;=r  r  r  *Could not match the product row in the CSVr9  r6   r  r-  r  r  r  zdrawing-attachedr'  r9   zDrawing attached to r:  ): )r  r  r	  T)rU  rJ  r  )*r   r  r   r  r  rb   r   rE  _SP_DRAWING_EXTSrg  r   r   _SP_DRAWING_COLra  rG  r
  r,   r*   rw   r  r  r  r   r   r	  r?  r  r  rO  r  r  r+   r8   r   rC  ri   r  r  r  r	  r	  r   )r  r  r  r  r  r  warnr  r  r)  r  rn  r  r6  r  rJ  r  s                    r1   project_builder_upload_drawingr  @X s    W]]"!345s::fA::!345s::
''

1::
&q
)
/
/
1C
""!89:C??<<##H-3::<K!!+.4";;=I'!456;;l00=AT!123S88 ;.779N~))#j.@.@.B*Cbff*LMUcUjUjUl!;<=sBB#N3NHd',,

+I!456;;  3 7I
h=gh
hC	h[	9 $RCJJrN#$ y#6G!MNOQTTTFF3~	)*+
)
$C!((-HJak34AeIB6O5P
QC
w{
C
c(R-

2 c(R-CH	hR'	: 'b

2  &' NGYD"#451;;v&,"11&9*;-r)C	{S!*	B	
 $s	BCC9$ $ ' 's   
O8%OOO&z/project-builder-detach-drawingc                     t        j                  d      xs i j                  d      xs dj                         } j                  d      xs dj                         }|t        vrt        ddi      dfS | rt        j                  |       rd	| v rt        dd
i      dfS t        | z  j                         }t        |      j                  t        t        j                               t        j                  z         r|j                         st        ddi      dfS t        |      \  }}|t        d|i      dfS j                  d      }t!        |t"              s	 t#        |      }t)        fddD              }t+        |dd      5 }t-        t/        j0                  |            }ddd       t3        ||      }	|	t        ddi      dfS t        |   }
||	dz      }t5        |      |
kD  rd||
<   t+        |ddd      5 }t/        j6                  |      j9                  |       ddd       t;        ||	|d       t        ddi      S # t$        t&        f$ r d}Y w xY w# 1 sw Y   xY w# 1 sw Y   GxY w)zClear 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).Tr  r  r7   r  r  r  r`  r  r  r  r  Nr  c              3   v   K   | ]0  }t        j                  |      xs d       j                          2 ywr}  r  )r,  r  r   s     r1   r/  z1project_builder_detach_drawing.<locals>.<genexpr>X s,     eQTXXa[&B'..0es   69r  r  r  r  r9  r^   r  r  rU  )r   r  r   r   r  r   ra  rG  r
  r,   r*   rw   r  r  r  r  r   r   r   r   r	  r?  r  r  rO  r  rC  r  r  r  )r  r  r  r  r  r  r)  r  rn  r  r6  r  r   s               @r1   project_builder_detach_drawingr  X s]    4(.BD88H%+224K+&,"335I'!456;;l00=AT!123S88 ;.779N~))#j.@.@.B*Cbff*LMUcUjUjUl!;<=sBB#N3NHd',,%Ii%	II e:de
eC	h[	9 $RCJJrN#$ y#6G!MNOQTTT
)
$C
w{
C
3x#~C	hR'	: 'b

2  &' NGYCD$<  % :& 	I	$ $' 's*    I I$%I0I! I!$I-0I9)r   r   r   r   z \b(2[4-7])-?(\d{4})([A-Za-z]?)\bc                     t         j                  | xs d      }|sy|j                  d       d|j                  d       |j                  d      xs d S )zPull 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).r7   r^   rc
  r   rP   )_PB_TRANID_REr  rb  )r&  r  s     r1   _parse_tran_idr  X sQ     	TZR(Aggaj\1771:,qwwqz'7R&899r3   c                     ddl }|j                  t        |             }	 |j                         }|j	                          t        |      j                  |       y# |j	                          w xY w)u   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.r   N)r=  r?  r*   convert_to_pdfr[  r   r-   )r  r
  r=  r   r
  s        r1   _pb_image_to_pdfr  X sT     
))CM
"C&&(			Ny) 			s   A A-c                 Z   | j                   j                         }|t        vri S | }d}	 |dk7  rFt        j                  d      \  }}t        j                  |       	 t        | |       t        |      }i }t        |g d      }|j                  d      r|d   |d<   |j                  d      r|d   |d<   	 ddlm}  ||      }t!        |      xs2 t!        |j                  d
d	            xs t!        | j"                        }	|	r|	|d<   ||r	 t        j                  |       S S # t        $ r- i cY |r&	 t        j                  |       S # t        $ r Y S w xY wS w xY w# t        $ r d	}Y w xY w# t        $ r Y S w xY w# |r&	 t        j                  |       w # t        $ r Y w w xY ww xY w)zOCR 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.Nr   r!  )r  r  r  r  r  r   extract_title_block_textr7   r  rE  )r!  rE  _PB_OCR_EXTSr  r  r  r[  r  r   ry   r/   r   r  r   r"  r  r  r9   )
rb   r  r  tmp_pdfr  r   labeledr  r   trans
             r1   _pb_discover_fieldsr  X s    ++


C
,	HG!&="**&9KBHHRL w/= *E
 ;;'#*?#;C ;;~&")."9C	;*84C s# -!'++lB"?@-!$)), 	  CM		'" -  	* 		'"  -  	C	   		'"  s   3F  D' 5AF  8E  AF  E1'E2F  6E	EEEF   E.+F  -E..F  1	E=<E= F*FF*	F&#F*%F&&F*c                     i | D ]T  }t        |      j                         D ]  \  }}|s	j                  |      r||<   ! t        fddD              sS S  S )zOCR each path in order; first non-empty value per field wins. Stops once
    all three target fields are filled to keep large folders responsive.c              3   @   K   | ]  }j                  |        y wrT   r  )r,  r  	suggesteds     r1   r/  z*_pb_aggregate_discovery.<locals>.<genexpr>
Y s     UAy}}QUr  )r  r  rE  )r  r   r   r  )pathsr  r  r  r  s       @r1   _pb_aggregate_discoveryr  Y ss     I '*002 	!DAqq) 	!	! U)TUU r3   c                      t        j                  d      } | rt        j                  dt	        |             s't        j                         j                  } | t         d<   | S )zStable 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.pb_upload_sidz^[a-f0-9]{32}$)r   r   rF  rG  r*   rW  rX  rY  )r   s    r1   _pb_session_idr  Y sG     ++o
&Cbhh0#c(;jjl#& Jr3   createc                     t         dz  j                         }|t               z  j                         }t        |      j	                  t        |      t
        j                  z         sy| r|j                  dd       |S )zResolve PROJECTBUILDER_DIR/_uploads/<session_id>/, rejecting traversal.
    Returns the Path (creating it when asked) or None on a bad session id._uploadsNTrM   )r   r,   r  r*   rw   r  r  r  )r  r  rn  s      r1   _pb_temp_dirr  Y sg     +446D	 	 ))+Aq6SY/0	t,Hr3   c                     t        d      } g }| r{| j                         rkt        | j                               D ]O  }|j	                         s|j                  |j                  |j                  j                         t        v d       Q |S )znList this session's staged uploads: [{name, ocr}] where ocr flags a
    PDF/image that OCR Discovery can read.Fr  )r9   ocr)
r  r  r  rY  r   ri   r9   r!  rE  r  )rn  r   r	  s      r1   _pb_temp_listingr  %Y sx     	E"A
CQXXZ199;' 	ID||~

DII#';;#4#4#6,#FH I	I Jr3   dest_folderc                    t        d      }|r|j                         sy	 |j                         D ][  }|j                         s| |j                  z  }|j                         r4t        j                  t        |      t        |             ] 	 t        j                  t        |      d       t        j                  dd       y# t        $ r+}t        j                  j                  d| |       Y d}~fd}~ww xY w)zMove 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).Fr  Nz%PB temp upload move failed for %s: %sTrO	  r  )r  r  rY  r   r9   r  r  r  r*   r   rm   rn   ro   r:  r   r  )r  r$  r	  r  rl  s        r1   rt  rt  2Y s     e
$Ccjjl	VKKM 	0D<<> 499,F}}KKD	3v;/	0 MM#c($/KK&  V

BKQTUUVs   A.C 	C;!C66C;z/project-builder/upload-tempc                  $   t         j                  j                  d      xs t         j                  j                  d      } | st        d      dfS t	        d      }|t        d      dfS | D ]  }|r|j
                  st        |j
                        }|s*||z  j                         }t        |      j                  t        |      t        j                  z         sr|j                  t        |              t        dt               	      S )
u   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).r  r  No files providedr  r`  Tr  zInvalid sessionrU  r  )r   r  r
  r   r  r  r   r,   r*   rw   r  r  r  r  )r  rn  r  r9   r  s        r1   project_builder_upload_tempr  GY s     MM!!'*Kgmm.C.CF.KE01366D!Ay./44 	

qzz*d(##%6{%%c!frvvo6	s6{	 d"2"455r3   z/project-builder/remove-tempc                     t        j                  d      xs i } t        | j                  d      xs dj	                               }|st        d      dfS t        d	      }|rx|j                         rh||z  j                         }t        |      j                  t        |      t        j                  z         r!|j                         r	 |j                          t        dt!               
      S # t        $ r Y  w xY w)z=Remove one staged upload from the session temp dir (DVI-661).Tr  r  r7   rL  r  r`  Fr  r  )r   r  r   r   r   r   r  r  r,   r*   rw   r  r  r   r/   r   r  )r   r9   rn  r  s       r1   project_builder_remove_tempr  `Y s     4(.BDDHHZ06B==?@D/0#55E"AQXXZd(##%v;!!#a&266/2v~~7G d"2"455  s   C2 2	C>=C>z/project-builder/temp-filesc                  *    t        t                     S )z4List the current session's staged uploads (DVI-661).r
  )r   r  rX   r3   r1   project_builder_temp_filesr  sY r  r3   z/project-builder/discover-tempc                  "   t        d      } g }| rf| j                         rVt        | j                               D cg c]4  }|j	                         r"|j
                  j                         t        v r|6 }}t        t        |            S c c}w )z|OCR the session's staged PDF/image uploads and return suggested,
    user-editable Project Information values (DVI-661 A.1).Fr  r  )
r  r  r  rY  r   r!  rE  r  r   r  )rn  r  r  s      r1   project_builder_discover_tempr  zY sz    
 	E"AEQXXZ"199;/ FqIIKAHHNN$4$D  F F1%899Fs   9Bz /project-builder/discover-folderc                     t        j                  d      xs i } | j                  d      xs dj                         }|rt        j                  |      rd|v rt        d      dfS t        |z  j                         }t        |      j                  t        t        j                               t        j                  z         r|j                         st        d	      d
fS t        |j                               D cg c]4  }|j!                         r"|j"                  j%                         t&        v r|6 }}t        t)        |            S c c}w )u   OCR the documents in an opened/imported project folder to discover
    Project Information — primarily the Customer/Contractor name (DVI-661 B).Tr  r  r7   r  r  r  r`  r  r  r  )r   r  r   r   ra  rG  r   r
  r,   r*   rw   r  r  r  r  rY  r   r!  rE  r  r  )r   r  r  r  r  s        r1   project_builder_discover_folderr  Y s   
 4(.BD88M*0b779Kl00=AT-.33 ;.779N~))#j.@.@.B*Cbff*LM!((*78#==~5578 B1		 0L @  BE B1%899Bs   =9Erz
  c                    | j                   j                         }|t        vry| }d}	 |dk7  rFt        j                  d      \  }}t        j                  |       	 t        | |       t        |      }	 ddlm}  ||      xs d}t        j                  dd|      j!                         }|dt"         |r	 t        j                  |       S S # t        $ r+ Y |r&	 t        j                  |       y# t        $ r Y yw xY wyw xY w# t        $ r d}Y w xY w# t        $ r Y S w xY w# |r&	 t        j                  |       w # t        $ r Y w w xY ww xY w)	zShort, 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.r7   Nr   r  r   r  z\s+r\   )r!  rE  r  r  r  r  r[  r  r   ry   r/   r   r"  r  rF  r\  r   _PB_CORR_SNIPPET_CHARS)rb   r  r  r  r  r  r   s          r1   _pb_doc_snippetr  Y s^    ++


C
,HG&="**&9KBHHRL w/=	;*84:C ffVS#&,,.**+		'"    		'"  
  	C	   		'"  s   3D% C 5D -D% 7D	DD% C1 1	C=<C=DD% DD% DD% 	D"!D"%E)D?>E?	EE
EEc                   	 g t               	dt        f	fd}t        d      }g }| rt        j	                  |       rd| vrt
        | z  j                         }t        |      j                  t        t
        j                               t        j                  z         rG|j                         r7t        |j                               D cg c]  }|j                         s| }}g }|rG|j                         r7t        |j                               D cg c]  }|j                         s| }}||z   }t        |d       D ]
  } ||        S c c}w c c}w )zCollect 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.r  c                     | j                   j                         }|vr4| j                         r#j                  |       j	                  |        y y y rT   )r9   rE  r   r  ri   )r  r)  r  r  s     r1   r  z#_pb_correlation_paths.<locals>._addY s>    ffllnd?qyy{HHSMLLO  +?r3   Fr  r  c                 B    | j                   j                         t        vS rT   )r!  rE  r  r  s    r1   r	  z'_pb_correlation_paths.<locals>.<lambda>Y s    ahhnn.>l.R r3   r  )r  r   r  ra  rG  r
  r,   r*   rw   r  r  r  r  rY  r   )
r  r  temp_dirfolder_pathsr  r  
temp_pathsrB  r  r  s
           @@r1   _pb_correlation_pathsr  Y s*    E5D  5)HL|))+64{;R${2;;=~))#j.@.@.B*Cbff*LM"))+'-n.D.D.F'GW!199;AWLWJHOO%!'(8(8(:!;KAqyy{aK
Kl*JJ$RS QL X Ls   E#EE.Er  c                 \   g d}t        | d      D ]  \  }}|j                  d       |j                  d| d|j                          t        |      }|r4|j                  d       |j                  |       |j                  d       y|j                  d        dj	                  |      S )	zBuild the Assistant seed prompt: a correlation instruction followed by a
    markdown list of each in-scope document with a short OCR snippet.)z}I'm setting up a project in our Project Builder and need help understanding how the following documents relate to each other.r7   z%For each document, help me determine:zR- which is the **submittal** package vs **shop drawings** vs other reference docs,zP- which **product line** each drawing maps to (e.g. by transaction/part number),z[- the overall **project identity** (customer/contractor, project name, transaction number).r7   z## Documents in scoper^   r7   z### z. z```u=   _(no readable text extracted — non-OCR file type or empty)_r0  )r  ri   r9   r  r  )r  rP  r  r  r!  s        r1   _pb_build_correlation_promptr  Y s    
E %# 	Z1RtA3b)*!!$LLLL!LLLLXY	Z 99Ur3   z/project-builder/correlate-docsc                      t        j                  d      xs i } | j                  d      xs dj                         }t	        |      dt
         }|st        d      dfS t        t        |      t        |      	      S )
u_  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.Tr  r  r7   NzINo documents in scope to correlate. Upload files or open a project first.r  r`  )r	  	doc_count)	r   r  r   r   r  _PB_CORR_MAX_DOCSr   r  rC  )r   r  r  s      r1   project_builder_correlate_docsr  Y st     4(.BD88M*0b779K!+./A0ABEhiknnn6u=UTTr3   z/project-builder-load-projectc                     t        j                  d      xs i } | j                  dd      j                         }|rt        j                  |      rd|v rt        ddi      dfS t        |z  }|j                         st        dd	i      d
fS |d g d}|dz  }|j                         r0	 t        |dd      5 }t        j                  |      |d<   d d d        d }|j                         D ]4  }|j                   j#                         dk(  s!|j                         s2|} n |r	 t        |dd      5 }t%        j&                  |      }|D ]  }	i }
t(        D ]F  }dddddddddddddddddddddd d!dd"d#df	D ]  }|d$   |k(  s||	v s|	|   |
|d%   <    H |	j                  t(        d&         xs dj                         |
d'<   |	j                  t(        d(         xs dj                         |
d)<   t+        d* |
j-                         D              s|d+   j/                  |
        	 d d d        t3        |d   t4              r7t3        |d   j                  d,      t6              rt9        |d   d,   |d+          t        |      S # 1 sw Y   xY w# t        j                  t        f$ r Y w xY w# 1 sw Y   xY w# t        t$        j0                  f$ r Y w xY w)-NTr  r  r7   r  r  r  r`  r  r  )r  r  r  r  r  r  r  r  r  rE  )r  r  rF  ro  rG  r	  rH  quantityrI  
salesPricerJ  r  rK  itemSizerL  r  rM  r  r  r  ry  r  r   r  c              3       K   | ]  }|  y wrT   rX   r,  r  s     r1   r/  z/project_builder_load_project.<locals>.<genexpr>?Z s     616s   r  rg  )r   r  r   r   ra  rG  r   r
  r  r   r?  r   r  r   r   rY  r!  rE  r  
DictReaderro  r2  r  ri   r]  r   r   r  r  )r   r  r  r>  r  r  r  r  rO  r  mappedr  sp_fields                r1   project_builder_load_projectr  
Z s    4(.BD((="-335Kl00=AT!678#=={*K!345s::($BGF55I	iw7 /2!%2v/
 H  " 88>>v%!))+H
 	hg6 :"+! :CF!0 D$,H=$?{S$36B$7zJ$9N$5XF$/zB$4IF$:-P
) DH  (%7ESL9<Ux~ 6DD" *-1C)D)J(Q(Q(SF9%.1ggob6I.J.Pb-W-W-YF>*6fmmo66z*11&9-::< &.$'Jvf~7I7I*7UW[,\ 
!;VJ=OP
 6?c/ /$$g. 		: :2 # 		sh   )J 7JJ %K 3AJ;J;B	J;J;1K JJ J87J8;K K K#"K#z/searchc                     t         j                  j                  d      xs dj                         } g }| r	t	        j                  di       }|j                  d      s|j                  d      rt
        j                         r| j                         }t        t
        j                         d d      D ]  }|j                         r|j                  j                  d	      r/||j                  j                         v r/|j                  |j                  |d
z  j                         d       t        |      dk\  s n t        | |d      S )Nr  r7   r'  r   r   c                 6    | j                         j                  S rT   rS  rT  s    r1   r	  zglobal_search.<locals>.<lambda>^Z s    AFFH4E4E r3   Tr
  r  r  r  rx  )r#  projects)r   rh  r   r   r   r
  r  rE  r  rY  r9   rw   ri   r   rC  r   )r  r  r  needler   s        r1   global_searchr  TZ s    
		#		$"++-AHKK#EE$%})=:CTCTCVWWYF""$*Et  ||~)>)>s)CUZZ--//OO %

%*-D%D$M$M$O%  x=B& QH566r3   z/var/www/html/togen/tutorialsz/togen/tutorials/manifest.jsonc                  L   t         dz  } | j                         r/	 t        j                  | j	                               }t        |      S g }t         j                         rt        t         j                               D ]  }|j                  j                         dv s |j                  |j                  j                  dd      j                  dd      j                         dt!        |j"                         dd        t        |      S # t        $ r t        g       cY S w xY w)	zJReturn the list of available tutorial videos from the tutorials directory.zmanifest.json).mp4z.webmz.movrc
  r\   r  z/togen/tutorials/r7   )re  rJ  rN  )_TUTORIALS_DIRr   r   r   r   r   ry   r  r  rY  r!  rE  ri   r  r  re  r   r9   )manifest_pathr   	tutorialsr  s       r1   tutorials_manifestr  oZ s     #_4M	::m5578D4=  I..01 	Axx~~#<<  VV^^C5==c3GMMO.uQVV}o>!#" 	 9  	2;	s   -D D#"D#c                 (   | j                         j                  d      }d|j                  d      v s|j                  d      ryt        |z  j	                         }t        |      j                  t        t        j	                                     sy|S )zBResolve a relative path under _FB_ROOT; return None if it escapes.r6   r  N)r   r  rw   r8  r,   r*   )r  cleanedr  s      r1   r6  r6  Z st    iik$Gw}}S!!W%7%7%<7"++-Hx=##C(8(8(:$;<Or3   z
/fb/browsec            	         t         j                  j                  dd      } | rt        |       nt        }||j                         st        ddi      dfS g }	 t        |j                         d       D ]  }|j                  j                  d      r|j                         }|j                  |j                  |j                         |j                         r|j                  nd	t        j                   |j"                        j%                         d
        	 t)        |j+                  t                    }|dk(  rd}t        ||d      S # t&        $ r t        ddi      dfcY S w xY w)z?List files and folders in the given directory under SHARED_DIR.rb   r7   r  Invalid directoryr`  c                 X    | j                          | j                  j                         fS rT   )r  r9   rE  r  s    r1   r	  zfb_browse.<locals>.<lambda>Z s    
NAFFLLN;[ r3   r  r  r   )r9   r  rd  modifiedPermission deniedr  )rb   r  )r   rh  r   r6  r8  r  r   r  rY  r9   rw   r  ri   r   r&  r  r  r  r  rr  r*   r{  )r  r  r  r	  r  rel_displays         r1   	fb_browser%  Z s;    ,,

62
&C#&]3HF~V]]_!456;;G<6>>+1[\ 		Dyy##C(99;DNN		++-(,A*88GQQS	 			 f((23KcKG<==  <!456;;<s   CE E)(E)c                 L    | j                  d      }t        |t              r|S g S )z=Return the list of watcher dicts stored in a user's settings.fb_watchersr  )r  watcherss     r1   _fb_watchers_from_settingsr)  Z s$    ||M*H!(D189r9r3   c                     | sy	 t        j                  t        |       j                         dd       j	                         S # t
        t        f$ r Y yw xY w)zParse 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.
    Nr   )r  r  r*   r   r   r   r   r^  s    r1   _fb_parse_expiryr+  Z sT     ++CJ,<,<,>s,CDIIKK	" s   <A AAwatcherc                     t        | j                  d            }|duxr% t        j                         j	                         |kD  S )zATrue when the watcher carries a termination date that has passed.
expires_atN)r+  r   r  r  r   )r,  exps     r1   _fb_watcher_expiredr0  Z s;    
7;;|4
5Cd?@~11388:S@@r3   rQ  widc                 <    t          dt        t        |              S )zG1-click link that opens Togen and prompts to remove a specific watcher.z/?fb_remove_watcher=)_FB_APP_URLr   r*   r1  s    r1   _fb_watcher_remove_linkr5  Z s    ].uSX.?@@r3   c                  T    t        j                  d      xs i j                  dd      S Nr'  r  r7   r  rX   r3   r1   _fb_current_emailr8  Z s#    KK%2**7B77r3   c                    i }| j                         s|S 	 | j                  d      D ]~  }|j                         r|j                  j	                  d      r/	 |j                         }t        |j                         d|j                   |t        |j                  |             <    	 |S # t        $ r Y w xY w# t        $ r Y |S w xY w)zBuild 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.
    r1  r  rS  )r  rz  r   r9   rw   r  r   r   r  r&  r*   r{  )r  snapfpathr{	  s       r1   _fb_snapshot_folderr<  Z s     D==?
\\#& 	VE==?ejj&;&;C&@ZZ\ 7:"++6F5Gq3UDU&&v./0	V K    Ks7   A B: B+'AB: +	B74B: 6B77B: :	CCc                      t         j                         si S 	 t        j                  t         j	                               } t        | t              r| S i S # t        j                  t        f$ r i cY S w xY wrT   )	FB_WATCHER_STATE_FILEr   r   r   r   r   r   r   r   r  s    r1   _fb_load_watcher_stater?  Z sc     ((*	zz/99;<!$-t525  '* 	   8A A A10A1c                     	 t         j                  t        j                  | d             y # t        $ r" t
        j                  j                  d       Y y w xY w)Nr   r   z'Failed to persist fb_watcher_state.json)r>  r   r   r   r   rm   rn   r  ro  s    r1   _fb_save_watcher_staterC  [ sE    H((E!)DE H

FGHr  z/fb/watchersc                  R    t               } t        t        t        |                   S )z*Return the current user's folder watchers.)r(  )r8  r   r)  r  r]  s    r1   fb_watchers_listrE  [ s#     E67J57QRSSr3   c            	      "  	 t               } t        j                  d      xs i }|j                  d      xs dj	                         j	                  d      		st        d      dfS t        	      }||j                         st        d
      dfS |j                  d      xs dj	                         }|rt        |      t        d      dfS t        |       }t        t        |            }t        	fd|D              rt        d      dfS t        j                         j                  }|j!                  |	t#        	      j$                  xs 	dt'        j(                         j+                         |xs d	d       ||d<   	 t-        | |       t1               }| 	t3        |      t'        j(                         j+                         d||<   t5        |       t        d|      S # t.        $ r}t        d|       dfcY d	}~S d	}~ww xY w)zECreate a folder watcher for the current user on a folder in the tree.Tr  rb   r7   r6   zA folder path is required.r  r`  NzFolder not found.r.  Invalid expiration date.c              3   F   K   | ]  }|j                  d       k(    yw)rb   Nr  )r,  r  r  s     r1   r/  z"fb_watchers_add.<locals>.<genexpr>)[ s     
2A155=C
2   !z%That folder is already being watched.r9  )r  rb   r   r~  r  r.  r'  Failed to save watcher: r  ownerrb   snapshotlast_checkedrU  r(  )r8  r   r  r   r   r   r6  r  r+  r  r  r)  r2  rW  rX  rY  ri   r   r9   r  r  r  r  r   r?  r<  rC  )
r  r  r  expires_rawr  r(  r1  rl  ro  r  s
            @r1   fb_watchers_addrQ  [ s    E4(.BD88F!r
(
(
*
0
0
5C9:C??3F~V]]_0136688L)/R668K'4<78#=="5)H.x89H

2
22DEsJJ
**,

COOc&3$((*446!)T  'H]DE8,
 #$E'/&**,668	E#J 5!dX..  D7u=>CCDs   	G* *	H3H	H	Hz/fb/watchers/<wid>/togglec                    t               }t        j                  d      xs i }t        |j	                  d            }t        |      }t        t        |            }d}|D ]  }|j	                  d      | k(  s||d<   d} n |st        d      dfS ||d	<   	 t        ||       t        d|      S # t        $ r}t        d
|       dfcY d}~S d}~ww xY w)zEEnable/disable email notifications for a watcher (keeps the watcher).Tr  r~  Fr  Watcher not found.r  r  r'  rJ  r  NrO  )r8  r   r  r   r   r  r  r)  r   r  r   )	r1  r  r  r~  r  r(  r
  r  rl  s	            r1   fb_watchers_togglerT  G[ s     E4(.BD488I&'G"5)H.x89HE 55;#"AiLE	
 12C77&H]DE8, dX..  D7u=>CCDs   B3 3	C<CCCz/fb/watchers/<wid>PUTc                    t               }t        j                  d      xs i }|j                  d      xs dj	                         }|rt        |      t        d      dfS t        |      }t        t        |            }d	}|D ]#  }|j                  d
      | k(  s|xs d|d<   d} n |st        d      dfS ||d<   	 t        ||       t        d|      S # t        $ r}t        d|       dfcY d}~S d}~ww xY w)z=Edit a watcher's termination date (set, change, or clear it).Tr  r.  r7   NrG  r  r`  Fr  rS  r  r'  rJ  r  rO  )r8  r   r  r   r   r+  r   r  r  r)  r  r   )	r1  r  r  rP  r  r(  r
  r  rl  s	            r1   fb_watchers_updaterW  `[ s    E4(.BD88L)/R668K'4<78#=="5)H.x89HE 55;#)1TAlOE	
 12C77&H]DE8, dX..  D7u=>CCDs   C 	C?$C:4C?:C?c                 r   t               }t        |      }t        |      D cg c]  }|j                  d      | k7  s| }}||d<   	 t	        ||       t               }| |v r|j                  | d       t        |       t        d|      S c c}w # t
        $ r}t        d|       dfcY d}~S d}~ww xY w)	z4Delete a watcher automation and its stored snapshot.r  r'  zFailed to delete watcher: r  r  NTrO  )
r8  r  r)  r   r  r   r   r?  r  rC  )r1  r  r  r  r(  rl  ro  s          r1   fb_watchers_deleterY  {[ s     E"5)H5h?Va155;RUCUVHV&H]FE8, #$E
e|		#tu%dX.. W  F9#?@#EEFs(   BB	B 	B6B1+B61B6z
/fb/uploadc            
      4   t         j                  j                  dd      } | rt        |       nt        }||j                         st        ddi      dfS t         j                  j                  d      }|st        ddi      dfS g }|D ]  }|j                  st        |j                        }|s)||z  }|j                         j                  d	      r|d
t        j                         j                   d| z  }	 |j!                  t#        |             t%        j&                  t#        |            5 }|j)                         D ]G  }	||	z  j+                         }
t#        |
      j-                  t#        |j+                                     rHI |j/                  t#        |             ddd       |j1                  | d       |j7                         sQ|j9                          c|j!                  t#        |             |j1                  |        |rit;        d      r^t=        ddt?        j                  d      xs i j                  d      dtA        |       d| xs d | xs dtA        |      | xs dd       t        d|i      S # 1 sw Y   xY w# t$        j2                  $ r@ |j5                  d       |j!                  t#        |             |j1                  |       Y (w xY w# |j7                         r|j9                          w w xY w)zQUpload one or more files to the given directory. Extract ZIP files automatically.rb   r7   Nr  r   r`  r  r  r  _tmp_r  z (extracted)r   r  rH  r'  r9   z	Uploaded z file(s) to r6   )r  r  r	  uploaded)!r   rg  r   r6  r8  r  r   r  r
  r  r   rE  r|  rW  rX  rY  r  r*   r  r  r  r,   rw   r  ri   
BadZipFiler  r  r/   r	  r	  r   rC  )r  r  r  uploaded_namesr  r  r  tmp_pathr  r  member_dests              r1   	fb_uploadra  [ s    ,,

62
&C#&]3HF~V]]_!456;;MM!!'*E!456;;N -zz#AJJ/		!??%%f-%

(8(8'99+ FFH&s8}%__S]3 /r"$++- %'-&?&?&A";/::3v~~?O;PQ$% MM#f+./ %%<&@A ??$OO%FF3t9!!),9-< 5nEH;;v&,"11&9N 34LM
s 0D	
 J/005/ / %% 1q	s4y!%%i01
 ??$OO% %sD   ?8J7AJJ,JJ	JAK1-K40K11K44#Lz/fb/downloadc            
         t         j                  j                  dd      } | st        ddi      dfS t	        |       }||j                         st        ddi      dfS |j                         rht        d	      r<t        d	d
t        j                  d      xs i j                  d      d|  | ddi       t        t        |      d|j                        S |j                         rb|j                         t        j                         k(  rt        ddi      dfS t!        j"                         }t%        j&                  |dt$        j(                        5 }|j+                  d      D ]b  }|j                         s|j                  j-                  d      r0|j/                  t        |      t        |j1                  |                   d 	 ddd       |j3                  d       t        d	      r=t        d	d
t        j                  d      xs i j                  d      d|  d| ddi       t        |d|j                   dd      S t        ddi      dfS # 1 sw Y   xY w)z9Download a file or folder. Folders are zipped on the fly.rb   r7   r  No path specifiedr`  Nr  r  r  downloadr'  r9   zDownloaded rT  r  r	  Trn	  zCannot download root directoryr  r1  r  r   zDownloaded folder z (zip)r  r  r/  r0  zUnknown target type)r   rh  r   r   r6  r  r   r	  r	  r   r   r*   r9   r  r,   r8  r>  rJ  r  r  r1  rz  rw   r  r{  r  )r  r  r2  r  r;  s        r1   fb_downloadre  [ s"    ,,

62
&C!456;;3F~V]]_!123S88~~&~6
{{6*0b55f=%cU+' VDTT}}>>x//11G%EFGLLjjl__S#w';';< 	Ic* I==?5::+@+@+EHHSZU->->v-F)GHI	I 	&~6
{{6*0b55f=,SE8) D6;;-t@T"35 	5 G234c99!	I 	Is   %I:I5II!z
/fb/deletec                     t        j                  d      xs i } | j                  dd      }|st        ddi      dfS t	        |      }||j                         st        ddi      d	fS |j                         t        j                         k(  rt        dd
i      dfS 	 |j                         rt        j                  t        |             n|j                          t        d      r9t        ddt!        j                  d      xs i j                  d      d| |       t        d|i      S # t        $ r t        ddi      dfcY S w xY w)zDelete a file or folder.Tr  rb   r7   r  rc  r`  r  r  zCannot delete root directoryr#  r  r  r  r'  r9   Deleted r		  r
	  r  r	  )r   r  r   r   r6  r  r,   r8  r  r  r:  r*   r/   rr  r	  r	  r   )r   r  r  s      r1   	fb_deleteri  [ s=    4(.BD
((62
C!456;;3F~V]]_!123S88~~8++--!?@A3FF<==?MM#f+&MMO #>2H;;v&,"11&9se$		
 Is#$$  <!456;;<s   ?D/ /E
	E
z
/fb/renamec            	      h   t        j                  d      xs i } | j                  dd      }| j                  dd      j                         }|r|st	        ddi      dfS d	|v sd
|v sd|v rt	        ddi      dfS t        |      }||j                         st	        ddi      dfS |j                         t        j                         k(  rt	        ddi      dfS |j                  t        |      z  }|j                         rt	        ddi      dfS 	 |j                  |       t        d      rPt        ddt        j                  d      xs i j                  d      d|j                    d|j                    |       t	        d|j                   i      S # t        $ r t	        ddi      dfcY S w xY w)zRename a file or folder.Tr  rb   r7   r@  r  zMissing path or new_namer`  r6   r  r  zInvalid new namer  r  zCannot rename root directoryz.A file or folder with that name already existsr9  r#  r  r  renamer'  r9   zRenamed     → rh  renamed)r   r  r   r   r   r6  r  r,   r8  r  r   rk  rr  r	  r	  r   r9   )r   r  r@  r  new_paths        r1   	fb_renamero  \ s    4(.BD
((62
Cxx
B'--/Hh!;<=sBB
h$(*dh.>!345s::3F~V]]_!123S88~~8++--!?@A3FF}}x88H!QRSUXXX<h #>2H;;v&,"11&9v{{m5@		
 Ix}}-..  <!456;;<s   F F10F1z/fb/previewc                      t         j                  j                  dd      } | syt        |       }||j	                         syt        t        |      |j                        S )z Serve a file inline for preview.rb   r7   )zMissing pathr`  r  r*  )r   rh  r   r6  r   r   r*   r9   )r  r  s     r1   
fb_previewrq  <\ sP     ,,

62
&C"3F~V^^-$S[<<r3   c                  Z   t               } t        j                         j                  d      }| D cg c]-  }|j	                  d      dk(  r|j	                  dd      |k  r|/ }}|s| S |D ]  }d|d<   	 t        |        t               }|rt        r|D ]  }t        ||        | S c c}w )u]  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.
    r  rd   	scheduled
publish_atr7   	published)	r  r  r  r  r   r  r  r   _send_news_email)r  r
  r  duer  subscriberss         r1   _promote_scheduled_postsry  P\ s     E##%../CDG UeeHo,|R1HG1S  UC U %$X%U#%K 	0DT;/	0LUs   2B(z/news/postsc                      t               } t        j                  di       j                  d      s$| D cg c]  }|j                  d      dk7  s| } }t        |       S c c}w )z@Return news posts, newest first. Scheduled posts are admin-only.r'  r   rd   rs  r  )ry  r   r   r   )r  r  s     r1   	news_listr{  j\ sX     %&E;;vr"&&}5!DqQUU8_%CDD Es   A A c                  ,   t        j                  d      xs i } | j                  dd      j                         }| j                  dd      j                         }| j                  d      xs dj                         }|r|st	        d      d	fS t        j                         j                  d
      }d}|rr	 t        j                  |j                  dd            }|j                  /|j                  t        j                        j                  d      }|j                  d
      }t        |      xr ||kD  }t!        j                  di       }t#        t%        j&                               |||j                  dd      |j                  dd      |r|n||rdndd}	|r||	d<   t)               }
|
j+                  d|	       t-        |
       |st/               }|rt0        rt3        |	|       t	        d|	      S # t        $ r t	        d      d	fcY S w xY w)zHCreate a news post, optionally scheduled for future publish. Admin only.Tr  re  r7   r  rt  Title and body are required.r  r`  r  r  r  Invalid publish date/time.Nr  r'  r9   r  r  rs  ru  )r  re  r  r  author_emailr   rd   r   rU  r  )r   r  r   r   r   r  r  r  r  r  r   r  r  r  r  r   r   r*   rW  rX  r  insertr  r  r   rv  )r  re  r  publish_at_rawr
  rt  dtrs  r'  r  r  rx  s               r1   news_creater  t\ s    4(.BDHHWb!'')Ehhvr"((*Ghh|,299;N;<cAA##%../CDGJ	D--n.D.DS(.STB 99 |//0888EB[[!56
Z 9Z'%9I;;vr"D$**,((69-"-'
W!*+D '\E	LLDU')?T;/d&&=  	D!=>CC	Ds   8%G9 9HHz/news/posts/<post_id>c                     t        j                  d      xs i }|j                  dd      j                         }|j                  dd      j                         }|j                  d      xs dj                         }|r|st	        d      d	fS t               }t         fd
|D        d      }|t	        d      dfS |j                  d      dk7  rt	        d      d	fS t        j                  di       }|j                  d      xs dj                         }|j                  d      xs dj                         |k7  rt	        d      dfS t        j                         j                  d      }	|st	        d      d	fS 	 t        j                  |j                  dd            }
|
j                  /|
j!                  t"        j$                        j                  d      }
|
j                  d      }||	k  rt	        d      d	fS ||d<   ||d<   ||d<   ||d<   t'        |       t	        d|      S # t        $ r t	        d      d	fcY S w xY w)a  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.
    Tr  re  r7   r  rt  r}  r  r`  c              3   L   K   | ]  }|j                  d       k(  s|  ywr	  r  )r,  r  post_ids     r1   r/  znews_update.<locals>.<genexpr>\ s      <qQUU4[G%;<   $$NzPost not found.r  rd   rs  z#Only scheduled posts can be edited.r'  r  r  z,Only the original poster can edit this post.r  r  z*A scheduled publish date/time is required.r  r  r~  r  z"Pick a publish time in the future.r   r  )r   r  r   r   r   r  r  r   rE  r  r  r  r  r  r   r  r  r  r  r  )r  r  re  r  r  r  r  r'  rp  r
  r  rt  s   `           r1   news_updater  \ s1    4(.BDHHWb!'')Ehhvr"((*Ghh|,299;N;<cAAE<E<dCD|./44xx[(BCSHH;;vr"D((7#)r002J &B--/:=KLcQQ##%../CDGIJCOO@)).*@*@h*OP 
yy]]<++,44D4A12JWABCGGDMDL#DDLUd&&  @9:C??@s   %H3 3IIc                     t               }|D cg c]  }|j                  d      | k7  s| }}t        |       t        d      S c c}w )zDelete a news post. Admin only.r  TrY  )r  r   r  r   )r  r  r  s      r1   news_deleter  \ sF     E81t!7Q8E8Ud 9s
   AAc                 f    | j                  dd      j                  dd      j                  dd      S )z>Escape HTML special chars so user content can't inject markup.&&amp;<&lt;>&gt;r  r%  s    r1   
_md_escaper  \ s-    LLg&f%f%'r3   c                 p   dfd}| j                  dd      j                  dd      j                  d      }g g g dfd}fd}|D ]<  }|j                         }|s |         |        %t        j                  d	|      }|ri |         |        t        |j                  d
            }dddd}	j                  dj                  ||	|    ||j                  d                         t        j                  d|      }
t        j                  d|      }|
s|rI |        |
rdnd}r|k7  r |        |j                   ||
xs |j                  d
                    |        j                   ||             ?  |         |        dj                        xs dS )a  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.
    z(color:#0077A8;text-decoration:underline;c                 h   t        |       } g fdfd}t        j                  d||       } fd}t        j                  d||       } t        j                  dd|       } t        j                  dd	|       } t              D ]&  \  }}| j	                  d
j                  |      |      } ( | S )Nc                 `    j                  |        dj                  t              dz
        S )N {} r^   )ri   r	  rC  )tag_htmlr  s    r1   _stashz6_render_news_markdown.<locals>._inline.<locals>._stash\ s,    )&&s<'81'<==r3   c           	      r     dj                  | j                  d      | j                  d                  S )N<a href="{}" style="{}">{}</a>r   r^   )r	  rb  )r  r  
link_styles    r1   _md_linkz8_render_news_markdown.<locals>._inline.<locals>._md_link] s5    :AA
J
4 5 5r3   z"\[([^\]]+)\]\((https?://[^\s)]+)\)c                 X    | j                  d      } dj                  ||            S )Nr^   r  )rb  r	  )r  rJ  r  r  s     r1   
_bare_linkz:_render_news_markdown.<locals>._inline.<locals>._bare_link	] s2    ''!*C:AAZ& ' 'r3   z(?<![\"'>=])(https?://[^\s<]+)z\*\*([^*]+)\*\*z<strong>\1</strong>z(?<!\*)\*([^*\s][^*]*?)\*(?!\*)z<em>\1</em>r  )r  rF  r\  r  r  r	  )r  r  r  r  	html_fragr  r  r  s        @@r1   _inlinez&_render_news_markdown.<locals>._inline\ s    qM	>
	5 FF8(AF	' FF4j!DFF%'=qAFF5~qI'5 	?NC		,--c2I>A	?r3   z
r0  Nc                  x     r7j                  ddj                         z   dz           j                          y y )Nz,<p style="margin:0 0 14px;line-height:1.6;">z<br></p>)ri   r  rR  )parar0
  s   r1   
flush_paraz)_render_news_markdown.<locals>.flush_para] s<    LLG!;;t,-/56 7JJL r3   c                      rIdj                  d D              } j                  dj                  |              j                          d y )Nr7   c              3   >   K   | ]  }d j                  |        yw)z3<li style="margin:0 0 6px;line-height:1.6;">{}</li>Nr	  r,  r|	  s     r1   r/  z<_render_news_markdown.<locals>.flush_list.<locals>.<genexpr>%] s$      & FLLRP&s   z9<{0} style="margin:0 0 14px;padding-left:22px;">{1}</{0}>)r  ri   r	  rR  )r   
list_itemslist_tagr0
  s    r1   
flush_listz)_render_news_markdown.<locals>.flush_list"] sO    GG &$& &E LLT &513r3   z^(#{1,3})\s+(.*)$r^   22px18px16px)r^   r   rP   zX<h{0} style="margin:18px 0 10px;font-size:{1};color:#1f2937;font-weight:700;">{2}</h{0}>r   z^[-*]\s+(.*)$z^\d+\.\s+(.*)$ulolr7   z<p style="margin:0;">&nbsp;</p>)
r  r  r   rF  rG  rC  rb  ri   r	  r  )r&  r  rP  r  r  rs   strippedheadingr
  sizesr  r  wantr  r  r  r  r0
  s                @@@@@r1   r  r  \ s    <J8 LL&..tT:@@FEEDJH	  '::<LL((/:LLa()E6f5ELL==CV5<q1A)B>DE XX&1XX'2L44DH,HgrxR&6&6q&9:;GH%&?'B LL775>>>>r3   c                 .    dj                  | ||||      S )zAWrap content in a branded, Outlook-safe (table-based) HTML email.a8  <!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;"><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><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><tr><td style="padding:0 28px 24px;">{footer}</td></tr></table></td></tr></table></body></html>)re  r  r  ctafooterr  re  	meta_html	body_htmlcta_htmlfooter_htmls        r1   _news_email_shellr  S] s*     	3B f5yyh  !E#!r3   ab  <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>c                 ^      fd}t        j                  |d      j                          y)zNSend a news post to all subscribers via Microsoft Graph API (fire-and-forget).c            
      x   	 dj                  t        j                  dd            t        j                  dd      d d             } d}t        t        d         | t	        j                  d	d            |t
        
      }t        j                  t              dd    |       y # t        $ r Y y w xY w)NzR<p style="margin:0;color:#6b7280;font-size:13px;">Posted by {author} on {date}</p>r  r7   r   r   )r  r   a  <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>re  r  r  zTogen News: )
r	  r  r   r  r  _NEWS_UNSUBSCRIBE_FOOTERr=  r  r  ry   )r  r  r  r  rx  s      r1   _do_sendz"_send_news_email.<locals>._do_send] s    	3396%dhhx&<=#DHHVR$8"$=> 4: 4@   ( g/#/0DE!4G ))[!\$w-#A7L 		s   B)B- -	B98B9Tr  Nr  r  rq   )r  rx  r  s   `` r1   rv  rv  ] s#    8 HT288:r3   c                 Z      fd}t        j                  |d      j                          y)z9Send a subscription confirmation email (fire-and-forget).c                  D   	 t        j                  t        t        t              } | j                  dg      }d|vry d}d}t        dd||t        	      }d
d|dddiigddd}t        j                  dt         dd|d    dd|d       y # t        $ r Y y w xY w)Nr  r  r  r  z<p style="margin:0 0 14px;line-height:1.6;">You will now receive email notifications when new posts are published on Togen.</p>a  <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>z You're subscribed to Togen News!r7   r  z"Togen News: Subscription ConfirmedHTMLr  r  r  r  r  r  r  r  r  r  r  r  r  )r  r  r  r  r  r  r  r  r  r  r   ry   )r  r  r  r  r  r  r  s         r1   r  z._send_subscribe_confirmation.<locals>._do_send] s    3	;;);z[J%>>>? ? AL\1Q 
  (8#!4G  D'-#*
 ()U);<%	 $+J 9/9J)T'.|N/K.L%M$6    		s   ;B AB 	BBTr  Nr  )r  r  s   ` r1   _send_subscribe_confirmationr  ] s$    4l HT288:r3   z/news/subscribersc                      t        j                  di       } | j                  dd      j                         }t               }t	        ||D cg c]  }|j                          c}v       S c c}w )z(Check if the current user is subscribed.r'  r  r7   )
subscribed)r   r   rE  r  r   )r'  r  r  r  s       r1   news_subscribers_getr  ] sY     ;;vr"DHHWb!'')EDe4'@a	'@@AA'@s   A+
z/news/subscribec                     t        j                  d      xs i } | j                  dd      j                         j	                         }|rd|vrt        d      dfS t               }|D cg c]  }|j	                          }}||v}|r|j                  |       t        |       |rt        rt        |       t        dd	      S c c}w )
z)Subscribe an email to news notifications.Tr  r  r7   r  z Valid email address is required.r  r`  rU  r  )r   r  r   r   rE  r   r  ri   r  r   r  )r  r  r  r  r  is_news         r1   news_subscriber  ] s     4(.BDHHWb!'')//1ECu$?@#EED#'(a	(H(("FE$/$U+dt,, )s   +Cz/news/unsubscribec                  &   t        j                  d      xs i } | j                  dd      j                         j	                         }t               }|D cg c]  }|j	                         |k7  s| }}t        |       t        dd      S c c}w )z-Unsubscribe an email from news notifications.Tr  r  r7   Fr  )r   r  r   r   rE  r  r  r   )r  r  r  r  s       r1   news_unsubscriber  ^ s}     4(.BDHHWb!'')//1ED2!qwwyE1A2D2ddu-- 3s   B0Bz/togen-admin/public-versionc                  *    t        t                     S )z)Return the current public version string.)r  )r   r!  rX   r3   r1   togen_admin_public_version_getr  ^ s     /122r3   c                  0   t        j                  d      xs i } | j                  dd      j                         }|st	        d      dfS 	 t
        j                  |dz          t	        d|      S # t        $ r}t	        d	|       d
fcY d}~S d}~ww xY w)z-Update the public version string. Admin only.Tr  r  r7   zVersion text is required.r  r`  r0  zFailed to save version: r  N)rU  r  )r   r  r   r   r   r   r   r   )r  r  rl  s      r1   togen_admin_public_version_postr  !^ s     4(.BDhhy"%++-G893>>D&&w~6 dG,,  D7u=>CCDs   A1 1	B:B
BBz/togen-admin/aboutc                  *    t        t                     S )zDReturn the editable About popup text (Markdown). Any logged-in user.)r  )r   r'  rX   r3   r1   togen_admin_about_getr  5^ s     .011r3   c                     t        j                  d      xs i } | j                  d      xs dj                         }|st	        d      dfS 	 t        |       t	        d|      S # t        $ r}t	        d|       d	fcY d
}~S d
}~ww xY w)z(Update the About popup text. Admin only.Tr  r  r7   zAbout text is required.r  r`  zFailed to save About text: r  N)rU  r  )r   r  r   r   r   r)  r   )r  r  rl  s      r1   togen_admin_about_postr  <^ s     4(.BDxx	"(b//1G67<<GG$ dG,,  G:3%@A3FFGs   A' '	B0B BBz/togen-admin/site-textc                  (    t        t                     S )z=Return the editable header title/tagline. Any logged-in user.)r   r0  rX   r3   r1   togen_admin_site_text_getr  K^ s     ?$%%r3   c                     t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         }|st	        d      dfS |st	        d	      dfS 	 t        ||       t	        d||      S # t        $ r}t	        d
|       dfcY d}~S d}~ww xY w)z,Update the header title/tagline. Admin only.Tr  re  r7   r+  zTitle is required.r  r`  zTagline is required.zFailed to save title: r  N)rU  re  r+  )r   r  r   r   r   r2  r   )r  re  r+  rl  s       r1   togen_admin_site_text_postr  R^ s     4(.BDXXg$"++-Exx	"(b//1G12C7734c99Bw' d%99  B5cU;<cAABs   B 	C %B;5C ;C z /togen-admin/sync-service/statusc                  *    t        t                     S )NrA  )r   r?  rX   r3   r1   togen_admin_sync_service_statusr  l^ s     5788r3   z/togen-admin/sync-service/stopc                      	 t        j                  dddt        gddd       t	        d       t               } t        d|       S # t        $ r Y -w xY w)	Nsudor4  r  Tr  r  r6  r7  FrU  r8  r9  r:  r;  ry   rB  r?  r   rA  s    r1   togen_admin_sync_service_stopr  r^ sY    [&,7tR	
 u%(*GdG,,	     !A 	AAz/togen-admin/sync-service/startc                      	 t        j                  dddt        gddd       t	        d       t               } t        d|       S # t        $ r Y -w xY w)Nr  r4  rq   Tr  r  r  r  rA  s    r1   togen_admin_sync_service_startr  ^ sY    ['<8tR	
 t$(*GdG,,	  r  z/togen-admin/backup/statusc                  (    t        t                     S rT   )r   ry  rX   r3   r1   togen_admin_backup_statusr  ^ s     %'((r3   z/togen-admin/backup/runc                  Z   dd l } 	 t               }|j                  d| j                         t        z
  f      j	                         }|j                          |rt        dd      dfS 	 t        j                  t        d      j                          t        dd	
      S # t        $ r Y Aw xY w)Nr   z?SELECT id FROM runs WHERE status = 'running' AND started_at > ?FzBackup already in progressrU  r  r9  Tr  r8  )rU  ro  )rU   rP  rL  rt  ru  r[  r   ry   r  r  rm  rq   )re  rO  rv  s      r1   togen_admin_backup_runr  ^ s     
|MZZ\113
 (* 	 	

e+GH#MM  .t<BBDd),,  s   A B 	B*)B*z/togen-admin/backup/historyc                  8   t        t        t        j                  j	                  dd            d      } t        t        j                  j	                  dd            }	 t               }|j                  d| dz   |f      j                         }|j                          t        |      | kD  }g }|d |  D ]H  }t        |      }t        |d         |d<   t        |d	   xs d      xs d |d	<   |j                  |       J t        |||
      S # t        $ r g }Y {w xY w)Nr   rx  rd  offsetr   zuSELECT id, started_at, finished_at, status, exit_code, duration_s FROM runs ORDER BY started_at DESC LIMIT ? OFFSET ?r^   rq  rr  )r  has_morer  )rL  r   r   rh  r   rP  rL  r  r[  ry   rC  r   ri   r   )r   r  rO  rn  r  r  r  rn  s           r1   togen_admin_backup_historyr  ^ s    GLL$$Wb12C8E!!(A./F	|||CQY
 (*	 	
 	

 4y5 HD&5\ Gao.,q/415=-A	
 x??  s   ?D DDz /togen-admin/backup/log/<run_id>c                 "   	 t               }|j                  d| f      j                         }|j                          |st	        d      dfS t	        |d   xs d|d         S # t
        $ r!}t	        t        |            d	fcY d }~S d }~ww xY w)
Nz.SELECT log_text, status FROM runs WHERE id = ?rK  r  r  log_textr7   rd   )rk  rd   r  )rP  rL  ru  r[  r   ry   r*   )rf  rO  r  rl  s       r1   togen_admin_backup_logr  ^ s    
,|ll<vi

(* 	 	

-s223z?0bXGG ,SX&++,s$   A
A$ A$ $	B-B	B	Bc           	      t   t        |       }t        | j                  d            | j                  dd      | j                  dd      | j                  d      xs g d| j                  d      xs d	|r-|j                  t        j
                        j                         nd
|r|j                  d      dS d
dS )zHSerialize a schedule for the client, adding a computed next-run instant.r~  r  r  rU   rz  r  r{  r   r|  Nz%a %b %-d, %Y at %-I:%M %p %Z)r~  r  rU   r  r   r_  next_run_local)r  r   r   r  r  r  r  r  )r  r  s     r1   _backup_schedule_payloadr  ^ s    
#E
*C		),-YY{G4		&'*		&!:%:IIj)>->DGCNN<#3#34>>@TKN#,,'FG  UY r3   z/togen-admin/backup/schedulec                  <    t        t        t                           S )Nr  )r   r  r  rX   r3   r1   togen_admin_backup_schedule_getr  ^ s     45J5LMNNr3   c            	      B   ddl m}  t        j                  d      xs i }t	        |j                  d            }|s0t               }d|d<   t        |       t        dt        |            S t        |j                  dd	            j                         }|d
vrt        d      dfS t        |j                  dd            j                         }	 |j                  d      dd D cg c]  }t        |       c}\  }}d|cxk  rdk  rn J d|cxk  rdk  sJ  J 	 |dd|d}|j                  d      }	|dk(  rK	 t!        |	D 
ch c]%  }
dt        |
      cxk  rdk  sn nt        |
      ' c}
      }	|	st        d      dfS g d}	t        |j                  d      xs d      j                         xs d}	  | |      }d|||	|dd}t#        j$                  |      }|j'                  ||dd       }|d	k(  xs |j)                         |	v }|r||k\  r|j+                  d!      |d"<   t        |       t        dt        |            S c c}w # t        $ r t        d      dfcY S w xY wc c}
w # t        $ r g }	Y 
w xY w# t        $ r d} | |      }Y w xY w)#a  Save the recurring-backup schedule (DVI-966).

    Body: {enabled, frequency: daily|weekly, time: "HH:MM", days: [0-6],
    timezone: IANA name}. The time is interpreted in the setter's timezone
    (auto-detected in the browser), defaulting to America/Chicago (CST/CDT).
    r   r  Tr  r~  Fr  r  r  )r  r  z&Frequency must be 'daily' or 'weekly'.r  r`  rU   rz  rS  Nr   r   r   z%Time must be in HH:MM 24-hour format.rQ  r  r  r   z$Select at least one day of the week.r{  r   r7   r|  r}  r  r  r  )r  r  r   r  r   r   r  r  r   r  r*   rE  r   r  r   ry   r  r  r  r  r  r  )r  r  r~  r  r  time_strr  r  r  r  rn  tznamer  	now_localr  fires_todays                   r1    togen_admin_backup_schedule_postr   ^ s    "4(.BD488I&'G%' ie$$)A%)HIIDHH['2399;I++EFKK488FG,-335HK"*.."5bq"9:Q#a&:BB||,,R",,,, S2c(#H88FDH	4Da1A3C!3C3q6DED !GH#MM$*%+,224I8IFf E ""2&I''R1RS'TOw&E)*;*;*=*EKyO3#,#5#5j#A % d%=e%DEEU ; KDEsJJK E 	D	  "fs`   I (I:%I  	I6 	I1#I13I6 ?J I I.-I.1I6 6JJJJz/togen-admin/shared-url-prefixc                  4    t        t               t              S )z;Return the current shared URL prefix and available options.)r  r  )r   r  SHARED_URL_PREFIX_OPTIONSrX   r3   r1   togen_admin_url_prefix_getr  7_ s     &() r3   c                  $   t        j                  d      xs i } | j                  d      xs dj                         j	                  d      }t
        D cg c]  }|d   	 }}||vrt        d      d	fS t        |       t        d|
      S c c}w )z)Update the shared URL prefix. Admin only.Tr  r  r7   r6   r  zInvalid URL prefix selected.r  r`  )rU  r  )r   r  r   r   r8   r  r   r  )r  r  optvalid_valuess       r1   togen_admin_url_prefix_postr  A_ s     4(.BDhhx &B--/66s;F,EFSCLFLF\!;<cAAF#d6**	 Gs   Bz/togen-admin/brandingc                      t               } t        | d   | d   | d   | d   | d   | d   t        j                         t        j                         t
        j                         	      S )zCReturn current branding settings plus which corporate assets exist.re  rf  rg  rh  ri  rj  )	re  rf  rg  rh  ri  rj  has_logohas_bg_lighthas_bg_dark)rd  r   r  r   CORPORATE_BG_LIGHT_FILECORPORATE_BG_DARK_FILErf  s    r1   togen_admin_branding_getr  S_ sp     '(H;'#!"45 !23n-"#67$,,.,446*224
 
r3   c                     t        j                  d      xs i } t               }d| v r!| d   t        vrt	        d      dfS | d   |d<   d| v r!| d   t
        vrt	        d      dfS | d   |d<   d	D ],  }|| v st        | |         }|t	        d
      dfc S |||<   . d| v r#t        | d         }|t	        d      dfS ||d<   d| v r#t        | d         }|t	        d      dfS ||d<   t        |       t	        dddi|S )z5Update branding logo_mode and/or bg_mode. Admin only.Tr  re  zInvalid logo mode.r  r`  rf  r  r]  r  ri  r  rj  r  rU  rX   )
r   r  rd  r`  r   ra  r  r  r  rg  )r  r  r)  rc  ri  rj  s         r1   togen_admin_branding_postr  e_ s`    4(.BD&(Hd$77!56;; $[ 1D	?"33!;<cAA"9o6 $$;(c3G%UVX[[[#HSM$ +D,@A!KLcQQ#/ d"5d;N6OP$!TUWZZZ(9$%H%'d'h''r3   z/togen-admin/branding/logo.svgc                  b    t         j                         st        d       t        t         d      S )zKServe the corporate logo SVG. No auth (referenced by <img>); 404 if absent.r  rp  r   )r  r   r   r   rX   r3   r1   togen_admin_branding_logor  _ s%     &&(c
(?CCr3   z/dashboards/logo/icast.svgc                      t         j                         rt        t         d      S t        dz  } | j                         rt        t	        |       d      S t        d       y)uz  Serve the iCast / Infrastructure Precast mark for the Logo dashboard tile
    (DVI-1280). No auth — it is referenced by <img> from the SPA, the signage
    kiosk (no session), and report snapshots. Prefers the admin-uploaded
    corporate logo; falls back to the repo-committed mark (logo-black.svg, the
    same file baked into the MTS Label PDFs) so the tile always renders.rp  r   logo-black.svgr  N)r  r   r   r  r*   r   )fallbacks    r1   dashboards_logo_icastr  _ sM     ""$,GG 00HXAA	#Jr3   z"/togen-admin/branding/bg-light.svgc                  b    t         j                         st        d       t        t         d      S )zGServe the corporate light-theme background SVG. No auth; 404 if absent.r  rp  r   )r  r   r   r   rX   r3   r1   togen_admin_branding_bg_lightr  _ s%     #**,c
,GGr3   z!/togen-admin/branding/bg-dark.svgc                  b    t         j                         st        d       t        t         d      S )zFServe the corporate dark-theme background SVG. No auth; 404 if absent.r  rp  r   )r  r   r   r   rX   r3   r1   togen_admin_branding_bg_darkr  _ s%     "))+c
+oFFr3   z/togen-admin/subscribersc                  .    t               } t        |       S )z.Return all news subscriber emails. Admin only.)rx  )r  r   r  s    r1   togen_admin_subscribers_listr  _ s     Dt$$r3   c                     dddd} t         j                         r	 t        j                  t         j	                               }t        |t              rut        |j                  d      t              r|d   | d<   t        |j                  d      t              r|d   | d<   t        |j                  d      t              r|d   | d<   | S | S # t        j                  t        f$ r Y | S w xY w)a\  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).r7   T)document_printerlabel_printerr~  r  r  r~  )PRINTING_SETTINGS_FILEr   r   r   r   r   r   r   r*   r   r   r   )r  r  s     r1   _load_printing_settingsr!  _ s     %'MH%%'
	JJ5??ABE%&eii(:;SA389K3LH/0eii8#>05o0FH_-eii	2D9*/	*:HY' O8O $$g. 	O	s   B,C C)(C)c                  6    t               j                  dd      S )zBTrue when the Print system is enabled for regular users (DVI-764).r~  T)r!  r   rX   r3   r1   _print_system_enabledr#  _ s    "$((D99r3   c                 >    | r| j                  d      ryt               S )zWhether ``user`` may use the Print system (DVI-764).

    Togen admins always may; everyone else only when the system is enabled.r   T)r   r#  r-  s    r1   r  r  _ s     ' ""r3   zsales@icastinc.com&Infrastructure Precast Inc_ - TakeoffszAmerica/Indiana/IndianapolisQuoteXQuoteYQuoteZLP)XYr  r)  )SPCACBWBCBRESEMHRWSHWQWW)rY  standard)draft_mailboxclickup_api_keyclickup_list_idazure_client_idazure_tenant_idazure_client_secretonedrive_usertakeoffs_folder	report_to	report_ccshared_quotes_locationshared_quote_takeoff_locationschedule_timezonedry_runtag_mapc                     t        t              } t        j                         r}	 t	        j
                  t        j                               }t        |t               rCt        j                         D ],  \  }}||v st        ||         t        |      u s%||   | |<   . | S | S # t        j                  t        f$ r Y | S w xY wrT   )r   _WINSTON_DEFAULTSWINSTON_SETTINGS_FILEr   r   r   r   r   r   rT  r   r   )r  r  r  r.  s       r1   _load_winston_settingsrJ  _ s    %&H$$&	JJ4>>@AE%&"3"9"9"; /JAwEzd58nW&E&+Ah/
 O8O $$g. 	O	s   AB# ;B# 
B# #C ?C c                 X    t         j                  t        j                  | d             y r   )rI  r   r   r   rf  s    r1   _save_winston_settingsrL  	` s    $$TZZ%CDr3   c                  @   i i }} t               }	 |j                  d      j                         }|j                          |D ]t  }|d   xs dj	                         xs |d   xs dj	                         }|s6|j                         }|d   r| j                  ||       |d   sc|j                  ||       v t        | j                         t        j
                        t        |j                         t        j
                        dS # |j                          w xY w)	u  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)
    zSELECT 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'published_namer7   raw_namefor_documents
for_labelsr  )	documentsr  )
	_print_dbrL  r  r[  r   rE  r}  r  r  r*   )docsr  rO  rn  r  r9   r)  s          r1   _available_printersrU  ` s    r&D;D||<

 (* 	 	

 )"#)r002Sq}7J6Q6Q6Sjjl_OOC&\?c4() DKKMsyy9cii8  	

s   D D__default__c                     t        |       xs i j                  d      xs i }|j                  d      }|j                  d      }t        |t              r|r|nt        t        |t              r|r|dS t        dS )zReturn 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).printingr  r  r  r  )r  r   r   r*   PRINT_USER_DEFAULT)r  r4  r   lbls       r1   _user_printing_overridesr\  6` sv    
 "%(.B33J?E2F
**'
(C
**_
%C#-c3#7CCEW *3 4 BT r3   c                 ^    t               t        |       fd} |d       |d      dS )a  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).c                 d    j                  |       }|r|t        k7  r|S j                  | d      S r  )r   rZ  )r)  chosenr  r'  s     r1   _pickz*_effective_default_printers.<locals>._pickM` s1    #f 22MxxR  r3   r  r  rY  )r!  r\  )r  r`  r  r'  s     @@r1   _effective_default_printersra  D` s9     #$D#E*D! ""45/ r3   z/togen-admin/printingc                  V    t               } t        | d   | d   | d   t                     S )zAReturn default printer settings + available printers. Admin only.r  r  r~  )r  r  r~  available_printers)r!  r   rU  rf  s    r1   togen_admin_printing_getrd  Y` s8     '(H!"45/#.0	 r3   z/user-settings/printingc                      t         d   j                  dd      } t               }t        t	               |d   |d   dt        |       t              S )zReturn 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)r'  r  r7   r  r  rY  )rc  global_defaultsr'  default_sentinel)r   r   r!  r   rU  r\  rZ  )r  r  s     r1   user_settings_printing_getrh  f` sX     FO,E"$D.0 $%7 8!/2
 &e,+ r3   c                  0   t         d   j                  dd      } t        j                  d      xs i }t	               }|d   D ch c]  }|j                          c}|d   D ch c]  }|j                          c}d}t        |       }t        |j                  d	      xs i       }dD ]{  }||vr||   }|d
dt        fv r|j                  |d
       +t        |t              st        d| d      dfc S |j                         ||   vrt        | d      dfc S |||<   } |r||d	<   n|j                  d	d
       	 t        | |       t        dt        |             S c c}w c c}w # t        $ r}	t        d|	       dfcY d
}	~	S d
}	~	ww xY w)zPersist the user's default-printer overrides. The sentinel ``__default__``
    (or a blank value) clears the override so the global default applies.r'  r  r7   Tr  rR  r  rY  rX  NzInvalid value for r  r  r`  z is not an available printer.r  r  rU  r'  )r   r   r   r  rU  rE  r  r   rZ  r  r   r*   r   r  r   r\  )
r  r  availr  r   r  rX  r)  rQ  rl  s
             r1   user_settings_printing_postrl  z` s   
 FO,E4(.BD!E05k0BC1QWWYC-28_=!'')=G #5)HHLL,23H4 d?3i4/00LLd##s##5cU!!<=sBB99;gcl*C5(E!FGLL 'Z&EE8, d!9%!@AA3 D=,  E8>?DDEs*   E'%E,E1 1	F:F
FFc                  L   t        j                  d      xs i } t               }d| v rt        | d   xs d      |d<   d| v rt        | d   xs d      |d<   d| v rt	        | d         |d<   t
        j                  t        j                  |d             t        d|d   	      S )
z;Save default document/label printer selections. Admin only.Tr  r  r7   r  r~  r   r   )rU  r~  )
r   r  r!  r*   r   r   r   r   r   r   r  r  s     r1   togen_admin_printing_postro  ` s     4(.BD&(HT!'*40B+C+Ir'J#$$$'_(=(C$D!D"4	?3%%djj!&DEdHY$788r3   z/togen-admin/quotes-locationc                  p    t               } t        | j                  dd      | j                  dd            S )zHReturn the admin-configured shared quotes paths (completed + take-offs).rB  r7   rC  rB  rC  )rJ  r   r   rf  s    r1   togen_admin_quotes_location_getrr  ` s8     &'H'||,DbI&.ll3RTV&W r3   c                     t        j                  d      xs i } t               }dD ]B  }|| v s| |   }t        |t              st        | d      dfc S |j                         ||<   D 	 t        |       t        d|j                  d
d      |j                  dd            S # t        $ r}t        d|       dfcY d	}~S d	}~ww xY w)zDPersist the shared quotes paths (completed + take-offs). Admin only.Tr  rq   must be a string.r  r`  r;  r  NrB  r7   rC  )rU  rB  rC  )
r   r  rJ  r   r*   r   r   rL  r   r   )r  r  r)  rQ  rl  s        r1    togen_admin_quotes_location_postru  ` s     4(.BD%'HJ ($;s)Cc3'u,>%?@#EEIIKHSM(<x( '||,DbI&.ll3RTV&W   </u56;;<s   -B& &	C
/C?C
C
z/user-settings/documentsc                      t         d   j                  dd      } t        |       }|j                  d      xs i }t        |j                  dd      |j                  dd            S )z=Return the user's personal documents settings (quotes paths).r'  r  r7   rR  quotes_locationquote_takeoff_locationrw  rx  )r   r   r  r   )r  r  rT  s      r1   user_settings_documents_getrz  ` sb     FO,E"5)H<<$*D!2B7#xx(@"E r3   c                     t         d   j                  dd      } t        j                  d      xs i }t	        |       }t        |j                  d      xs i       }dD ]e  }||v s||   }t        |t              st        | d	      d
fc S |j                         r|j                         ||<   T|j                  |d       g |r||d<   n|j                  dd       	 t        | |       t        d|j                  dd      |j                  dd            S # t        $ r}t        d| 	      dfcY d}~S d}~ww xY w)z>Persist the user's personal documents settings (quotes paths).r'  r  r7   Tr  rR  ry  rt  r  r`  Nr  r  rw  rx  )rU  rw  rx  )r   r   r   r  r  r   r   r*   r   r   r  r  r   )r  r  r  rT  r)  rQ  rl  s          r1   user_settings_documents_postr|  ` sA    FO,E4(.BD"5)H[)/R0D< $$;s)Cc3'u,>%?@#EEyy{IIKS	d#$  $[$'EE8, !2B7#xx(@"E   E8>?DDEs   "D 	E %D;5E ;E z/winston/quote-pathsc            	      "   t               } t        d   j                  dd      }t        |      j                  d      xs i }t	        | j                  dd      | j                  dd      |j                  dd      |j                  dd      	      S )
u  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
    r'  r  r7   rR  rB  rC  rw  rx  )shared_completedshared_quote_takeoffpersonal_completedpersonal_quote_takeoff)rJ  r   r   r  r   )re  r  	user_docss      r1   winston_quote_paths_getr  a s     $%FFO,E$U+//<BI$<bA#ZZ(GL$==):B?(}}-ErJ	 r3   )takeoffsr   individual_takeoffsindividual_quotesz/winston/filesc                  h   ddl m}  t        j                  j	                  d      xs dj                         }|t        vrt        d      dfS t               }t        d   j	                  d	d
      }t        |      j	                  d      xs i }|dk(  rDd}d| j                         z   }|j	                  dd
      xs d
j                         }d}d|z   dz   }	n|dk(  rDd}d| j                         z   }|j	                  dd
      xs d
j                         }d}d|z   dz   }	nj|dk(  r3d}|}|j	                  dd
      xs d
j                         }d}d|z   dz   }	n2d}|}|j	                  dd
      xs d
j                         }d}d|z   dz   }	|st        dd||||	      S | j                         }
|
st        d d|||d!"      S d#d$d%d&}t        j                  j	                  d'      xs d(j                         }||vrd(}t        j                  j	                  d)      xs d*j                         d+k(  rd+nd*}||    d,| }t        j                  j	                  d-      xs d
j                         xs d.}|dk(  r| j                  |
|||/      }n| j                  |
||||/      }|j	                  d0      4t        d d||||j	                  d1      |j	                  d2      xs d34      S g }|d0   D ]  }d5|v }|j                  |j	                  d6d
      |j	                  d$d
      |j	                  d%d      |j	                  d#d
      ||j	                  d7d
      |rd
n|j	                  d8d
      d9        t        d d |||t!        |      |j	                  d:d      |j	                  d;      |<	      S )=u  List the files at one of the four Winston quote locations (DVI-943).

    Always returns HTTP 200 with a structured body so the UI can render an
    informative diagnostic even when the folder cannot be reached:
      configured  — False when no path is set for this view
      ok          — True when the listing succeeded
      scope       — 'pipeline' (onedrive_user) or 'sso' (logged-in user)
      account     — the UPN whose drive was queried
      path        — the folder path that was queried
      http_status/error — Graph failure detail (when ok is False)
      files       — list of {name, size, modified, is_folder, download_url}
    r   winston_graphviewr  Unknown files view.r  r`  r'  r  r7   rR  pipelinezSharePoint site: rC  uH   Togen Admin → Documents → Quotes → Shared Quote Take-offs Locationz<No Shared Quote Take-offs Location is configured. Set it in r  r   rB  uI   Togen Admin → Documents → Quotes → Shared Completed Quotes Locationz=No Shared Completed Quotes Location is configured. Set it in r  ssorx  uF   User Settings → Documents → Quotes → My Quote Take-offs LocationzPYou have not set a My Quote Take-offs Location. You can define this location in rw  uG   User Settings → Documents → Quotes → My Completed Quotes LocationzQYou have not set a My Completed Quotes Location. You can define this location in F)
configuredrU  r  accountconfig_hintr  TzMMicrosoft Graph is not configured. Set Azure credentials in Winston Settings.)r  rU  r  r  rb   r  lastModifiedDateTimer9   rd  )r"  r9   rd  r  r"  r  r  ascr\   r  N)order_by	next_linkr   rd   r  zCould not list the folder.)r  rU  r  r  rb   http_statusr  r  r  webUrl@microsoft.graph.downloadUrl)r  r9   rd  r"  	is_folderweb_urlr  	truncatedr  )	r  rU  r  r  rb   r  r  r  r  )rZ  r  r   rh  r   r   _WINSTON_FILE_VIEWSr   rJ  r   r  get_sharepoint_siteacquire_graph_tokensite_drive_list_folder_detailuser_drive_list_folder_detailri   rC  )_wgr  re  r  r  r  r  r  r  not_configured_msgrD  _sort_fieldssort_key	directionr  r  r>  r  r|	  r  s                       r1   winston_files_listr  %a s    +LLV$2
99;D&&23S88#%FFO,E#E*..{;ArIz%(?(?(AA**<bAGRNNP`[^iiloo		%(?(?(AA**5r:@bGGIa\_jjmpp	&	&-- 8"=CJJL^or}}  AD  D-- 126<"CCE_ps~~  BE  EeW#-?
 	

 ##%EUG&a
 	
 !7PVWL  (6J==?H|#!,,**51;VBBDMSYIx()9+6H!!&)/R668@DI
 225&8_h2i225'6T\hq2rzz'"UG&

8,**W%E)E
 	
 EWo 
N	&&r"FF62&FF61%5r:"vvh+#,R"&&9WY[2\
 	
 DwV%jFJJ{E$B**[) r3   z/winston/files/previewc                  X   ddl m}  t        j                  j	                  d      xs dj                         }t        j                  j	                  d      xs dj                         }t        j                  j	                  d      xs dj                         xs d}|t        vry|sy	|d
v rdnd}| j                         }|sy|dk(  r| j                  ||      }n,t        d   j	                  dd      }| j                  |||      }|yt        t        j                  |      |      S )a  Stream a Winston Files item inline for preview (DVI-975).

    Serves the file content same-origin (like the File Browser's /fb/preview)
    so PDFs/images/text render in the preview pane without cross-origin or
    download-URL-expiry issues. Shared views (takeoffs/completed) read with the
    pipeline account via the SharePoint site drive; individual views read the
    logged-in user's OneDrive.
    r   r  r  r7   r  r9   r  )r  r`  )zMissing item id.r`  )r  r   r  r  )"Microsoft Graph is not configured.rU  r'  r  )zCould not fetch the file.rU  r*  )rZ  r  r   rh  r   r   r  r  site_drive_read_itemr   user_drive_read_itemr   r>  rJ  )r  r  item_idr9   r  rD  r  r  s           r1   winston_files_previewr  a s    +LLV$*113D||%+224GLLV$.557A6D&&)&";;JE##%E8
**5':##GR0**5%A/ RZZ(==r3   )r  weekdaysrY  )prev_bizr	  )winston_scheduler_winston_active_runsc                  0   t         j                         r\	 t        j                  t         j	                               } t        | t              r!| D cg c]  }t        |t              s| c}S 	 g S g S c c}w # t        j                  t        f$ r Y g S w xY wrT   )
WINSTON_SCHEDULES_FILEr   r   r   r   r   r  r   r   r   )r   r  s     r1   _load_winston_schedulesr  a s    %%'	::4>>@AD$%#'?a:a+>?? & I2I @$$g. 	I	)   ;A8 A3'A3+A8 3A8 8BB	schedulesc                 X    t         j                  t        j                  | d             y r   )r  r   r   r   r  s    r1   _save_winston_schedulesr  a s    %%djj1&EFr3   c                  0   t         j                         r\	 t        j                  t         j	                               } t        | t              r!| D cg c]  }t        |t              s| c}S 	 g S g S c c}w # t        j                  t        f$ r Y g S w xY wrT   )
WINSTON_RUN_RECORDS_FILEr   r   r   r   r   r  r   r   r   rv  s     r1   _load_winston_run_recordsr  a s    '')	::6@@BCD$%#'?a:a+>?? & I2I @$$g. 	I	r  r=  c                 X    t         j                  t        j                  | d             y r   )r  r   r   r   r=  s    r1   _save_winston_run_recordsr  a s    ''

71(EFr3   r  c                 
   t         5  t               }|D cg c](  }|j                  d      | j                  d      k7  s'|* }}|j                  d|        |dt         }t        |       ddd       yc c}w # 1 sw Y   yxY w)zDThread-safe append + trim to _WINSTON_MAX_RUN_RECORDS, newest first.r  r   N)_WINSTON_RUN_LOCKr  r   r  _WINSTON_MAX_RUN_RECORDSr  )r  r=  r  s      r1   _append_winston_run_recordr  a sy    	 ++-%It

48H)H1IIq&!334!'*+ +I+ +s!   A9(A4A4(A94A99Br  r  c                 $   | j                  d      xs dj                         }|syt        |      dkD  ry| j                  d      xs dj                         }|t        vrdd	d
j	                  t               dfS d}|dk(  rd| j                  d      xs g }t        |t              sy	 t        |D ch c]%  }dt        |      cxk  rdk  sn nt        |      ' c}      }|sy	 t        | j                  dd            }d|cxk  r
dk  st         t        	 	 t        | j                  dd            }d|cxk  r
dk  st         t        	 | j                  d      xs dj                         }	ddlm}
 |
j                         t        t              z  }|	|vryt!        j"                         j%                         }|xs i j                  d      xs t'        t)        j*                               ||||||	t-        | j                  dd            t-        | j                  d d!            |xs i j                  d"      xs ||d#}|dfS c c}w # t        t        f$ r Y yw xY w# t        t        f$ r Y yw xY w# t        t        f$ r Y yw xY w)$z:Build/validate a schedule dict. Returns (schedule, error).r9   r7   NzSchedule name is required.r  Nz/Schedule name is too long (max 120 characters).cadencer  N!Invalid cadence; expected one of r  r  rY  custom_days)Nzcustom_days must be a list.r   r   )Nu<   custom_days values must be integers 0–6 (Mon=0 … Sun=6).)Nz'custom_days must have at least one day.r     )Nu   hour must be an integer 0–23.r  ;   )Nu!   minute must be an integer 0–59.profiler	  winston_date_logic)Nz0Invalid Date Logic; select an existing pipeline.r  r~  TrE  Fr  )r  r9   r  r  r  r  r  r~  rE  r  r  )r   r   rC  _WINSTON_SCHEDULE_CADENCESr  r   r  r  r   r   r   rZ  r  valid_pipeline_idsr  _WINSTON_SCHEDULE_PROFILESr  r  r  r*   rW  rX  r   )r  r  r9   r  r  raw_daysrn  r  r  r  _wdlvalid_profilesr  r  s                 r1   _winston_schedule_from_bodyr  a s   HHV"))+D1
4y3Fxx	"-g446G008C]9^8__`aaaK(88M*0b(D)6	X (!OQa3q6>NQ>N#a&!OPK B7488FA&'TR    9TXXh*+V!r! " "
 xx	"-g446G0,,.5O1PPNn$G




(
(
*C~2""4(=C

,="D12 E23~2**<8?CH T>S "P:& 	XW	X z" 767 z" 989sN   *	I 3III (3I( 3I= I I%$I%(I:9I:=JJz/winston/configc                     ddl m}  t               }|j                  dd      xs | j                  }|j                  dd      xs | j
                  }t        |j                  dd      xs | j                        }|j                  dd      xs. t        j                  j                  dd      j                         }|j                  d	d      xs | j                  }|j                  d
d      xs$ t        j                  j                  dd      xs d}t        t        j                  j                  d      xs |j                  dd            }t        |xr |xr |      }	t        |j                  dd      |||	|||rdnd|||j                  dg       |j                  dg       t        |j                  dd            |j                  dt        d               S )z9Return Winston configuration status for the Settings tab.r   r  r;  r7   r<  r=  r:  CLICKUP_LIST_IDr>  r?  WINSTON_TAKEOFFS_FOLDERr%  CLICKUP_API_KEYr9  r8  z****r@  rA  rE  FrF  )r8  clickup_api_key_configuredr:  azure_configuredr;  r<  r=  r>  r?  r@  rA  rE  rF  )rZ  r  rJ  r   r  
_AZ_TENANTr   r  r  r  r   WINSTON_ONEDRIVE_USERr   rH  )
r  r  r;  r<  azure_secret_setr:  r>  r?  clickup_key_setr  s
             r1   winston_config_getr  .b s    +%'H ll#4b9NS=N=NOll#4b9KS^^OHLL)>CUs~~Vll#4b9jRZZ^^L]_a=b=h=h=jOLL"5R9R9RM&+ 	4::>>3R8	43  


()PX\\:KR-PO OT0@T_Ull?B7#2')''&6FB#',,{B/,,{B/X\\)U34Y(9)(DE r3   c                  `   t        j                  d      xs i } t               }d| v r%t        | d   xs d      j	                         }||d<   d| v rt        | d         |d<   dD ]/  }|| v st        | |   xs d      j	                         }|s+|||<   1 dD ]  }|| v s| |   }t        |t              st        | d	      d
fc S g }t               }|D ][  }t        |t              s|j	                         j                         }|s5||vs:|j                  |       |j                  |       ] |||<    d| v r| d   }t        |t              st        d	      d
fS |j                  d      }	|j                  d      }
|	t        |	t              st        d	      d
fS |
t        |
t              st        d	      d
fS i }t        |	t              r|	j                         D ci c]v  \  }}t        |      j	                         rXt        |      j	                         r?t        |      j	                         j!                         t        |      j	                         x c}}|d<   t        |
t              rit               }g }|
D ]S  }t        |      j	                         j!                         }|s-||vs2|j                  |       |j                  |       U ||d<   ||d<   	 t#        |       t        d      S c c}}w # t$        $ r}t        d| 	      dfcY d}~S d}~ww xY w)z>Save Winston configuration (mailbox, ClickUp key, recipients).Tr  r8  r7   rE  )r9  r:  r>  r?  r;  r=  r<  )r@  rA   must be a list.r  r`  rF  ztag_map must be an object.rY  r7  Nz!tag_map.custom must be an object.z tag_map.standard must be a list.r;  r  rY  )r   r  rJ  r*   r   r   r   r  r   r  rE  r  ri   r   r   r   rB  rL  r   )r  r  rQ  r)  r   r  r  rB  tmrY  r7  saved_tmr  r  seen_stdstd_outr   tvalrl  s                      r1   winston_config_postr  Vb s    4(.BD%'H$$'-2.446$'!D"4	?3 $ $;d3io2&,,.C #$ * $$;s)Cc4(u,<%=>CCG5D &!!S)GGIOO%$HHQKNN1%& $HSM$" D)_"d#!=>CC!66*%j&>!DEsJJ
8T(B!CDcIIfd# #LLN"Aqq6<<>c!flln A$$&A6"HX
 h%!$H!#G )1v||~++-D0LL&NN4(	)
 $+HZ &<x( d)"$  </u56;;<s%   +A;L,L	 		L-L("L-(L-c                     g t               }}t        | t              r`| D ][  }t        |t              s|j	                         j                         }|s5||vs:|j                  |       |j                  |       ] |S )zENormalize a list of email addresses (dedupe, lowercase, drop blanks).)r  r   r  r*   r   rE  r  ri   )r   r  r  rB  s       r1   _winston_clean_email_listr  b sl    TG#t 	"Aa%	!AQd]q!	" Nr3   c                     	 ddl m} |j                         D cg c]  }|d   |d   |d   | v d c}S c c}w # t        $ r g cY S w xY w)zGList Date Logic pipelines annotated with whether they have an override.r   r  r  r9   )r  r9   has_override)rZ  r  list_pipelinesry   )override_idsr  r  s      r1   _winston_email_pipelinesr  b sc    4 ((*
 T7AfIqw,?VW
 	
 
  	s   : 5: : AAz/winston/email-templatec                     ddl m}  t        j                  j	                  d      xs dj                         xs d}| j                  d      }| j                         }t        d| j                  | j                  | j                         t        |      |dd|j	                  dd      |j	                  d	d      |j	                  d
g       |j	                  dg       |j	                  dg       di}|r| j                  |      }|j                  t        |      |j	                  dd      |j	                  d	d      |j	                  d
g       |j	                  dg       |j	                  dg              ng|j                  d|j	                  dd      |j	                  d	d      |j	                  d
g       |j	                  dg       |j	                  dg              t!        di |S )a&  Return the email draft template + token legend for the Email tab.

    With ?pipeline_id set, the field values reflect that pipeline's SPARSE
    override (empty where it inherits) and `global` carries the fallback
    (single) template so the UI can show inherited values as placeholders.
    r   winston_pipelinepipeline_idr7   N)r  tokensper_pipeline_enabled	pipelinesr  globalr  r  ccbccfallback_to)r  r  r  r  r  )is_overrider  r  r  r  r  FrX   )rZ  r  r   rh  r   r   load_email_templatepipeline_override_idsr   EMAIL_TEMPLATE_DEFAULTSEMAIL_TOKENSr  r  get_pipeline_overrider  r   r   )_wpr  gtplr  r  r  s         r1   winston_email_template_getr  b s    .<<M*0b
7
7
9
ATC""4(D,,.L ,, 557*<8 xx	2.+r2((4$88E2&88M26
 D &&s+RFF9b)ff["-vvdBub!}b1 	 	
 	HHY+hh{B/xxb!#3 	 	
 ?T?r3   c                     ddl m}  t        j                  d      xs i }d|v r	 | j	                  t        |d                |j                  d
      xs dj                         xs d	}i }d}d|v r%t        |d   xs d      j                         |d<   d}d|v rt        |d   xs d      |d<   d}dD ]@  }||v st        ||   t              st        | d      dfc S t        ||         ||<   d}B |r	 | j                  ||       t        d      S # t        $ r}t        d|       dfcY d	}~S d	}~ww xY w# t        $ r}t        d|       dfcY d	}~S d	}~ww xY w)zSave the email draft template (subject/body/recipients).

    Optional `pipeline_id` saves a per-pipeline override (Option C) instead of
    the global template. Optional `per_pipeline_enabled` toggles the feature.
    r   r  Tr  r  r;  r  r  Nr  r7   Fr  r  )r  r  r  r  r`  )r  rY  )rZ  r  r   r  set_per_pipeline_enabledr   r   r   r   r   r*   r   r  r  save_email_template)r  r  rl  r  tpl
has_fieldsr)  s          r1   winston_email_template_postr  b s    .4(.BD %	@((d3I.J)KL 88M"(b
/
/
1
9TC
CJDT)_2399;I
dtK06B7K
+ $;d3i.u,<%=>CC0c;CHJ 	@##CS#9 d3  	@#3C5!9:C??	@.  	@#3C5!9:C??	@s;   D :E  	D="D82D=8D= 	E$	EE$E$z/winston/email-template/previewc                     ddl m}  t        j                  d      xs i }|j	                  d      xs dj                         xs d}t        | j                  |            }d|v r1t        |d   xs d      j                         rt        |d         |d<   d	|v r1t        |d	   xs d      j                         rt        |d	         |d	<   | j                         }| j                  ||      \  }}t        |||
      S )u   Render a subject/body preview from a candidate template + sample data.

    Never sends mail — renders against sample pipeline data so admins can see
    the output before saving.
    r   r  Tr  r  r7   Nr  r  )r  r  r  )rZ  r  r   r  r   r   r   r  r*   sample_email_contextrender_emailr   )r  r  r  r  ctxr  r  s          r1   winston_email_template_previewr  !c s     .4(.BD88M"(b
/
/
1
9TC s&&s+
,CDSi!6B7==?T)_-Ids4#4#:;AACtK01K

"
"
$C))#s3GY7iEEr3   z/winston/schedulesc                  *    t        t                     S )Nr  )r   r  rX   r3   r1   winston_schedules_listr  9c s     4677r3   c                      t        j                  d      xs i } t        |       \  }}|rt        |      dfS t	               }|j                  |       t        |       t        |      dfS )NTr  r  r`  r     )r   r  r  r   r  ri   r  r  r  r  r  s       r1   winston_schedules_creater  ?c sh     4(.BD/5MHc
S!3&&')IXI&H%s**r3   z/winston/schedules/<sched_id>c                 &    t        j                  d      xs i }t               }t         fdt	        |      D        d       }|t        d      dfS t        |||         \  }}|rt        |      dfS |||<   t        |       t        |	      S )
NTr  c              3   R   K   | ]  \  }}|j                  d       k(  s|   ywr	  r  r,  r  r  sched_ids      r1   r/  z+winston_schedules_update.<locals>.<genexpr>Qc $     NdaaeeDkX6MNr	  Schedule not found.r  r  r  r`  r  )r   r  r  r  r  r   r  r  r  r  r  r  r  r  s   `     r1   winston_schedules_updater  Lc s     4(.BD')I
Ni	2NPT
UC
{23S88/y~NMHc
S!3&&IcNI&H%%r3   c                     t               }|D cg c]  }|j                  d      | k7  s| }}t        |      t        |      k(  rt        d      dfS t	        |       t        d      S c c}w )Nr  r  r  r  TrY  )r  r   rC  r   r  r  r  r  r  s       r1   winston_schedules_deleter  \c sh     ()I%Aqt)@AIA
9~Y'23S88I&d	 B
   A+A+z/winston/date-logicc            
          ddl m}  t        | j                         | j                  j                         D cg c]
  \  }}||d c}}| j                        S c c}}w )zGList Date Logic pipelines + the canonical process order and base rules.r   r  r  )r  
base_rulesprocess_keys)rZ  r  r   r  
BASE_RULESr   PROCESS_KEYS)r  r  r  s      r1   winston_date_logic_listr%  gc sP     1%%'9=9N9N9PQAa!,Q&& Qs   Ac                     ddl m}  t        j                  d      xs i }| j	                  |j                  d      |j                  d      xs dj                               \  }}|rt        |      d	fS t        |
      dfS )Nr   r  Tr  r9   	base_ruler7   r  r`  r  r  )rZ  r  r   r  r  r   r   r   )r  r  r  r  s       r1   winston_date_logic_creater)  sc ss     14(.BDKK 0488K3H3NB2U2U2WXMHc
S!3&&H%s**r3   z!/winston/date-logic/<pipeline_id>c                    ddl m} t        j                  d      xs i }d|v r|j	                  d      nd }d|v r|j	                  d      nd }|j                  | ||      \  }}|r|dk(  rd	nd
}t        |      |fS t        |      S )Nr   r  Tr  r9   process_labels)r9   r+  zDate Logic not found.r  r`  r  r(  )rZ  r  r   r  r   r  r   )r  r  r  r9   r+  r  r  rd   s           r1   winston_date_logic_updater,  ~c s     14(.BD%~488F4D3Ct3KTXX./QUNKK$~KVMHc
66CS!6))H%%r3   c                      ddl m} t         fdt               D              }|j	                   |      \  }}}|st        |      |fS t        d      S )Nr   r  c              3   F   K   | ]  }|j                  d       k(    yw)r  Nr  )r,  r  r  s     r1   r/  z,winston_date_logic_delete.<locals>.<genexpr>c s     XQUU9%4XrI  r  TrY  )rZ  r  r2  r  r  r   )r  r  
referencedrU  r  rd   s   `     r1   winston_date_logic_deleter0  c sP     1X>U>WXXJkk+z:OBVS!6))dr3   z/winston/run-historyc                  L   dt         dt         fd} t        j                  dt              }|D ch c]  }|d   	 }}|D cg c]
  } | |       }}t	               D ]'  }|j                  d      |vs|j                  |       ) |d t         }t        |      S c c}w c c}w )Nr  r$   c                    i }| j                  d      r	 t        j                  | d         }| j                  d      xs d}|r3t        j                  |t        j                        j                         nd}| d   || j                  d      xs d| j                  d      xs d| j                  d	      xs d
t        |j                  dd            |j                  dd      |j                  dd      |j                  dd      |j                  dd      |j                  dd      |j                  dd      | j                  d      |j                  dg       dS # t        j                  t        f$ r Y Mw xY w)zOTransform bot_diagnostics DB row to the legacy run record shape the UI expects.summary_jsonrq  r   )r  r7   r  target_datetriggerrd   r  rE  Fquotes_founddrafts_createddrafts_skippeddrafts_failed
pdf_routedpdf_route_failedr  quotes)r  ran_atr   r  rd   rE  r6  r7  r8  r9  r:  r;  r  r<  )r   r   r   r   r   r  r  r  r  r  r   )r  r
	  rg  r=  s       r1   _db_run_to_recordz.winston_run_history.<locals>._db_run_to_recordc sX   55 **Q~%67 %%%*  ((\5E5EFPPR 	
 D'EE-(.BEE),2eeHo/GKK	59:#KK:%kk*:A>%kk*:A>$[[!<!++lA6 ',> BUU7^kk(B/
 	
 (()4 s   E E;:E;winstonr   r  r  )r   	_diag_mod	list_runsr  r  r   ri   r   )r>  db_runsr  db_idsr>  old_recs         r1   winston_run_historyrF  c s    
T 
d 
< !!)3KLG&'!ag'F',34q"4F4 -. #;;tF*MM'"# --.F6"" (4s   BB!z/winston/run-nowc                  d   ddl m t        j                  d      xs i } | j	                  d      xs dj                         }|s#t        j                         j                  d      }	 t        j                  |d       t        t        j                               }d| v rt        | d         }n#t        t!               j	                  dd            }fd}t#        j$                  ||||fd      }|j'                          t        |d||      dfS # t        $ r t        d	      d
fcY S w xY w)zFKick off the Winston pipeline for a given date in a background thread.r   r  Tr  r   r7   r  "Invalid date; expected YYYY-MM-DD.r  r`  rE  Fc                     	 j                  |d| |       j                  |        y # t        $ r# t        j                  j	                  d|        Y =w xY w# j                  |        w xY w)Nr'  )r5  rf  rE  z*Winston run-now background error run_id=%s)r:  ry   rm   rn   r  _clear_cancel)ridr  dryr  s      r1   _run_bgz winston_run_now.<locals>._run_bgc se    	#GGBsCG@ c"  	TJJ  !MsS	T c"s!   * )AA AA A,r(  r8  )rf  rd   r   rE  r  )rZ  r  r   r  r   r   r  r  r  r  r   r   r*   rW  rX  r   rJ  r  r  rq   )r  r	  rf  rE  rM  r   r  s         @r1   winston_run_nowrN  c s    .4(.BD &B--/H!%%'00<H*5 F DtI'-/33IuEF# 	vx.IRVWAGGI&7SUXXX-  HABCGGHs   +D D/.D/z/winston/stop-runc                     ddl m}  t        j                  d      xs i }|j	                  d      xs dj                         }|st        d      d	fS t        j                  |      }|r|j	                  d
      dk7  rt        d      dfS |j	                  d      dk7  rt        d|j	                  d            dfS | j                  |       t        j                  |       t        d|d      dfS )zRequest cooperative cancellation of a running Winston pipeline (DVI-893).

    The pipeline checks the cancel flag between quote tasks and stops with a
    'cancelled' status. Only currently-running Winston runs can be stopped.
    r   r  Tr  rf  r7   zrun_id requiredr  r`  botr?  r	  r  rd   r8  zRun is not runningr  rd   r9  r   )rU  rf  rd   r  )rZ  r  r   r  r   r   r   rA  get_runrequest_cancelforce_cancel)r  r  rf  r:  s       r1   winston_stop_runrU  c s     .4(.BDhhx &B--/F./44


F
#C#''%.I-_-s22
wwxI%1#''(:KLcQQ v6"d6+>CCr3   c                  ^    ddl m}  t               j                  dd      xs | j                  S )zIEffective mailbox whose Drafts folder Winston shows (sales@icastinc.com).r   r  r8  r7   )rZ  r  rJ  r   WINSTON_MAIL_FROM)r  s    r1   _winston_draft_mailboxrX  d s&    *!#''<U@U@UUr3   z/winston/draftsc                     ddl m}  t               }| j                         }|st	        d      dfS | j                  ||d      }|t	        d      d	fS g }|D ]  }g }|j                  d
      xs g D ]  }|j                  d      r|j                  |j                  dd      |j                  dd      |j                  dd      |j                  dd      |j                  dd      dk(  xs/ |j                  dd      j                         j                  d      d        |j                  |j                  dd      |j                  dd      xs d|j                  dd      |j                  d      xs g D cg c]$  }|j                  di       j                  dd      & c}|j                  dd      |j                  dd      t        |j                  d            |d        t	        ||      S c c}w ) a  List messages in the Winston mailbox's Drafts folder (DVI-848).

    Returns lightweight message records for the Drafts tab. Attachment
    metadata (id/name/contentType/size) is expanded so the UI can offer
    inline PDF viewing without a second round-trip per message.
    r   r  zEMicrosoft Graph is not configured. Set Azure credentials in Settings.r    rd  )mailboxtopz'Could not load Drafts from the mailbox.rU  rS  isInliner  r7   r9   r  r  rd  r  r   )r  r9   r5  rd  is_pdfr  (no subject)bodyPreviewr  r  r  r  createdDateTimehasAttachments)r  r  rL  rX  modified_atr  has_attachmentsrS  )r[  r=  )rZ  r  rX  r  r   mail_list_draftsr   ri   rE  r|  r   )	r  r[  rD  messagesr=  r  attsr  r  s	            r1   winston_draftsrh  
d s    +$&G##%Edegjjj##E7#DHFGLLG %%&," 
	AuuZ KKeeD"ofl3 !mR 8fa(5537HH HUU62.446??G 
	 	%%b/uuY+=~uu]B/UU>28b; 55,00B? ;55!7<%% 126#AEE*:$;<

 
	0 7G44;s   ?)G=z8/winston/drafts/<message_id>/attachments/<attachment_id>c                    ddl m} t               }|j                         }|st	        d      dfS |j                  || ||      }|t	        d      dfS |\  }}}t        t        j                  |      |xs d	d
|      S )zAStream a Drafts-folder attachment inline (PDF viewing) (DVI-848).r   r  r  r  rZ  )r[  z0Attachment not found or could not be downloaded.r  r  Fr  )	rZ  r  rX  r  r   mail_get_attachmentr   r>  rJ  )	
message_idattachment_idr  r[  rD  r>  r  r5  r9   s	            r1   winston_draft_attachmentrm  8d s     +$&G##%EABCGG$$UJw$WF~OPRUUU"(G\4


7;!;	 r3   im  iP  c                      t        j                  t        t              d      } t         j                  | _        | j                  d       | j                  d       | S )zEOpen history.db, creating the schema on first use. Caller must close.FrD  rF  aZ  
        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);
        CREATE TABLE IF NOT EXISTS tool_history_quarantine (
            id                INTEGER PRIMARY KEY AUTOINCREMENT,
            orig_id           INTEGER,
            tool              TEXT,
            action            TEXT,
            actor             TEXT,
            summary           TEXT,
            ref               TEXT,
            detail            TEXT,
            created_at        TEXT,
            quarantined_at    TEXT NOT NULL,
            quarantine_reason TEXT,
            quarantine_key    TEXT
        );
        CREATE INDEX IF NOT EXISTS idx_hist_quar_key
            ON tool_history_quarantine (quarantine_key);
    )rG  rH  r*   HISTORY_DB_PATHrJ  rK  rL  rM  rN  s    r1   r  r  Ud sJ    ??3/5ID{{DLL*+  	< Kr3   c                  6   t               j                  di       xs i } | j                  dt              }| j                  dt              }t	        |t
              r|dkD  r|nd}t	        |t
              r|dkD  r|nd}||t        | j                  dd            fS )av  Effective retention/archival config (DVI-1045).

    Returns ``(retention_days, max_rows_per_tool, archive)``. A non-positive or
    missing day/row value means "unlimited" for that dimension (``None``). Falls
    back to the module defaults when nothing is saved. ``archive`` defaults True
    so pruned rows are rotated to compressed archives, never silently deleted.
    	retentionretention_daysmax_rows_per_toolr   NarchiveT)_load_history_settingsr   _HISTORY_RETENTION_DAYS_HISTORY_MAX_ROWS_PER_TOOLr   r   r   )r  r  caps      r1   _history_retention_cfgry  {d s     	 $$["5;A55!#:;D
%%#%?
@CdC(TAX44DC%#'#tCd155D1222r3   c                    | syddl }ddl}t        j                  dd       t	        j
                         j                  d      }t        d| dz  }|j                  |dd	
      5 }| D ]F  }|j                  |j                  |d   |d   |d   |d   |d   |d   |d   |d   d      dz          H 	 ddd       y# 1 sw Y   yxY w)a?  Append pruned tool_history rows to a monthly gzipped JSON-lines archive.

    Concatenated gzip members are valid gzip, so ``gzip.open(..., 'at')`` safely
    appends across prune runs; readers streaming the whole file get every row.
    Called before the DELETE so a write failure aborts the prune (no data loss).
    Nr   TrM   z%Y-%mtool_history_z	.jsonl.gzatr  r  r  r  r  r		  r
	  r  r	  r  )r  r  r  r		  r
	  r  r	  r  r0  )
gzipr   HISTORY_ARCHIVE_DIRr  r  r  r  r?  r  r   )rn  _gzip_jsonmonthrb   r  r  s          r1   _archive_history_rowsr  d s     dT:!!#,,W5E=y!AAD	D$	1 R 	AHHU[[gqyAhK7)QuXH+Q|_"  	 	  s   'AB==Cc            
      N   d} 	 t               \  }}}||yt               	 t               g cfd}|=t        j                         t        |      z
  j                  d      } || dz   |f       |Dj                  d      j                         D cg c]  }|d   	 }}|D ]  } || d	z   ||f        s	 j                          y|rt               t              }	t        dt        |	      d
      D ];  }
|	|
|
d
z    }j                  ddj                  dt        |      z         d|       = j                          j                          yc c}w # j                          w xY w# t         $ r" t"        j$                  j'                  d       Y yw xY w)a  Archive + remove rows older than the retention window or beyond the
    per-tool cap (DVI-789 / DVI-1045).

    Rows selected for pruning are first rotated into a gzipped JSON-lines archive
    (unless archiving is disabled) and only then deleted, so history is retained
    off the live DB instead of silently discarded. Retention is admin-configurable
    via ``history_settings.json``; both dimensions may be set to unlimited.
    zRSELECT id, tool, action, actor, summary, ref, detail, created_at FROM tool_historyNc                     j                  | |      j                         D ]/  }|d   vsj                  |d          j                  |       1 y Nr  )rL  r  r  ri   )sqlr  r  rO  
doomed_idsdoomed_rowss      r1   _collectz#_prune_history_db.<locals>._collectd sP    c62;;= .Awj0"qw/#**1-.r3   r  r  z WHERE created_at < ?z&SELECT DISTINCT tool FROM tool_historyr   z: WHERE tool = ? ORDER BY created_at DESC LIMIT -1 OFFSET ?r  z&DELETE FROM tool_history WHERE id IN (r  r]   r7  zhistory prune error)ry  r  r  r  r  r  r  rL  r  r[  r  r  r  rC  r  rZ  ry   rm   rn   r  )_SELr  rx  rt  r  r   r  r  r  r|  r  r  rO  r  r  s               @@@r1   _prune_history_dbr  d s   !D0435c7<CK})	&)eR#J. "))+o4.HH(/0   77&C "\\@hj	 aD  " D  - -s  JJL %k2z"C1c#h, Aa#g<SXXcCPUJFV=W<XXYZ KKMJJL54 JJL 4

234sM   E9 
E9 A6E$ E'E$ E9 A<E$ E9 E$ $E66E9 9(F$#F$`T  c                 D    ddl }	 |j                  |        t                )zDaemon: re-run the archive/prune sweep on an interval (DVI-1045).

    The one-time startup prune covers deploy restarts; this keeps retention
    honored on long-lived processes so the DB does not drift past the window
    between deploys.
    r   N)rU   sleepr  )interval_secondsre  s     r1   _history_prune_loopr  d s"     
$% r3   z^T(WOR|SWO|MMWO|PMWO)_c                 j    t        |       xr' t        t        j                  t        |                   S )a  True if a wor-admin history ref names a segregated test WO (T-prefix).

    Created-as-test WOs use the ``T*`` filename prefixes (TWOR/TSWO/TMMWO/TPMWO);
    this is a cheap, FS-free check used to keep test-WO lifecycle events out of the
    production audit trail (DVI-1084).
    )r   _WOR_TEST_REF_RErG  r*   )r  s    r1   _wor_ref_is_testr  d s(     9?.44SX>??r3   r	  c          
         | dk(  r!t        |      r|xs i j                  d      sy	 ddl}t               }	 t	        j
                         j                  d      }||j                  |      nd}	|j                  d| |||xs d|xs d|	|f       |j                          |j                          y# |j                          w xY w# t        $ r Y yw xY w)z8Insert one history event. Fire-and-forget; never raises.r  r  Nr   r  zmINSERT INTO tool_history (tool, action, actor, summary, ref, detail, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)r7   )r  r   r   r  r  r  r  r   rL  rZ  r[  ry   )
r  r  r		  r
	  r  r	  r  rO  r  
detail_strs
             r1   r	  r	  d s     	 0 5\r&&'78}
	'..099:NOJ060BV,JLL0vugmSYB
JW
 KKMJJLDJJL s)   C A)B1  C 1CC 	CCc                     ddit         t        ddd} t        j                         r	 t	        j
                  t        j                               }t        |t              rt        |j                  d      t              r4|d   j                         D ]  \  }}t        |t              s|| d   |<     t        |j                  d      t              r_|d   }dD ]+  }||v s||   t        ||   t              s!||   | d   |<   - t        |j                  d      t              r|d   | d   d<   | S | S # t        j                  t        f$ r Y | S w xY w)	zReturn {capture: {tool: bool}, retention: {...}} settings.

    Capture defaults all-on; retention defaults to the module constants with
    archiving enabled (DVI-1045).
    rA  T)rr  rs  rt  )capturerq  r  rq  rr  rs  rt  )rv  rw  HISTORY_SETTINGS_FILEr   r   r   r   r   r   r   r   r   r   r   r   )r  r  r  r  rets        r1   ru  ru  e sV    T?5!;
H $$&	JJ4>>@AE%&eii	2D9 %i 0 6 6 8 71%a.56HY/27 eii4d;,CD >8Q:c!fc;R7:1vH[1!4> "#'')"4d;;>y>-i8 O8O $$g. 	O	s$   A?D6 *7D6 "D6 ;7D6 6EEc                     t               \  } }}| ||ddddg ddg d}dD ]S  }t        t        t              |z         }|j	                         s/|dxx   |j                         j                  z  cc<   U 	 t               }	 |j                  d      j                         d   |d<   |j                  d      j                         }|d	   |d
   c|d<   |d<   |j                  d      j                         D cg c]  }|d   |d   |d	   |d
   d c}|d<   |j                          	 t         j#                         ryt%        t         j'                  d            }	t)        |	      |d<   t+        d |	D              |d<   |	D 
cg c])  }
|
j,                  |
j                         j                  d+ c}
|d<   |S c c}w # |j                          w xY w# t        $ r" t        j                  j                  d       Y w xY wc c}
w )zISize/age telemetry for history.db and its compressed archives (DVI-1045).r   N)rr  rs  rt  db_bytes
total_rowsoldestnewestper_toolarchive_filesarchive_bytesarchives)r7   z-walz-shmr  z!SELECT COUNT(*) FROM tool_historyr  z?SELECT MIN(created_at) mn, MAX(created_at) mx FROM tool_historymnmxr  r  zoSELECT tool, COUNT(*) n, MIN(created_at) mn, MAX(created_at) mx FROM tool_history GROUP BY tool ORDER BY n DESCr  r  )r  rn  r  r  r  zhistory telemetry errorztool_history_*.jsonl.gzr  c              3   P   K   | ]  }|j                         j                     y wrT   )r  r&  r  s     r1   r/  z%_history_telemetry.<locals>.<genexpr>^e s     "C1668#3#3"Cs   $&r  )r9   bytesr  )ry  r   r*   ro  r   r  r&  r  rL  ru  r  r[  ry   rm   rn   r  r~  r  r  r  rC  sumr9   )r  rx  rt  r   r!  r  rO  r  r  r  r  s              r1   _history_telemetryr  9e s   /1D#wSWQ$$QBC
 ' 0_%./99;
Oqvvx///O08}	 $3!hj!C ,,Qhj  ,/t9c$i(CM3x= G (*  6AcFT7ag7C
O JJL !!#*//0IJK"5zO""CU"CCOAF
<=QVVaffh&6&67
J J' JJL 8

678
sC   6
G A6F< 7F7F< G .G?7F< <GG (G<;G<c                 <    t               d   j                  | d      S )zBTrue when history capture is enabled for this tool (default True).r  T)ru  r   )r  s    r1   r	  r	  ee s    !#I.224>>r3   c                 Z   g g }}|r&|j                  d       |j                  d| d       |r&|j                  d       |j                  d| d       |rddj                  |      z   nd}t               }	 |j                  d|z   |      j	                         d   }|j                  d	|z   d
z   || |gz         j                         }	|j                          g }
|	D ]  }|d   xs d}t        j                  ||xs d      }|d   xs	 |d   xs d}| d|d   xs |d    }|r|d| z  }|
j                  |d   d|xs d|d   xs d||d   xs d|d   r0t        j                  t        |d               j                  d      nddd        |
|fS # |j                          w xY w)z6Adapt print_jobs into the generic history entry shape.LOWER(j.created_by) LIKE ?%zLOWER(j.source) LIKE ? WHERE  AND r7   &SELECT COUNT(*) AS n FROM print_jobs jr  a  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, ORDER BY j.created_at DESC LIMIT ? OFFSET ?r	  r	  rN  machine_namer
  file_refr  rl  rA  r	  r  r  Nr  r  r  r		  r
	  r  r  r	  )ri   r  rS  rL  ru  r  r[  _PRINT_SOURCE_LABELSr   r  r  r  r  )r   r  f_userf_actionwherer  clauserO  r@  rn  r  r  r$  r   printerr
	  s                   r1   _print_history_as_genericr  me s   6E12&m$-.(1o&27i',,u--RF;D4v=v

(*S ||G  == eV_$

 (* 	 	

G kR$((clU;$%@>):@bG5:!9!D' :;wi((GD'nW|_*Z=&B 12, )99ao&h+,R
 	& E>- 	

s   8AF F*zEmailed (internal)zTest run (no email)Dry runzNo email (notifications off)zNo email (no recipients)zNo email (attachment too large)zEmail send failedzExternal held for approvalz,Emailed internal; external held for approvalu(   External held — no approver configuredu:   Emailed internal; external held — no approver configured)sentmanual_no_emailrE  skipped_notify_offskipped_no_recipientsskipped_oversizedsend_failedpending_approvalzsent+pending_approvalpending_approval_no_approverz!sent+pending_approval_no_approverc                    t        j                         }t        |d d      }fd}|D cg c]  } ||      s| }}t        |      }|||| z    }	g }
|	D ]  }|j	                  dd      }|j	                  d      xs g }|j	                  d      xs g }d}|j	                  d	      s|j	                  d
      r'|j	                  d	d       d|j	                  d
d       }|j	                  d      xs dg}|r|j                  |       |j                  |j	                  dd       d       t        j	                  ||xs d      }dj                  |      d| z   }|
j                  |j	                  d      xs |j	                  d      xs dd|j	                  d      xs d|j	                  d      xs d||j	                  d      xs d|j	                  d      xs d|j	                  d      ||||j	                  d      |j	                  dd      |j	                  dd      |j	                  dd      dd        |
|fS c c}w )ao  Adapt scada_report_log.json run records into the generic history entry
    shape (DVI-1164 D4). Read-only: the standalone scheduler owns the log; the
    Auditor sub-tab and CSV export never write history.db for these.

    ``f_user`` filters on the run source (schedule/manual) or schedule name;
    ``f_q`` filters on the schedule name, report file, or status.
    c                 &    | j                  dd      S Nrecorded_atr7   r  rx	  s    r1   r	  z5_scada_reporting_history_as_generic.<locals>.<lambda>e     AEE-$< r3   Tr
  c                    r:| j                  dd       d| j                  dd       j                         }|vryrM| j                  dd       d| j                  dd       d| j                  dd       j                         }|vryy)	Nr	  r7   r\   schedule_nameFreport_fileemail_statusTr<  )r~  hayf_qr  s     r1   _matchz3_scada_reporting_history_as_generic.<locals>._matche s    WWXb)*!CGGOB,G+HIOOQCS ggob12!CGGM"4M3NaggnR01349EG #~r3   r  r7   
recipientsexternal_recipients	date_fromdate_tor]   u    – r  z(unnamed schedule)	row_countr   z rowsr	  r	  r
  r  r  scada-reportingr	  r  schedule_idapproval_idrun_date)r  r  r  r  r  r  r	  r  r  )scada_report_schedulerload_run_logr  rC  r   ri   !_SCADA_REPORT_EMAIL_STATUS_LABELSr  )r   r  r  r  rk  r  r  filteredr@  rO  r  rd   recipsr  rngr0
  r   r
	  s     ``              r1   #_scada_reporting_history_as_genericr  e s9    !
-
-
/C
<d
KC
 ,a&),H,MEF6E>*DG ~r*|$*ee)*0b55y!1UU;s+,E!%%	#2F1GHC'?+?@LLk1-.e45155ffoN++e$ug6%%&D!%%*>D"%eeHo3UU8_*55'-2%%.4" uu]3 &$'* uu]3 uu]B7%%"-EE*b1	
 	@ E>K -s
   II)asset_addedasset_editedasset_viewedassets_importedassets_syncedassets_sync_settingsasset_model_addedasset_model_editedasset_model_deletedsub_assembly_addedsub_assembly_editedsub_assembly_deleted
part_addedpart_editedpart_deletedpart_stock_adjustedpart_linkedpart_unlinkedr  parts_notify_config_savedc                 0   ddj                  dt        t              z        z  g}t        t              }|r&|j	                  d       |j	                  d| d       |r&|j	                  d       |j	                  d| d       |r |j	                  d       |d| dd| dgz  }dd	j                  |      z   }t               }	 |j                  d
|z   |      j                         d   }	|j                  d|z   dz   || |gz         j                         }
|j                          ddl
}|
D cg c]D  }|d   d|d   |d   xs d|d   xs d|d   xs d|d   |d   r|j                  |d         nddF }}||	fS # |j                          w xY wc c}w )zFiltered adapter over history.db for the Assets sub-tab: matches the
    asset_* action set across every tool key. Same generic entry shape as the
    history.db path in history_tool, so the frontend renders it unchanged.action IN (%s)r  r]    LOWER(COALESCE(actor,'')) LIKE ?r  !LOWER(COALESCE(action,'')) LIKE ?F(LOWER(COALESCE(summary,'')) LIKE ? OR LOWER(COALESCE(ref,'')) LIKE ?)r  r  &SELECT COUNT(*) AS n FROM tool_historyr  LSELECT id, action, actor, summary, ref, created_at, detail FROM tool_history* ORDER BY created_at DESC LIMIT ? OFFSET ?r   Nr  r  r  r		  r7   r
	  r  r  r	  r  )r  rC  _ASSET_HISTORY_ACTIONSr  ri   r  rL  ru  r  r[  r   r   )r   r  r  r  r  r  r  r  rO  r@  rn  r  r  r  s                 r1   _assets_history_as_genericr  f s    s3I/J)J KKLE()F78&m$89(1o&
]^Qse1:3%qz**e,,F=D4v=v

(*S ||!#)*89 eV_$	

 (* 	 	

 
 	 gx1X;7!ra	l.@bx~2Q|_./k%++ak*t	 G  E> 	

s   AE> /A	F>F)dashboard_source_addeddashboard_source_value_setdashboard_source_renameddashboard_source_editeddashboard_source_deleteddashboard_sources_savedc                    ddj                  dt        t              z        z  g}t        t              }|r&|j	                  d       |j	                  d| d       |r&|j	                  d       |j	                  d| d       |r |j	                  d       |d| dd| dgz  }dd	j                  |      z   }t               }	 |j                  d
|z   |      j                         d   }	|j                  d|z   dz   || |gz         j                         }
|j                          ddl
}g }|
D ]x  }|d   r|j                  |d         nd}|d   xs d}|st        |t              rt        |d   |      }|j	                  |d   d|d   |d   xs d||d   xs d|d   |d       z ||	fS # |j                          w xY w)zFiltered adapter over history.db for the Data sources sub-tab: matches the
    dashboard_source_* action set across every tool key. Same generic entry shape
    as the history.db path in history_tool, so the frontend renders it unchanged.r  r  r]   r  r  r  r  r  r  r  r  r  r  r   Nr	  r
	  r7   r  r  datasourcesr		  r  r  r  )r  rC  _DATASOURCE_HISTORY_ACTIONSr  ri   r  rL  ru  r  r[  r   r   r   r   _datasource_history_summary)r   r  r  r  r  r  r  r  rO  r@  rn  r  r  r  r	  r
	  s                   r1   _datasources_history_as_genericr  8f s    c-..!0 0 1E-.F78&m$89(1o&
]^Qse1:3%qz**e,,F=D4v=v

(*S ||!#)*89 eV_$	

 (* 	 	

G -.x[Qx[)dI,$" :fd31!H+vFGD'=AhKwZ%2'U8>r<	
 	 E>! 	

s   AF. .G c                    |j                  d      xs |j                  d      xs d}|j                  d      xs d}ddddj                  ||      }| d	k(  r>d
|xs d d| d}|dk(  r*|j                  d      dvr|d|j                  d       z  }|S | dk(  rd| d|j                  dd       S | dk(  r)d|j                  dd       d|j                  dd       dS | dk(  rUdj                  t        |j                  d      xs i j                                     }d|xs d d| d|rd| dz   S dz   S | d k(  rd!|xs d d| dS | d"k(  rd#|j                  d$d%       dS |xs | S )&z<A readable one-line summary for a data-source history event.r9   r  r7   rT  zManual valuezSharePoint cellzReport value)r  sharepoint_cellreport_cellr  zAdded r	  z 'rd
  r  r  r  z = r  Set 'z' = r   z	Renamed 'r2  u   ' → 'rX  r  r  changeszEdited r:  r7  r  rg  r  zSaved data sources (r  r   )r   r  r  r,  )r  r	  r9   stypetlabelr  r  s          r1   r  r  hf s   ::f7D!17RDJJv$"E&;L+--0S-> ))V'x(4&2HG!4J!F3vzz'*+,,A--tfDGR!8 9::++6::fb12'&**T2:N9OqQQ**66::i#8#>B"D"D"FGH+8,BtfA6FBvha.[[XZ[[++&,H-RvQ77**%fjj!&<%=Q??>6r3   z/history/<tool>c                    	 t        t        t        j                  j	                  dd            d      }t        t        t        j                  j	                  dd            d      }t        j                  j	                  d	d
      j                         j                         }t        j                  j	                  dd
      j                         j                         }t        j                  j	                  dd
      j                         j                         }| dk(  r$t        ||||xs |      \  }}t        ||||      S | dk(  r$t        ||||xs |      \  }}t        ||||      S | dk(  r!t        |||||      \  }}t        ||||      S | dk(  r!t        |||||      \  }}t        ||||      S dg| g}	}|r&|j                  d       |	j                  d| d       |r&|j                  d       |	j                  d| d       |r |j                  d       |	d| dd| dgz  }	ddj!                  |      z   }
t#               }	 |j%                  d|
z   |	      j'                         d   }|j%                  d|
z   dz   |	||gz         j)                         }|j+                          ddl}g }|D ]V  }|j                  |d   |d   |d   |d    xs d
|d!   xs d
|d"   xs d
|d#   |d$   r|j/                  |d$         ndd%       X t        ||||      S # t        t        f$ r t        d      dfcY S w xY w# |j+                          w xY w)&zGeneric history endpoint. Print is served by an adapter; others from history.db.

    Query params: limit, offset, user (actor substring), action (action substring), q.
    r   rd  r  r  r   Invalid limit or offset.r  r`  r'  r7   r  r  rA  r  r@  r  r   r  r  r  tool = ?r  r  r  r  r  r  r  r  zRSELECT id, tool, action, actor, summary, ref, created_at, detail FROM tool_historyr  Nr  r  r		  r
	  r  r  r	  r  )rL  r   r   rh  r   rC  r   r   r   r   rE  r  r  r  r  ri   r  r  rL  ru  r  r[  r   r   )r  r   r  r  r  r  r  r@  r  r  r  rO  rn  r  r  s                  r1   history_toolr  f sk   >C((#67=S))(A67; \\fb)//1779F||"-335;;=H
,,

3
#
)
)
+
1
1
3Cw25&&(/VYZweF%PP  <668?s4weF%PPx3668S2weF%PP}8668S2weF%PP  L4&6E78&m$89(1o&
]^Qse1:3%qz**e,,F=D4v=v

(*S ||!#)*89 eV_$	

 (* 	 	

G 
D'fIkwZ%2|)rU8>rL/23H+ekk!H+.4	
 		
 7%eLLE z" >78#==>f 	

s   A&L" AM "MMMc                 P   | xs dj                         }|rd|v sd|v ryt        |      }i }|r$	 t        j                  |j	                               }|j                  d      }t        |t              s2t        j                  d|      }|rt        |j                  d            }|g}t        |t              r|j                  d|d	       t               }	 d
j                  dt!        |      z        }|j#                  d|z   dz   |      j%                         }	|j'                          ddl}
g }|	D ]R  }|j                  |d   |d   |d   xs d|d   xs d|d   xs d|d   |d   r|
j                  |d         ndd       T t)        |      }|rt+        |      nd}|j                  d      xs d}|j                  d      xs d}d}|r.|j,                  |j.                  dz   z  }|j1                         r|}t3        |xs |xs i j                  d            }|rt5        ||xs d|      n|}|t        |t              rd|d	n||||||t7        |      t3        |      |||d}|dfS # t        j
                  t        f$ r i }Y <w xY w# |j'                          w xY w)uD  Gather one Work Order's full audit trail (DVI-1040/1044).

    Shared by the JSON detail endpoint and the CSV/PDF export endpoints. Returns
    ``(payload, error)``: on a bad ref ``payload`` is None and ``error`` is a
    message; otherwise ``error`` is None and ``payload`` carries the public JSON
    fields plus two internal ``Path`` keys (``wor_path``/``evh_pdf_path``) used
    to build the PDF packet.

    history.db stores lifecycle events under two ref shapes: ``created`` uses
    ``WO#NNNNN`` (filename in detail), while ``status_changed``/``evh_*``/
    ``edited`` use the filename directly — so we query both. The payload also
    carries the *live* sidecar snapshot (EVH sign-offs + completion details),
    which callers label distinctly because pre-DVI-1033 WOs lack completion
    details in the recorded events.
    r7   r  r6   )NzInvalid work order referencer  r  r^   r
  r  r  r]   zrSELECT id, action, actor, summary, ref, detail, created_at FROM tool_history WHERE tool = 'wor-admin' AND ref IN (z!) ORDER BY created_at ASC, id ASCr   Nr  r  r		  r
	  r  r  r	  )r  r  r		  r
	  r  r  r	  r  rf  r
  r  r   r  )r  r
  re  r  r:  r  rj  r  
has_wo_pdfhas_evh_pdfr  r  )r   r  r   r   r   r   r   r   r   r   rF  r  rb  ri   r  r  rC  rL  r  r[  r  rn  r  r  r   r   r  r  )r  r  r  r   r!
  r  r  rO  r  rn  r  r:  r  r  r  r  rj  r  r  r  re  payloads                         r1   _collect_wor_auditr  f s     	r  "Htx'3(?3"8,GD	::g//12D XXm$Ffc"IInh/_F:D&#c&&'=D	xxc$i0||G@A 	

 (* 	 	

F 	D'kwZ%2|)rU8>rL/23H+ekk!H+.4
 		 !*H-5)4H
((6

"dC-(0DJL(--*"<=<<>L|A	ry'ABKMQx)B]DIW_E ,6vs,CtF3<( !$'8n"$G D= $$g. 	D	, 	

s   #I1 AJ 1JJJ%z/history/wor/<ref>r  c                     t        |       \  }}|rt        |      dfS |j                         D ci c]  \  }}|dvr|| }}}t        di |S c c}}w )z=Aggregate the full audit trail for one Work Order (DVI-1040).r  r`  )r  r  rX   )r  r   r   )r  r  r  r  r  publics         r1   history_wor_detailr  +g sk     &c*LGS
S!3&&&}} 8tq!66 d 8F 8V8s   Az/auditor/searchc                  2
   ddl } t        j                  j                  d      xs dj	                         }t        |      dk  rt        d      dfS t        t        t        t        j                  j                  d	d
      xs d
      d      d      }d|j                          d}g g g g f\  }}}}	 t               }	 |j                  d||||f      j                         }	|j                          	 i }
|	D ]<  }d}|d   r	 | j!                  |d         }|d   dk(  rd}t'        |t(              r|j                  d      r|d   }n'|d   r"t+        |d         j-                  d      s|d   }|s{|
j                  |      }|[t/        j0                  d|      }|rdt        |j3                  d            dn|}|||d   |d   xs d|d   xs d|d   ddx}|
|<   |dxx   dz  cc<   t        |      |k  s|j5                  |d   |d   |d   xs d|d   xs d|d   xs d|d   d       ? t7        |
j9                               d| }	 t;        j<                         }	 |j                  d||||||f      j                         }|j                          	 |D ]  }d}|d    rQ	 | j!                  |d          }t'        |t(              r,d!D cg c]  }||v r
| d"||     }}d#j?                  |      }|j5                  |d$   |d%   |d&   xs d|d'   xs d|d(   xs d|d)   |xs	 |d*   xs dd+        	 tA               }tC               }	 |j                  d,      j                         }|j                          |j                         }|D ]  }|d-   }|j                  |      xs d}|d.   xs d d/| d/| j                         }||v sA|j5                  ||xs	 |d.   xs ||d.   xs d|d(   xs d|d0   d1       t        |      |k\  s n t        |      t        |      t        |      t        |      d2}t        ||tE        |j9                               ||||3      S # |j                          w xY w# t        $ r g }	Y w xY w# t"        t$        f$ r d}Y w xY w# |j                          w xY w# t        $ r g }Y 4w xY wc c}w # t"        t$        f$ r d}Y w xY w# |j                          w xY w# t        $ r Y w xY w)4uc  Unified cross-tool audit search by reference (DVI-1043).

    A single query string is matched against the three audit stores and the
    hits are returned grouped by source so the Auditor UI can deep-link each
    one into its existing per-tool view:

    * ``wor``     — Work Order lifecycle events in ``history.db`` (grouped per
                    WO, links to the WO audit-trail popup).
    * ``history`` — every other tool's ``tool_history`` rows (links to that
                    tool's History sub-tab).
    * ``bot``     — bot pipeline runs in ``bot_diagnostics.db`` (links to the
                    Diagnosis → Bots run view).
    * ``vision``  — Vision agents matched by machine/friendly name (links to
                    the Diagnosis → Vision agent view).

    Typical refs: a WO number (``47260``), a machine name, or a quote number.
    r   Nr  r7   r   z&Enter at least 2 characters to search.r  r`  r   rM
  r^   rd  r  a  SELECT id, tool, action, actor, summary, ref, detail, created_at FROM tool_history WHERE LOWER(COALESCE(ref,'')) LIKE ?    OR LOWER(COALESCE(summary,'')) LIKE ?    OR LOWER(COALESCE(detail,'')) LIKE ?    OR LOWER(COALESCE(actor,'')) LIKE ? ORDER BY created_at DESC, id DESC LIMIT 2000r	  r  r  r  r  r
  r  r  r  r  r
	  r		  r  )r  r
  latest_actionlatest_summarylatest_actorr  r:  r:  )r  r  r		  r
	  r  r  a@  SELECT id, bot, trigger, target_date, started_at, status,       summary_json, error FROM runs WHERE LOWER(COALESCE(summary_json,'')) LIKE ?    OR LOWER(COALESCE(error,'')) LIKE ?    OR LOWER(COALESCE(trigger,'')) LIKE ?    OR LOWER(COALESCE(target_date,'')) LIKE ?    OR LOWER(id) LIKE ? ORDER BY started_at DESC LIMIT ?r3  )r6  r7  r:  r  r  rP  r  r5  r4  rd   rq  r  )rP  rf  r5  r4  rd   rq  r
	  z_SELECT machine_id, hostname, last_seen_at, status FROM vision_agents ORDER BY last_seen_at DESC
machine_idr!  r\   last_seen_at)r!  r9   r!  rd   r"  )worr  rP  r  )r  countsr@  r#  r  rP  r  )#r   r   rh  r   r   rC  r   rL  rC  r   rE  r  rL  r  r[  ry   r   r   r   r   r   r*   rw   rF  r  rb  ri   r  r  rA  _open_dbr  _load_vision_agent_names
_vision_dbr  )r  r  rx  likewor_hitshistory_hitsbot_hitsvision_hitsrO  rn  
wor_groupsr  r	  r  r   r  r
  browssummsjr  r0
  r.  arowsr#  midfriendlyr  r$  s                                r1   auditor_searchr4  7g s   ( 			#		$"++-A
1vzEFKK
c#gll&&w39r:A>
DCqwwykD46BN1HlHk}	<< tT4(
 hj  JJL J &X;Qx[1 V9#E&$'FJJz,Bz*5#ah-":":5"A%u%AyIIne4;<T#aggaj/#!67% % (%&x[&'	l&8b$%gJ$4""#L/) Ju% hK1K< 3&##fIkwZ-2 |1rU8>r"#L/% ?&N J%%'(#.H!!#	LL4 tT4s3 hj  JJL  ^[[>!23b$'M) !R !"s!BqE7^ )E )  99U+D 	U8g|)r]+1rk'RL/1'
 0b
 	.(*|	LLA hj 
 JJLWWY 	AL/Cyy~+Hz](b)8*AcU;AACCSy"""%$<*< !* 3k/R$%n$5$  {#s*	$ 8}|$8}k"	F 
&--/" w JJL  	* j JJL ) 	* , JJL   s   3
R >$Q2 "R RS )&R1 S -(SS*ST
 S5 6A"T
 AT
 T
 2RR RRR.-R.1SS SSSS21S25TT
 
	TTc                      t        j                  t        t              d      } t         j                  | _        | j                  d       | j                  d       | S )zGOpen diag_timeline.db, creating the schema on first use. Caller closes.FrD  rF  uN  
        CREATE TABLE IF NOT EXISTS diag_events (
            source_key TEXT PRIMARY KEY,  -- "<source>:<native id>" — idempotent upsert key
            source     TEXT NOT NULL,     -- 'bot' | 'vision'
            tool       TEXT NOT NULL,     -- bot name / 'vision'
            event_type TEXT NOT NULL,     -- 'run.<status>' | 'agent.event' | 'recovery'
            ts         REAL NOT NULL,     -- epoch seconds
            status     TEXT,              -- raw source status (normalized to a badge on read)
            actor      TEXT,
            ref        TEXT,              -- machine name / target date / WO ref, for search
            title      TEXT,              -- one-line human summary
            detail     TEXT,              -- JSON blob
            link_kind  TEXT,              -- 'bot' | 'vision' — how the UI deep-links
            link_id    TEXT               -- run id / machine id
        );
        CREATE INDEX IF NOT EXISTS idx_diag_events_ts ON diag_events (ts DESC);
        CREATE INDEX IF NOT EXISTS idx_diag_events_source_ts ON diag_events (source, ts DESC);
    )rG  rH  r*   DIAG_TIMELINE_DB_PATHrJ  rK  rL  rM  rN  s    r1   _diag_timeline_dbr7  h sK    ??345OD{{DLL*+  	$ Kr3   c                     | xs dj                         dv rydv ryt        fddD              ryd	k(  ry	d
k(  ry
y)z<Map a raw source status onto a normalized UI badge/severity.r7   )
rU  r  successcompleter   rv  r  r  r  r  rU  )r  ro   r  noopr  c              3   &   K   | ]  }|v  
 y wrT   rX   )r,  r  r  s     r1   r/  z_diag_badge.<locals>.<genexpr>#h s     
Ka16
Kr  )r  failcrashr7  deniedr  r8  r   rl   )rE  r2  )rd   r  s    @r1   _diag_badger@  h s[    	2A . .22

KJ
KKI~Kr3   c                  j   ddl } g }	 t        j                         }	 |j                  d      j	                         }|j                          	 |D ]%  }|d   xs	 |d   xs d}g }|d   rt	 | j                  |d         }t        |t              rOt        |      dd D ]>  }||   }	t        |	t        t        t        t        f      s)|j                  | d|	        @ d	j%                  |      xs |d
   xs	 |d   xs d}
|j                  d|d    d|d   xs dd|d   xs d t        |xs d      |d   xs d|d   xs d|d   xs d|
|d
   r| j'                  d
|d
   i      ndd|d   f       ( |S # |j                          w xY w# t        $ r |cY S w xY w# t         t"        f$ r Y w xY w)uJ   Adapter: bot pipeline runs (bot_diagnostics.db) → normalized event rows.r   NzSELECT id, bot, trigger, target_date, started_at, finished_at,       status, summary_json, error FROM runs ORDER BY started_at DESC LIMIT 1000rq  rr  r3  r   r  r  r  r5  r:  zbot:r  rP  zrun.rd   r2  r7   r4  )r   rA  r%  rL  r  r[  ry   r   r   r   r  r*   r   r  r   ri   r   r   r  r   )r  r   rO  rn  r  r_   bitsr0  r  r  re  s              r1   _diag_ingest_botrC  ,h s   
C!!#	<<A hj	  JJL  |_5- 05A^[[>!23b$'!"Xbq\ 4qE%a#sE4)@A KK1#Qqc
34 		$HAgJ$G!I,$G%

1T7)eQuX%61X;+),-uRW1~hK2q|1r1]3C3Ir23G*EKK!G*-.$1T7
 	, J3 JJL 
 	* s@   F E: F +AF F :FF FF F21F2c                     ddl } g }	 t               }	 t               }	 	 |j	                  d      j                         }|D ]  }|d   }|j                  |      xs |}|d   xs	 |d   xs d}|d   xs ddd	 xs d}	|j                  d
|d    dddt        |xs d      dd||d   xs d|	r| j                  d|	i      ndd|f        	 |j	                  d      j                         }
|
D ]  }|d   }|j                  |      xs |}|d   xs d}|d   xs d}|j                  d|d    dddt        |d   xs d      ||d   xs d|d| d| dj                         | j                  |d   |d   d      d|f        	 |j                          |S # t        $ r i }Y w xY w# t        $ r |cY S w xY w# t        $ r g }Y w xY w# t        $ r g }
Y w xY w# |j                          w xY w)zGAdapter: Vision agent error events + auto-recovery actions (vision.db).r   NzwSELECT id, machine_id, signature, detail, ts, received_at FROM vision_agent_events ORDER BY received_at DESC LIMIT 1000r!  r_   received_atr	  r7   r  z
vision-ev:r  r  zagent.eventr  	signaturezagent eventz{SELECT id, ts, machine_id, signature, action, actor, outcome, detail FROM vision_recovery_audit ORDER BY ts DESC LIMIT 1000r  outcomezvision-rec:recoveryr		  zauto-recoveryzAuto-recovery: r:  r7  )rF  r	  )r   r&  ry   r'  rL  r  r   ri   r  r   r   r[  )r  r   r.  rO  erowsr  r2  r9   r_   detrrowsr  rG  s                r1   _diag_ingest_visionrL  Uh sb   
C(*|(	LLQ hj   	AL/C99S>(SD41Am,1BX;$"ds+3tCJJQtWI&(MbgAT+/-03XsO,# 		LLJ hj   	AL/C99S>(SDx[&BF	l(bGJJagY'8Zagl#Waj.KOT"6("WIQ7>>@!K.AhKPQ# 	 	

J_    
  	E	&  	E	 	

sp   
F 
F* F; BG G +BG F'&F'*F87F8;G
G 	G

G GG GG G0c                     g } t         D ]  }	 | j                   |               t               }	 |5  |j                  d       |j                  d|        ddd       |j                          t        j                         at        |       S # t        $ r- t        j                  j                  d|j                         Y w xY w# 1 sw Y   qxY w# |j                          w xY w)zBackfill/refresh the unified store by running every ingestion adapter and
    fully replacing `diag_events` in one transaction (idempotent).zdiag timeline adapter %s failedzDELETE FROM diag_eventszINSERT OR REPLACE INTO diag_events (source_key, source, tool, event_type, ts, status, actor, ref,  title, detail, link_kind, link_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)N)_DIAG_ADAPTERSr  ry   rm   rn   r  r  r7  rL  executemanyr[  rU   _diag_timeline_built_atrC  )rn  adapterrO  s      r1   _diag_timeline_rebuildrR  h s     D! V	VKK	"V
 D 	LL23? 	 	

"iikt9!  	VJJ  !BGDTDTU	V	 	 	

s3   BC $CC 3CCCC C$c                    t         5  | s"t        j                         t        z
  t        kD  r	 t	               cddd       S ddd       y# t
        $ r" t        j                  j                  d       Y 3w xY w# 1 sw Y   yxY w)zuRebuild if forced or the cache is older than the TTL. Serialized so
    concurrent requests don't stampede a rebuild.Nzdiag timeline rebuild failed)	_diag_timeline_lockrU   rP  _DIAG_TIMELINE_TTLrR  ry   rm   rn   r  r  s    r1   _diag_timeline_maybe_rebuildrV  h sy     
 ETYY[#::>PPE-/E E E   E

$$%CDE	E s(   %A7	A		(A41A73A44A77B z/auditor/diagnostics/timeliner  c                     ddl } t        t        j                  j	                  d      dk(         t        j                  j	                  d      xs dj                         j                         }t        j                  j	                  d      xs dj                         j                         }t        j                  j	                  d	      xs dj                         j                         }t        t        t        t        j                  j	                  d
d      xs d      d      d      }d}g g }}|dv r"|j                  d       |j                  |       |r,d| d}|j                  d       |j                  |gdz         |r|ddj                  |      z   z  }|dz  }t               }		 |	j                  ||      j                         }
|	j!                          g ddd}}|
D ]  }t#        |d	         }|r||k7  rt%        |      |k\  r n|j	                  |d   d      dz   ||d   <   d}|d   r	 | j'                  |d         }|j                  |d   |d   |d   |d   |d	   xs d||d   xs d|d   xs d|d   xs d||d   xs d|d   xs dd         t-        ||t%        |      t.        !      S # |	j!                          w xY w# t(        t*        f$ r d}Y w xY w)"a  Unified cross-tool diagnostics timeline (DVI-1047).

    Serves the merged, time-ordered event stream from the `diag_events` cache
    (lazily rebuilt when stale, or forced with ``?refresh=1``). Supports free-text
    search (``q``) across title/ref/actor/tool/detail, a ``source`` filter
    (bot/vision) applied in SQL, and a normalized ``status`` badge filter applied
    after mapping raw statuses.
    r   NrefreshrE   r  r  r7   r	  rd   r   rK   r^   rs	  zkSELECT source, tool, event_type, ts, status, actor, ref, title, detail, link_kind, link_id FROM diag_events)rP  r  z
source = ?r  z(LOWER(COALESCE(title,'')) LIKE ? OR LOWER(COALESCE(ref,'')) LIKE ? OR LOWER(COALESCE(actor,'')) LIKE ? OR LOWER(COALESCE(tool,'')) LIKE ? OR LOWER(COALESCE(detail,'')) LIKE ?)r   r  r  z ORDER BY ts DESC LIMIT 2000r	  r  
event_typer_   r		  r  re  	link_kindlink_id)r	  r  rY  r_   rd   badger		  r  re  r	  rZ  r[  )r:  r$  r@  built_at)r   rV  r   rh  r   r   rE  rL  rC  r   ri   r  r  r7  rL  r  r[  r@  rC  r   r   r   r   rP  )r  r  r	  rd   rx  r  r  r  r(  rO  rn  r:  r$  r  r\  rJ  s                   r1   auditor_diag_timeliner^  h s     (8(8(Cs(JL			#		$"++-335Allx(.B557==?Fllx(.B557==?F
c#gll&&w4;<a@$
GC:C6E""\"f1#Qx > 	?
 	tfqj!y7<<...))CD||C(113

a0FF AhK(evov;#$jj8a81<q{X;kk!H+. 	kfIL/D'k'RwZ%2U8>rwZ%2;-2|)r
 	6 &s6{35 5= 	

 	* s   2 K  :K KK)(K)ReopenedAccepted	Completed	Cancelledrp  Preventative)r   r   r   r   rs
  rt
  c                    | j                  d      xs i }| j                  d      }|dk(  ry|dk(  r%t        j                  |j                  d      d      S |dk(  r|j                  d	      rd
S dS |dk(  ry|dk(  ry|xs dS )Nr	  r  r  CreatedrX  rW  zStatus changedr  r  zRtS approved (finalized)zRtS approvedr~  zRtS rejectedr  EditedEvent)r   _WOR_STATUS_LABELS)r-  rn  r  s      r1   _wor_event_labelri  i s    
xBAVVHF!!!%%aeeL&9;KLL-.UU>-B)VVWr3   c                     t        | t              r| syg }| j                         D ]$  \  }}|ddg i fv r|j                  | d|        & dj	                  |      S )z<Compact one-cell rendering of a history event's detail JSON.r7   Nr  ; )r   r   r   ri   r  )r	  r0
  r  r  s       r1   _flatten_detailrl  "i sj    fd#6E !1r2r""s!A3Z ! 99Ur3   c                 l   ddl }|j                         }t        j                  |      }|j	                  |       |D ]%  }|j	                  |D cg c]  }|dn|
 c}       ' t        |j                         j                  d            }d|j                  d<   d|  d|j                  d	<   |S c c}w )
zCBuild a UTF-8 CSV attachment response (BOM so Excel keeps unicode).r   Nr7   r  ztext/csv; charset=utf-8r  attachment; filename="r  r  )	r>  StringIOr  r  rs  r   getvaluer  rx   )	r+  ry  rn  r
  r2  r  r  r  r  s	            r1   _csv_attachmentrq  .i s    
,,.CZZ_F
OOF ?=Aqya/=>?..{;<D#<DLL ,B=/QR*SDLL&'K	 >s   B1
c                 t    t        j                  ddt        | xs d            j                  d      }|xs |S )Nr\  r  r7   r]  )r&  r  slugs      r1   
_safe_slugrt  <i s3    66%sC
O<BB3GD8r3   i'  z/history/<tool>/export.csvc                    t         j                  j                  dd      j                         j	                         }t         j                  j                  dd      j                         j	                         }t         j                  j                  dd      j                         j	                         }| dk(  rt        t        d||xs |      \  }}n| dk(  rt        t        d||xs |      \  }}na| dk(  rt        t        d|||      \  }}nD| d	k(  rt        t        d|||      \  }}n'd
g| g}}|r&|j                  d       |j                  d| d       |r&|j                  d       |j                  d| d       |r |j                  d       |d| dd| dgz  }ddj                  |      z   }t               }		 |	j                  d|z   dz   |t        gz         j                         }
|	j                          ddl}|
D cg c]C  }|d   |d   |d   xs d|d   xs d|d   xs d|d   |d   r|j#                  |d         nddE }}g d}|D cg c]  }|j                  dd      |j                  d|       |j                  dd      |j                  dd      |j                  dd      |j                  dd      t%        |j                  d            g }}t'        j(                         j+                  d      }t-        dt/        |        d| d||      S # |	j                          w xY wc c}w c c}w ) z@Export a tool's history (matching the sub-tab's filters) as CSV.r'  r7   r  r  rA  r   r  r  r  r  r  r  r  r  r  r  zNSELECT tool, action, actor, summary, ref, created_at, detail FROM tool_historyz! ORDER BY created_at DESC LIMIT ?Nr  r		  r
	  r  r  r	  )r  r  r		  r
	  r  r  r	  )rK
  ToolActionr^  Summary	ReferenceDetailsrO  history_r  r  )r   rh  r   r   rE  r  _HISTORY_EXPORT_CAPr  r  r  ri   r  r  rL  r  r[  r   r   rl  r  r  r  rq  rt  )r  r  r  r  r  r  r  r  r  rO  rn  r  r  ry  rB  	data_rowsstamps                    r1   history_tool_export_csvr  Di sD    \\fb)//1779F||"-335;;=H
,,

3
#
)
)
+
1
1
3Cw.FHO=
	"	"8FHO=
		/FHc;
		4FHc;
 $tfvLL;<MMAfXQ-(LL<=MMAhZq/*LLab3%qzQse1:..FW\\%00}	<<%'-.34 -..	
 hj  JJL 
 	 fI8qz?OR|)r!E(.bL/23H+ekk!H+.4	
   SF
 	  	
lBvt!4aeeHb6I	grAEE)R0!%%r2Bh( I 
 !!#,,X6EXj&6%7qtDfiXX! JJLs   .L( AL=BM(L:z/prodrun-history/export.csvc                  N   t               } t        | d      r$| j                         xs i j                  dg       ng }g d}g }|D ]*  }|j                  d      }|r't	        j
                  |dz        j                  d      nd}|j                  ||j                  dd      |j                  d	d      |j                  d
d      |j                  dd      |j                  dd      |j                  d      |j                  d      |j                  dd      |j                  dd      |j                  dd      dj                  |j                  dg             |j                  dd      g       - t	        j                         j                  d      }t        d| d||      S )zDExport the Auditor Production Run sub-tab (with its filters) as CSV.r  r  )rK
  zJob IDzCSV fileCustomerzSO #ItemszDrawing pageszLabel pageszFailed downloadsCorrectionsStatus
Categoriesr]  rt	  rs	  r  r7   r^  r  r	  r	  r  r   ru	  rv	  r  r  rd   r  r	  r  rO  history_production_run_r  )r	  hasattrr  r   r  r  r  ri   r  r  rq  )r  r  ry  r}  r  r_   whenr~  s           r1   prodrun_history_export_csvr  i s}    D6=dJ6ODMMO!r&&vr2UWD/F I UU>" //T	:CCDXY 	!%%"%quu^R'@EE*b!1552#6lA8NEE/"AEE-$8EE$a(!%%0A1*EEE(B155r+B!CEE'2
 		 !!#,,X6E4UG4@&)TTr3   z/winston/run-history/export.csvc                     t               } t        | d      r$| j                         xs i j                  dg       ng }g d}|D cg c]  }|j                  dd      |j                  dd      |j                  dd      |j                  dd      |j                  d	      rd
nd|j                  dd      |j                  dd      |j                  dd      |j                  dd      |j                  dd      |j                  dd      |j                  d      xs dg }}t	        j
                         j                  d      }t        d| d||      S c c}w )uB   Export the Auditor History → Bots (Winston runs) sub-tab as CSV.r  r=  )zRan atzTarget datezTriggered byr  r  zQuotes foundzDrafts createdzDrafts skippedzDrafts failedz
PDF routedzPDF route failedr]  r=  r7   r   r  rd   rE  rf  r  r6  r   r7  r8  r9  r:  r;  r  rO  history_bots_winston_r  )rF  r  r  r   r  r  r  rq  )r  r=  ry  r  r}  r~  s         r1   winston_run_history_export_csvr  i sA     D<CD*<Ut}}$")))R8[]G9F   	
hQUU62.nb0I	haeeI&6UD	na !%%(8!"<	"AEE/1$=	lA&8! <aeeGn>RPR I  !!#,,X6E25'>	RRs   CEz/history/wor/<ref>/export.csvc                    t        |       \  }}|rt        |      dfS g d}|j                  dg       D cg c]k  }|j                  dd      t        |      |j                  dd      |j                  dd      |j                  d	d      t	        |j                  d
            gm }}t        |j                  d      xs |d   d      }t        d| d||      S c c}w )zBExport one Work Order's recorded event timeline as CSV (DVI-1044).r  r`  )rK
  rg  rw  r^  rx  rz  r:  r  r7   r  r		  r
	  r	  r
  r  
work_orderaudit_r  )r  r   r   ri  rl  rt  rq  )r  r  r  ry  r-  r}  r9   s          r1   history_wor_export_csvr  i s     &c*LGS
S!3&&FF Hb)+  	|R "22"6x8L
wRVVIr2OBFF8DT4U +I + gkk*-D1DlSDVD6.	BB+s   A0Cz/history/wor/<ref>/export.pdfc                 l   !"#$ t        |       \  }}|rt        |      dfS d d }t        ddd      !!j                  d	d
       !j	                          !j
                  dz
  "d#d$d  !"#$fd}!"fd}!j                  ddd       !j                  dddd       !j                  ddd       !j                  dddd       !j                  ddd       !j                  ddd       !j                  dd  |d!    d"t        j                         j                  d#      z   d$t        d%   j                  d&d      xs d' z         d        !j                     !j                  d(        |d)        |d*|j                  d+              |d,|j                  d-              |d.t        j                  |j                  d/      |j                  d/      xs d0             |j                  d1      }|j                  d2      xs i }|s$|j                  d3      s|j                  d4      r|}|r|j                  d3      s|j                  d4      r |d5        |d6|j                  d3              |d7|j                  d4             |j                  d8      xs d} ||j                  d9            }	|s|	r[!j                  dd:d;       !j                  ddd       !j                  "d  | |	rd<|	z   nd       d        !j                     |r|j                  d=      sg r |d>       |j                  d?      xs i }
d@dAdBdC}!j                  ddd       !j                  "d  dD|j                  |j                  dE      |j                  dE      xs d0      z         d       t         D ]  }||j                  d=      xs g vr|
j                  |      xs i }|j                  dF      }|dGk(  rdAn|dHk(  rdBndI}|j                  dJ      xs |j                  dK      xs d} ||j                  dL            }	!j                  ddd;       !j                  ddM t"        j                  ||                   !j                  ddd;       |}|r|d<| z  }|	r|d<|	 z  }!j%                  "dz
  dM |             |j                  dN      s)!j                  dd:d;       !j'                  !j(                  dz          !j%                  "dz
  d  |dHk(  rdOndP|dN   z                 |j                  dQ      rh!j                  ddd;       !j                  ddRd       !j                  "dM dS ||j                  dQ            z         d        !j                      |dT       |j                  dU      xs g }|s0!j                  ddd       !j                  "dM dV      d       n |D ]  }!j                  ddd;       !j                  "dWz  d  t+        |                   !j                  ddd;       !j                  "dXz  d   ||j                  dY                  dZd[       |j                  d\      xs d0}t-        |j                  d]            }|r|d<|z   z  }!j                  ddd;       !j                  ddd       !j%                  "d^ |              !j                     !j                  d        t/        !j1                               }t2        j4                  j                  d_      d`v }g }|sJ|j                  da      r|j7                  |da          |j                  db      r|j7                  |db          |}|r	 ddclm}  |       }|j7                  t=        j>                  |             |D ]  }	 |j7                  tA        |              t=        j>                         }|jE                  |       |jG                         }tI        |j                  d!      xs |d-   dd      }tK        t=        j>                  |      ded	df| dgh      S # tB        $ r Y w xY w# tB        $ r |}Y dw xY w)iaj  Printable PDF audit packet for one Work Order (DVI-1044).

    Renders a summary page (metadata + completion details + Return-to-Service
    sign-offs + event timeline) then, where the source PDFs exist, appends the
    WO Request PDF and the EVH form PDF so the packet is a self-contained
    compliance record. ``?summary_only=1`` skips the appended PDFs.
    r  r`  c                     t        | xs d      } dD ]  \  }}| j                  ||      }  | j                  dd      j                  d      S rb
  rh
  ri
  s      r1   rl
  z#history_wor_export_pdf.<locals>._l1i sT    RL8 	%IC 		#t$A	% xx	9-44Y??r3   c                     | sy	 t        j                  t        |       j                  dd            j	                  d      S # t
        t        f$ r t        |       cY S w xY w)Nr7   r  r  %Y-%m-%d %H:%M)r  r  r*   r  r  r   r   )r_   s    r1   _fmt_tsz'history_wor_export_pdf.<locals>._fmt_tsi s[    	!//BX.0089I0JKI& 	r7N	s   <A AAr	  r  r	  r	  Tr  r_
  rx  r	  r	  r	  c                     j                  d        j                     j                    j                  ddd       j	                  dd |       z   dd	        j                    y )
Nr   r	  r	  r   r   r	  Tr^   r	  )r	  r	  r	  r	  r	  )re  rl
  r	  r6  r	  r	  r	  s    r1   r	  z.history_wor_export_pdf.<locals>.section_headeri sm    q	D!E"[#r*Qs5z);E"r3   c                    j                  ddd       j                  d |              j                  d       j                  ddd       j                  d |xs d             j                  d	       y )
Nr	  r	  r   r   r7   ry  r	  rc
  r^   )r	  r	  r	  r	
  )r   r  rl
  r6  r	  s     r1   r  z%history_wor_export_pdf.<locals>.fieldi sf    [#q)QE
#q	["a(r3ELS 12q	r3   r	  r	     #   r  r	  r   r	  r>	  zWork Order Audit Packetr^   r7   ry  r  r   r
  z  -  generated %Y-%m-%d %H:%M UTCz  by r'  r  r  r   rn
  Titlere  Filer  Typer  rc
  rj  r  rq
  rr
  zCompletion Detailsrp
  zOutstanding Follow-uprg  rh  r	  r  z  -  rk  zReturn-to-Service Sign-offsr  zAwaiting approvalApprovedRejected)r
  r  r|  z	Overall: rd   r  r  rz  Pendingru
  r  rv
  r	  rm  rw
  zNote: r  r  zReleased to production - zEvent Timeline (recorded)r:  z'No recorded events for this work order.gffffff?g333333?r  r4
  )r6
  r	  r		  r	  r	  summary_onlyrr	  r  r  r  r  r  audit_packet_r   r  )&r  r   r   r
  r
  r  r	  r	  r	  r  r  r  r   r   r	  _WOR_TYPE_LABELSrO  _RTS_DEPT_LABELSr	
  r
  r
  ri  rl  r  r  r   rh  ri   rJ  r  r>  rJ  r*   ry   r  rp  rt  r   )%r  r  r  r  r	  r  compr  r	  r  r  
status_lblrP  r  r  ro  r
  rs   r:  r-  r  rJ  summary_bytesr  attach_paths	out_bytesr  r  r  r  r9   rl
  r	  r6  r	  r	  r	  s%                                  @@@@@@r1   history_wor_export_pdfr  i s*    &c*LGS
S!3&&@ 3T(
;Cb1LLN	BDEE# # LLc2&HHRWH#LLc2&HHQ-!H4LLb!$r2r"HHQ3:
/



!
*
*+?
@	A'&/%%gr2CmD
E	FG LM  N CFF1I< 	'7;;w'(	&'++j)*	&"&&w{{:'>J@W@^[^_`;;|$D
++e

"CSWW/0CGG<R4S,-:P1Q+,-txx8J/KL%txx0F'GHhh~&,"txx/0$LLc1-r2r*HHRC3%$$B'G HIaHPC&
()R45GGK(.B	+>",*F
["a(QK*..9JCGGT\L]Ldad*eefklm 	qDCGG$45;<t$*BVVH%F"(I"5J&T\J\JbkEvvo.P"&&9I2JPbH266+./DLLc1-HHRc"2"6"6tT"BCDLLb!,D%z**%v&NN27CT3vvh[#q1		#,,+,rBw3f>P
V^bdembn/n+op)	q* 77./LLb!,q#q)HHRc"=PkHl@m"mnstHuC&./[["(bF["a(S#GHQO 	BLLc1-HHR#Xq#&6r&:";<LLb!,HHR#Xq#gbff\.B&C"DCTUHV66'?)cD!"&&"23C#%LLb!,r2r*NN2sCI.C&FF1I	 #**,'M<<##N37KKLL;;z"
 34;;~& 78I	&'[FMM"**]34! MM#a&)
 ZZ\FLL )I gkk*-D1DlSDRZZ	*5F#'tfD7QS S ! 
  	&%I	&s6   6b% =b7b% 	b"b% !b""b% %b32b3z/togen-admin/historyc                  (    t        t                     S )z Return history capture settings.)r   ru  rX   r3   r1   togen_admin_history_getr  nj r	  r3   c                     t        j                  d      xs i } t               }t        | j	                  d      t
              r4| d   j                         D ]  \  }}t        |t              s||d   |<     t        | j	                  d      t
              rx| d   }dD ]>  }||v s||   }|(t        |t              r|dkD  st        | d      d	fc S ||d   |<   @ d
|v r,t        |d
   t              st        d      d	fS |d
   |d   d
<   t        j                  t        j                  |d             t        d|d   |d         S )zMSave per-tool history capture toggles + retention/archival policy (DVI-1045).Tr  r  rq  r  r   z# must be a positive integer or nullr  r`  rt  zarchive must be a booleanr   r   )rU  r  rq  )r   r  ru  r   r   r   r   r   r   r   r  r   r   r   )r  r  r  r  r  s        r1   togen_admin_history_postr  uj sU    4(.BD%'H$((9%t,O))+ 	+DAq!T")*#A&	+ $((;'.;8 	-ACxF=*Q*<Q"A3.Q)RSUXXX+,%a(	- c)nd3%@A3FF/29~H[!),$$TZZ%CDdHY$78KCXYYr3   z/togen-admin/history/telemetryc                  (    t        t                     S )zHHistory.db size/age + archive telemetry, surfaced in Auditor (DVI-1045).)r   r  rX   r3   r1   togen_admin_history_telemetryr  j s     %'((r3   z/togen-admin/history/prunec                  @    t                t        dt                     S )zLRun the archive+prune sweep now, then return refreshed telemetry (DVI-1045).T)rU  	telemetry)r  r   r  rX   r3   r1   togen_admin_history_pruner  j s     d&8&:;;r3     open_enroll_secret)r  rv  revokedc                      t        j                  t        t              d      } t         j                  | _        | j                  d       | j                  d       | S )zJOpen the Print SQLite DB, creating tables on first use. Caller must close.FrD  rF  ak  
        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);
    )rG  rH  r*   PRINT_DB_PATHrJ  rK  rL  rM  rN  s    r1   rS  rS  j sM    ??3}-GD{{DLL*+ I I	T Kr3   c                 V    | j                  d|f      j                         }|r|d   S d S )Nz(SELECT value FROM print_kv WHERE key = ?r  rL  ru  rO  r)  r  s      r1   _print_kv_getr  j s/    
,,AC6
J
S
S
UC3w<(D(r3   c                 R    | j                  d||t        j                         f       y )NzINSERT INTO print_kv (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_atrL  rU   rO  r)  r  s      r1   _print_kv_setr  k %    LL	, 
eTYY[!	r3   c                     t        | t              }|sA|r?t        j                         j                  }t        | t        |       | j                          |S zGReturn the current open-enroll shared secret, minting one on first use.)r  _PRINT_OPEN_ENROLL_KEYrW  rX  rY  r  rZ  rO  r  secrets      r1   _print_get_open_enroll_secretr  k s@    4!78Ff!!d2F;Mr3   c                 |   t        j                          }|xs g D ]  }t        |t              s|j                  d      xs dj	                         }|s:| j                  d|||j                  d      xs d|j                  d      xs d|j                  d      rdnd|j                  d	      xs d||f        y
)zUpsert 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.rO  r7   a  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_atr  port
is_defaultr^   r   rd   N)rU   r   r   r   r   rL  )rO  r!  printersr  r  rO  s         r1   _print_upsert_printersr  k s     ))+C^ 
!T"EE*%+224	; 155?#8b!%%-:M2%%%Q1aeeHo.C#	

r3   z/print/agents/enrollc                     t        j                  d      xs i } | j                  d      xs dj                         xs d}t	        t        j                               }t        j                         }t               }	 |j                  d||||t        z   f       |j                          |j                          t        |||rd	      S d	      S # |j                          w xY w)
u   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).Tr  r!  r7   NzbINSERT INTO print_enrollment_tokens (token, machine_id, issued_at, expires_at) VALUES (?, ?, ?, ?)quickselfenrollment_tokenr!  rO  )r   r  r   r   r*   rW  rX  rU   rS  rL  _PRINT_ENROLL_TTL_SrZ  r[  r   r  r!  rD  r  rO  s        r1   print_agents_enrollr  1k s     4(.BD((<(.B557?4J

E
))+C;D#JS+>%>?	

 	

Ej#-; ;39; ; 	

s   >-C C$z/print/agents/registerc            
         t        j                  d      xs i } t         j                  j                  dd      }| j                  d      xs& |j	                  d      r|dd j                         nd}| j                  d	      xs dj                         }| j                  d
      xs dj                         }| j                  d      xs dj                         xs d}|s|st        d      dfS t        j                         }t               }	 t        |d      }t        |      xr ||k(  xs ||k(  }	|	r%|st        d      df|j                          S |d}}
n|j                  d|f      j                         }|st        d      df|j                          S |d   t        d      df|j                          S ||d   kD  rt        d      df|j                          S |d
   r0|r&||d
   k7  rt        d      df|j                          S |d
   d}}
n$|st        d      df|j                          S |d}}
|j                  d||f       |j                  d|
f      j                         }|r|d   }|d    }n<t        t        j                                }|j                  d!|
|||||d"k(  rd#nd$f       |j#                          |j                          t        ||
|%      S # |j                          w xY w)&u   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.
    Tr  r  r7   r  r  r   Nr  r!  r!  0enrollment_token or open_enroll_secret required.r  r`  Fr  $machine_id required for self-enroll.r  z5SELECT * FROM print_enrollment_tokens WHERE token = ?Invalid enrollment_token.r  consumed_atenrollment_token already used.r.  enrollment_token expired.%machine_id does not match enrollment.r  rv  zBUPDATE print_enrollment_tokens SET consumed_at = ? WHERE token = ?zASELECT agent_token, status FROM print_agents WHERE machine_id = ?agent_tokenrd   ztINSERT INTO print_agents (machine_id, hostname, agent_token, enrolled_at, status, revoked) VALUES (?, ?, ?, ?, ?, ?)r  r^   r   r  r!  rd   )r   r  rx   r   rw   r   r   rU   rS  r  r   r[  rL  ru  r*   rW  rX  rZ  )r  auth
credentialopen_secret_bodymachine_id_bodyr!  r  rO  open_secretvia_openeffective_machine_idrd   r  r  r  s                  r1   print_agent_registerr  Ik s    4(.BD??3D((-. K'+y'A48>>#r !56<"CCExx-3::<O$*113;tH.OPRUUU
))+C;D33DG$ 
+Hz[/H 	 "%KLcQV 	

U ,;I& ,,G*hj  %@A3FH 	

G =!-%EFKD 	

C S&&%@A3F@ 	

? < "#l:K'K")PQSVV: 	

9 03</@(f$&")OPRUU2 	

1 0?	f$LLTj!
 <<O!#
 (* 	 "=1Kh'Fdjjl+KLL- &xc6	)q2	 	

{7KTZ[[ 	

s1   7K. 5K.  K. K. )K. K. >BK. .L z/print/checkinc            
      >   t         j                  j                  dd      } | j                  d      r| dd j	                         nd}|st        d      dfS t               }	 |j                  d	|f      j                         }|st        d
      df|j                          S |d   }|d   }t        j                  d      xs i }t        j                         }|j                  d||j                  d      |j                  d      |j                  d      |j                  d      |f       |dk(  rt        |||j                  d             |j                  d|f      j                         }|r|j                  d||d   f       |j                          |j                          d}	|r,d}
|d   r	 t        j                  |d         }
|d   |d   |
d}	t        dd|	|      S # |j                          w xY w# t        j                   t"        f$ r d}
Y Mw xY w)u   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).r  r7   r  r   NMissing bearer agent_token.r  r  zQSELECT machine_id, status FROM print_agents WHERE agent_token = ? AND revoked = 0Invalid or revoked agent_token.r!  rd   Tr  zUPDATE 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 = ?r!  
os_versionlogged_in_useragent_versionrv  r  z|SELECT id, action, params_json FROM print_commands WHERE machine_id = ? AND acked_at IS NULL ORDER BY created_at ASC LIMIT 1zOUPDATE print_commands SET delivered_at = COALESCE(delivered_at, ?) WHERE id = ?r  params_jsonr  r  r  r  r   )acknext_interval_scommandagent_status)r   rx   r   rw   r   r   rS  rL  ru  r[  r  rU   r  rZ  r   r   r   r   )r  rD  rO  agentr!  r  r  r  cmd_rowr  r  s              r1   print_checkinr  k s   
 ??3D $	 :DHNNE:;S@@;D*_H
 (* 	 !BCSHH 	

E <(
Xt,2iik$ $((:&(>XX&'/)BJP		
 8#"4TXXj5IJ,,/ M	

 (* 	 LL gdm$
 	

G=!GM$:; !'(2CvVtR ,. . 	

 (()4 s%   "0G) #C7G) 6G> )G;>HHz/print/commands/<cmd_id>/ackc                 n   t         j                  j                  dd      }|j                  d      r|dd j	                         nd}|st        d      dfS t        j                  d	
      xs i }|j                  d      xs dj	                         }|dvrt        d      dfS |j                  d      xs d}t        j                         }t               }	 |j                  d|f      j                         }|st        d      df|j                          S |j                  d| |d   f      j                         }	|	st        d      df|j                          S |j                  d|||| f       |j                          |j                          t        d	      S # |j                          w xY w)4Agent acknowledges execution of a delivered command.r  r7   r  r   Nr  r  r  Tr  rd   r  status must be 'ok' or 'error'.r`  error_messageISELECT machine_id FROM print_agents WHERE agent_token = ? AND revoked = 0r  z=SELECT id FROM print_commands WHERE id = ? AND machine_id = ?r!  Command not found.r  zXUPDATE print_commands SET acked_at = ?, acked_status = ?, error_message = ? WHERE id = ?r  )r   rx   r   rw   r   r   r  rU   rS  rL  ru  r[  rZ  
cmd_idr  rD  r  rd   r  r  rO  r  ri  s
             r1   print_command_ackr  k s    ??3D $	 :DHNNE:;S@@4(.BDhhx &B--/F_$>?DDHH_-5M
))+C;DWH
 (* 	 !BCSH 	

 llKU<()
 (* 	 !56; 	

 	&-0	

 	

t 	

   0F" 4F"  &F" "F4z/print/agentsc                     t        j                          } t               }	 |j                  d      j                         }|j	                          g }|D ]n  }|d   }|j                  |d   |d   |d   |rt        |      ndt        |      xr | |z
  t        k  |d   t        |d         |d   |d	   |d
   |d   dd       p t        |      S # |j	                          w xY w)zList enrolled print agents with last-seen + status. Pending agents carry
    their fingerprint so the Print pane can render an approval card.zSELECT machine_id, hostname, enrolled_at, last_seen_at, revoked, status,       os_version, logged_in_user, agent_version FROM print_agents ORDER BY enrolled_at DESCr"  r!  r!  enrolled_atNrd   r  r  r  r  r!  r  r  r  )r!  r!  r  	last_seenonlinerd   r  fingerprintagents)
rU   rS  rL  r  r[  ri   r   r   PRINT_STALE_SECONDSr   )r  rO  rn  r  r  r  s         r1   print_agents_listr
   l s    
 ))+C;D||;
 (*	 	 	

F n%	L/*]++4Y$9oR3??R*RkAiL)jMo"#$4"5!"?!3	
 	" &!!' 	

s   C C"c                    | xs dj                         } t               }	 |j                  d| f      j                         }|sddidf|j	                          S |r%|d   dk7  rdd|d    d	id
f|j	                          S |j                  d||dk(  rdnd| f       |j                          |j	                          d| |ddfS # |j	                          w xY w)zFShared approve/deny/revoke transition. Returns (payload, http_status).r7   z4SELECT status FROM print_agents WHERE machine_id = ?r  machine_id not found.r  rd   r  machine is '', not pending.r9  zDUPDATE print_agents SET status = ?, revoked = ? WHERE machine_id = ?r  r^   r   TrU  r!  rd   rK   )r   rS  rL  ru  r[  rZ  )r!  rW  require_pendingrO  r  s        r1   _print_set_agent_statusr  $l s    "))+J;DllBZM

(* 	 45s: 	

 s8}	9|CM?/JKSP 	

 	RjI51jI	
 	

jJGLL 	

s   (C C  ,C Cz"/print/agents/<machine_id>/approvec                 >    t        | dd      \  }}t        di ||fS )u9   Approve a self-enrolled print agent (pending → active).rv  Tr  rX   r  r   r!  r  r3  s      r1   print_agent_approver  :l s*     ,JRVWMGTWt##r3   z/print/agents/<machine_id>/denyc                 >    t        | dd      \  }}t        di ||fS )u7   Deny a self-enrolled print agent (pending → revoked).r  Tr  rX   r  r  s      r1   print_agent_denyr  Bl s*     ,J	SWXMGTWt##r3   z!/print/agents/<machine_id>/revokec                 :    t        | d      \  }}t        di ||fS )zHRevoke a print agent's token (status=revoked); future check-ins refused.r  rX   r  r  s      r1   print_agent_revoker  Jl s'     ,J	BMGTWt##r3   z/print/agents/<machine_id>c                 &   | xs dj                         } t               }	 |j                  d| f      j                         }|st	        d      df|j                          S |d   st	        d      df|j                          S |j                  d	| f       |j                  d
| f       |j                  d| f       |j                  d| f       |j                          |j                          t	        d| d      S # |j                          w xY w)zDelete 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.r7   z5SELECT revoked FROM print_agents WHERE machine_id = ?r  r  r  r  0agent is not revoked; revoke it before deleting.r9  z+DELETE FROM print_jobs WHERE machine_id = ?z/DELETE FROM print_commands WHERE machine_id = ?z/DELETE FROM print_printers WHERE machine_id = ?z-DELETE FROM print_agents WHERE machine_id = ?TrU  r!  r	  )r   rS  rL  ru  r   r[  rZ  r!  rO  r  s      r1   print_agent_deleter  Rl s     "))+J;DllCj]

(* 	 !893> 	

 9~!STVYY 	

 	BZMRFVFVDzmT

dz4@@ 	

s   0C> !C> AC> >Dz#/print/agents/<machine_id>/printersc                 j   | xs dj                         } t               }	 |j                  d| f      j                         }|st	        d      df|j                          S |j                  d| f      j                         }|j                          |D cg c]i  }|d   |d   |d	   |d
   t        |d         |d   t        |d         t        |d         t        |d         |d   xs d|d   rt        |d         nddk }}t	        | |d   |d   |      S # |j                          w xY wc c}w )zAList the printers discovered by an agent with their Togen config.r7   zJSELECT machine_id, hostname, status FROM print_agents WHERE machine_id = ?r  r  r  zSELECT 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 NOCASEr  rO  r  r  r  printer_statusincludedrP  rQ  rN  r"  N)r  rO  r  r  r  r!  r"  rP  rQ  rN  r  r!  rd   )r!  r!  rd   r  )	r   rS  rL  ru  r   r[  r  r   r   )r!  rO  r  rn  r  r  s         r1   print_agent_printers_getr#  ll sf    "))+J;DXM
 (* 	 !893> 	

 ||Y M	

 (* 	 	

   gjMH+&	1\?+,-:'a011\?+,-3/0/@S>*+d H  j53D/H> > 	

s   0D !!D A.D0D-c           
      \   | xs dj                         } t        j                  d      xs i }|j                  d      }t	        |t
              st        d      dfS t               }	 |j                  d| f      j                         }|st        d	      d
f|j                          S |D ]  }t	        |t              rd|vr	 t        |d         }|j                  d      rdnd}|j                  d      rdnd}|j                  d      rdnd}	t        |j                  d      xs d      j                         }
|j                  d|||	|
|| f        |j!                          |j                          t        d      S # t        t        f$ r Y w xY w# |j                          w xY w)zSave 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.r7   Tr  r  zprinters list required.r  r`  z8SELECT machine_id FROM print_agents WHERE machine_id = ?r  r  r  r"  r^   r   rP  rQ  rN  z}UPDATE print_printers SET included = ?, for_documents = ?, for_labels = ?, published_name = ? WHERE id = ? AND machine_id = ?rY  )r   r   r  r   r   r  r   rS  rL  ru  r[  r   r   r   r   r*   rZ  )r!  r  r   rO  r  r|	  r  r"  rP  rQ  ru  s              r1   print_agent_printers_postr%  l s    "))+J4(.BDHHZ EeT"67<<;DF

(* 	 !893>& 	

%  	Bb$'4r>"T(m FF:.qAH!#!8AaM ff\2JBFF#34:;AACILLV=*ijQ	  	

d z*  	

s7   )0F *F FBF FF FF F+z/print/enroll-secretc                      t               } 	 t        | d      }| j                          t        |      S # | j                          w xY wzDReturn the current open-enroll shared secret (minted on first read).Tr  r  )rS  r  r[  r   rO  r  s     r1   print_enroll_secret_getr*  l s=     ;D.tDA

f-- 	

	   5 Az/print/enroll-secret/rotatec                      t        j                         j                  } t               }	 t	        |t
        |        |j                          |j                          t        |       S # |j                          w xY wzRotate the open-enroll secret. Prior secret stops working immediately;
    already-enrolled agents keep their agent_token (unaffected).r(  )	rW  rX  rY  rS  r  r  rZ  r[  r   
new_secretrO  s     r1   print_enroll_secret_rotater0  l sX    
 !!J;Dd2J?

j11 	

   !A' 'A9PRINT_SPOOL_DIRtogen_print_spool)watchprojectbuilderre  	mtslabels>   .bmpr  r   r   r  r   r  r	  c                  R   t         j                         t        j                         t        j                         t        j                         t
        j                         t        j                         g} 	 | j                  d t        d      D               | S # t        $ r Y | S w xY w)zGAbsolute roots a resolved print file may live under (defense in depth).c              3   <   K   | ]  }|j                           y wrT   )r,   )r,  rn  s     r1   r/  z)_print_roots_allowlist.<locals>.<genexpr>l s     ?QQYY[?r{  N)
rb  r,   r   r2  r
  r   rB  r  r  ry   )rootss    r1   _print_roots_allowlistr;  l s     "4"<"<">@W@W@Y!>#9#9#;[=P=P=RTE?.*>?? L  Ls   6!B 	B&%B&c                     t        t        |       j                               }t               D ]6  }t        |      }||k(  s#|j	                  |t
        j                  z         s6 y y)NTF)r*   r   r,   r;  rw   r  r  )rb   r  rootr  s       r1   _print_path_within_allowlistr>  l sV    DJ !A&( I6Q\\!bff*- r3   c                    t        |       }|j                  j                         }|dk(  r|dfS |t        v r	 t        j                  dd       t        t        j                         j                   dz  }t        j                  |      5 }|j                  d      j                  t        |      dd       ddd       |dfS dd
|xs d fS # 1 sw Y   xY w# t        $ r}dd	| fcY d}~S d}~ww xY w)zReturn 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).r   NTrM   r9  PDFg     r@)r	  z Could not convert image to PDF: z$Unsupported file type for printing: rl  )r   r!  rE  _PRINT_IMAGE_EXTSr2  r  rW  rX  rY  rI  r?  rL  r  r*   ry   )rb   r$  r  r   r  rl  s         r1   _print_ensure_pdfrB   m s    t*C
**


C
f}Dy
	B!!$!>!tzz|'7'7&8$==C% J

5!&&s3x5&IJ9 7x7HIIIJ J  	B;C5AAA	Bs6   AC -C9C CC 	C6%C1+C61C6c                    | xs dj                         j                         } |xs dj                         }|sy| dk(  rt        |      }|syt        |      S | dk(  r<|j	                  d      \  }}}|r
|rd|v sd|v ryt        ||      }|sy	t        |      S t        j                  |       }|d
d| xs d fS d|v ry|j                         }||j                  d      z  j                         }||k7  r5t        |      j                  t        |      t        j                  z         sy|j                         sy	t        |      S )aZ  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).r7   )Nzfile_ref is required.r#  )NWOR file not found.r	  r6   r  )NzInvalid file_ref.)NFile not found.NzUnknown print source: rl  )Nz!file_ref escapes the source root.)r   rE  r  rB  	partitionr/	  _PRINT_SOURCE_ROOTSr   r,   rV  r*   rw   r  r  r   )	r	  r  r  r^  r  r  r=  root_resolvedr  s	            r1   _resolve_print_filerI  m sR    l!!#))+FB%%'H,$X.. ** $--c25Udfn,%fe4* **""6*D|-f.@-ABBBx(LLNM!55>>@IM!#i.*C*CCDVY[Y_Y_D_*`8&Y''r3   c                    | xs dj                         }|syt               }	 |j                  d      j                         }|j	                          |j                         }|D ]6  }|d   xs dj                         j                         |k(  s,|d   |d   fc S  |D ]6  }|d   xs dj                         j                         |k(  s,|d   |d   fc S  y# |j	                          w xY w)zMap 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.r7   NzSELECT 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 DESCrN  r!  rO  )r   rS  rL  r  r[  rE  )rN  r9   rO  rn  rK  r  s         r1   _resolve_published_printerrK  >m s      b'')D;D	||,
 (* 	 	


**,C 2%2,,.446#=\?AjM112  2jMR&&(..0C7\?AjM112  	

s   C C+c                     | dk(  rdS dS )zSumatraPDF -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.r   noscaleshrinkrX   )r!	  s    r1   _print_kind_settingsrO  [m s     95X5r3   c                 2    | d   | d   dk(  rdS dS | d   yy)	z@Derive a job status from its command row's lifecycle timestamps.acked_atacked_statusrU  r  r  r  rX  queuedrX   )r  s    r1   _print_job_statusrT  bm s5    z& 0D8vEgE~*r3   z/print/optionsc                  p    t         d   j                  dd      } t        t               t	        |             S )a0  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.r'  r  r7   )rc  effective_defaults)r   r   r   rU  ra  r]  s    r1   print_optionsrW  km s5     FO,E.06u= r3   z/print/jobsc                  
   t        t        j                  d            st        d      dfS t        d   j                  dd      } t	        j
                  d      xs i }|j                  d	      xs d
j                         j                         }|dvrt        d      dfS 	 t        |j                  d      xs d      }|dk  s	|t        kD  rt        dt         d      dfS |j                  d      }|ddt        fv r8t        |       }|j                  |dk(  rdndd      }|s+t        d| d      dfS t        |      j                         }t        |      }|st        d|       dfS |\  }}	t!        |j                  d      |j                  d            \  }
}|r|dv rdnd}t        |      |fS t        t#        j$                               }t'        j&                         }|	||dt)        |      id }t+               }	 |j-                  d!||t/        j0                  |      |f       |j-                  d"|| |j                  d      xs dj                         |j                  d      xs dj                         t        |
      ||	||||f       |j3                          |j5                          t        |d#||||$      d%fS # t        t        f$ r t        d      dfcY S w xY w# |j5                          w xY w)&zCreate 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?}
    r'  z'The Print system is currently disabled.r  r  r  r7   Tr  r!	  r2  )r2  r   z#kind must be 'document' or 'label'.r`  copiesr^   zcopies must be an integer.zcopies must be between 1 and r  r  Nr   r  r  zNo default z printer configured.zPrinter not available: r	  r  )rE  rD  r  print_settings)r  r!	  rY  r  ziINSERT INTO print_commands (id, machine_id, action, params_json, created_at) VALUES (?, ?, 'print', ?, ?)zINSERT INTO print_jobs (id, created_by, source, file_ref, file_path, machine_id, printer_raw, published_name, kind, copies, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)rS  )r^  rd   r  r!  r!	  rY  r  )r  r   r   r   r   r  r   rE  r   r   r   _PRINT_MAX_COPIESrZ  ra  r*   rK  rI  rW  rX  rU   rO  rS  rL  r   r   rZ  r[  )r  r  r!	  rY  r  r  ru  resolved_printerr!  rO  r  ferrr3  r^  r  r  rO  s                    r1   print_jobs_creater^  zm s    7;;v./FGLLFO,E4(.BDHHV*
11399;D((BCSHH@TXXh',1- zV//<=N<OqQRTWWW #IT2122.u5LLDGOI[]_`	;tf4H!IJCOO	N((*	1)<6ykBCSHH+J(();TXXj=QRNHdHHscT"D((F
))+C$&:4&@A	F ;D,ZF!3S9	

 	8 UTXXh/52<<>hhz"(b//13x=*y$5		
 	

&9(tFDEHI Im z" @9:C??@h 	

s   $K 	B!K0 K-,K-0Lz/print/jobs/<job_id>c           
         t         d   j                  dd      }t               }	 |j                  d| f      j	                         }|j                          |st        d      dfS |d   |k7  r&t         d   j                  d	      st        d
      dfS t        |d   t        |      |d   xs d|d   |d   |d   |d   xs d      S # |j                          w xY w)zGReturn a print job's status. Visible to its creator or any Togen admin.r'  r  r7   zSELECT 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 not found.r  r  r	  r   
Forbidden.r  r  rN  r!	  rY  r  r  N)r^  rd   r  r!	  rY  r  r  )r   r   rS  rL  ru  r[  r   rT  )r^  r  rO  jobs       r1   print_job_statusrc  m s     FO,E;D
ll
 I
 (* 	 	

-.33
<E!'&/*=*=m*L\*C//4y %$%+[8}|$/*2d  	

s   !C	 	Cz/print/jobs/<job_id>/filec                    t         j                  j                  dd      }|j                  d      r|dd j	                         nd}|st        d      dfS t               }	 |j                  d	|f      j                         }|st        d
      df|j                          S |j                  d| f      j                         }|j                          |st        d      dfS |d   |d   k7  rt        d      dfS |d   }t        |      rt        j                  j                  |      st        d      dfS t        |dd|  d      S # |j                          w xY w)zzAgent download of a job's PDF. Gated by the agent bearer token; the job
    must belong to the requesting agent's machine.r  r7   r  r   Nr  r  r  r  r  z9SELECT machine_id, file_path FROM print_jobs WHERE id = ?r`  r  r!  ra  r  ri  zJob file unavailable.r  Tr   r  )r   rx   r   rw   r   r   rS  rL  ru  r[  r>  r  rb   r  r   )r^  r  rD  rO  r  rb  ri  s          r1   print_job_filere  m sY    ??3D $	 :DHNNE:;S@@;DWH
 (* 	 !BCSH
 	

	 llG&

(* 	 	

-.33
<E,//\*C//K I'	2"''..:S45s::Y):#'&H H 	

s   "0E #!E E/enroll/print-agent.zipc                      t         t        z  } | j                         st        ddt         dt          d      dfS t	        t        |       dt              S )	zServe the frozen print-agent bundle (exe + installer). 404 with guidance
    until the artifact is built and deployed. No auth: the ZIP has no secrets.z%Print agent bundle not available yet.	Expected z in PRINT_AGENT_DIST_DIR (z`); run build_print_agent.ps1 on a Windows host to produce the bundle, then deploy to the server.r  r	  r  Trn	  )r_  r`  r   r   r   r*   rb   s    r1   print_enroll_agent_ziprk  n sh      "66D<<>934 5+, -TU

  	 SYdBVWWr3   z/enroll/print-bootstrap.ps1c                     t               } t        j                  j                  d      xs dj	                         }|r|d}}n_t        j                  d      rt
        d   j                  d      sdddd	d
fS t               }	 t        |d      }|j                          d}|  d}|j                  dd      }| j                  dd      }d| dt        j                  d       d| d| d| d}|dddd	dfS # |j                          w xY w)u  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).rD  r7   r  r'  r   zWrite-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
exit 1
r  text/plain; charset=utf-8no-storer  rv   Tr  r  rf  rd
  ''z# Togen Print agent bootstrap (   -enroll) — generated %Y-%m-%d %H:%M:%Sz
# Run as Administrator.  Downloads the agent bundle and delegates to install-print.ps1.
$ErrorActionPreference = 'Stop'
$Server     = ''
$Credential = 'a  '
$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 'a  ' -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.'
rK   z&attachment; filename=install-print.ps1r  r  rv   )_vision_server_base_urlr   rh  r   r   r   rS  r  r[  r  rU   r  	r  rD  r  rO  rO  zip_urlps_credps_basescripts	            r1   print_enroll_bootstrapr{  n s@    #$D\\g&,"335E 'D
F#(;(;M(J1 !<zZ  {	6tDIJJJL-.G  d+Gll3%G06Mdmm\oNpMq r y y  %I &*FV 33G#  a JJL   C9 9Dz/togen-admin/assistantc                     ddl m} m}m}m}m}m}  |        }t        |j                  d      xs i       }|j                  d      s|j                  d      r|d   |d<    ||      }t        |j                  dd      |j                  dd      |j                  d	d
       ||j                  d             ||       ||      |j                  dd
      |j                  dd
      |d   |d   t        |j                                     S )z;Return global assistant settings (keys masked). Admin only.r   )load_assistant_settingsmask_key	mask_keysASSISTANT_PROVIDERSorg_provider_enabled_maporg_ocr_modelsapi_keys	anthropicapi_keyr~  Faccessible_to_usersdefault_context_mdr7   default_model	ocr_modelmodels)r~  r  r  r  r  provider_enabledr  r  
ocr_modelsocr_default_model	providers)r  r~  r  r  r  r  r  r   r   r   r  r  )	r~  r  r  r  r  r  rJ  org_keysr  s	            r1   togen_admin_assistant_getr  rn s      "
#CCGGJ'-2.H<<$); #I

C	5)GG$95A77#7<+,8$1#6ggor2''+r*x=o.*1134 r3   c                  L   ddl m} m}m}m} t        j                  d      xs i } |        }d|v rt        |d         |d<   d|v rt        |d         |d<   d|v rt        |d         |d<   d|v rt        |d         j                         |d<   d	|v r7t        |d	   xs d
      j                         }|r ||      r||d	<   nd	|v r|d	= d|v rq|d   rt        |d         j                         nd
}|r@t        d |D              s.||d<   t        |j                  d      xs i       }||d<   ||d<   n|d
k(  rd|v r|d= d|v rt        |d   t              rt        |j                  d      xs i       }|j                  d      s|j                  d      r|d   |d<   |d   j                         D ]O  \  }	}|	|vr|rt        |      j                         nd
}|rt        d |D              s|||	<   B|d
k(  sH|	|v sM||	= Q ||d<   |j                  d      r	|d   |d<   nd|v r|d= d|v rdt        |d   t              rQt        |j                  d      xs i       }|d   j                         D ]  \  }	}
|	|v st        |
      ||	<    ||d<    ||       t        d      S )zUpdate global assistant settings. Admin only.
    Accepts: enabled, accessible_to_users, default_context_md, api_key (legacy),
    api_keys {anthropic, google}, default_model, ocr_model. Keys are write-only.
    r   )r~  save_assistant_settingsr  _is_known_modelTr  r~  r  r  r  r  r7   r  c              3   $   K   | ]  }|d v  
 ywrf
  NrX   r,  r  s     r1   r/  z-togen_admin_assistant_post.<locals>.<genexpr>n      ;aqEz;   r  r  c              3   $   K   | ]  }|d v  
 ywr  rX   r  s     r1   r/  z-togen_admin_assistant_post.<locals>.<genexpr>n      "?!1:"?r  r  rY  )r  r~  r  r  r  r   r  r   r*   r   r  r   r   r   r   r   )r~  r  r  r  r  rJ  ocr_valkey_valr4  prov_idrQ  s              r1   togen_admin_assistant_postr  n s     4(.BD
!
#CDd9o.I$%)$/D*E%F!"t#$'-A(B$C !$"4#89??AOdd;'-2.446w/&CCK D26y/#d9o&,,.r3;7;;$C	N#''*-34F")F;$C
O]yC/ITjj)94@cggj)/R0zz+&3779+="%i.F; $Z 0 6 6 8 	$GW11.5c'l((*2Gs"?w"??")wB7f#47O	$ !J::k"#K0C	N#IT!j6H1I4&Pcgg017R8 !34::< 	,LGS--"&s)w	, #)C dr3   z/assistant/connectionc                      ddl m} m}m}m}m}m} t        d   d   }t        |      } | |      }t        |j                  d      xs i       }	|	j                  d      s|j                  d      r|d   |	d<    ||      }
t         ||j                  d             ||	       ||      |j                  dd	      |j                  d
      xs |j                  dd	      t        |j                               |
d         S )z=Return the current user's assistant connection (keys masked).r   )get_user_assistantr  r  r  user_provider_enabled_mapavailable_modelsr'  r  r,  r  r  modelr7   r  r  )r  r,  r  r  r  r  r  )r  r  r  r  r  r  r  r   r  r   r   r   r  r  )r  r  r  r  r  r  r  user_cfgua	user_keysrk  s              r1   assistant_connection_getr  n s      FOG$E"5)H	H	%BRVVF^)r*I==%"&&*;!#I	+X&E	*+y!28<ffWb!ff_-D1D*1134x r3   z/assistant/statusc                      ddl m}  t        d   d   }t        d   j                  dd      }t	        |      } | |||      }t        |d      S )a  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.
    r   rV  r'  r  r   F)	availablebusy)r  rW  r   r   r  r   )rW  r  r  r  r  s        r1   assistant_statusr  n sP     1FOG$Ev""=%8H"5)H&uhAIYU33r3   c                  2   ddl m} m}m} t        d   d   }t        j                  d      xs i }t        |      }t         | |            }d|v rt        |d         j                         |d<   d|v r't        |d         j                         |d<   |d   |d<   d	|v r||d	   rt        |d	         j                         nd
}|r@t        d |D              s.||d	<   t        |j                  d      xs i       }||d<   ||d<   n|d
k(  r|j                  d	d       d|v rt        |d   t              rt        |j                  d      xs i       }|j                  d      s|j                  d	      r|d	   |d<   |d   j                         D ]O  \  }	}|	|vr|rt        |      j                         nd
}|rt        d |D              s|||	<   B|d
k(  sH|	|v sM||	= Q ||d<   |j                  d      r	|d   |d	<   n|j                  d	d       d|v rdt        |d   t              rQt        |j                  d      xs i       }|d   j                         D ]  \  }	}
|	|v st!        |
      ||	<    ||d<    |||      }t#        ||       t%        d      S )zSave the current user's assistant connection (provider + keys + model).
    Accepts: api_key (legacy), keys {anthropic, google}, model (legacy),
    default_model. Keys are write-only; omit to leave unchanged, send empty to clear.
    r   )r  set_user_assistantr  r'  r  Tr  r  r  r  r7   c              3   $   K   | ]  }|d v  
 ywr  rX   r  s     r1   r/  z,assistant_connection_post.<locals>.<genexpr>o r  r  r,  r  Nc              3   $   K   | ]  }|d v  
 ywr  rX   r  s     r1   r/  z,assistant_connection_post.<locals>.<genexpr>)o r  r  r  rY  )r  r  r  r  r   r   r  r  r   r*   r   r  r   r  r   r   r   r  r   )r  r  r  r  r  r  r  r  r4  r  rQ  s              r1   assistant_connection_postr  o s    VUFOG$E4(.BD"5)H	 *	+B $$w-(..07$!$"78>>@?)7D26y/#d9o&,,.r3;7;;#ByM"&&..B/F")F;BvJ]FF9d#~*T&\48bffVn*+zz+&266)+<"$Y-F; $V 2 2 4 	$GW11.5c'l((*2Gs"?w"??")wB7f#47O	$ 6
::k"";/ByMFF9d#T!j6H1I4&Pbff/06B7 !34::< 	,LGS--"&s)w	, "(!(B/Hx(dr3   z/assistant/contextc                  `    ddl m}  t        d   d   }t        |      }t	         | |            S )z;Return the effective context markdown for the current user.r   )_effective_context_mdr'  r  )
context_md)r  r  r   r  r   )r  r  r  s      r1   assistant_context_getr  @o s0     0FOG$E"5)H3H=>>r3   c                     ddl m} m} t        d   d   }t	        j
                  d      xs i }t        |      }t         | |            }t        |j                  dd            |d<    |||      }t        ||       t        d	      S )
z+Set the current user's context md override.r   )r  r  r'  r  Tr  r  r7   rY  )r  r  r  r   r   r  r  r   r*   r   r  r   )r  r  r  r  r  r  s         r1   assistant_context_putr  Jo s     AFOG$E4(.BD"5)H	 *	+B488L"56B|!(B/Hx(dr3   z/assistant/chatc                     ddl m} m}m}m}m}m}m}m} t        d   }|d   }	t        |j                  d            }
t        |	      } | |	|
|      st        d      dfS t        j                  d	
      xs i }|j                  d      xs g }t!        |t"              r|st        d      dfS |j                  d      }| ||      }|r|j%                         nd}t'        |j                  d      xs d      j%                         xs d}|
xs  ||      }	 |r ||||||      }n |||||      }t        ||      S # |$ rO}t        |j(                  |j*                  |j,                  |j.                  d|j0                  d	      cY d}~S d}~w|$ r!}t        t'        |            dfcY d}~S d}~w|$ r!}t        t'        |            dfcY d}~S d}~ww xY w)u  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().
    r   )rW  chatchat_agentichas_any_tool_accessAssistantUnavailableAssistantErrorPendingActionRequiredr  r'  r  r   z=Assistant is not available. Configure an API key in Settings.r  r  Tr  rf  z"messages must be a non-empty list.r`  r  Nr7   r  )r'  r9  r  r9  r  rD  r  display_namedisplay_argspending_actionpartial_replyagenticrU  replyr  )r  rW  r  r  r  r  r  r  r  r   r   r   r  r   r   r  r   r  r   r*   rD  r  r  r  r  )rW  r  r  r  r  r  r  r  r'  r  r  r  r  rf  ctx_mdr|  model_override
is_agenticr  rl  s                       r1   assistant_chatr  Yo s       6?DMEDHH]+,H"5)H!%8<\]_bbb4(.BDxx
#)rHh%XABCGGXXl#F~&x0%V\\^2F '*0b1779ATN 606J, 6H\jkE6XE" 
33! ! 

 #		 # # 0 0 # 0 0	 ++	
 		
   ,SX&++ ,SX&++,sC   E G 	AFG G F71G 7G ?GG G z/assistant/confirmc                     ddl m} m}m}m}m} t        d   }|d   }t        |j                  d            }t        |      } | |||      st        d      dfS t        j                  d	
      xs i }	t        |	j                  d      xs d      j                         }
t        |	j                  dd            }|
st        d      dfS 	  ||
|||      }t        |d	      S # |$ rO}t        |j                  |j                   |j"                  |j$                  d|j&                  d	      cY d}~S d}~w|$ r!}t        t        |            dfcY d}~S d}~w|$ r!}t        t        |            dfcY d}~S d}~ww xY w)a  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.
    r   )rW  resume_agenticr  r  r  r'  r  r   zAssistant is not available.r  r  Tr  rD  r7   	confirmedFztoken is required.r`  )r'  r9  r  r  NrU  r  )r  rW  r  r  r  r  r   r   r   r  r   r   r  r*   r   rD  r  r  r  r  )rW  r  r  r  r  r'  r  r  r  r  rD  r  r  rl  s                 r1   assistant_confirmr  o sk     6?DMEDHH]+,H"5)H!%8<:;S@@4(.BD!'R(..0ETXXk512I12C77,uid(S" --! ! 

 #		 # # 0 0 # 0 0	 ++	
 		
   ,SX&++ ,SX&++,sC   C% %F*AD4.F4F<EFF E<6F<Fz/auditor/policies>   r  r  r  c                  j    t        t               t               t               t	               t
              S )as  Consolidated read-only snapshot for the Auditor Policies pane (DVI-1052).

    Aggregates the four policy datasets the pane used to fetch from their owning
    tools' role-gated endpoints. Serving them from one Auditor-gated route lets a
    non-admin holding only ``policies.*`` view them without needing the WOR/RPM/
    admin roles those source endpoints require.
    )r#  rpmApprovalrpmSettingsr  visionDefaults)r   r  r  r  _load_vision_policy_VISION_POLICY_DEFAULTSrX   r3   r1   auditor_policiesr  o s,     !#&(&("$. r3   z/auditor/accessc            	          t               } t        | j                               D cg c]  \  }}||d   |d   |d   d }}}t        |t              S c c}}w )zHList per-user Auditor grants + the grantable resource tree (admin only).rx  ry  rz  r  rx  ry  rz  )r  r  )r  r  r   r   _AUDITOR_RESOURCE_TREEaccessr  r~  r  s       r1   auditor_access_getr  o sk     "#F !0 E3 c+&6_#j/	CE 
 %;<<s   Ac                     t        j                  d      xs i } t        | j                  dd            j	                         j                         }|rd|vrt        d      dfS | j                  d	      }t        |t              st        d
      dfS |D cg c]  }|t        vs| }}|r.t        ddj                  t        t        |            z         dfS t        j                  di       j                  dd      }t               }||v }t        D cg c]  }|t        |      v s| }}|s@|j                  |d       t!        |       t#        dd|d| |d|i       t        dd      S ||j                  |      xs i j                  d      xs# t%        j&                         j)                  d      |j                  |      xs i j                  d      xs |d||<   t!        |       t#        d|rdnd||rdnd d| dt+        |       d|||d       t        dd|i||          S c c}w c c}w )!zUpsert one user's Auditor grant. Body: {email, resources:[keys]}.
    An empty resource list removes the user (revokes Auditor entirely).Tr  r  r7   r  r  r  r`  rx  resources must be a list.Unknown resource keys: r  r'  Nr  auditor_access_revokeRemoved Auditor access for r	  rU  r  ry  r  rz  r{  auditor_access_updateauditor_access_grantUpdatedGrantedz Auditor access for r:  z resource(s))r  rx  rj  )r   r  r*   r   r   rE  r   r   r  r}  r  mapr   r  r  r  r  r	  r  r  r  rC  )	r  r  rx  r  invalidr		  r  existedr  s	            r1   auditor_access_postr  o sB   
 4(.BD"%&,,.446ECu$ABCGG%Ii&893>>#GQq0F'FqGGG63sGCT9UUVX[[[KK#''4E!#FvoG-E!c)n1DAEDE

5$V$}&=U!<UGD%&.	0 $-- ZZ&,"11*=wAVAVAXAaAabvAwZZ&,"11*=FF5M
  =W"9J`+2i	BBVW\V]]_`cdh`i_jjwxu4%HJ d'5!BF5M!BCC5 H Fs   H7'H7H<,H<c            	         t        j                  d      xs i } t        | j                  dd            j	                         j                         }|st        d      dfS t               }||v rW|j                  |d       t        |       t        d	d
t        j                  di       j                  dd      d| |d|i       t        d      S )z7Remove a user's Auditor access entirely. Body: {email}.Tr  r  r7   email is required.r  r`  Nr  r  r'  r  r	  rY  )r   r  r*   r   r   rE  r   r  r  r  r	  r   r  r  r  s      r1   auditor_access_deleter  #p s     4(.BD"%&,,.446E12C77!#F

5$V$}&=$[[488"E!<UGD%&.	0 dr3   z/tool-accessc            	          t               } t        | j                               D cg c]  \  }}||d   |d   |d   d }}}t        |t	               t        j                  d            S c c}}w )zZList per-user tool grants + the grantable tool-capability tree (DVI-1373,
    admin only).rx  ry  rz  r  Tr  )r  r  qc_segments)r  r  r   r   r  r  segment_optionsr  s       r1   tool_access_getr  6p s    
  F !0 E3 c+&6_#j/	CE 
 %6%8'77NP Ps   A.z/tool-access/directoryc                  *    t        t                     S )zLogin-snapshot display-name directory feeding the Access pane (DVI-1432
    D1-A, admin only). {email: {name, given_name, family_name, last_login}}.)r  )r   r  rX   r3   r1   tool_access_directoryr  Ep s    
 1344r3   c                     t        j                  d      xs i } t        | j                  dd            j	                         j                         }|rd|vrt        d      dfS | j                  d	      }t        |t              st        d
      dfS t               }|D cg c]	  }||vs| }}|r.t        ddj                  t        t        |            z         dfS t        j                  di       j                  dd      }t               }||v }t        |      D cg c]  }|t        |      v s| }	}|	s@|j!                  |d       t#        |       t%        dd|d| |d|i       t        dd      S |	|j                  |      xs i j                  d      xs# t'        j(                         j+                  d      |j                  |      xs i j                  d      xs |d||<   t#        |       t%        d|rdnd||rdnd d| dt-        |	       d|||	d       t        dd|i||          S c c}w c c}w )!zUpsert one user's tool grant. Body: {email, resources:[keys]}.
    An empty resource list removes the user (revokes every capability).Tr  r  r7   r  r  r  r`  rx  r  r  r  r'  Nr  tool_access_revokeRemoved tool access for r	  r  ry  r  rz  r{  tool_access_updatetool_access_grantr  r  z tool access for r:   capability(ies))r  rj  )r   r  r*   r   r   rE  r   r   r  r  r  r  r   r  r  r  r  r  r	  r  r  r  rC  )
r  r  rx  r  r  r  r		  r  r  r  s
             r1   tool_access_postr  Mp sM   
 4(.BD"%&,,.446ECu$ABCGG%Ii&893>>!#J#;Qq
':q;G;63sGCT9UUVX[[[KK#''4E FvoGj)A!Q#i.-@AADA

5$&!}&:%!9%Au&.	0 $-- ZZ&,"11*=wAVAVAXAaAabvAwZZ&,"11*=FF5M
 f='"6GZ+2i	BBSTYSZZ\]`ae]f\ggxyu4%HJ d'5!BF5M!BCC5 < Bs   #	I-I$I7Ic            	         t        j                  d      xs i } t        | j                  dd            j	                         j                         }|st        d      dfS t               }||v rW|j                  |d       t        |       t        d	d
t        j                  di       j                  dd      d| |d|i       t        d      S )z4Remove a user's tool access entirely. Body: {email}.Tr  r  r7   r  r  r`  Nr  r  r'  r  r	  rY  )r   r  r*   r   r   rE  r   r  r  r  r	  r   r  s      r1   tool_access_deleter  wp s     4(.BD"%&,,.446E12C77 F

5$&!}&:$[[488"E!9%Au&.	0 dr3   z/tool-access/groupsc            
          t               } t        | j                         d       D cg c]  \  }}||d   |d   |d   |d   d }}}t        |t	                     S c c}}w )	zaList Azure AD group -> tool-capability grants + the grantable tree
    (DVI-1373 P3, admin only).c                 ^    | d   j                  d      xs | d   j                         | d   fS )Nr^   r9   r   r<  kvs    r1   r	  z(tool_access_groups_get.<locals>.<lambda>p s1    1		&0A0JRU/Q/Q/SUWXYUZ.[ r3   r  r9   rx  ry  rz  )rL  r9   rx  ry  rz  )r   r  )r  r  r   r   r  )r   r  r~  r   s       r1   tool_access_groups_getr  p sz    
 '(G w}}#[] C #f+C<L_#j/	CF  &'8':;;s   A c                     t        j                  d      xs i } t        | j                  dd            j	                         j                         }|st        d      dfS t        | j                  dd            j	                         d	d
 }| j                  d      }t        |t              st        d      dfS t               }|D cg c]	  }||vs| }}|r.t        ddj                  t        t        |            z         dfS t        j                  di       j                  dd      }t               }||v }	t        |      D cg c]  }|t        |      v s| }
}|
s\|j!                  |d	      }t#        |       t%        dd|d|xs |xs i j                  d      xs | |||d       t        dd      S |xs( |j                  |      xs i j                  d      xs d|
|j                  |      xs i j                  d      xs# t'        j(                         j+                  d      |j                  |      xs i j                  d      xs |d||<   t#        |       t%        d|	rdnd||	rdnd d||   d   xs | d t-        |
       d!||||   d   |
d"       t        dd|i||   #      S c c}w c c}w )$zUpsert one Azure AD group's tool grant. Body: {group_id, name, resources:[keys]}.
    An empty resource list removes the group mapping (revokes every capability).Tr  rL  r7   z!An Azure AD group ID is required.r  r`  r9   NrK   rx  r  r  r  r'  r  r  tool_access_group_revoke'Removed Azure AD group tool access for )rL  r9   r	  r  ry  r  rz  r  tool_access_group_updatetool_access_group_grantr  r  z  Azure AD group tool access for r:  r  )rL  r9   rx  )rU  rb  )r   r  r*   r   r   rE  r   r   r  r  r  r  r   r  r  r  r  r  r	  r  r  r  rC  )r  r  r9   rx  r  r  r  r		  r   r  r  prevs               r1   tool_access_groups_postr  p s   
 4(.BD
dhhz2&
'
-
-
/
5
5
7C@A3FFtxx#$**,Tc2D%Ii&893>>!#J#;Qq
':q;G;63sGCT9UUVX[[[KK#''4E&(GWnGj)A!Q#i.-@AADA{{3% )}&@!HIpRVR\Z\QaQabhQiIpmpHqrC'F	H $-- BS)/R44V<B[[%+00<v@U@U@W@`@`au@v[[%+00<E	GCL W%=18->W+2i	BBbcjkncopvcwc~{~b  @B  CF  GK  CL  BM  M^  _WS\&=Q`d#e	g
 d:s"Cgcl"CDD9 < Bs   	KKKKc            	         t        j                  d      xs i } t        | j                  dd            j	                         j                         }|st        d      dfS t               }||v rn|j                  |d      }t        |       t        d	d
t        j                  di       j                  dd      d|xs i j                  d      xs | |d|i       t        d      S )zBRemove an Azure AD group's tool access entirely. Body: {group_id}.Tr  rL  r7   zgroup_id is required.r  r`  Nr  r  r'  r  r  r9   r	  rY  )r   r  r*   r   r   rE  r   r  r  r  r	  r   )r  r  r   r
  s       r1   tool_access_groups_deleter  p s     4(.BD
dhhz2&
'
-
-
/
5
5
7C45s::&(G
g~{{3% )}&@$[[488"E!H$*RTIYIYZ`IaIhehHij
C'8	: dr3   c                 8   ddl m} |xs dj                         }|xs dj                         j                         }|xs dj                         j                         }|xs dj                         }g }|j	                         s|S |j                  d      5 }|D ];  }|j                         }|s	 t        j                  |      }	t        |	j                  dd            }
| r|
dk(  rP|r|	j                  d      |k7  rg|r,|t        |	j                  d	d            j                         vr|r|
j                  |      s|rd
j                  t        |	j                  d	d            t        |	j                  dd            |
t        j                  |	j                  di       d      g      j                         }||vr+|j                  |	       > 	 ddd       |j                          |S # t        j                  $ r Y pw xY w# 1 sw Y   5xY w)a  Read + filter the Assistant tool-use audit log (``tool_audit.jsonl``),
    newest first. Shared by ``/auditor/entries`` and its CSV export so the
    downloaded compliance packet always matches the on-screen filtered view.

    Raises OSError if the log exists but cannot be read.
    r   )TOOL_AUDIT_LOGr7   r  r  rG  r9  r  r'  r\   rh  F)ensure_asciiN)r  r  r   rE  r   r?  r   r   r   r*   r   rw   r  r   ri   r  )rq	  f_toolr  r  	f_outcomer  r  r  rs   r   rG  r  s               r1   _read_tool_audit_entriesr  p s    )l!!#Fl!!#))+F9"



#
#
%Cb'')IG!!#			g		. "" 	"D::<D

4( %))Ir23GI!5%))F+v5&EIIfb,A(B(H(H(JJ!3!3I!>hh		&"-.		&"-.JJuyy45I	  
 57  c>NN5!7	"": OON/ '' " "s1   H4G6	DH6H	HHHHz/auditor/entriesc            
         	 t        t        t        j                  j	                  dd            d      } t        t        t        j                  j	                  dd            d      }	 t        t        j                  j	                  d	d
      dk(  t        j                  j	                  dd
      t        j                  j	                  dd
      t        j                  j	                  dd
      t        j                  j	                  dd
            }t        |      }|||| z    }t        ||||       S # t        t        f$ r t        d      dfcY S w xY w# t        $ r}t        d|       dfcY d}~S d}~ww xY w)a
  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)
      outcome:       outcome prefix filter (e.g. 'denied', 'handler_error')
      q:             free-text substring across user/tool/outcome/args
    r   rd  r  r  r   r  r  r`  rq	  r7   rE   r  r'  r  rG  rq	  r  r  r  r  Could not read audit log: Nr  )rL  r   r   rh  r   rC  r   r   r   r  r   rC  )r   r  r  rl  r@  rO  s         r1   auditor_entriesr  q sB   >C((#67=S))(A67;	F*!,,**?B?3F<<##FB/<<##FB/  b)ll&&y"5
 LE6&5.)D4uV5II! z" >78#==>  F9#?@#EEFs1   A&D4 )B)E 4EE	E; E60E;6E;z/auditor/entries/export.csvc                  H   	 t        t        j                  j                  dd      dk(  t        j                  j                  dd      t        j                  j                  dd      t        j                  j                  dd      t        j                  j                  dd            d	t         } g d}| D cg c]  }|j                  dd      |j                  dd      |j                  dd      t        j                  |j                  di       dd      |j                  d      rdnd|j                  dd      g }}t        j                         j                  d      }t        d| d||      S # t
        $ r}t        d
|       dfcY d	}~S d	}~ww xY wc c}w )zFExport the Auditor Assistant tool-use trail (with its filters) as CSV.rq	  r7   rE   r  r'  r  rG  r  Nr  r  r  )rK
  r^  rv  ArgsAccessOutcomer_   rh  FT)r  r  authzgrantedr?  rO  assistant_tool_trail_r  )r  r   rh  r   r|  r   r   r   r   r  r  r  rq  )r  rl  ry  rB  r}  r~  s         r1   auditor_entries_export_csvr  0q sv   	F*!,,**?B?3F<<##FB/<<##FB/  b)ll&&y"5
   CF 
 	 	
dBvr*AEE&",=

155$5DIUU7^		i	 I  !!#,,X6E25'>	RR  F9#?@#EEFs%   B0E8 :BF8	FFFFzCarrier Sheet / File BrowserzWork Order AdminzProduction Runz
MTS Labels)re  r#  r	  r6  z/auditor/print-historyr  c                     	 t        t        t        j                  j	                  dd            d      } t        t        t        j                  j	                  dd            d      }t        j                  j	                  d	d
      j                         j                         }t        j                  j	                  dd
      j                         }g g }}|r&|j                  d       |j                  d| d       |r"|j                  d       |j                  |       |rddj                  |      z   nd
}t               }	 |j                  d|z   |      j                         d   }|j                  d|z   dz   || |gz         j!                         }	|j#                          g }
|	D ]y  }|d   xs d
}|
j                  |d   |d   xs d
|t$        j	                  ||xs d      |d   xs d
|d   xs d
|d   xs d
|d   |d   |d   t'        |      |d   xs dd        { t        |
||| !      S # t        t        f$ r t        d      dfcY S w xY w# |j#                          w xY w)"u  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)
    r   rd  r  r  r   r  r  r`  r'  r7   r	  r  r  zj.source = ?r  r  r  r  a5  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_idr  r  r	  r	  r  rN  r  r!	  rY  r  r  N)r  r'  r	  source_labelr  r  r  r!	  rY  r  rd   r  )jobsr@  r  r   )rL  r   r   rh  r   rC  r   r   r   r   rE  ri   r  rS  rL  ru  r  r[  r  rT  )r   r  r  f_sourcer  r  r  rO  r@  rn  r"  r  r$  s                r1   auditor_print_historyr$  Tq sj   >C((#67=S))(A67; \\fb)//1779F||"-335H6E12&m$^$h27i',,u--RF;D4v=v

(*S ||G  ;; eV_$

 (* 	 	

D kRD'lO)r044S#,G*+)*0bn-3fIkL/'*/74
 	  E&FFg z" >78#==>@ 	

s   A&H5 AI 5III*z/auditor/tagsc                  "   ddl } | j                         }t               }|j                  di       }i }	 | j	                         5 }|j                  d      D ]  }|d   ||d   <    	 ddd       g }|j                  dg       D ]  }|j                  dd      }	|j                  |	i       }
|j                  |	|
j                  d      xs |	|j                  d	      |j                  d
      |j                  d      |j                  d      |j                  d      |j                  d      |j                  |	d      d	        |j                  d        | j                         }t        ||      S # 1 sw Y   xY w# t        $ r Y w xY w)u   LoRaWAN tag telemetry snapshot for the Auditor → Tags tab.

    Returns the latest uplink per device (from thingpark_store.latest_snapshot)
    plus device metadata from the ThingPark config and ingest health.r   Ndevicesz6SELECT dev_eui, COUNT(*) FROM uplinks GROUP BY dev_euir^   dev_euir7   r9   r  rssisnrr  latlng)	r'  r9   r_   r(  r)  r  r*  r+  r  c                 N    | j                  d      xs | d   j                         S )Nr9   r'  r<  r  s    r1   r	  zauditor_tags.<locals>.<lambda>q s     f =9DDF r3   r  )r&  health)thingpark_storelatest_snapshot_load_thingpark_configr   _connectrL  ry   ri   r  ingest_healthr   )_tpsrM  rJ  dev_meta	count_map_conn_rr&  r  r'  r  r-  s               r1   auditor_tagsr8  q s|    ###%H
 
"Cwwy"%HI]]_ 	)mmLN )#%a5	"Q% )	) G||Ir* '')R(||GR(HHV$/''+&GGFO775>wwy)775>775>]]7A.

 
	 LLFLG!F7622/	) 	)  s)   F "E4'F 4E>9F 	FFz/simple-qr/generatec                     t        j                  d      xs i } | j                  d      xs dj                         }|st	        d      dfS 	 t        j                  t
        j                  j                  dd	
      }|j                  |       |j                  d       |j                  dd      j                  d      }|j                  dt        j                        }t!        j"                         }|j%                  |d       |j'                  d       t)        j*                  |j-                               j/                  d      }t	        d|      S # t0        $ r}t	        d|       dfcY d}~S d}~ww xY w)z)Generate a QR code PNG from a URL string.Tr  rJ  r7   zURL is required.r  r`  r   r   error_correctionbox_sizer	  fitr	  r	  
fill_color
back_colorr9  )r`  r`  PNGr  r   ascii)rU  r~
  zQR generation failed: r  N)r   r  r   r   r   _qrcode_modQRCode	constantsERROR_CORRECT_Madd_datamake
make_imagerL  resizerI  LANCZOSr>  rJ  r  r  r  rZ  r  r  ry   )r  rJ  qr_objrR  r2  img_b64rl  s          r1   simple_qr_generaterO  q sD    4(.BD88E? b
'
'
)C/0#55B##(22BB

 	7wGOOPUVjjZ%7%78jjlU#""388:.55g>$g.. B5cU;<cAABs   DE 	E?$E:4E?:E?z/fb/email-linkc                     t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         }| j                  d      xs dj                         }|st	        d	      d
fS |st	        d	      d
fS t
        st	        d	      dfS t        j                  t        t        t              }|j                  dg      }d|vr3|j                  d|j                  dd            }t	        d| 	      dfS d| }|dd| d| dddd|iigddd }t        j                  d!t
         d"d#|d    d$d%|d&'      }	|	j                  d(vrN|	j                         j                  di       j                  d)|	j                   d*d+       }
t	        d,|
 	      d-fS t	        d.      S )/z.Email a document link via Microsoft Graph API.Tr  r  r7   rJ  r9   DocumentEmail address is required.r  r`  zNo document URL provided.rM  r  r  r  r  r  r4  r  r2  rN  u   Document — r  z&A document has been shared with you:

r0  7

This email was sent from the ICAST Togen application.r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  NrK   rT  rU  rY  r   r  r   r   r   r   r  r  r  r  r  r  r  r  rg   r   r&  )r  r[  rJ  r9   r  r  r  r  r  r  r	  s              r1   fb_email_linkrU  q s    4(.BD!'R..0H88E? b
'
'
)CHHV*
113D9:C??893>>STVYYY33!3zSJ6667 7 9L \)2L4D4DWi4XY=cUCDcIIdV$G %>fBse $LL  )X!67
 #J$ 
1/1B)L&|N'C&DE.
 D z)"-11)TYYt_M09:C??dr3   rM
  z https://graph.microsoft.com/v1.0>   r  r   rr  r6  rq  docxrs  xlsxc                  
   t         rt        sy	 t        j                  t         t        t              } | j                  dg      }|j                  d      S # t        $ r" t        j                  j                  d       Y yw xY w)z@Acquire the shared app-only Graph token, or None if unavailable.Nr  r  r  r  z$Email: Graph token acquisition error)r  r  r  r  r  r  r   ry   rm   rn   r  r  s     r1   _email_graph_tokenrY  8r sw    j77%7:W
44:; 5 =zz.)) 

CDs   AA (BBc                  \    t        j                  d      xs i j                  dd      xs dS )z=Return the signed-in user's mailbox address from the session.r'  r  r7   r  rX   r3   r1   _email_current_mailboxr[  Gr s(    KK%2**7B7=2=r3   c           	         	 t        j                  t         d| d| d| dd|  id      }|j                  dk7  r9t        j
                  j                  d	|j                  |j                  d
d        y|j                         }|j                  d      }|sy|j                  d      xs d|j                  d      xs dt        j                  |      fS # t        $ r" t        j
                  j                  d       Y yw xY w)zTFetch one attachment. Returns (name, content_type, raw_bytes) or (None, None, None)./users/z
/messages/z/attachments/r  r  r   r;  rK   z&Email attachment fetch failed (%s): %sNr  )NNNrQ  r9   r  r  r  zEmail attachment fetch error)r  r   _GRAPH_BASErg   rm   rn   r  r&  r   r  	b64decodery   r  )rD  r[  rk  rl  r  r   content_b64s          r1   _email_fetch_attachmentra  Lr s    !!m77):j\}o^$w&78

 s"JJE!--tyy#@#yy{hh~.# 0L'E+E  -/ 	/   

;< s   A3C 6#C =C (DDz/email/messagesc                     t               } | st        d      dfS t               }|st        ddd      dfS t        d	d
dd}	 t	        j
                  t         d|  ddd| i|d      }|j                  dk(  rt        dd| d      dfS |j                  dk7  rf|j                  r=|j                         j                  di       j                  d|j                  dd       n|j                  }t        d|       dfS g }d}|j                         j                  dg       D ]~  }|j                  d d!      s|d"z  }|j                  d#      xs g D cg c]_  }|j                  d$      sL|j                  d%      |j                  d&      xs d'|j                  d(d      |j                  d)      xs d*d+a }	}|j                  |j                  d%      |j                  d,      xs d-|j                  d.      xs i j                  d/      xs i j                  d0d*      |j                  d.      xs i j                  d/      xs i j                  d&d*      |j                  d1      |j                  d d!      |j                  d2      xs d*dd |	t        |	      d3	        t        d!| ||4      S # t        $ r0 t        j                  j                  d       t        d      dfcY S w xY wc c}w )5zIReturn the signed-in user's recent inbox messages (attachments expanded).(No mailbox associated with this session.r  r`  Mail service is not configured.Frc  r  r  rm  rK   zreceivedDateTime desczBid,subject,from,receivedDateTime,isRead,hasAttachments,bodyPreviewz6attachments($select=id,name,contentType,size,isInline))$top$orderbyr  $expandr]  /mailFolders/inbox/messagesr  r  r   r  zEmail messages fetch errorz!Could not reach the mail service.rU  r  
permissionzMailbox access has not been granted. An administrator must grant the Mail.Read application permission to the Togen app registration in Azure AD.r  rm  r[  r  r  r  NrT  r   r  isReadTr^   rS  r]  r  r9   r  rd  r  r7   )r  r9   rd  r  r  r_  r2  r  r  receivedDateTimer`  )	r  r  r2  	from_namereceivedis_readrL  rS  rd  )r  r[  rf  unread)r[  r   rY  _EMAIL_INBOX_TOPr  r   r^  ry   rm   rn   r  rg   r&  r   ri   r   )
r[  rD  r  r  r	  rf  rq  r  r  rg  s
             r1   email_messagesrs  dr s    %&GGH#MM E>%&(),- 	- !+WK	F	G!!m77)+FG$w&78	
 3|WCD FII 	I 3QUQZQZ"-11)TYYt_M`d`p`p09:C??HFYY[__Wb) uuXt$aKF eeM*0b	
 55$ eeDkf5fa( uu]39r		
 	
 	%%+uuY'9>eeFm)r..~>D"II)UWX55=.B33NCIrNNvWYZ01uuXt,m,2DS9#Dz

 
	0 T7XfUUO  G

9:@A3FFG$	
s   (J5 A$K156K.-K.z/email/unread-countc            	         t               } | st        dd      S t               }|st        dd      S dd| i}	 t        j                  t
         d|  d|dd	id
      }|j                  dk7  r$t        dd|j                  dk(  rd      S d      S |j                         j	                  d	d      }d}d}	 t        j                  t
         d|  d|dddddd      }|j                  dk(  r|j                         j	                  dg       D ]h  }|j	                  d      xs g D 	cg c](  }	|	j	                  d      s|	j	                  d      xs d* }
}	|
sL|dz  }t        d |
D              sd|dz  }j t        |||d !      S # t        $ r t        dd      cY S w xY wc c}	w # t        $ r" t        j                  j                  d       Y Zw xY w)"zCounts for the Tools page tile badges: total unread, unread with
    attachments, and unread with a workflow-supported attachment (DVI-635).r   F)r  r  r  r  r]  z/mailFolders/inboxr  unreadItemCountr  r  rK   r  rj  r  )r  r  rm  ri  z*isRead eq false and hasAttachments eq truerd  r  z"attachments($select=name,isInline))z$filterrf  r  rh  -   r  rS  r]  r9   r7   r^   c              3   v   K   | ]1  }d |v r+|j                  d d      d   j                         t        v  3 yw)r  r^   r  N)rj  rE  _EMAIL_WORKFLOW_EXTS)r,  r9   s     r1   r/  z%email_unread_count.<locals>.<genexpr>r s=      9C4K {{3*2.446:NN 9s   79z(Email unread-count attachment scan errorT)r  rS  workflowr  )r[  r   rY  r  r   r^  ry   rg   r   r2  rm   rn   r  )r[  rD  rx   r  r@  unread_attachunread_workflowa_respr  r  r.  s              r1   email_unread_countr}  r s'   
 %&GQ%00 EQ%00'% 12G1!!m77)+=>01	
 3Q%.2.>.>#.ElT 	TKRT 	TIIKOO-q1E
 MOI##m77)+FGG?	 

 $[[]&&w3 )78uu]7K7Qr 3 !j 1 v,", 3 3" 9#(9 9#q(O) M+t= =K  1Q%00143  I

GHIs<   %F	 8A1F* )-F%F* 3F* 	F"!F"%F* *(GGz/email/attachmentc                     t               } t        j                  j                  dd      }t        j                  j                  dd      }| r|r|st	        d      dfS t               }|st	        d      dfS t        || ||      \  }}}|t	        d	      d
fS t        j                  j                  d      dk(  }t        t        j                  |      |||      S )z@Stream an attachment's bytes (inline preview / direct download).rk  r7   rl  *message_id and attachment_id are required.r  r`  rd  rU  "Attachment could not be retrieved.r  rd  rE   r  )
r[  r   rh  r   r   rY  ra  r   r>  rJ  )r[  rk  rl  rD  r9   r5  r   	as_attachs           r1   email_attachmentr  r s     %&G!!,3JLL$$_b9M
}IJCOO E>?DD5eWjR_`D,
{ABCGG  ,3IRZZ_|#,DB Br3   z/email/save-attachmentc                  Z   t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         }| j                  d      xs dj                         }| j                  d      xs dj                         }|r|st	        d	      d
fS |rt        |      nt        }||j                         st	        d	      d
fS t               }t               }|st	        d	      dfS t        ||||      \  }}	}
|
t	        d	      dfS t        |xs |      xs d}||z  }|j                         rOt        j                  j                  |      \  }}|| dt!        j"                         j%                  d       | z  }	 |j'                  |
       t1        |j3                  t                    }t	        d||j4                        S # t(        $ r0 t*        j,                  j/                  d       t	        d	      dfcY S w xY w)zKSave an email attachment into the shared Projects tree (File Browser root).Tr  rk  r7   rl  rd  r  r  r  r`  zInvalid destination folder.rd  rU  r  r  r  r  z%H%M%Sz!Email save-attachment write errorz,Could not write the file to the destination.r  )rU  rS	  r  )r   r  r   r   r   r6  r8  r  r[  rY  ra  r   r  r  rb   r   r  r  r  r-   r   rm   rn   r  r*   r{  r9   )r  rk  rl  dest_relr  r  r[  rD  r9   _ctr   r  r  r  r  	saved_rels                   r1   email_save_attachmentr  r s    4(.BD((<(.B557JXXo.4";;=M%+224H$*113H=IJCOO*2}X&Hx0:;S@@$&G E>?DD,UGZWND#s
{ABCGG 0D1A\IiD{{}GG$$Y/	cTF!N$6$6$8$A$A($K#LSERRR D$$X./Idy499EE	  R

@AKLcQQRs   *G1 16H*)H*z/email/share-attachmentc            	         t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         }| j                  d      xs dj                         }|r|st	        d      d	fS |st	        d
      d	fS t
        st	        d      dfS t               }t               }|st	        d      dfS t        ||||      \  }}}|t	        d      dfS t               xs d}	t        j                  dd 
j                  |	       d |
j                  |       dd      }
t        j                  |d| |
|||      }|st	        d      dfS t	        d      S )z>Forward an email attachment to a recipient via Graph sendMail.Tr  rk  r7   rl  r  r  r  r`  z&A recipient email address is required.rM  r  rd  rU  r  r  za colleaguezA file was shared with youz<p>z shared the file <strong>z7</strong> with you from the ICAST Togen Email tool.</p>zShared attachmentsubtitleu   Shared file — )r  r  r5  z&The mail service rejected the message.rY  )r   r  r   r   r   r   r[  rY  ra  r=  render_email_shellrN  send_email_with_attachment)r  rk  rl  r[  r[  rD  r9   r5  r   senderr  rU  s               r1   email_share_attachmentr  s s    4(.BD((<(.B557JXXo.4";;=M!'R..0H=IJCOOEFKKSTVYYY$&G E>?DD5eWjR_`D,
{ABCGG#%6F++$
kdkk&!"";KDKK<M;N O8 	8$	D 
	1	1$TF+Ts
?B EFKKdr3   z/email/add-to-manualsc            	         t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         }| j                  d      xs dj                         }|r|st	        d      d	fS |st	        d
      d	fS t               }t               }|st	        d      dfS t        ||||      \  }}}|t	        d      dfS t        j                  j                  |      d   j                         }	|	t        vr/dj                  t        t                    }
t	        d|
       d	fS t        j!                  dd       t#        |      xs d|	z   }t%        j&                         j)                  d       d| }	 t        |z  j+                  |       t5        |||t7        j                  d      xs i        t9        ||       t	        d|||t;        |            S # t,        $ r0 t.        j0                  j3                  d       t	        d      dfcY S w xY w)zGSave an email attachment as an Equipment Manual associated with a name.Tr  rk  r7   rl  r  r  r  r`  z,Equipment / Asset / System name is required.rd  rU  r  r  r^   r  r  rM   r  r  r  z Email add-to-manuals write errorzCould not save the manual.r  r'  r1  )r   r  r   r   r   r[  rY  ra  r  rb   r   rE  r  r  r  r  r  r   r  r  r  r-   r   rm   rn   r  r  r   r)  r  )r  rk  rl  r  r[  rD  r9   r  r   r  r   r  r4  s                r1   email_add_to_manualsr  Cs s"    4(.BD((<(.B557JXXo.4";;=M+&,"335I=IJCOOKLcQQ$&G E>?DD,UGZWND#s
{ABCGG
''

4
 
#
)
)
+C
%%))F#678FwiPQSVVV$64 4X^D""$--o>?qGF@	6	!..s3 y&$F0C0IrJ%dV4&,>v,FH H  @

?@9:C??@s   %H 6I ?I z/calendar/eventsc                     t               } | st        ddd      dfS t        j                  j	                  d      xs dj                         }t        j                  j	                  d      xs dj                         }|r|st        d	
      dfS t               }|st        ddd      dfS ||dddd}	 t        j                  t         d|  dd| dd|d      }|j                  dk(  rt        dd| d      dfS |j                  dk7  rf|j                  r=|j                         j	                  di       j	                  d |j                  d!d       n|j                  }t        d"| 
      dfS g }|j                         j	                  d#g       D ]  }|j!                  |j	                  d$      |j	                  d%      xs d&|j	                  d      xs i j	                  d'      |j	                  d      xs i j	                  d'      t#        |j	                  d(            |j	                  d)      xs i j	                  d*      xs d|j	                  d+      xs i j	                  d,      xs i j	                  d-      xs d|j	                  d.      xs dd!d d/d0	        t        d1| |2      S # t        $ r0 t        j                  j                  d       t        d
      dfcY S w xY w)3zReturn 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).
    rc  Frc  re  rK   rq   r7   r  start and end are required.r  r`  #Calendar service is not configured.)r  rm  r  z<id,subject,start,end,isAllDay,location,organizer,bodyPreviewstart/dateTimestartDateTimeendDateTimer  rg  rf  r]  /calendarViewr  outlook.timezone="UTC"r  Preferr   r  zCalendar events fetch error%Could not reach the calendar service.rU  r  rj  zCalendar access has not been granted. An administrator must grant the Calendars.Read application permission to the Togen app registration in Azure AD.rk  r  r  NrT  r  r  r  
(no title)dateTimeisAllDayr  displayName	organizerr  r9   r`  	microsoft)	r  r  rq   r  all_dayr  r  rL  r	  T)r  r[  r:  )r[  r   r   rh  r   r   rY  r  r^  ry   rm   rn   r  rg   r&  r   ri   r   )	r[  rq   r  rD  r  r  r	  r:  r-  s	            r1   calendar_eventsr  ts s    %&GG!&x9:=> 	>\\g&,"335E<<E"(b
/
/
1Cc:;S@@ ExBDEHI 	I Q$FK!!m77)=9#*5'!22
 	
 3|WGH JMM 	M 3YY ))+//'2.229diioN$($4$4 	09:C??Fiikoogr* &&,vvi(8LffWo+00<FF5M'R,,Z8BFF:./&&,277FL"FF;/52::>JPbUUV\]cac}-3Tc:!

 
	 T76BB7  K

:;DEsJJKs   6)J9 96K21K2c                    ddddd}g }t               j                         D ]  \  }}|j                  dd      s|j                  dd	      }|j                  d
      xs i }t        |||      }t	        |j                  dd      |j                  dd      |j                  dd      | |      D ]m  }	|	j                  d      }
|j                  d| d|
 ||
|
d|j                  d      xs |j                  d      xs d|dd||j                  ||      dd       o  dD ]  }t        |      }dD ]v  }|j                  |      }|r|j                         s(|j                  d      D ]:  }|j                  d      }|j                         s&	 t        j                  |j                               xs i }t        |j"                  ||      }dD ]  \  }}|j                  |      xs dj%                         }|s,	 t'        j(                  |dd d      j+                         }|| k  s||kD  r_|j                  | d|j"                   ||dd |dd d|j                  d      xs |j                  d      xs d|j"                  d|||j                  ||      dd        = y  |S # t        t         f$ r Y Xw xY w# t        t,        f$ r Y w xY w) a  Work Order date events within [win_start, win_end] (date objects) as a
    list of event dicts. Two kinds: recurring-schedule due markers and active
    (new/in-progress) WOs carrying a Desired Completion Date or Date Promised.

    Shared by /calendar/wo-events (the Calendar tool) and the Calendar dashboard
    tile (DVI-1196) so both stay in lockstep. Caller enforces the WO access gate.
    r  r  r  
Preventiver#  r~  Tr  r   r  r  r7   r  r   r  r  z
recurring:rS  r  r  r`  zRecurring due	workorder)r  r  rq   r  r  r  wo_filewo_kindwo_date_typer  wor_type_labelr	  r$  r   r+  r  ))r  zDesired completion)r@
  zDate promisedNr   rw  )r  r   r   r  r  r  ri   r  r  r  r  r   r   r   r   r   r   r9   r   r  r  r   r   )r  r  _type_labelr:  r  r~  r  r  re  r  isor  rv  r  r  r  r  r  r   rQ  rn  s                        r1   _wo_calendar_eventsr  s s    %)"	K F !9 : @ @ B wwy$'77:}577?+1r"?HhG1b)3773CQ+G
B'G= 	H ##J/CMM"?"31SE: $LL4VZ8PVTV*& /$"-//(H"E% 		4 M 'h'0 %	J,II$4$4$6^^G, !--0(G$5$5$78>BB +1668R@%I LE566%=.B557C !*33CHjINNP 9}G MM!&q1#(!$Sb"3Bx#'$&FF:$6$R"&&:L$RPR#$66#((-$,*5//(H*M"-# !	%	'P M9 #G,  '	2 ! !s$   'J2 'K2K	K	KKz/calendar/wo-eventsc                     t        j                  d      xs i } t        |       st        ddg       dfS t        j
                  j                  d      xs dj                         }t        j
                  j                  d      xs dj                         }|r|st        d	
      dfS 	 t        j                  |j                  dd            j                         }t        j                  |j                  dd            j                         }t        dt        ||            S # t        t        f$ r t        d
      dfcY S w xY w)ua  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.
    r'  Frj  )r  rm  r:  rK   rq   r7   r  r  r  r`  r  r  zInvalid start/end.T)r  r:  )r   r   r  r   r   rh  r   r  r  r  r   r   r   r  )r'  rq   r  r  r  s        r1   calendar_wo_eventsr  t s    ;;v$"Dt$|BGLL\\g&,"335E<<E"(b
/
/
1Cc:;S@@8"00sH1MNSSU	 ..s{{3/IJOOQ T-iAC C 	" 812C778s   *A&D' 'EEz/simple-qr/emailc                     t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         }| j                  d      xs dj                         }|st	        d      d	fS |st	        d
      d	fS t
        st	        d      dfS t        j                  t        t        t              }|j                  dg      }d|vr3|j                  d|j                  dd            }t	        d|       dfS |rd| nd}|dd| dddd|iigddd|d gd!d"d#}t        j                  d$t
         d%d&|d    d'd(|d)*      }	|	j                  d+vrN|	j                         j                  di       j                  d,|	j                   d-d.       }
t	        d/|
       d0fS t	        d1      S )2z=Email a QR code PNG as an attachment via Microsoft Graph API.Tr  r  r7   r~
  rJ  rR  r  r`  zNo QR code image provided.rM  r  r  r  r  r  r4  r  r2  rN  u   QR Code — zQR Coder  z&Please find the attached QR code for:
rS  r  r  r  rO  zqr-code.pngrn  rP  rR  r  r  r  r  r  r  r  r  r  r  r  NrK   rT  rU  rY  rT  )r  r[  	image_b64rJ  r  r  r  r  r  r  r	  s              r1   simple_qr_emailr  0t s-    4(.BD!'R..0H'"(b//1I88E? b
'
'
)C9:C??9:C??STVYYY33!3zSJ6667 7 9L \)2L4D4DWi4XY=cUCDcII&)SE"yG %=cU CL L  )X!67
 $E)#.$-	
* #-J2 
1/1B)L&|N'C&DE.
 D z)"-11)TYYt_M09:C??dr3   z/mts/productsc            	         g } t         j                         rt         j                  d      D ]  }	 t        j                  |j                  d            }t        |j                   dz  j                         }| j                  |j                  d|j                        |j                  dd      |j                  dd      |d	        | j                  d
        t        |       S # t        j                  t        f$ r Y w xY w)z;Return a list of all stock products sorted by product code.r  r  r  r   r  r  r7   r  )r  r  r  has_drawingc                 (    | d   j                         S )Nr  rs  r  s    r1   r	  z#mts_products_list.<locals>.<lambda>t s    . 1 7 7 9 r3   r  )rg  )r   r  r  r   r   r   STOCK_DRAWINGS_DIRr  r   ri   r   r   r   r  r   )rg  r  r   r  s       r1   mts_products_listr  yt s     H  "#((2 	A
zz!++w+"?@1qvvhdOCLLN$(HH^QVV$D#'88M2#>"hhx4#.	! 		 MM9M:H%% (('2 s   BC**DDz/mts/product/<product_code>c                 h   t        |       }|st        d      dfS t        | dz  }|j                         st        d      S 	 t	        j
                  |j                  d            }t        | d	z  j                         }d
|d<   ||d<   t        |      S # t        $ r t        d      cY S w xY w)zDReturn stored product data (description, weight) for a product code.Invalid product coder  r`  r  Fr  r  r  r   Tr
  r  )	r   r   r   r   r   r   r   ry   r  )r  r  r
  r   r  s        r1   mts_product_getr  t s     <(D34c99"vU^3IU##$zz)--w-?@ &4&5>>@KDM%D4=  $U##$s   %B B10B1z/mts/productc                     t        j                  d      } | j                  dd      j                         }t	        |      }|st        d      dfS t        j                  dd       t        | d	z  }|| j                  d
d      j                         | j                  dd      j                         d}|j                  t        j                  |d      d       t        d      S )z.Save / update product data for a product code.Tr  r  r7   zProduct code is requiredr  r`  rM   r  r  r  )r  r  r  r   r   r  r  rY  )r   r  r   r   r   r   r   r  r   r   r   )rn  r3  r  r
  r  s        r1   mts_product_saver  t s     	t$A55$**,D4 D78#==TD9"vU^3Iuu]B/557%%"%++-G
 GA6Idr3   z/mts/drawing/<product_code>c                    t        |       }|st        d      dfS dt        j                  vrt        d      dfS t        j                  d   }|j                  r)|j                  j                         j                  d      st        d      dfS t        j                  dd	       t        | dz  }|j                  t        |             t        d
      S )z(Upload a drawing PDF for a product code.r  r  r`  rh  zNo drawing file providedr   zDrawing must be a PDF fileTrM   rY  )r   r   r   r  r  rE  r|  r  r  r  r*   )r  r  r  r  s       r1   mts_drawing_uploadr  t s     <(D34c99%78#==i A::QZZ--/88@9:C??TD94&-DFF3t9dr3   c                     t        |       }|st        d       t        | dz  }|j                         st        d       t	        t        |      d      S )z)Serve the drawing PDF for a product code.r`  r   r  r  r   )r   r   r  r   r   r*   )r  r  rb   s      r1   mts_drawing_getr  t sJ     <(Dc
4&-D<<>c
SY):;;r3   z
/mts/labelc                  v   t        j                  d      } | j                  dd      j                         }| j                  dd      j                         }| j                  dd      j                         }| j                  dd      j                         }t	        j                  di       j                  d	d
      }|r|r|r|st        d      dfS t        |      }|st        d      dfS t        |      }| d| d}t        j                  dd       t        |z  }	t        | dz  }
|
j                         }d}d}|rt               }| dt        |       d}	 t        j                  t        j                  j                   dd      }|j#                  |       |j%                  d       |j'                  dd      j)                  d      }t+        j,                  d      \  }}t/        j0                  |       |j3                  |d       	 t9        d#d$d%&      }|j;                  d'(       |j=                          |j>                  }|j@                  }d}|d|z  z
  }tB        d)z  }d*}|d+z  }||z
  dz  }|j                         r|jE                  tG        |      |||,       ||z   d-z   }d.}|jI                  d/d0|       |jK                  |      |d1z
  kD  r:|d2kD  r5|dz  }|jI                  d/d0|       |jK                  |      |d1z
  kD  r|d2kD  r5tM        |d3z  d2      }|jO                  ||       |jQ                  |||d45       ||d1z   z  }|jS                  d6       |jU                  ||||z
  |       |d7z  }d8}d9} ||z
  |z
  }!||z
  | z
  }"d:}#||#z
  }$d}%d;|fd<|ffD ]  \  }&}'|jI                  d/d0d=       |jO                  ||       |jQ                  |#|%|&d>5       |jI                  d/|&d<k(  rd0ndd=       |jO                  ||#z   |       |jW                  |$|%|'d>5       |jY                         dz   } |rG|"d1z
  }(|(|z
  })t[        d?|)d1z
        }*|*d@kD  r)||*z
  dz  }+||)|*z
  dz  z   },|jE                  ||+|,|*|*A       |jI                  d/ddB       |jO                  |!|"dCz          |jQ                  |d-dD| dE5       |jO                  |!|"dz          |jQ                  |d-dF| dE5       |j]                  tG        |	             |r5t.        j^                  ja                  |      rt/        jb                  |       	 td        j                  dd       td        | dGz  }-||||dH}.|-jg                  ti        jj                  |.dI      dJK       tm        dL      rto        dLdM|dN| d!| |||||dOP       t        d|Q      S # t4        $ r }t7        d | d!| d"       d}Y d}~d}~ww xY w# |r6t.        j^                  ja                  |      rt/        jb                  |       w w w xY w)Rz4Generate an MTS label PDF and return it for preview.Tr  r  r7   r  r  r   r'  r9   r  zAll fields are requiredr  r`  r  r  r   rM   Nz/stockproducts/drawings/r   r   r:  r=  r	  r	  r?  r9  r   r  rB  r  z[MTS] QR generation failed for r>  r8  r	  r  )g9f@gs@r	  Fr	  r  H   g
<h?)r  r|
  r}
  r   .   r	  r	  r   r>	  g?Cr5
        ?r  r   rN
  $   zDescription:zWeight:   Lr{
  rx  r  r|
  r  r}
  ry  rP   zDate: r4
  zCreated by: r  )r  r  r  qr_urlr   r  r  r  r  u   MTS label created — )r  r  r   r  r	  )rU  r  )8r   r  r   r   r   r   r   r   r  r  r   r  r   rD  rE  rF  rG  rH  rI  rJ  rL  r  r  r  r[  r  ry   rA  r   r
  r
  r  r}
  r  r~
  r*   r	  get_string_widthrC  r
  r	  set_line_widthrs   r	
  r
  rL  r  rb   r  r/   r   r   r   r   r	  r	  )/rn  r3  r  r  
label_dater	  	safe_code	safe_dater  r
  drawing_pathr  r  qr_tmp_pathr  rM  qr_imgr  rl  r6  WHr`
  r	  	logo_pathlogo_hlogo_wlogo_xr|
  	font_sizecell_hbox_wbox_hbox_xbox_ylabel_col_w	val_col_wr	  r  r  	qr_bottomr  qr_sizeqr_xqr_y	prod_path	prod_datas/                                                  r1   mts_label_creater  t sL    	t$A55$**,D%%r*002KUU8R &&(Fvr"((*JVR(,,VY?J{&
67<<%I34c99
+IAi[-H5(H%9+T(::L&&(KFK(*83E)4D3ETJ	 ''!,!6!6!F!FF
 OOF#KKDK!&&'g&NVVW\]F&..f=OBHHRLKKEK2
K#snEU+EEEEV^ #%55	9%f*!IIc)n&FIC VOa	[#y1""4(261i"nNILLc95 ""4(261i"n Y%r*

61VT-	VaZ 	3AJ*	Q F
U"F
U" $	 .<y&>QR 	 NGULLc2.JJvq!HH[%H<LLW	-Acr2NJJv+Q/NN9eU#N>		aA	  	I!AI$	A.G|Gq(I/144		+G	L 	["a(

5%!)$VJ<0<

5%"*%\*6cB

3x=!277>>+6IIk"TD9"	{%%88I"	I Ia87K";/,TF"[MB$(FJ\de	
 dX..I  	3D6C5ANK	\ 277>>+6IIk" 7;s-   B6W DW> G9W> 	W;W66W;>:X8z/mts/label/preview/<filename>c                     t        |       }|st        d       t        |z  }|j                         st        d       t	        t        |      d      S )z5Serve a generated MTS label PDF for preview/download.r`  r  r  r   )r   r   r   r   r   r*   )r  r  rb   s      r1   mts_label_previewr  hu sD     8$Dc
D D<<>c
SY):;;r3   z
/mts/emailc                  X   t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         }|st	        d      dfS |st	        d	      dfS t        |      }|st	        d
      dfS t        |z  }|j                         st	        d      dfS t        st	        d      dfS |j                         }t        j                  |      j                  d      }t        j                  t        t         t"              }|j%                  dg      }d|vr3|j                  d|j                  dd            }	t	        d|	       dfS d| }
|
dd| dddd|iigd|d |d!gd"d#d$}t'        j(                  d%t         d&d'|d    d(d)|d*+      }|j*                  d,vrN|j-                         j                  di       j                  d-|j.                  d.d/       }t	        d0|       d1fS t	        d2      S )3z@Email an MTS label PDF as an attachment via Microsoft Graph API.Tr  r  r7   r  rR  r  r`  zNo label filename provided.rD  zLabel file not found.r  rM  r  rC  r  r  r  r  r4  r  r2  rN  u   MTS Label — r  z$Please find the attached MTS label: rS  r  r  r  rO  r  rP  rR  r  r  r  r  r  r  r  r  r  r  r  NrK   rT  rU  rY  )r   r  r   r   r   r   r   r   r   r.   r  rZ  r  r  r  r  r  r  r  r  r  rg   r   r&  )r  r[  r  r  rb   r
  pdf_b64r  r  r  r  r  r  r	  s                 r1   mts_email_labelr  uu sw    4(.BD!'R..0H$*113H9:C??:;S@@8$D01366D D<<>45s::STVYYY!Iy)009G33!3zSJ6667 7 9L \)2L4D4DWi4XY=cUCDcIItf%G %:4& AL L  )X!67
 $E #4$+	
* #-J2 
1/1B)L&|N'C&DE.
 D z)"-11)TYYt_M09:C??dr3   zscadalogs@icastinc.comz/scada/logsc                     t        j                  t        t        t              } | j                  dg      }d|vr3|j                  d|j                  dd            }t        d| 	      d
fS d|d    dd}t        j                  dt         d|d      }|j                  dk(  rt        dt        |      z   	      dfS |j                  dk7  r^d}	 |j                         j                  di       j                  d|j                  dd       }t        d|j                   d| 	      dfS |j                         j                  dg       }g }|D ]  }|j                  dd      }	|	r7	 t        j                   |	j#                  dd            }
|
j%                  d       }	d}|j                  d!i       }|j                  d"      d#k(  r|j                  d$d      }n|j                  d%d      }|j'                  |	|j                  d&d'      |d(        t        |)      S # t        $ r |j                  dd }Y +w xY w# t        $ r Y w xY w)*u  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.
    r  r  r  r  r4  r  r2  #Failed to acquire Graph API token: r  r  r  r  r  r  zb/messages?$top=50&$orderby=receivedDateTime desc&$select=subject,receivedDateTime,bodyPreview,bodyr  r;  r  #SCADA mailbox access denied (403): rK   r7   r  Nr  Graph API error (r  rU  r  rm  r  r  r  r  r  r&  r  r`  r  r_  )r   r  r  rJ   )r  r  r  r  r  r  r   r   r  SCADA_MAILBOXrg   _scada_graph_error_detailr   r&  ry   r  r  r  r  ri   )r  r  r  rx   r  r	  rf  rJ   msgro  r  body_contentbody_objs                r1   
scada_logsr  u s    33!3zSJ6667 7 9L \)2L4D4DWi4XYB3%HI3NN #<#?"@A*G
 
1- A= 	= D 37:STX:YY
 	 3	%YY[__Wb155i4CQF 01A1A0B#fXNOQTTTyy{w+HD 77-r2#11(2B2B32QR;;'78 7762&<<&&0#<<	26L77="5Lwwy.9 
 	* 7  	%YYt_F	%  s$   =H" ,6I"H>=H>	IIc                    g }d}| j                         D ]  }|j                         }|s|j                         j                  d      r$|j	                  dd      d   j                         }Yd|v s^|j                  d      \  }}}|j                         }|j                         }|s|s|j                  ||d        ||fS )zAParse a SCADA email body into (timestamp, [{label, value}, ...]).Nztime:rS  r^   r   r  )r  r   rE  rw   r  rF  ri   )r  readingsrv
  rs   r   r  r  s          r1   _parse_scada_bodyr  v s    HI$$& Bzz|::<""7+

3*1-335I$;"nnS1OE1eKKMEKKME%% @AB hr3   c                     | j                  di       }|j                  d      dk(  r|j                  dd      S | j                  dd      S )z<Extract plain-text body content from a Graph message object.r  r  r&  r  r7   r`  r  )r  r  s     r1   _scada_message_bodyr  'v sE    wwvr"H||M"f,||Ir**77="%%r3   c                    	 t        j                         }t        j                         }|d   rt        |d   |d   d| |      S t        | |      |fS # t        $ r) t        j
                  j                  d       dg dd}}Y [w xY w)	a.  Degrade a failed live mailbox fetch to the stored snapshot (DVI-1095).

    When scada.db has readings, respond 200 with the last stored values plus
    the real error so the UI shows stale-but-present data under a warning
    banner; only when the store is also empty does the error win outright.
    z#SCADA status: store fallback failedNrU   r  r  rU   db)r  rU   r	  r  	collector)r  r  )scada_storer/  collector_healthry   rm   rn   r  r   )err_msgrg   r:  r-  s       r1   _scada_status_fallbackr  /v s    <**,--/ JZ 0tF|"'VE 	EF3[@@  <

BC $"5tf<s   (A /BBz/scada/statusc                  x   	 t        j                  t        t        t              } | j                  dg      }d|vr1|j                  d|j                  dd            }t        d| d	      S d
|d    dd}t        j                  dt         d|d      }|j                  dk(  rt        dt        |      z   d      S |j                  dk7  r&t        d|j                   dt        |      z   d      S |j                         j                  dg       }	 t        |       t%        d |D        d      }|t'        g dd      S t)        t+        |            \  }}	t'        |	|d      S # t        $ r}t        d| d      cY d}~S d}~ww xY w# t        $ r" t        j                   j#                  d       Y w xY w)u)  Parse the most recent SCADA email into structured sensor readings.

    Live values also land in scada.db, and a failed mailbox fetch falls back
    to the stored snapshot with the real Graph error surfaced (DVI-1095 P0/P1)
    — an access outage no longer renders as a silently empty grid.
    r  r  r  r  r4  r  r2  r  r  r  r  r  r  zb/messages?$top=25&$orderby=receivedDateTime desc&$select=subject,receivedDateTime,bodyPreview,bodyr  r;  SCADA mailbox fetch failed: rU  Nr  r  rK   r  r  r  z(SCADA status: failed to persist readingsc              3   \   K   | ]$  }t        |j                  d             dk(  s!| & ywr  r5  Nscada_subject_kindr   r,  r  s     r1   r/  zscada_status.<locals>.<genexpr>xv &     Rq 21553C D	 QR   ",,live)r  rU   r	  )r  r  r  r  r  r  r   r  r  r  ry   rg   r  r   _scada_persist_messagesrm   rn   r  r  r   r  r  )
r  r  r  rx   r  rl  rf  r5  rv
  r  s
             r1   scada_statusr  Bv s   Q77%7:W
!:::; ; = -""#68H8HR[8\]C),OPSu*UWZ[[  '|N'C&DE.
 !!5m_ EA A 
 3%14Md4SSUXZ 	Z 3% 0 0158QRV8WWY\^ 	^ yy{w+HI) RHRG f==+,?,HIIxH9VDD/  Q%(DSE&JCPPQ  I

GHIs6   A+E) .+E) F )	F2F FF(F98F9z/scada/historyc                     	 t        j                         } | r?t        | t        d | j                         D              dt        j                               S t        j                  t        t        t              }|j                  dg      }d|vr3|j!                  d	|j!                  d
d            }t        d|       dfS d|d    dd}t#        j                   dt$         d|d      }|j&                  dk(  rt        dt)        |      z         dfS |j&                  dk7  r(t        d|j&                   dt)        |      z         dfS |j+                         j!                  dg       }	 t-        |       i }|D ]  }t/        |j!                  d            }	|j!                  dd       }
|
}|
r7	 t1        j2                  |
j5                  d!d"            }|j7                  d#      }|	d$k(  rZt9        t;        |            \  }}|xs |}|D ]7  }t=        |d%         }|j?                  |g       jA                  ||d   d&       9 |	d'k(  stC        tE        |            }|d(   }tG        |      d)k\  r|d*d+ ng }|d,   D ]  }|j!                  d-      }|r|j7                  d#      n|}|D ]i  }|j!                  |d       xs d jI                         }|r|jK                         d.k(  r=|j?                  t=        |      g       jA                  ||d&       k   |j                         D ]  }|jM                  d/ 0        t        |tG        |      1      S # t        $ r% t        j                  j                  d       i } Y tw xY w# t        $ r# t        j                  j                  d       Y =w xY w# t        $ r Y w xY w)2u  Per-label time series for the History tab.

    Reads scada.db when it has data (populated by the collector, status
    fetches, and agent ingest — DVI-1095 P1), so history survives mailbox
    outages and stops re-parsing ~200 emails per call. Only while the store
    is still empty does it bootstrap by deriving series from the mailbox
    directly (persisting what it parses).
    z!SCADA history: store query failedc              3   2   K   | ]  }t        |        y wrT   )rC  r  s     r1   r/  z scada_history.<locals>.<genexpr>v s      AAQ A   r  )seriesr  r	  r  r  r  r  r  r4  r  r2  r  r  r  r  r  r  r  zc/messages?$top=200&$orderby=receivedDateTime desc&$select=subject,receivedDateTime,bodyPreview,bodyr  r;  r  r  rK   r  r  rU  r  z)SCADA history: failed to persist readingsr  rm  r7   r  r  r  r5  r   )rU   r  rk  r   r   r^   r  rn  _dtx.xc                     | d   S )NrU   rX   r  s    r1   r	  zscada_history.<locals>.<lambda>v s
    !F) r3   r  )r  r  )'r  query_seriesry   rm   rn   r  r   r  r  r  r  r  r  r  r  r  r   r  r  rg   r  r   r  r  r  r  r  r  r  r  _scada_norm_sensorr}  ri   _parse_scada_log_email_scada_log_body_textrC  r   rE  r  )r4  r  r  r  rx   r  rf  r  r  r!	  ro  
time_labelr  _tsr  
point_timer  r)  rL  colssensor_colsr  r6  rQ  pointss                            r1   scada_historyr%  v s   ))+ f  A AA"!,!=!=!?A 	A
 33!3zSJ6667 7 9L \)2L4D4DWi4XYB3%HI3NN #<#?"@A*G
 
1- A= 	= D 37:STX:YY
 	 3%d&6&6%7s;-d34569: 	: yy{w+HJ) F <!#'')"4577-r2
#11(2B2B32QR[[)9:
 9-.A#.FGMC*
J ?(74!!#r*11'!G*=?? U]+,@,EFF)$D(+D	Q$q*BKf~ <WWU^>@R[[)9:j
& <C773+1r88:C#))+"6 %%&8&=rBII!+c:<	<<+<B --/ -+,- &H66o  

@AX  J

HIJ  s5   M 1N 56N=*N
N(N:9N:=	O
	O
)DEFAULT_REPORT_TYPE_ID_graph_error_messager  r  r  get_report_typelist_report_typesreport_email_settingsscada_rate_rule_checkscada_report_criteriar  c            
      V   	 t        j                  d      ryt        j                         sy	 t	        j
                  t        j                               } t        | t              r| ni }t        j                  |      }t        j                  j                  ddD ci c]  }||j                  |       c}       |j                  d      r,t        j                  j!                  dt#        |d                	 t        j%                  t        j&                  dz         }|j)                         st        j+                  |       yy# t        j                  t        f$ r i } Y w xY wc c}w # t        $ r Y yw xY w# t,        $ r" t        j                  j/                  d       Y yw xY w)	a  DVI-1472 P2 (D3): one-time idempotent merge of the legacy
    wor_equipment.json into assets.db, then freeze the JSON on disk as a
    rollback archive (never deleted). Guarded by an assets_store meta flag and
    the migration's own per-name already-linked skip, so it is safe to run on
    every boot and across both gunicorn workers (SQLite serializes the write
    transaction; the second worker finds every name already linked).equipment_migration_atNz9DVI-1472 P2: equipment registry merged into assets.db: %s)r@  linked_by_asset_nolinked_by_namer  already_linked	ambiguouszhDVI-1472 P2: %d equipment names had ambiguous asset matches (not linked; see equipment_migration_report)z	.migratedz&DVI-1472 P2 equipment migration failed)r  get_metaWOR_EQUIPMENT_FILEr   r   r   r   r   r   r   r   migrate_equipment_registryrm   rn   rl   r   ro   rC  ro  r9   r  rk  ry   r  )r   r  reportr  rt  s        r1    _migrate_equipment_registry_oncer7  !w sj   G  !9:!))+	**/99;<C %S$/3R88B

G(: ;!Q

1 ;	<
 ::k"JJ?F;'(*
	(22"''+57G>>#"))'2 $% $$g. 	C	;  		 G

EFGsf   E= E= 'E AE= E)
5AE= :AE. E&"E= %E&&E= .	E:7E= 9E::E= =(F('F()rM
  r	  rd  rK   z/assetsc            
         t         j                  } 	 t        dt        | j	                  dd                  }	 t        | j	                  dd            }|t        vrd}t        j                  | j	                  d      xs dj                         | j	                  d      xs dj                         | j	                  d      xs dj                         | j	                  d	      xs dj                         | j	                  d
      xs dj                         ||dz
  |z        }t        |d   |||d         S # t
        t        f$ r d}Y w xY w# t
        t        f$ r d}Y w xY w)u>   Paged, filtered asset search for the Equipment → Assets tab.r^   rO  per_pager	  r  r7   rT  areaform_idsheet)r  
asset_typer:  r;  r<  r   r  r@  r  )r@  rO  r9  r  )r   rh  rC  r   r   r   r   _ASSETS_PAGE_SIZESr  search_assetsr   r   )rh  rO  r9  r>  s       r1   assets_searchr@  Pw sM    <<D1c$((61-./txx
B/0 ))''88C=B
%
%
'HHV$*113hhv$"++-)$*113xx &B--/qH46F th *, ,% z"  z" s"   %D9 E 9EEE&%E&z/assets/metac            	          t        t        d         } t        t        j                         t        j
                         d| v d| v d| v d| v       }t        di |S )zFilter options + import health for the Assets tab shell, plus the
    caller's Assets management capabilities (DVI-1472 P1 D5).r'  r  r  r  r  )r  r-  can_editcan_catalogcan_inventory
can_importrX   )r#  r   r   r  filter_optionsimport_healthr   )effr  s     r1   assets_metarI  mw se    
 $GFO
4C<668&446
 *S0/36!3s!:,3	5G Wr3   z/assets/<tag>/equipmentc           	      &   t        j                  |       }|st        d      dfS t        j                  |       }t        j                  |       }g }|D ]  }|j                  |d   |d   f        |j                  d      | fD ]=  }t        |xs d      }|s||D ch c]  }|d   	 c}vs+|j                  ||f       ? g t               }
}	|D ]^  \  }}t        |      D ]K  }|j                  d	      |j                  d
      f}||
vs*|
j                  |       |	j                  |       M ` |	j                  d d       d}|D ]  \  }}t        |xs d      }|s n t        t        d         }t        |||	||dud|v       S c c}w )u  DVI-1472 P2 D4: the equipment view for one asset — the RtS defaults,
    Service Status, Oversight/LoRaWAN binding, WO history, and linked manual
    the old Equipment tab showed, now folded into the Assets detail. Read-open
    (like the asset browser); editing is gated by assets.edit on the routes it
    calls. WO history + linked manual are gathered across every equipment name
    (alias + the asset's description/tag) that could reference this asset.Asset not found.r  r  	norm_namer9   r  r7   r   r  r  c                 ,    | j                  d      xs dS r  r  rx	  s    r1   r	  z!asset_equipment.<locals>.<lambda>w s    !%%-"52 r3   Tr
  Nr'  r  )r  aliases
wo_historyr  trackedrB  )r  r  r   equipment_record_for_assetequipment_aliases_forri   r   r  r  r  r  r  r  r#  r   )r  assetr  rN  rB  r  extrarH  r  rO  r  _namer  r)  r  _normr9   rH  s                     r1   asset_equipmentrW  w s    ""3'E/0#5544S9F005GJ 71[>1V9567))M*C0 -#EKR0Dz :!1 ::tUm,- 35J! 'e(. 	'C77:&
(;<C$!!#&		'' OO5tODF! t&tzr2 $GFO
4C&'j &*<)S02 2! !;s   Fz/assets/<tag>/equipment/trackr  c           	         t        j                  |       st        d      dfS t        j                  d      xs i j                  d      }t        j                  | |      \  }}|rt        |      dfS t        j                  |       }t        dd	t        d
   j                  d      d| z  | |d       t        ||      S )a,  Link an inventory asset to an equipment alias so its RtS/Oversight/
    Service settings become editable (and it enters the registry consumers).
    Idempotent: an already-tracked asset returns its existing equipment record.
    Editing the ops fields afterward reuses PATCH /equipment/registry/<id>.rK  r  r  Tr  r9   r`  r  equipment_trackedr'  r  zTracked asset %s as equipment)r  r  r  )r  r  )
r  r  r   r   r  r   ensure_equipment_aliasrQ  r	  r   )r  r9   rH  r  r  s        r1   asset_equipment_trackr[  w s     !!#&/0#55D)/R44V<D55c4@KD%U#S((44S9F(fo!!'*/#5 d3	5
 D00r3   z/assets/import-reportr  c                  b    t        j                         } | st        d      dfS t        |       S )NzNo import has been run yet.r  r  r6  )r  last_import_reportr   r]  s    r1   assets_import_reportr_  w s1     ,,.F:;S@@&!!r3   z/assets/importc                      t         j                  j                  d      } | r| j                  st	        d      dfS | j                  j                         j                  d      st	        d      dfS ddl}d}	 |j                  dd	
      5 }| j                  |       |j                  }ddd       t        j                  || j                        }	 |r	 t#        j$                  |       t)        ddt*        d   j                  d      d|d    d| j                   | j                  |d   |d   |d   d       t	        |      S # 1 sw Y   xY w# t        $ rb}t        j                  j!                  d       t	        d|       dfcY d}~|r&	 t#        j$                  |       S # t&        $ r Y S w xY wS d}~ww xY w# t&        $ r Y w xY w# |r&	 t#        j$                  |       w # t&        $ r Y w w xY ww xY w)zFull-replace import of an uploaded Asset File workbook (admin-only).

    Returns the exceptions report so the admin sees exactly what was flagged
    (duplicates/missing tags skipped, unknown vocab imported verbatim).r  z#Choose an .xlsx workbook to import.r  r`  rN	  z#Only .xlsx workbooks are supported.r   NF)r!  r  )r5  zAsset File import failedzImport failed: r  r  r'  r  zAsset File import: r  z assets from r  exception_counts)r	  r  r  ra  r  r]  )r   r  r   r  r   rE  r|  r  NamedTemporaryFiler  r9   r  import_workbookry   rm   rn   r  r  r/   r   r	  r   )r  	_tempfiler_  rS  r6  rl  s         r1   assets_importre  w s    	&!AAJJBCSHH::&&w/BCSHH H)))G 	 3FF3KxxH	  --hAJJO
 		(# (fo!!'*&vj'9&:-JJ<!**":.$\2$*+=$>@A &!!-	  	   ;

78se45s::		(#  	;   		(#  s   :E E
+)E G 
EE 	G/F<GG F,,	F87F8<GG 	GGG=G-,G=-	G96G=8G99G=z/assets/savec                  p   t        j                  d      xs i } t        | j                  d      xs d      j	                         }t
        j                  D ci c]  }|| v s|| |    }}d| v r| d   |d<   |st        d      dfS t        j                  ||xs d	      \  }}}|rt        |      dfS |rm|d
   |d}|r||d
   k7  r||d<   t        d|rdndt        d   j                  d      |d
   d|d
   d|rdndddj                  t        |            |       t        ||      S c c}w )aC  Create or update one asset from the Assets tab editor (DVI-1185 P2).

    Requires the `assets.edit` leaf (DVI-1472 P1 D5; admins + grantees). The
    base browser view stays open to all logged-in users; only management
    actions are gated. Every real change is audited to history with actor +
    per-field before/after.Tr  original_tagr7   model_idzNo fields provided.r  r`  Nr  )r  r  renamed_fromr  r  r  r'  r  Asset r\   r  r
  r>  r  r  )rS  r  )r   r  r*   r   r   r  EDITABLE_FIELDSr   
save_assetr	  r   r  r  )r  rg  r  r  rS  r  r  r	  s           r1   assets_saverm  w sY    4(.BDtxx/526<<>L".">">LQ!t)aajLFL T!*-z23S88(33$&E7EU#S(({+@LE+,>>%1F>"\>}&/%%g.k"k"(g5		&/*, 	 001 Ms   	D3#D3c                     dj                  d | j                  d      | j                  d      | j                  d      fD              S )Nr	  c              3   $   K   | ]  }|r| 
 y wrT   rX   r,  r  s     r1   r/  z'_asset_model_summary.<locals>.<genexpr>x s      1./ 	
1r  r9   manufacturermodel_nor  r   r  s    r1   _asset_model_summaryru  x sE    ;; 1IIf%uyy'@IIj)+1 1 1r3   z/assets/modelsc                  h    t        t        d         } t        t        j                         d| v       S )zEAll asset models (read-open, feeds the editor dropdown + catalog UI).r'  r  )r  rC  )r#  r   r   r  list_asset_modelsrH  s    r1   asset_models_listry  $x s2     $GFO
4C,88:/368 8r3   r  c            
         t        j                  d      xs i } t        j                  |       \  }}}|rt	        |      dfS t        ddt        d   j                  d      d	|d
   z  dt        |      z  |d
   |d       t	        |      S )NTr  r  r`  r  r  r'  r  model:%sr  zAsset model added: %srh  r  r  rt  )	r   r  r  save_asset_modelr   r	  r   r   ru  )r  r  r  r  s       r1   asset_model_creater~  -x s     4(.BD(99$?E7EU#S((*fo!!'*
U4[0H'*>u*EE!$KG<	>
 r3   z/assets/models/<int:model_id>c                 P   t        j                  d      xs i }t        j                  ||       \  }}}|rt	        |      dfS |rTt        ddt        d   j                  d	      d
| z  dt        |      ddj                  t        |            | |d       t	        |      S )NTr  rh  r  r`  r  r  r'  r  r{  zAsset model 	 edited: r  r|  r  rt  )r   r  r  r}  r   r	  r   r   ru  r  r  )rh  r  r  r  r  s        r1   asset_model_updater  <x s     4(.BD(99$RE7EU#S((/&/%%g.J4I$U+TYYvg-GI (W=	? r3   c           
         t        j                  |       }t        j                  |       \  }}|st        |      dfS t	        ddt
        d   j                  d      d| z  d|rt        |      n| z  d	| i
       t        d      S )Nr  r`  r  r  r'  r  r{  zAsset model deleted: %srh  r  TrY  )r  get_asset_modeldelete_asset_modelr   r	  r   r   ru  )rh  r  rU  r  s       r1   asset_model_deleter  Mx s     ((2E//9IBU#S((,fo!!'*
X0E)+0 'h@H%' dr3   z/assets/<tag>/sub-assembliesc                     t        j                  |       st        d      dfS t        j                  |       }t	        t
        d         }t        ddd|v i|S )zCOwn + model-inherited sub-assemblies for one asset (D7, read-open).rK  r  r  r'  rC  r  rX   )r  r  r   sub_assemblies_for_assetr#  r   )r  r>  rH  s      r1   asset_sub_assembliesr  ]x sZ     !!#&/0#552237F
#GFO
4CA/36A&AAr3   z/assets/sub-assembliesc                     t         j                  j                  d      xs dj                         } t         j                  j                  d      xs dj                         }t	        t        j                  | |            S )uH   Sub-assemblies for one parent (?parent_kind=asset|model&parent_ref=…).parent_kindr7   
parent_ref)sub_assemblies)r   rh  r   r   r   r  list_sub_assemblies)r!	  r  s     r1   sub_assemblies_listr  hx s`     LL]+1r88:D<<L)/R
6
6
8C,"B"B4"MNNr3   c                     | sy| j                  d      dk(  rAt        j                  | j                  d            }d|r|d   z  S | j                  d      z  S d| j                  d      z  S )Nr7   r  r  r  zmodel %sr9   zasset %s)r   r  r  )r\  r  s     r1   _sub_assembly_scoper  qx sh    
ww}(,,SWW\-BCeU6]OO9NOO---r3   c                  :   t        j                  d      xs i } t        j                  |       \  }}}|rt	        |      dfS t        ddt        d   j                  d      d	|d
   z  d|d   dt        |      |d
   |d   |d   |d       t	        |      S )NTr  r  r`  r  r  r'  r  sub:%sr  Sub-assembly 'r9   z' added to r  r  )sub_assembly_idr  r  r  r  sub_assembly)	r   r  r  save_sub_assemblyr   r	  r   r   r  )r  r\  r  r  s       r1   sub_assembly_creater  zx s     4(.BD&88>C%U#S((+fo!!'*3t90DK,S13#&t9S=O!,/GEF $$r3   z#/assets/sub-assemblies/<int:sub_id>c                 D   t        j                  d      xs i }t        j                  ||       \  }}}|rt	        |      dfS |rNt        ddt        d   j                  d	      d
| z  d|d   ddj                  t        |            | |d       t	        |      S )NTr  )sub_idr  r`  r  r  r'  r  r  r  r9   z
' edited: r  )r  r  r  r  )
r   r  r  r  r   r	  r   r   r  r  )r  r  r\  r  r  s        r1   sub_assembly_updater  x s     4(.BD&88fMC%U#S((0&/%%g.Hv4EFTYYvg79'-'B	D $$r3   c                     t        j                  |       \  }}|rt        |      dfS t        ddt        d   j                  d      d| z  d|j                  d	      d
t        |      d| i       t        d      S )Nr  r`  r  r  r'  r  r  r  r9   z' deleted from r  r  TrY  )r  delete_sub_assemblyr   r	  r   r   r  )r  r  r  s      r1   sub_assembly_deleter  x s     !44V<MFEU#S((-fo!!'*60AJJv 3F ;=!6*, dr3   c                 x    | sydj                  d | j                  d      | j                  d      fD              S )Nr7   r	  c              3   $   K   | ]  }|r| 
 y wrT   rX   rp  s     r1   r/  z _part_summary.<locals>.<genexpr>x s      5Q23  5r  r  r9   rs  r  s    r1   _part_summaryr  x s?    ;; 5TXXf-#/ 5 5 5r3   c                     	 t        |       }|t	        |      k(  rt        t	        |            S d|z  S # t        t        f$ r t        |       cY S w xY w)Nz%g)r  r   r   r*   r   )r  r  s     r1   _parts_fmt_qtyr  x sR    !H s1v+3s1v;5D1H5 z" 1vs   4 AAc                    t         j                  }| j                  d      xs | j                  d      xs d}d|z  }|r ||      nd}g }|D ]  }|j                  d      rdnd}	|j                  d      rd	nd
}
|j                  d ||j                  d      xs d      d|j                  d      rd ||j                  d            z   ndd ||j                  d      xs d      dt	        |j                  d            dt	        |j                  d            d|
d|	d        ddj                  |      z   dz   }d ||      d|d|d}t        j                  d|d      }||fS ) z=Build (subject, html) for the low-stock reorder digest email.r  r9   r  zLow stock reorder alert: %sza stock changer  NegativezBelow minimumz#b91c1cz#b45309zJ<tr><td style='padding:6px 10px;border-bottom:1px solid #e5e7eb;'><strong>r7   z	</strong>r
  zC</td><td style='padding:6px 10px;border-bottom:1px solid #e5e7eb;'>r  zT</td><td style='padding:6px 10px;border-bottom:1px solid #e5e7eb;text-align:right;'>r  r  zG</td><td style='padding:6px 10px;border-bottom:1px solid #e5e7eb;color:z;font-weight:600;'>z
</td></tr>a[  <table role='presentation' width='100%' cellpadding='0' cellspacing='0' style='border-collapse:collapse;font-size:14px;margin-top:8px;'><tr style='background:#f9fafb;'><th align='left' style='padding:6px 10px;border-bottom:2px solid #e5e7eb;'>Part</th><th align='left' style='padding:6px 10px;border-bottom:2px solid #e5e7eb;'>Location</th><th align='right' style='padding:6px 10px;border-bottom:2px solid #e5e7eb;'>On hand</th><th align='right' style='padding:6px 10px;border-bottom:2px solid #e5e7eb;'>Min</th><th align='left' style='padding:6px 10px;border-bottom:2px solid #e5e7eb;'>Status</th></tr>z</table>z$<p style='margin:0 0 12px;'><strong>z.</strong> has reached its reorder point after zL. The parts below are at or under their minimum and should be reordered.</p>u   <p style='margin:14px 0 0;color:#6b7280;font-size:13px;'>Manage stock in Togen → Equipment → Assets → Parts &amp; inventory.</p>   Low stock — reorder neededzParts & inventoryr  )r=  _escr   ri   r  r  r  )trigger_part	low_partsr  escr
  r  
trig_labelrn  r  ro  colortabler  r  s                 r1   _parts_low_stock_emailr  x sq   


C			)	$	J(8(8(@	JFB+b0G&2\"8HJD  uuZ0oUU:.	I AEE)$*+23%%-3quuV}--RGAEE*%+,quuY/0quuY/0u!	,	% (*wwt}	5 8B	B 
( GZ	( 	 ++&7JLDD=r3   c                 
   g }|dd D ]  }|j                  d|j                  d      xs dd|j                  d      xs ddt        |j                  d	            d
t        |j                  d            d|j                  d      rdnd
        |j                  d      xs |j                  d      xs d}dddddd|ddj                  |      d}t	        j
                  | |d      }|j                          y)u   POST a MessageCard to a Teams Incoming-Webhook URL. Self-contained (no
    Graph permissions) — an admin pastes a channel webhook URL in Settings.NrM
  z- **r  r7   z** r9   u    — on hand r  z (min r  r7  r  u    ⚠ NEGATIVEzA partMessageCardzhttps://schema.org/extensionszLow stock reorder alertD9534Fr  z**z1** reached its reorder point. Parts to reorder:

r0  )z@typez@contextr
	  
themeColorre  r&  r   r   r7  )ri   r   r  r  r  r  r  )webhookr  r  rP  r  r
  r  r  s           r1   _parts_low_stock_teamsr  y s     Es^ ;EE)""AEE&M$7R$7155+,nQUU9=M.N uuZ0Ob8: 	;;
 
		)	$	L(8(8(@	LHB3,/		%(*G wWbADr3   r  c                v   	 | r| j                  d      y| d   }| j                  d      st        j                  |       yt        j                         }|j                  d      syt	        |j                  dd            dz  }t        j
                  ||      sy|j                  d      xs g }|j                  d	      xs d
}|s|syt        j                         }|r(	 t        | ||      \  }}	t        j                  |||	       |r	 t        || |       t        j                  |g       y# t        $ r" t        j                  j                  d       Y Qw xY w# t        $ r" t        j                  j                  d       Y ow xY w# t        $ r" t        j                  j                  d       Y yw xY w)a  Best-effort low-stock reorder notification, called after any stock change.

    If the part is now low (below reorder point / negative) and due per the
    per-part cooldown, send the reorder digest to the configured recipients (+
    optional Teams webhook) and stamp the cooldown. If the part is no longer low,
    clear its cooldown stamp so a future dip alerts immediately. Never raises.r  NrK  r~  cooldown_hoursr   g      N@rA  teams_webhookr7   zlow-stock email send failedzlow-stock Teams post failedzlow-stock alert check failed)r   r  clear_low_stock_alertget_parts_notify_configr  part_alert_duelow_stock_partsr  r=  r  ry   rm   rn   r  r  record_low_stock_alert)
r  r  r  rJ  cooldown_minrA  r  r  r  r  s
             r1   r  r  y s   "=txx~-4jxx..s3224wwy!SWW%5r:;dB**3="(b''/*0bg 002	D 6tY7C!E--fgtD D&wi@
 	++SE2  D

$$%BCD
  D

$$%BCD
  =

;<=sv   F +F %F (4F .F F #'D1 
F E F 1(EF EF (F
F 	F

F (F87F8z/assets/partsr  c            	         t         j                  j                  d      xs dj                         xs d } t         j                  j                  d      xs dj                         dv }	 t	        dt        t         j                  j                  d      xs d            }d}t        j                  | |||dz
  |z        }t        d
||t        t        j                               d	|S # t        t        f$ r d}Y ^w xY w)Nr  r7   	low_stockrr	  r^   rO  rd  r  r  r   r  )rO  r9  low_stock_countrX   )r   rh  r   r   rC  r   r   r   r  
list_partsr   rC  r  )r  rK  rO  r9  r>  s        r1   assets_parts_listr  Hy s     
		#		$"++-5A<<K(.B
5
5
7;O
OC1c',,**627a89 H$$
s(D1H3HJF Rx#&|'C'C'E#FRJPR R z" s   *6C+ +C?>C?z/assets/parts/low-stockc                  >    t        t        j                               S )uE   The low-stock digest — parts below their reorder point or negative.r0
  )r   r  r  rX   r3   r1   assets_parts_low_stockr  Xy s     55788r3   z/assets/parts/notify-configc                  >    t        t        j                               S )zkLow-stock reorder-notification config (recipients, optional Teams webhook,
    per-part cooldown, enabled).rc  )r   r  r  rX   r3   r1   assets_parts_notify_config_getr  _y s    
 ,>>@AAr3   c                  Z   t        j                  d      xs i } | j                  d      }t        |t              rt        j                  d|      }t        j                  | j                  d      |xs g | j                  dd      | j                  dd	      d
      }t        ddt        d   j                  d      dd|d   rdndt        |d         t        |d         dk(  rdndt        |d         |d   rdndfz  |d   |d   |d   t        |d         d       t        |      S )NTr  rA  z[,\s;]+r~  r  r7   r  r   )r~  rA  r  r  r  r  r'  r  zparts:notifyu=   Low-stock notifications %s — %d recipient%s, cooldown %sh%sdisabledr^   r  z, Teams webhook set)r~  r  r  r  r  r  )r   r  r   r   r*   rF  r  r  save_parts_notify_configr	  r   rC  r  r   r   )r  rA  r  s      r1   assets_parts_notify_config_postr  gy s:    4(.BDXXhF&#*f-1188I&,B/26((#3R8	3 E 2fo!!'*Oy)Izh E(O(<(A"s5!123%*?%;!	SE E
 !+5?"'(8"9!%eO&<!=?
@ %  r3   z/assets/parts/notify-testc                     t        j                         } | j                  d      xs g }| j                  d      xs d}|s|st        d      dfS t        j                         }|r|d   ndd	dd
ddd}dx}}|r9	 t        ||xs |gd      \  }}t        t        j                  |d|z   |            }|r	 t        |||xs |g       d}t        |||t        |            S # t        $ r$ t        j                  j                  d       d}Y [w xY w# t        $ r$ t        j                  j                  d       d}Y tw xY w)zSend a test low-stock notification to the configured recipients now,
    ignoring cooldown/low state (proves the pipeline for prod-verify).rA  r  r7   z4Add at least one recipient or a Teams webhook first.r  r`  r   SAMPLEu%   (test — no parts are currently low)r^   FT)r  r9   r  r  r  r  Nza testz[Test] zlow-stock test email failedz low-stock test Teams post failed)r6  
teams_sentr  	low_count)r  r  r   r   r  r  r   r=  r  ry   rm   rn   r  r  rC  )	rJ  rA  r  r  sampleemail_okteams_okr  r  s	            r1   assets_parts_notify_testr  y si   
 
.
.
0CWWX$"Fggo&,"G' & '(+, 	,,,.I&Yq\%LD-JF Hx	2	-fXx9MGTM99	G+T3 4H
 	"7FI4I&JH h8$I@ @  	JJ  !>?H	  	JJ  !CDH	s$   <8C# 7D #*DD*E ?E c                  X   t        j                  d      xs i } t        j                  |       \  }}}|rt	        |      dfS t
        d   j                  d      }t        dd|d	|d
   z  dt        |      z  |d
   |d       t        | j                  d      xs d      j                         }|dvr^t        j                  |d
   |d|      \  }}}|s<|r:|}t        dd|d	|d
   z  dt        |      d|d|d   d|d
   ||d   dd       t        |d       t	        |      S )NTr  r  r`  r'  r  r  r  r  r  zPart added: %sr  r  r  r  r7   )r7   rF   zOpening stockrm  r		  r  zOpening stock for r>  r  r7  )r  r  r  rm  zopening stockr  r  )r   r  r  	save_partr   r   r   r	  r  r*   r   adjust_stockr  )	r  r  r  r  r		  openingadj_mvadj_errs	            r1   assets_part_creater  y sF    4(.BD'11$7D'5U#S((FO(E|5i$t*6L =#66:':<
 $((9%+,224Gi(55JuFS'3D4ET
*!$'$y/C#':#'	?oOP 4o>r3   z/assets/parts/<int:part_id>c                     t        j                  |       }|st        d      dfS t        |t        j                  |       t        j                  |             S )NPart not found.r  r  )r  	movementslinks)r  get_partr   list_part_movementspart_links_detail)r  r  s     r1   assets_part_detailr  y sU       )D./44)==gF%77@B Br3   c                 j   t        j                  d      xs i }t        j                  ||       \  }}}|rt	        |      dfS |rTt        ddt        d   j                  d	      d
| z  dt        |      ddj                  t        |            | |d       t        |d       t	        |      S )NTr  )r  r  r`  r  r  r'  r  r  r  r  r  r  r  za reorder-point changer  r  )r   r  r  r  r   r	  r   r   r  r  r  r  )r  r  r  r  r  s        r1   assets_part_updater  y s     4(.BD'11$HD'5U#S((=0C0CG0LG#d#TYYvg%?A&7;	= 4.FGr3   c           
          t        j                  |       \  }}|rt        |      dfS t        ddt        d   j                  d      d| z  dt        |      z  d	| i
       t        d      S )Nr  r`  r  r  r'  r  r  zPart deleted: %sr  r  TrY  )r  delete_partr   r	  r   r   r  )r  r  r  s      r1   assets_part_deleter  y ss     **73KD%U#S((~WV_-@-@-I"]4%887#	%
 dr3   z"/assets/parts/<int:part_id>/adjustc                    t        j                  d      xs i }t        j                  |       }|st	        d      dfS |j                  d      t        |j                  d            j                         dk7  rU	 t        t        j                  |j                  d      d            }|t	        d	      dfS |t        |d
   xs d      z
  }n|j                  d      }|j                  d      xs dj                         }t        j                  | ||t        d   j                  d            \  }}}|rt	        |      dfS t        ddt        d   j                  d      d| z  dt        |      d|r
|d   dkD  rdnd|r|d   ndd|d
   d|rd|z  nd	| |r|d   nd|d
   ||d   |d   d       t!        |d       t	        ||       S # t        t        f$ r t	        d	      dfcY S w xY w)!zManual stock adjustment. Accepts a signed ``delta`` OR ``set_to`` (an
    absolute new on-hand, converted to a delta). Below-min/negative flag,
    never block.Tr  r  r  r`  set_toNr7   zEnter a number.r  r   r  rm  r'  r  r  r  r  r  zStock adjusted for r>  r  r  r7  u    — %sr  r  )r  r  r  rm  r  r  r  za manual adjustmentr  )r  movement)r   r  r  r  r   r   r*   r   r  _coerce_qtyr   r   r  r   r	  r  r  )	r  r  r  r  r  rm  updatedr  r  s	            r1   assets_part_adjustr  y s    4(.BD  )D./44xx%#dhhx.@*A*G*G*IR*O	9<33DHHX4FMNF >!23S88tI3!44!hhx &B--/F+88vWV_-@-@-I KGXuU#S((,GFO4G4G4P'"'!2Q!6CR?!)HWr1793E$*Y2	4
 #(Xg->PT"9-$[1wz?RT
U 71FG(331 :& 	9!23S88	9s   =.G G76G7z /assets/parts/<int:part_id>/linkc                 z   t        j                  d      xs i }t        j                  | |j	                  d      xs dj                         |j	                  d            \  }}|rt        |      dfS t        dd	t        d
   j	                  d      d| z  d| d|d   d|d   | |d       t        |      S )NTr  target_kindr7   
target_refr  r`  r  r  r'  r  r  r  z linked to r\   r  r   r  )r   )	r   r  r  	link_partr   r   r   r	  r   )r  r  r   r  s       r1   assets_part_linkr  z s     4(.BD(($((=)/R668 KD% U#S((}GFO,?,?,HT-($|*<>"D13 r3   z!/assets/parts/links/<int:link_id>c                     t        j                  |       \  }}|rt        |      dfS t        ddt        d   j                  d      d|d   z  d	|d   d
|d   d|d   |d   |d       t        d      S )Nr  r`  r  r  r'  r  r  r  r  z unlinked from r  r\   r  r  r  TrY  )r  unlink_partr   r	  r   r   )r[  r~  r  s      r1   assets_part_unlinkr  *z s     ))'2JCU#S((gfo.A.A'.JI&	NC.L0ACy>379 dr3   r  c                 :    t        j                         d| d   S )z7Absolute deep link a scanned label opens (part detail).z/?open_part=r  )r=  r;   r  s    r1   _part_label_deeplinkr   Bz s    !.!8!8!:DJGGr3   c                 L    | xs dj                  dd      j                  d      S )z{FPDF core fonts are latin-1 only; replace anything outside it so an
    exotic part name can never 500 the label generator.r7   rg
  r  )r  r  r%  s    r1   _label_latin1r  Gz s&     JBy)4;;IFFr3   c                 r   t        j                  t         j                  j                  dd      }|j	                  |        |j                  d       |j                  dd      j                  d	      }t        j                  d
      \  }}t        j                  |       |j                  |d       |S )zBRender a QR PNG to a temp file (caller unlinks). Returns the path.r   r   r:  Tr=  r	  r	  r?  r9  r   r  rB  r  )rD  rE  rF  rG  rH  rI  rJ  rL  r  r  r  r[  r  )r   rM  rR  r  rb   s        r1   _make_label_qr_pngr  Mz s    $..>>AF OOD
KKDK


w7

C
K
KE
RCv.HBHHRLHHT%H Kr3   c                    d}t        |j                  d      xs d      }	t        |j                  d      xs d      }
t        |j                  d      xs d      }| j                  ddd       | j                  d       | j	                  |dz   |dz   |d	z
  |d	z
         | j                  d
d
d
       |rt        |d	|z  z
  |dz  d      }|||z
  d	z  z   }||z   dz   }| j                  |||||       ||z   dz   }d}| j                  dd|       | j                  |	      |d	|z  z
  kD  r=|dkD  r8|dz  }| j                  dd|       | j                  |	      |d	|z  z
  kD  r|dkD  r8| j                  ||z   |       | j                  |d	|z  z
  |dz  |	d       ||dz  dz   z  }|
rC| j                  ddd       | j                  ||z   |       | j                  |d	|z  z
  d|
d       |rb| j                  ddd       | j                  ddd       | j                  ||z   ||z   |z
  dz
         | j                  |d	|z  z
  dd|z   d       yt        |d	|z  z
  d      }| j                  |||z   ||z   ||       ||z   |z   dz   }||z   |z
  |z
  }||z   }d }| j                  dd|       |	rM| j                  |	      |kD  r9|dkD  r4|dz  }| j                  dd|       |	r| j                  |	      |kD  r|dkD  r4| j                  ||       | j                  ||dz  |	       ||dz  d!z   z  }|
rN| j                  ddd"       | j                  ||       | j                  |d#|
dd$        | j                         dz   }|re| j                  ddd       | j                  ddd       | j                  ||       | j                  |dd%|dd& z          | j                  d
d
d
       yy)'zjDraw one part label (QR + human-readable part no / name / location)
    inside the box (x, y, w, h) in mm.g      @r  r7   r9   r  r;  r*
  r^   r      g?g     W@r   r  r  r  r	  r	  r   r  r  r5
  r   r  r   n   r   z
Location: N      >@rP   r-  g      ?ry  r	  r  zLoc: r   )r  r   r
  r  rD  r	  rL  r~
  r	  r  r
  r	  r	
  r
  )r6  r  r|
  r  r}
  r  qr_pathsinglepadr  r9   locqrr  r  tyfstxtws                      r1   _draw_part_label_cellr  [z s    CDHHY/526G&)/R0D
,2
3CsC%sHHQUAE1q5!a%(r2r"QWa$h-AFa<3w{		'TTR2	6BY][#r*""7+q1s7{;R!GBLLc2. ""7+q1s7{;R 	

1s7BQWb3hs;
b3hlLLb"-JJq3w#NN1q3w;4sN;LLb"-sC-JJq3wAa0HHQS[!\C%7sHC 
QS[$	BIIgSAGrRI8	
S2	B	
Qr	B	
SB	BLLc2&
c**73b8R!V
a[#r* c**73b8R!V JJr2HHRc7#"s(S.B["a(

2rr3Tc
+YY[3
["a(3S)

2rr1gCR012r2& r3   c                    g }	 t        ddd      }|j                  d       d\  }}d}|rd	nd
\  }}|d|z  z
  |z  }	|d|z  z
  |z  }
||z  }t        |       D ]p  \  }}||z  }|dk(  r|j                          ||z  }||z  }|||	z  z   }|||
z  z   }t	        t        |            }|j                  |       t        ||||	|
|||       r t        |j                               |D ]  }	 t        j                  |        S # t        $ r Y 'w xY w# |D ]'  }	 t        j                  |       # t        $ r Y %w xY w w xY w)zReturn PDF bytes for a Letter-size label sheet. ``single`` renders one
    large label; otherwise a 2x5 grid flows across pages.r	  r  Letterr	  Fr	  )gj@gfffffvq@g      $@)r^   r^   )r   r   r   r   )r   r
  r  r
  r  r   ri   r  r  r  r  r/   r   )r0
  r
  rS  r6  w_pageh_pager`
  r"  rn  cell_wr  r9  r  r  slotr  r  r  r|
  r	  r  s                        r1   _render_part_labels_pdfr  z s    Csh?U+%%V6
d1v:%-1v:%-$; ' 
	TGAtx<DqyAtAV#AV#A()=d)CDGJJw!#q!VVT7FS
	T SZZ\" 	A		!	    	A		! 	sB   C!D +D	DDE D/.E /	D;	8E :D;	;E z/assets/parts/labels.pdfc                     t         j                  j                  d      xs dj                         dv } t         j                  j                  d      xs dj                         }t         j                  j                  d      xs dj                         dv }g }|rj|j	                  d      D ]U  }|j                         }|j                         s$t        j                  t        |            }|sE|j                  |       W n|rt         j                  j                  d      xs dj                         xs d}t         j                  j                  d	      xs dj                         dv }t        j                  ||t        d
      d   }|st        d      dfS |dt         }| xr t        |      dk(  }	 t        ||      }	|rd|d
   d   z  nd}t'        t)        j*                  |	      dt-        |      xs d      S # t        $ r:}
t         j"                  j%                  d       t        d|
z        dfcY d}
~
S d}
~
ww xY w)zPrintable QR labels for parts. ``?ids=1,2,3`` for a chosen set, or
    ``?all=1`` (with optional ``q``/``low_stock``) for the whole filtered list;
    ``?single=1`` on a single part renders one large label.r
  r7   rr	  r|  r  r  r  Nr  r   r  r0
  zNo parts to print.r  r`  r^   )r
  z Part label PDF generation failedzLabel generation failed: %sr  zpart-label-%s.pdfr  zpart-labels.pdfr  r  )r   rh  r   r   r  r	  r  r  r   ri   r  _PART_LABELS_MAXr   rC  r  ry   rm   rn   r  r   r>  rJ  r   )r
  ids_argwant_allr0
  tokr  r  rK  oner   rl  dls               r1   assets_part_labels_pdfr!  z s    llx(.B557;OOF||&,"335G  '-2446:NNHE==% 	#C))+C;;=((S2DT"	# 
\\c"(b//19T||,299;?SS''3&6qBBIK12C77##$E

$SZ1_CG&uS9 9<
a 3
3ARBRZZ%0A#22#6#K:KM M	  G

?@:S@A3FFGs   <H 	I	/I>I	I	z/assets/<tag>/partsc                     t        j                  |       st        d      dfS t        di t        j                  |       S )zParts associated with one asset (direct + via model + via sub-assemblies),
    each with stock. Gated with the rest of the stock UI (assets.inventory).rK  r  r  rX   )r  r  r   parts_for_asset)r  s    r1   asset_partsr$  z s<    
 !!#&/0#557\11#677r3   z#/assets/models/<int:model_id>/partsc                     t        j                  |       st        d      dfS t        t        j                  dt	        |                   S )NzModel not found.r  r  r  r  )r  r  r   parts_for_targetr*   r  s    r1   asset_model_partsr'  z s?     ''1/0#5566wHNOOr3   r   c                    t        j                         t        t              z
  j	                  d      }t               }	 |j                  d|| |f      j                         }|j                          |duS # |j                          w xY w)zxTrue when this user already has an asset_viewed event for this tag
    within the dedupe window (so we skip re-logging).hoursr  z{SELECT 1 FROM tool_history WHERE action = 'asset_viewed' AND ref = ? AND COALESCE(actor,'') = ? AND created_at >= ? LIMIT 1N)	r  r  r  _ASSET_VIEW_DEDUPE_HOURSr  r  rL  ru  r[  )r		  r  r   rO  r  s        r1   _asset_view_recently_loggedr,  z s     ##%$<=>-.  =DlleV,. /7hj 	
 	

d? 	

s    #A7 7B	z/assets/view-logc                     t        j                  d      xs i } t        | j                  d      xs d      j	                         }|syt
        d   j                  d      xs d}t        ||      st        dd	||d
|z         y)zRecord that the caller opened an asset's detail view (usage tracking).

    Fire-and-forget beacon from the Assets tab; throttled per user + tag so it
    never floods history. Always returns 204 (never blocks the UI).Tr  r  r7   )r7      r'  r  r  r  zAsset %s viewed)r		  r  r
	  )r   r  r*   r   r   r   r,  r	  )r  r  r		  s      r1   assets_view_logr/  { s}     4(.BD
dhhuo#
$
*
*
,CFO(.BE&uc2}nEs036	8r3   z/assets/photo/<tag>/<filename>c                     ddl m}  |t        t        j                        | |      }|rt
        j                  j                  |      st        d       t        |d      S )z;Serve one recovered asset photo from the asset_photos tree.r   	safe_joinr  r  )rM  )
werkzeug.utilsr2  r*   r  ASSETS_PHOTO_DIRr  rb   r  r   r   )r  r  r2  rb   s       r1   assets_photor5  { sF     )S667hGDrww~~d+c
T5))r3   >   r7  r  r   r   r  r   r  z/assets/photo-uploadc                  N   ddl m} m} t        t        j
                  j                  d      xs d      j                         }|st        d      dfS t        j                  |      st        d|z        d	fS t        j                  j                  d
      D cg c]  }|s|j                  s| }}|st        d      dfS  | t        t        j                        |      }|st        d      dfS t        j                          j#                  d      }g g }}t%        |      D ]  \  }	}t&        j(                  j+                  |j                        d   j-                         }
|
t.        vr|j1                  |j                         d ||j                        xs d|
z   }d||	|fz  }t'        j2                  |d       |j5                  t&        j(                  j7                  ||             |j1                  |        |s-t        ddj7                  t9        t.                    z        dfS t        j:                  ||       t=        ddt>        d   j                  d      |d|tA        |      fz  ||d       t        t        jB                  |      |      S c c}w )zAttach photos to one asset from the Add/Edit dialog.

    Same gate as /assets/save (assets.edit; admins + grantees, audited). Files
    land in the asset_photos tree beside the workbook-extracted ones.r   )r2  r   r  r7   zAsset tag is required.r  r`  zUnknown asset tag %s.r  r  zNo files provided.zInvalid asset tag.r  r^   r  z%s_%d_%sTr  z/No image files among the upload (accepted: %s).r  r  r  r'  r  zAsset %s: %d photo(s) attached)r  photos_addedr  )r  r  )"r3  r2  r   r*   r   rg  r   r   r   r  r  r  r
  r  r4  r  r  r  r  r  rb   r   rE  _ASSETS_PHOTO_EXTSri   r  r  r  r  
add_photosr	  r   rC  
photos_for)r2  r   r  r  r  r  r~  r  r  r  r  r  r9   s                r1   assets_photo_uploadr;  ,{ sI    :
gllu%+
,
2
2
4C56;;!!#&4s:;S@@--h7L11QLEL12C77\::;SAH12C77 ))/:E7E%  	1ggqzz*1-335((NN1::&qzz*=w}UAt,,
Ht,	rww||Hd+,T	 N6*<#=>? @ADE 	EC'~fo!!'*0CU3DDE2	4
 ,11#6HH7 Ms   J""J"/J"z/assets/photo-deletec                     ddl m}  t        j                  d      xs i }t	        |j                  d      xs d      j                         }t	        |j                  d      xs d      j                         }|r|st        d	      d
fS t        j                  ||      st        d	      dfS  | t	        t        j                        ||      }|r5t        j                  j                  |      r	 t        j                  |       t'        ddt(        d   j                  d      |d|d|d||gd       t        t        j*                  |            S # t        $ r$ t         j"                  j%                  d||       Y }w xY w)z:Detach one photo from an asset (index row + file on disk).r   r1  Tr  r  r7   r  ztag and filename are required.r  r`  zPhoto not found.r  z#Asset photo file not removed: %s/%sr  r  r'  r  rj  z: photo removed (r7  )r  photos_removedr  )r  )r3  r2  r   r  r*   r   r   r   r  remove_photor4  r  rb   r  r  r   rm   rn   ro   r	  r   r:  )r2  r  r  r9   rb   s        r1   assets_photo_deleter?  W{ sG    )4(.BD
dhhuo#
$
*
*
,Ctxx
#)r*002Dd=>CC$$S$//0#55S667dCDt$	QIIdO ~fo!!'*25t<tf5	7
 ,11#677  	QJJDc4P	Qs   2E *FFc                  :    t         d   j                  d      xs dS r7  r  rX   r3   r1   	_qc_actorrA  { s    6?w'-2-r3   c                      t        t        j                  d      xs i       } d| v d| v d| v d| v d| v d| v t        j                  d	      D ci c]
  }|d
|z   | v  c}dS c c}w )u2  Per-user QC capability flags (DVI-1373 P2 + DVI-1410 P3). Admins implicitly
    hold every leaf; a granted non-admin holds whatever leaves are on their tool
    grant. These drive which QC tabs the SPA reveals — but the route decorators
    are the real boundary; a hidden tab is never the security gate.r'  r  r  r  r  r  r  Tr  r  )r  r  mixturesr  r  filebrowser
production)r#  r   r   r  r  )rH  r  s     r1   _qc_capsrF  { s    
 $GKK$7$=2
>C
 3;3&!S($!S('3. !) 4 4d KM +a/C77 M Ms   A!c                 V    | xs i j                  d      ryd|z   t        | xs i       v S )zTrue if the user may capture Daily Production for ``segment`` (admin, or
    holds the per-segment qc.production.<segment> leaf).r   Tr  )r   r#  )r'  segments     r1   _qc_can_productionrI  { s3     	
&w&+DTZR+PPPr3   z/qc/metac                     t               } t        t        j                  d      t        j                         t        j
                  d      t        j                         t        j                         D cg c]  }| d   j                  |d         r| c}t        j                         t        j                         t               t        j                         | t        | d   xr | d   xr | d	         
      S c c}w )al  Everything the QC pane shell needs: active materials, active mixtures
    (with current per-yd3 targets), segments, and the caller's granular caps.
    Gated on ANY qc-family leaf (DVI-1410 P3) so a Daily-Production-only operator
    can load the reference data their form needs; /qc/batches stays {qc}-gated so
    a scoped operator can't enumerate other batches.Finclude_aggregatesT)with_targetsrE  rH  rC  r  r  )	materials
aggregatesrC  r-  daily_productionbatch_ticket_fieldsscan_settingsocr_templatescustom_fieldscaps
can_manage)rF  r   r  list_materialslist_aggregateslist_mixturesr  daily_production_segmentsr   get_batch_ticket_fieldsget_scan_settings_qc_ocr_segment_template_nameslist_custom_fieldsr   )rU  rn  s     r1   qc_metar_  { s     :D ))UC ++-''T:))+ &.%G%G%I C!,/33AiLA  C %<<> 002 56 113Z(PT(^PZ@PQ?S SCs   6 C<z/qc/batchesc                  h   t         j                  j                  d      xs dj                         } t        j
                  j                  |       st        d      dfS t         j                  j                  d      xs dj                         xs d }t        t	        j                  | |            S )Nr   r7   date must be YYYY-MM-DDr  r`  rH  )r  )	r   rh  r   r   r  _DATE_RErG  r   list_batchesr   rH  s     r1   qc_batches_listre  { s     LLV$*113D""4(67<<||	*0b779ATG800w?@@r3   c                  X   t        j                  d      xs i } t        j                  | | j	                  d      t                     \  }}|rt        |      dfS t        ddt               d	|d
   xs d|d   z  d|d   xs dd|d   d|d   d|d   i       t        |      S )NTr  rP  r		  r  r`  r  qc_batch_added	QC batch batch_number#%dr  r:  mixture_nameno mixr  yardsz yd3) captured for 
batch_datebatch_idr  batch)r   r  r  create_batchr   rA  r   r	  )r  rr  r  s      r1   qc_batch_creatert  { s     4(.BD&&tTXXg->-6[:JE3
S!3&&'y{.!8UU4[%88.!-X-uW~,! E$K(* r3   z/qc/batches/<int:batch_id>c                 d    t        j                  |       }|st        d      dfS t        |      S )NBatch not found.r  r  rq  )r  	get_batchr   )rp  rr  s     r1   qc_batch_getrx  { s3     x(E/0#55r3   c           
      L   t        j                  d      xs i }t        j                  | ||j	                  d      t                     \  }}|rt        |      |dk(  rdfS dfS t        d	d
t               d|d   xs d|d   z  d|d   dd|d   i       t        |      S )NTr  rP  rg  r  rv  r  r`  r  qc_batch_editedri  rj  rk  r  r:  ro  z) editedrp  r  rq  )r   r  r  update_batchr   rA  r   r	  )rp  r  rr  r  s       r1   qc_batch_updater|  | s     4(.BD&&xtxx7H-6[:JE3
S!#1C*C3LLLL(	.!8UU4[%88%:MOE$K(	*
 r3   r  c                     t        j                  | t                     }|rt        |      dfS t	        ddt               d| z  d| i       t        d	
      S )Nrg  r  r  r  qc_batch_deletedzQC batch #%d deletedrp  r  TrY  )r  delete_batchrA  r   r	  )rp  r  s     r1   qc_batch_deleter  | sZ     

	
<C
S!3&&)&1H%' dr3   z/qc/report/dailyc                  h   t         j                  j                  d      xs dj                         } t        j
                  j                  |       st        d      dfS t         j                  j                  d      xs dj                         xs d }t        t	        j                  | |            S )Nr   r7   ra  r  r`  rH  r]  )	r   rh  r   r   r  rb  rG  r   daily_totalsrd  s     r1   qc_report_dailyr  %| s     LLV$*113D""4(67<<||	*0b779ATG(//g>??r3   z/qc/report/daily.csvc                  ^   t         j                  j                  d      xs dj                         } t        j
                  j                  |       st        d      dfS t         j                  j                  d      xs dj                         xs d }t	        j                  | |      }|d   D cg c]   }|d   |d	   |d
   |d   |d   |d   |d   f" }}|j                  d|d   dddddf       |j                  d|d   dddddf       |xs d}t        d| d|dg d|      S c c}w )Nr   r7   ra  r  r`  rH  rN  r9   captured_totalcapture_unitconsumption_totalconsumption_unit
n_capturedn_out_of_range	(batches)batch_count(total yards)total_yardsyd3r  z	qc-daily-rc
  r  )MaterialCaptured totalCapture unitConsumption totalConsumption unitBatches capturedOut of range)r   rh  r   r   r  rb  rG  r   r  ri   rq  )r   rH  repr  rn  segs         r1   qc_report_daily_csvr  /| sT    LLV$*113D""4(67<<||	*0b779ATG


g
.C
 %	' 	 vY !N"3"#Q'9%:|_a 013 'D '
 	KKc-0"b"b"EFKK#m"4eRRLM

UC $c*	ABFH H's   1%D*c                     d }| d   r$t        j                  | d         } ||| d         S g }| d   D ]   }|j                   ||d   |d                " |S )u  Flatten a (already report-option-filtered) qc_store.range_totals result
    into report/CSV rows: [Plant, Material, captured, capture_unit, consumption,
    consumption_unit, batches, out_of_range] — per-plant rows when no segment
    filter, else the one chosen plant. This is a PURE flatten: row inclusion
    (DVI-1424 per-material "include in reports" exclusion + the zero-capture
    omission, formerly hard-coded here) is applied upstream by
    qc_store.filter_range_report(rep, get_report_options()) — the callers below
    filter first so the JSON viewer, CSV export, and Auditor report all agree.
    Shared by the /qc/report/range.csv route and the Auditor report.c                 f    |D cg c]!  }| |d   |d   |d   |d   |d   |d   |d   g# c}S c c}w )Nr9   r  r  r  r  r  r  rX   )plant_labelrN  r  s      r1   mat_rowsz'_qc_range_report_rows.<locals>.mat_rowsO| sa    
 #	$ 	 ai#$a&7&'+=)><!$4"57 $ 	$ $s   &.rH  rN  r-  r   )r  segment_labelr  )r  r  r   rn  r  s        r1   _qc_range_report_rowsr  E| so    $ 9~&&s9~6s;/00D: >HS\3{+;<=>Kr3   z/qc/report/rangec                     t         j                  j                  d      xs dj                         xs d } t         j                  j                  d      xs dj                         }| -t        j
                  j                  |       st        d      dfS t        j
                  j                  |      st        d      dfS | | |kD  rt        d      dfS t         j                  j                  d	      xs dj                         xs d }t	        j                  | ||      }t	        j                  |t	        j                               }t        |
      S )Nrq   r7   r  start must be YYYY-MM-DDr  r`  end must be YYYY-MM-DDzstart must be on or before endrH  r]  )r   rh  r   r   r  rb  rG  r   range_totalsfilter_range_reportget_report_options)rq   r  rH  r  s       r1   qc_report_ranger  ^| s    \\g&,"335=E<<E"(b
/
/
1C!2!2!8!8!?78#==""3'56;;US[=>CC||	*0b779ATG


sG
4C 
&
&sH,G,G,I
JC#r3   z/qc/report/range.csvc            
      F   t         j                  j                  d      xs dj                         xs d } t         j                  j                  d      xs dj                         }| -t        j
                  j                  |       st        d      dfS t        j
                  j                  |      st        d      dfS t         j                  j                  d      xs dj                         xs d }t	        j                  | ||      }t	        j                  |t	        j                               }t        |      }|j                  dd	|d
   dddddf       |j                  dd|d   dddddf       |xs d}t        d|d   xs dd|d   d|dg d|      S )Nrq   r7   r  r  r  r`  r  rH  r  r  r  r  r  r  zqc-consumption-r  r  r  rc
  r  )Plantr  r  r  r  r  r  r  )r   rh  r   r   r  rb  rG  r   r  r  r  r  ri   rq  )rq   r  rH  r  rn  r  s         r1   qc_report_range_csvr  q| s}    \\g&,"335=E<<E"(b
/
/
1C!2!2!8!8!?78#==""3'56;;||	*0b779ATG


sG
4C

&
&sH,G,G,I
JC %DKK[#m"4b"b"bIJKK_c-&8%RRPQ

UC((#j/3	@	   r3   z/qc/materialsc                  B    t        t        j                  d            S )NTr  )rN  )r   r  rW  rX   r3   r1   qc_materials_listr  | s     X44dKLLr3   c            	         t        j                  d      xs i } | j                  d      }t        j                  | |      \  }}|rt        |      dfS t        ddt               d|d	   d
|rdnd|       t        |      S )NTr  r  r  r`  r  qc_material_savedzQC material 'r9   z' r  r
  r  )material)r   r  r   r  save_materialr   r	  rA  )r  material_idmatr  s       r1   qc_material_saver  | s     4(.BD((4.K%%dK8HC
S!3&&*)+Kkw>@	
 C  r3   z/qc/materials/<int:material_id>c                     t        j                  |       }t        j                  |       }|rt        |      dfS t	        ddt               d|r|d   n| z  d| i       t        d	
      S )Nr  r`  r  qc_material_deletedzQC material '%s' deletedr9   r  r  TrY  )r  get_materialdelete_materialr   r	  rA  )r  r  r  s      r1   qc_material_deleter  | sn     


,C

"
";
/C
S!3&&,IK*Sc&kkR{+- dr3   z/qc/report-optionsc                  h    t        t        j                         t        j                  d            S )NTr  )r  rN  )r   r  r  rW  rX   r3   r1   qc_report_options_getr  | s,     8668%44dKM Mr3   c            
      $   t        j                  d      xs i } | j                  d      }t        |t              rO|j                         D ]<  \  }}	 t        |      }t        j                  ||      \  }}|s.t        |      dfc S  t        j                  | j                  d      | j                  d      d      }t        d	d
t               dt        |d         |d   rdndfz  |       t        |      S # t        t        f$ r Y w xY w)NTr  aggregate_modesr  r`  exclude_material_idsshow_zero_capture)r  r  r  qc_report_options_savedzDQC report options updated (%d material(s) excluded, zero-capture %s)r  hiddenr  )r  )r   r  r   r   r   r   r   r   r   r  set_report_moder   save_report_optionsr	  rA  rC  )r  modesr2  rO  mid_intr  r  r  s           r1   qc_report_options_saver  | s"    4(.BD HH&'E% 	/ICc( --gt<FAsS)3..	/ ** $)? @!XX&9:, G 0	# 678&':;G&KK  7##! z* s   C==DDz/qc/batch-ticket-fieldsc                      t        t        j                         t        j                         t        j                  D  cg c]
  \  } }| |d c}}       S c c}} w )Nr)  r   )r  r-  tunable_fields)r   r  r[  r  BATCH_TICKET_TUNABLE_FIELDS)r  r[  s     r1   qc_batch_ticket_fields_getr  | sX     //1))+&.&J&JL"As !"C0 LM MLs   Ac                     t        j                  d      xs i } t        j                  | j	                  d      xs i       }t        d |j                         D              }t        ddt               d|z  |       t        |	      S )
NTr  r  c              3   N   K   | ]  }|j                         D ]  }|sd  
  ywr^   N)r  )r,  r  re  s      r1   r/  z.qc_batch_ticket_fields_save.<locals>.<genexpr>| s1      5 ZZ\5  51 5s   #%r  qc_batch_ticket_fields_savedz;QC Batch Ticket field tunables updated (%d field(s) hidden)r  r  )
r   r  r  save_batch_ticket_fieldsr   r  r  r	  rA  r   )r  r  r  s      r1   qc_batch_ticket_fields_saver  | s     4(.BD..txx/A/GRHF 5 5 5H5Y[')12	
 &!!r3   z/qc/scan-settingsc                  >    t        t        j                               S Nrf  )r   r  r\  rX   r3   r1   qc_scan_settings_getr  | s     H66899r3   c            	          t        j                  d      xs i } t        j                  | j	                  d      xs i       }t        ddt               d|d   |d   rd	nd
fz  |       t        |      S )NTr  r  r  qc_scan_settings_savedz:QC Scan Tickets settings updated (thumb %dpx, auto-OCR %s)
thumb_sizeauto_ocrre  offr  rf  )r   r  r  save_scan_settingsr   r	  rA  r   rn  s     r1   qc_scan_settings_saver  | sx     4(.BD**488J+?+E2FH/y{LL)8J3G4UST	
 H%%r3   z/qc/custom-fieldsc                      t        j                  d      } | D ]  }t        j                  |d         |d<    t        | t        j                         t        t         j                              S )zThe full custom field registry (incl. inactive) + an in_use flag per field
    so the manager can offer Deactivate vs Delete (delete only when unreferenced),
    plus the segment list + supported field types for the editor.Tr  r  in_use)r  r-  field_types)r  r^  custom_field_in_user   r  r  CUSTOM_FIELD_TYPES)r  r  s     r1   qc_custom_fields_getr  } sf     (($?F <221T7;(<&$446#H$?$?@B Br3   c            
      &   t        j                  d      xs i } t        j                  |       \  }}|rt	        |      dfS t        ddt               d|d   d	|d
   d|d   |d
   d       t	        d|t        j                  d            S )zwAdd a custom field. Body: {label, type, choices?, required?, segments?}.
    The id is minted and immutable thereafter.Tr  r  r`  r  qc_custom_field_addedQC custom Batch Ticket field 'r   z	' added (rT  r7  r  )r  rT  r  r  rU  r  r  )r   r  r  add_custom_fieldr   r	  rA  r^  )r  r  r  s      r1   qc_custom_field_addr  } s    
 4(.BD**40JE3
S!3&&="9GneFm5$)$KvGI d%"55tLN Nr3   z/qc/custom-fields/reorderc                      t        j                  d      xs i } t        j                  | j	                  d      xs g       }t        ddt               d       t        d|      S )	z+Reorder custom fields. Body: {order:[ids]}.Tr  r  r  qc_custom_fields_reorderedzQC custom fields reorderedr  rU  r  )r   r  r  reorder_custom_fieldsr   r	  rA  r   )r  r  s     r1   qc_custom_fields_reorderr  ,} sZ     4(.BD++DHHW,=,CDF=">".JLd6**r3   z/qc/custom-fields/<field_id>c           
      8   t        j                  d      xs i }t        j                  | |      \  }}|rt	        |      |dk(  rdfS dfS t        ddt               d	|d
   d|d   rdnd| |d   d       t	        d|t        j                  d            S )u   Edit a custom field. Body may carry label/type/choices/required/segments/
    active. The id is immutable — a referenced field is deactivated, not renamed
    away or deleted.Tr  r  Unknown field.r  r`  r  qc_custom_field_updatedr  r   	' updatedrv  r7    (deactivated))r  rv  r  r  r  )r   r  r  update_custom_fieldr   r	  rA  r^  )field_idr  r  r  s       r1   qc_custom_field_updater  7} s     4(.BD--h=JE3
S!32B+BCLLLL=";9;GnE(ObAQ&QS$,hHJ d%"55tLN Nr3   c                    t        j                  |       }|r|d   n| }t        j                  |       \  }}|st        |      |dk(  rdfS dfS t	        ddt               d|z  d	| i
       t        dt        j                  d            S )ui   Hard-delete a custom field — only when it has no captured values
    (otherwise deactivate it instead).r   r  r  r  r`  r  qc_custom_field_deletedz)QC custom Batch Ticket field '%s' deletedr  r  Tr  r  )r  get_custom_fielddelete_custom_fieldr   r	  rA  r^  )r  r  r   rU  r  s        r1   qc_custom_field_deleter  I} s    
 %%h/E#E'NE**84GBS!32B+BCLLLL=";9;FN"H-/ d"55tLN Nr3   z/qc/segmentsc                      t        j                  d      } | D ]  }t        j                  |d         |d<    t        |       S )zThe full segment registry (incl. inactive) + an in_use flag per segment so
    the manager can offer Deactivate vs Delete (delete only when unreferenced).Tr  r  r  r-  )r  list_segmentssegment_in_user   )segsr  s     r1   qc_segments_getr  `} sF    
 !!48D 7--ag6(7D!!r3   c            	      H   t        j                  d      xs i } t        j                  | j	                  d            \  }}|rt        |      dfS t        ddt               dt        j                  |      z  d	|i
       t        d|t        j                  d            S )znAdd a Production Segment. Body: {label}. The id is minted from the label
    slug and is immutable thereafter.Tr  r   r  r`  r  qc_segment_addedz QC Production Segment '%s' addedrH  r  r  )rU  
segment_idr-  )
r   r  r  add_segmentr   r   r	  rA  r  r  )r  r   r  s      r1   qc_segment_addr  k} s    
 4(.BD##DHHW$56HC
S!3&&="4IK=++C01$c*, ds$22DIK Kr3   z/qc/segments/reorderc                      t        j                  d      xs i } t        j                  | j	                  d      xs g       }t        ddt               d       t        d|      S )	z&Reorder segments. Body: {order:[ids]}.Tr  r  r  qc_segments_reorderedz QC Production Segments reorderedr  rU  r-  )r   r  r  reorder_segmentsr   r	  rA  r   )r  r  s     r1   qc_segments_reorderr  |} sX     4(.BD$$TXXg%6%<"=D="9=?dT**r3   z/qc/segments/<segment_id>c           
         t        j                  d      xs i }t        j                  | d|v r|j	                  d      ndd|v r|j	                  d      nd      \  }}|rt        |      |dk(  rd	fS d
fS t        ddt               d|d   d|d   rdnd| |d   d       t        d|t        j                  d            S )u   Rename and/or (de)activate a segment. Body: {label?, active?}. The id is
    immutable — a referenced segment is deactivated (hidden from new capture),
    never renamed away or deleted.Tr  r   Nrv  )r   rv  r  Unknown segment.r  r`  r  qc_segment_updatedzQC Production Segment 'r  r7   r  )rH  rv  r  r  )rU  rH  r-  )	r   r  r  update_segmentr   r   r	  rA  r  )r   r  r  r  s       r1   qc_segment_updater  } s     4(.BD&&#*d?dhhw%-%5txx!4AHC S!32D+DCNN#NN="6ik7|3x=R>N%NP&0CMJL dC$22DIK Kr3   c                    t        j                  |       }t        j                  |       \  }}|st        |      |dk(  rdfS dfS t	        ddt               d|z  d| i	       t        d
t        j                  d
            S )ul   Hard-delete a segment — only when unreferenced by any batch/mixture
    (otherwise deactivate it instead).r  r	  r  r`  r  qc_segment_deletedz"QC Production Segment '%s' deletedrH  r  Tr  r  )r  r  delete_segmentr   r	  rA  r  )r   r   rU  r  s       r1   qc_segment_deleter  } s    
 "":.E%%j1GBS!32D+DCNN#NN="6ik?%G$j13 d$22DIK Kr3   z/qc/ocr-templatesc                     t        t        j                         t        j                         t        j                  D  cg c]
  \  } }| |d c}} t        j
                  d      D cg c]  }|d   |d   d c}t        j                         D cg c]  }d|d   z  |d   |d	   d
 c}t        j                               S c c}} w c c}w c c}w )zEverything the OCR Region Template builder needs: existing templates, the
    per-segment assignments, and the templatable field catalog (header fields +
    a row per active material, D7-A).r  FrK  r  r9   r  r9   z	custom:%sr   rT  r)  r   rT  )	templatesassignmentsheader_fieldsrN  rT  r-  )r   r  list_ocr_templatesget_ocr_template_assignmentsOCR_TEMPLATE_HEADER_FIELDSrW  r^  r  )r  r[  r  r  s       r1   qc_ocr_templates_getr  } s     --/99;%-%H%HJ!1c  !3/ J %33uMO D'1V95 O !) ; ; =?  +QtW4qz !&	+ ? ))+- -JO
?s   C)CCc                     t        j                  d      xs i } t        j                  | j	                  d      | j	                  d      | j	                  d      xs i d      \  }}|rt        |      dfS t        d	d
t               d|d   t        |d         fz  |d   t        |d         d       t        |      S )NTr  r  r9   r  )r  r9   r  r  r`  r  qc_ocr_template_savedz6QC OCR region template '%s' saved (%d field region(s)))template_idr  r  )r  )
r   r  r  save_ocr_templater   r   r	  rA  rC  r  )r  r  r  s      r1   qc_ocr_template_saver  } s     4(.BD))hhtndhhv&6((8$*+, -HC S!3&&.ikHKS]+L- -"4yF3x=4IJ	L
 C  r3   z/qc/ocr-templates/deletec                     t        j                  d      xs i } t        | j                  d      xs d      j	                         }t        j                  |      }|st        d      dfS t        j                  |       t        dd	t               d
|d   z  d|i       t        dt        j                               S )NTr  r  r7   Template not found.r  r  r  qc_ocr_template_deletedz#QC OCR region template '%s' deletedr9   r  r  )rU  r  )r   r  r*   r   r   r  get_ocr_templater   delete_ocr_templater	  rA  r  )r  r   r  s      r1   qc_ocr_template_deleter%  } s     4(.BD
dhhtn"
#
)
)
+C

#
#C
(C23S88  %0	5FCs#% d(M(M(OPPr3   z/qc/ocr-template-assignmentsc            	          t        j                  d      xs i } t        j                  | j	                  d      xs i       }t        ddt               dt        |      z  |       t        |      S )	NTr  r  r  qc_ocr_template_assignedz3QC OCR template assignments updated (%d segment(s))r  )r  )	r   r  r  save_ocr_template_assignmentsr   r	  rA  rC  r   )r  assignss     r1    qc_ocr_template_assignments_saver*  } si     4(.BD44TXXm5L5RPRSG1Eg,	
 w''r3   z/qc/mixturesr  c                  B    t        t        j                  d            S )NTr  )rC  )r   r  rY  rX   r3   r1   qc_mixtures_listr,  } s     H22DIJJr3   c                  f   t        j                  d      xs i } | j                  d      }t        j                  | |t                     \  }}|rt        |      dfS t        ddt               d	|d
   t        j                  |d         |rdnd|d   fz  |d   |d   d       t        |      S )NTr  r  rg  r  r`  r  qc_mixture_savedu   QC mixture '%s' (%s) %s — v%dr9   rH  r  r
  r  )
mixture_idr  r  )mixture)	r   r  r   r  save_mixturerA  r   r	  r  )r  r/  mixr  s       r1   qc_mixture_saver3  } s     4(.BD$J$$T:Y[IHC
S!3&&)1K//I?#I#i.5B B "$iC	NCE 3r3   z&/qc/mixtures/<int:mixture_id>/versionsc                     t        j                  |       st        d      dfS t        t        j                  |             S )NzMixture not found.r  r  )versions)r  get_mixturer   mixture_versions)r/  s    r1   qc_mixture_versionsr8  	~ s9     
+12C77H55jABBr3   z/qc/import/pipe/mappingc                      t        t        j                         t        j                  dd      t        j                  t        j
                        S )zuThe admin's saved column->material overrides + the material list the
    Import panel's mapping editor binds against.TF)r  rL  )r   rN  ignore_valueyards_value)r   r  get_pipe_material_maprW  _PIPE_IGNORE_PIPE_YARDSrX   r3   r1   qc_import_pipe_mapping_getr?  ~ sE    
 ..0 ))4=BD**((* *r3   c            	          t        j                  d      xs i } t        j                  | j	                  d      xs i       }t        ddt               dt        |      z  d|i       t        |      S )	NTr  r   r  qc_pipe_map_savedz1QC Pipe import column mapping updated (%d mapped)r  r   )	r   r  r  save_pipe_material_mapr   r	  rA  rC  r   )r  r  s     r1   qc_import_pipe_mapping_saverC  &~ sj     4(.BD++DHHY,?,E2FE*)+Cc%jP5!# 5!!r3   z/qc/import/pipec                  T   t         j                  j                  d      } | | j                  st	        d      dfS | j                  j                         j                  d      st	        d      dfS t         j                  j                  d      xs# t         j                  j                  d      xs d	}t        |      j                         d
v }	 t        j                  | j                               }t        j                   |t#               |      }|j                  d      s!t	        |j                  d      xs d      dfS |st%        ddt#               d|j                  d      xs d|j                  dd      |j                  dd      |j                  d      rdt'        |d         z  nd	fz  t)        | j                        dD ci c]  }||j                  |       c}d       t	        |      S # t        $ r}t	        d|z        dfcY d}~S d}~ww xY wc c}w )a   Import (or, with analyze=1, dry-run analyze) a daily Pipe Mix Log .xlsx.
    The parsed batches land as `pipe` batches with source='import'; a same-day
    re-upload updates in place. Unmapped columns are returned in the report,
    never silently dropped.r  NzNo spreadsheet uploaded.r  r`  rN	  z.xlsmz+Upload the daily Pipe Mix Log .xlsx export.analyzer7   rr	  zCould not read the upload: %sr		  rE  rU  r  Import failed.r  qc_pipe_importz0Pipe Mix Log import for %s: %d new, %d updated%sro  r]   r  r   r  unmapped    — %d unmapped column(s)ro  datesr  r  r  skipped_no_date
line_countr  rJ  ignoredyards_columnyards_blankyards_totalmix_code_columnmixtures_matchedunmatched_mix_codes)r  r6  r  r]  )r   r  r   r  r   rE  r|  rh  rg  r*   r   r>  rJ  r  ry   r  import_pipe_mix_logrA  r	  rC  r   )r  rF  rE  r   rl  r6  r  s          r1   qc_import_piperX  2~ s    	&!Ay

78#==::&&'9:JKSPP||	*Ogll.>.>y.IORG'l  "&::GIzz!&&(# ))$ik29;F::dVZZ0D4DEsJJ+9;F

<(/CJ1J

9a(::j) .F:4F0GG/1	J3 3
 ,AJJ7++D,%&6::a= D, -	. &!!+  I<sBCSHHID,s$   #G> H%>	H"HH"H"c                    ddl m} |j                         }|sydd|z   i}dt        | d      d	t        |d      d
}	 t	        j
                  ||d      }|j                  dk7  rddd|j                  z  fS |j                         j                  d      xs d}|j                         j                  d      sydt        | d      d	t        |d      d}		 t	        j
                  |	|dd      }
|
j                  dk7  rddd|
j                  z  fS t        j                         }|
j                  d      D ]  }|s|j                  |        |j!                  d       ||dfS # t        j                  $ r}ddt        |d      fcY d}~S d}~ww xY w# t        j                  $ r}ddt        |d      fcY d}~S d}~ww xY w)a  Fetch a SharePoint spreadsheet (by drive_id+item_id from a prior File
    Browser listing) into memory via the app-only Graph token. Returns
    (BytesIO, name, error). PDFs/other types are rejected. Mirrors the
    Open-in-Scan-Tickets download path (qc_scan_import_sharepoint).r   r  )NNr  r  r  (https://graph.microsoft.com/v1.0/drives/r7   r  /items/?$select=name,file,sizer  r;  Ngraph.microsoft.comrK   file lookup failed (%s)r9   zmixlog.xlsxrE  )NNz6Only .xlsx/.xlsm spreadsheets can be opened in Import./contentTr  r  rx   r  r7  file read failed (%s)r  r  )rZ  r  r  r   r  r   r?  _powerbi_request_errorrg   r   rE  r|  r>  rJ  r  r  r  )drive_idr  r  rD  hdrmeta_urlr  rl  r9   content_urlr  r2  r  s                r1   _qc_fetch_sharepoint_xlsxri  Z~ s   
 +##%E?I-
.C +U7-DFHNxbA 	}}T4q}}DDD668<<0=D::<  !34S 5 H2.gB0GIKN{C'02 	}}T2Q]]BBB
**,C73 IIe HHQKd?+ ** NT1#7LMMMN ** NT1#7LMMMNs<   E, F ,F?FFFG.G<GGz/qc/import/pipe-sharepointc                  
   t        j                  d      xs i } t        | j                  d      xs d      j	                         }t        | j                  d      xs d      j	                         }|r|st        d      dfS t         j                  j                  d	      xs | j                  d	      xs d}t        |      j	                         d
v }t        ||      \  }}}|rt        |      d|v sd|v rdfS dfS t        j                  |t               |      }|j                  d      s!t        |j                  d      xs d      dfS |st        ddt               d|j                  d      xs d|j                  dd      |j                  dd      |j                  d      rdt        |d         z  ndfz  t        |      ddD 	ci c]  }	|	|j                  |	       c}	d       t        |       S c c}	w )!u  DVI-1410 P2 — the File Browser "Open in Import" workflow for a Pipe Plant
    .xlsx. Fetch the spreadsheet by ``drive_id``+``item_id`` (from a prior File
    Browser listing) and run it through the SAME Pipe Mix Log importer the local
    upload uses (``analyze=1`` for the dry-run preview + mapping editor; a bare
    POST commits). The file is re-fetched server-side on the final Import by the
    remembered ids, so nothing large round-trips through the browser.Tr  re  r7   r  "drive_id and item_id are required.r  r`  rF  rr	  GraphrV  rU  rG  rU  r  rH  r  rI  z=Pipe Mix Log import (SharePoint) for %s: %d new, %d updated%sro  r]   r  r   r  rJ  rK  
sharepointrL  )r  r	  r6  r  r]  )r   r  r*   r   r   r   rh  ri  r  rW  rA  r	  rC  r   )
r  re  r  rF  rE  r   r9   r  r6  r  s
             r1   qc_import_pipe_sharepointrn  ~ s    4(.BD488J'-2.446H$((9%+,224GABCGG||	*Gdhhy.AGRG'l  "&::G/'BOD$
S!'S.HO3TTQTTT))$ik29;F::dVZZ0D4DEsJJ+9;S

<(/CJ1J

9a(::j) .F:4F0GG/1	W3 3
 ,D1\:/ 0Aq&**Q-/ 01	2 &!!0s   H z/qc/daily-production/configc                  
   t        t        j                         D  cg c]<  } | d   | d   t        j                  | d         D cg c]  }|d   |d   d c}d> c}} t        j                               S c c}w c c}} w )zhThe per-segment Daily Production config + each segment's assignable
    mixtures, for the Settings card.r  r   rH  r9   r  )r  r   rC  )r-  rc  )r   r  r  rY  get_daily_production_config)r  r  s     r1   qc_daily_production_config_getrr  ~ s    
  $1135  41W:)1)?)?$)P R$% ()w&	 B  RS 5 3357 7 R5s   (A?
A:A?
:A?
c                  \   t        j                  d      xs i } t        j                  | j	                  d      xs i       }t        ddt               dt        |j                         D cg c]  \  }}|j	                  d      s| c}}      z  d|i       t        |	      S c c}}w )
zISave the per-segment Daily Production config (enable + assigned mixture).Tr  rc  r  qc_daily_production_configz:QC Daily Production config updated (%d segment(s) enabled)r~  r  r  )
r   r  r  save_daily_production_configr   r	  rA  rC  r   r   )r  r  r  rB  s       r1   qc_daily_production_config_postrv  ~ s     4(.BD11$((82D2JKE39;L
U[[]?TQaeeI.>q?
@A% 	"
 %   @s   ,B(B(z/qc/daily-productionc                     t         j                  j                  d      xs dj                         } t	        j
                  |       st        d      dfS t        t        j                  d      xs i |       st        d      dfS t         j                  j                  d	      xs dj                         }t        j                  j                  |      st        d
      dfS t        t	        j                  | |            S )zThe Daily Production form context for one (segment, date): the assigned
    mixture's ingredients + any already-captured entry. Requires the per-segment
    qc.production.<segment> leaf (or admin).rH  r7   r	  r  r`  r'  <You are not authorized for Daily Production on this segment.r  r   ra  r  )r   rh  r   r   r  
is_segmentr   rI  r   rb  rG  daily_production_view)rH  r   s     r1   qc_daily_production_getr|  ~ s     ||	*0b779Gw'/0#55gkk&17RA - ./23 	3LLV$*113D""4(67<<66wEFFr3   c                     t        j                  d      xs i } | j                  d      xs dj                         }t	        j
                  |      st        d      dfS t        t        j                  d      xs i |      st        d	      d
fS | j                  d      xs | j                  d      xs dj                         }t	        j                  ||| t                     \  }}|rt        |      dfS |j                  d      xs i }t        ddt               dt	        j                  |      d|d|j                  dd      d|||j                  d      d       t        |      S )zUpsert the day's Daily Production entry for a segment (one per segment per
    day). Requires the per-segment qc.production.<segment> leaf (or admin).Tr  rH  r7   r	  r  r`  r'  rx  r  r   ro  rg  r   r  qc_daily_production_savedzQC Daily Production for z captured for r:  rn  r   z yd3)r  )rH  r   rp  r  ry  )r   r  r   r   r  rz  r   rI  r   upsert_daily_productionrA  r	  r  )r  rH  r   r  r  r   s         r1   qc_daily_production_postr  ~ sO   
 4(.BDxx	"(b//1Gw'/0#55gkk&17RA - ./23 	3HHV< 6<"CCED00$7@{DID#
S!3&&HHW#E2)+""7+TIIgq!# #D!IIdO-. r3   QC_OCR_SETTINGS_FILEzqc_ocr_settings.jsonQC_SCAN_DIRqc_scan_uploadsr  z[0-9a-f]{6,40}\Zc                     ddl m} m} 	 t        j                  t
        j                               }t        |j                  d      xs d      j                         }|| v r|S 	  |       S # t        t        f$ r	 Y  |       S w xY w)zThe effective QC OCR backend id: the QC-scoped override if set, else the
    global OCR-tool backend. Values are ocr_engine backend ids (local|claude).r   )r)  r$  r%  r7   )r"  r)  r$  r   r   r  r   r*   r   r   r   r   )r)  r$  r   bs       r1   _qc_ocr_backendr   s     6zz.88:;#)r*002H  > Z  >s   AA+ +BBc                      ddl m}  	 t        j                  t        j                               }t        |j                  d      xs d      j                         }|| v r|S dS # t        t        f$ r Y yw xY w)zGThe stored QC override ('' = inherit the global backend), for the pane.r   r)  r%  r7   )r"  r)  r   r   r  r   r*   r   r   r   r   )r)  r   r  s      r1   _qc_ocr_backend_rawr   sl    'zz.88:;#)r*002%q-2-Z  s   AA# !A# #A54A5c                 d    t         j                  t        j                  d| xs did             y)zGPersist the QC OCR backend override. '' clears it (inherit the global).r%  r7   r   r   N)r  r   r   r   )r%  s    r1   _qc_ocr_save_backendr  ) s'    ##

Iw}"-a8:r3   QC_FILEBROWSER_PATHS_FILEzqc_filebrowser_paths.jsonc            	      Z   	 t        j                  t        j                               } i }t        | t              r| j                         D ]  \  }}t        j                  |      rt        |t              s,t        |j                  d      xs d      j                         }|s[|t        |j                  d      xs d      j                         t        |j                  d      xs d      t        |j                  d      xs d      d||<    |S # t        t
        f$ r i cY S w xY w)z{segment: {share_url, label, added_by, added_at}} for segments with a
    folder configured. Self-heals: drops entries for unknown segments or without
    a share_url.	share_urlr7   r   rz  ry  r  r   rz  ry  )r   r   r  r   r   r   r   r   r   r  rz  r*   r   r   )r   r   r  entrJ  s        r1   _qc_fb_load_pathsr  : s   zz3==?@ C$

 	HC&&s+:c43Hcggk*0b1779C SWWW-34::<
 3 9r:
 3 9r:	CH	 J! Z  	s   'D D*)D*c                 X    t         j                  t        j                  | d             y r   )r  r   r   r   )r  s    r1   _qc_fb_save_pathsr  S s    ((E!)DEr3   c                     | xs dj                         j                         }|j                  d      xs |j                  d      S )Nr7   r?  r@  )r   rE  rw   )rJ  r  s     r1   _qc_fb_url_okr  W s:    	!!#A<<	">all:&>>r3   c                    ddl m} |j                         }|sydd|z   i}dt        | d      d	t        |d      d
}g g }}	 |rt	        j
                  ||d      }|j                  dk(  ry|j                  dk7  r"ddd|j                  d|j                  dd fS |j                         }	|	j                  dg       D ]4  }
|
j                  d      rk|j                  |
j                  d      |
j                  d      xs d| |
j                  d      |
j                  d      xs i j                  d      d       |
j                  d      s|
j                  d      xs d}|j                         }|j                  d      rd}n|j                  d      rd}n|j                  |
j                  d      || ||
j                  d      |
j                  d      |
j                  d       d!       7 |	j                  d"      }|r|j                  d$ %       |j                  d& %       ||dfS # t        j                  $ r}ddt        |d#      fcY d}~S d}~ww xY w)'uT  List a SharePoint folder's children — subfolders + PDF/spreadsheet files.
    Returns (folders, files, error). ``drive_id``/``item_id`` identify the folder
    (the segment root resolved from its share URL, or a subfolder id returned by a
    prior list). Each file carries a ``kind`` ('pdf' | 'sheet') so the client can
    type-filter and pick the right workflow (DVI-1410 P1/P2); folders carry
    ``modified`` so "Newest first" can order them too. Never raises — a
    Graph/transport failure comes back as ``error`` so the route degrades to a
    clear message rather than a stack trace.r   r  )NN>   Microsoft Graph is not configured — cannot reach SharePoint.r  r  rZ  r7   r  r[  zO/children?$top=400&$select=id,name,size,folder,file,lastModifiedDateTime,webUrlr  r;  r  )NNuP   folder not found — it may have been moved or renamed, or the app lacks access.rK   Nzfolder listing failed (r  r  r  r  r9   r  
childCount)r  r9   re  r"  child_countr  r   r6  rE  r<  rd  r  )r  r9   re  r!	  rd  r"  r  z@odata.nextLinkr]  c                 0    | d   xs dj                         S r;  rs  rZ  s    r1   r	  z#_qc_fb_graph_list.<locals>.<lambda>     &	R668 r3   r  c                 0    | d   xs dj                         S r;  rs  rZ  s    r1   r	  z#_qc_fb_graph_list.<locals>.<lambda> s    aio2446 r3   )rZ  r  r  r   r  r   rg   r&  r   ri   rE  r|  r?  rd  r  )re  r  r  rD  rf  rJ  r  r  r  r   r|	  r9   rK  r!	  rl  s                  r1   _qc_fb_graph_listr  \ sM    +##%E+I-
.C H2&gB(?AC UG!N""3R@A}}#I}}#T'(}}affTcl%D E E668Dhhw+ 666(#NN ffTlBFF6N4Hb$,$&FF+A$B(*x(8(>B'C'CL'Q	$S T
 VVF^66&>/RD**,C||F+$&89& LL ffTlDh $bffVn$&FF+A$B#%66(#3	"5 6!6* ((,-C= B LL8L9	JJ6J7E4	 ** NT1#7LMMMNs2   *H; 00H; !B3H; B9H; ;I'I"I'"I'c                  6   	 t        j                          t        z
  } t        j                  d      D ]G  }	 |j	                         r4|j                         j                  | k  rt        j                  |d       I y# t        $ r Y Vw xY w# t        $ r Y yw xY w)z;Best-effort removal of scan upload dirs older than the TTL.r1  TrO	  N)
rU   _QC_SCAN_TTL_Sr  r  r  r  r  r  r:  r   )r   rn  s     r1   _qc_scan_pruner   s    	~-!!#& 	A88:!&&("3"3f"<MM!48	   s5   3B AA=:B =	B	B B		B 	BBc                 2   t         j                  | xs d      rt         j                  |xs d      syt        j                         }|| z  |dz   z  j                         }t	        |      j                  t	        |            sy|j                         r|S dS )zResolve a stored scan PDF path, guarding against traversal. Ids are hex
    minted server-side; a non-hex id or an escape attempt returns None.r7   Nr   )_QC_SCAN_ID_RErG  r  r,   r*   rw   r  )scan_idfile_idr=  rb   s       r1   _qc_scan_file_pathr   s     2.2. D7Ng./88:Dt9D	*;;=4*d*r3   c                 \   ddl }|j                  t        |             }	 |dk  s||j                  k\  r	 |j	                          y|j                  |      }	 |rt        |      nd}t        |t        |j                  j                  d      z  d      xs d}|j                  ||      }	t        |xs d      dz  }|r|	j                  |      }	|j!                  |	      }
|
j#                  d	      |j	                          S # t        t        f$ r d}Y w xY w# |j	                          w xY w)
u-  Render one PDF page to PNG bytes for preview/vision. Full render fits to
    ~2000px like the Take-off preview; DVI-1460 P1 passes a smaller `width` for
    fast thumbnails (lower DPI) and an optional `rot` (0/90/180/270°) to correct
    a mis-oriented scan. Returns None if the page is out of range.r   Nr  r^   r  r  h  r7  rq  )r=  r?  r*   r@  r[  rA  r  r   r   rL  rC  rD  rE  rH  r   	prerotaterG  rK  )r  r  rE  rotr=  r   rO  target_wrP  r  rQ  s              r1   _qc_scan_render_pngr   s   
 
))CM
"CQ;'S^^3 			 }}W%	',uU|&H 8c$))//155s;Bskk$%#(mc!--$CooSo){{5!		 :& 	H	 			s6   D D D 'B
D DD DD D+)r   z
batch date)rr  zbatch numberzbatch noticketzticket numberz	ticket no)r2  r0  z
mix designdesign)rn  yardagezcubic yardscyyds)zrock moisturerockzstone moisture)zsand moisturesand)initialsoperatorby)r|  notecomments)ro  rj  r0  rn  rock_moisturesand_moisturer  r|  c                 f    t        j                  ddt        | xs d      j                               S )u   Match the SPA's _qcNorm — lowercase, strip non-alphanumeric — for aligning
    template material actuals with the whole-page extractor's name-keyed rows.z	[^a-z0-9]r7   )rF  r\  r*   rE  r  s    r1   _qc_norm_namer   s(     66,CRL$6$6$899r3   g~jt?gQ?c                     t        d| d   |z
        t        d| d   |z
        t        d| d   |z         t        d| d   |z         dS )Nr  r
  r
  r  r  r  )r
  r
  r  r  )rC  rL  )rD  r  s     r1   _qc_pad_rectr   sX    #tDzC'(#tDzC'(#tDzC'(#tDzC'(	 r3   c                      ddl m  fd} ||      }t        D ]'  }|j                         r |S  |t	        ||            }) |S )u  Read one normalized 0-1 rect via targeted OCR (backend-aware). A failed
    region degrades to '' (blank) — never raises — so a bad template rect just
    leaves the field for the operator's manual correction, never blocks (D8-A).
    Exact-first, then progressively-padded retries so a slightly misaligned/
    twisted scan or a tight box still captures the value (DVI-1442 fix #2/#4).r   r  c           	          	  | d   | d   | d   | d   fd      xs dS # t         $ r*}t        j                  j                  d|       Y d }~yd }~ww xY w)	Nr
  r
  r  r  Tr   r%  r7   z!QC template region OCR failed: %s)ry   rm   rn   ro   )r  rl  r%  r  r  r  s     r1   _readz_qc_region_ocr.<locals>._read so    	!'4!D'1T7AdG4'+ 1 /11  	JJBCH	s    $ 	A AA)r"  r  _QC_REGION_OCR_PADSr   r  )r  r  rD  r%  r  rQ  r  r  s   `` `   @r1   _qc_region_ocrr   sR     * +C" -99;J Ls+,- Jr3   c                      t        j                         D  ci c]  } | d   | d    }} t        j                         j                         D ci c]  \  }}||v r|||    c}}S c c} w c c}}w )u   {segment: template_name} for segments with an assigned OCR region template
    (DVI-1442) — surfaced in /qc/meta so the Scan Tickets tab can show which
    template a segment's scans use without needing the qc.settings-gated catalog.r  r9   )r  r  r  r   )r   r.  r  r   s       r1   r]  r]   sx     *2)D)D)FGAQtWaiGEG$AACIIKSe| sO   Hs   A&A+c           	      	   |rt        j                  |      nd}|xs i j                  d      xs i }|st        | |||      S t	        t         j
                        }|D ch c]	  }||v s| }	}|j                         D 
ci c]  \  }}
t        j                  |      ||
 }}}
|j                         D 
ci c]  \  }}
t        j                  |      ||
 }}}
t        ||	z
        xs | }i g ||j                  d      |j                  d      d}|rit        | |||      }t        |j                  d      xs i       |d<   t        |j                  d      xs g       |d<   |j                  d      r|d   |d<   t        t         j                        }g }d}|	D ]I  }t        | |||   |      }||d   |<   |j                  |d	|j                  ||      |d
       |sE|dz  }K |rt        j                  d      D ci c]  }|d   |
 }}t	               g }}|j                         D ]  \  }}|j                  t        j                  |            }|s-t        | |||      }|j!                  t#        |d                |j                  |d   |d       |j                  |d|d   |d   |d       |s|dz  } |d   D cg c]   }t#        |j                  d            |vr|" c}|d<   |d   j%                  |       |rt        j&                         D ci c]  }|d   |
 }}i }|j                         D ]g  \  }}|j                  t        j                  |            }|s-t        | |||      }|||d   <   |j                  |d|d   |d   |d       |sc|dz  }i |r||d<   ||d<   t)        |	      t)        |      z   t)        |      z   |d<   ||d<   |ss|dk(  rnt        | |||      }t        |j                  d      xs i       |d<   t        |j                  d      xs g       |d<   |j                  d      r|d   |d<   d|d<   |S c c}w c c}
}w c c}
}w c c}w c c}w c c}w )u  Template-aware structured extraction of QC batch fields from one scanned
    page (DVI-1442 D8-A). When the segment has an assigned OCR region template,
    each templated field is read via a targeted per-rect OCR call and un-templated
    fields fall back to the whole-page extraction; otherwise it's whole-page only.
    Never raises — returns {fields:{...}, materials:[{name, actual}], engine,
    error?, template?}.Nr  r9   r  )r  rN  enginer  r  rN  r  r   ry  )r)  r!	  r   r   r^   FrK  )r9   actualr  )r)  r!	  r   r2  r   rY  r   )r)  r!	  r   r  r   custom_valuesregionstemplate_regionstemplate_hitsTtemplate_fallback)r  get_assigned_templater   _qc_scan_extract_wholepager  OCR_TEMPLATE_HEADER_FIELD_KEYSr   ocr_field_material_idocr_field_custom_idr   r   r  r  r  ri   rW  r  r  r  r^  rC  )r  r  r%  material_namesrH  r  
fields_mapheader_keysr  templated_headersr  	mat_rectscustom_rects	run_wholer   r  header_labelsr  region_hitsr)  rQ  r  matsoverride_namesregion_matsrD  r  r  cfieldsr  cfs                                  r1   _qc_scan_extractr    s    ;Bx--g6tH.b%%h/52J)gw8 	8 h==>K$.Cq!{2BCC","2"2"4 C$!Q2215A A CI C &0%5%5%7 DTQ33A6B qD DL D [#445FYIbG||F+HLL<NPC)gw8TXXh/526H 5 ;<K88G=CL <<=MGK  Xw
3I HcsH!.!2!23!<SJ 	K1K ''5ACq$
 C C&)eR"* 
	!IC((899#>?C 7D'BC}S[9:FsCDNN3
%([T3P Qq 
	! (+;'7 S!,QUU6];>Q  SKK, '/'B'B'DE!1T7A:EE%++- 		!ICX99#>?B 7D'BC&)M"T(#NN32g;#%d8C9 :q 		! #0C C	N"#45IF!$\!2 3C&C ))gw8TXXh/526H 5 ;<K88G=CL#' JI DCDHC S Fs*    	Q"*Q""Q';"Q-
Q3%Q8)Q=c                 4   i g |d}|dk(  rLt        | ||      }|<	 ddlm}  |||      }|j                  di       |d<   |j                  dg       |d<   |S 	 ddl	m
}
  |
| |dd|      xs i }|j                         D ci c]1  \  }}t        |xs d      j                         j                         |3 c}}fd}t        j                         D ci c]  \  }}| ||       c}}|d<   |S # t        $ r:}	t
        j                  j                  d	|	       t        |	      |d
<   |cY d}	~	S d}	~	ww xY wc c}}w c c}}w # t        $ rF}	t
        j                  j                  d|	       |j                  d
t        |	             Y d}	~	|S d}	~	ww xY w)u   Best-effort structured extraction of QC batch fields from one scanned
    page. The `claude` backend uses the vision provider for structured JSON
    (all header fields + per-material actuals); the `local` backend OCRs the
    whole page and parses "LABEL: value" lines (no per-material mapping). Never
    raises — returns {fields:{...}, materials:[{name, actual}], engine, error?}.
    DVI-1460 P1/R2: `rot` rotates the vision pixmap for a mis-oriented scan (the
    local Tesseract path reads the upright page — rotation is vision-only).)r  rN  r  clauder  Nr   )extract_batch_ticketr  rN  z!QC scan vision extract failed: %sr  r  )r  r  r  r  Fr  r7   c                 D    | D ]  }j                  |      s|   c S  yr  r  )r,  r  rK  s     r1   pickz(_qc_scan_extract_wholepage.<locals>.pick s+     "771:q6M" r3   z QC scan local extract failed: %s)r  r  r  r   ry   rm   rn   ro   r*   r"  r  r   r   rE  _QC_SCAN_LABEL_MAPr}  )r  r  r%  r  r  r   rq  r  r  rl  r  rL  r  r  r  r  r,  rK  s                    @r1   r  r   s    bG
<C(!(G=?	:*3? #" 5H#&77;#;K 
*-'3G%*G= C@B 	;A<<>J41as17|!!#))+Q.J	
 7I6N6N6PQ71dDJQH J)  

""#FL"3xG
 K R *

=sCwC))J*sS   :C6 *E 6D<: E E-E 6	D9?/D4.D94D9<E 	F;FFz/qc/ocr/backendc                  d    ddl m} m} t        t	               t                |        |              S )zQC-scoped OCR backend (mirrors the OCR tool's pane). Returns the stored
    override ('' = inherit), the effective backend, the global backend, and the
    registered backend list.r   r"  )r%  	effectiveglobal_backendr&  )r"  r#  r$  r   r  r  r"  s     r1   qc_ocr_backend_getr   s*     7.0O<M"..=?L Lr3   c                  .   ddl m}  t        j                  d      xs i }|j	                  d      xs dj                         }|r|| vrt        d      d	fS t        |       t        d
dt               d|xs dz         t        d|t                     S )zDSet the QC OCR backend. '' clears the override (inherit the global).r   r  Tr  r%  r7   r+  r  r`  r  qc_ocr_backend_savedzQC OCR backend set to %szglobal defaultr  )rU  r%  r  )r"  r)  r   r  r   r   r   r  r	  rA  r  )r)  r  r%  s      r1   qc_ocr_backend_postr  ɀ s     (4(.BDxx	"(b//1G7,.<=sBB!="8	59TDTUWdG7HIIr3   z/qc/filebrowser/pathsc                  P    t        t        j                         t                     S )zFThe configured per-segment SharePoint folders (for the Settings card).)r-  r  )r   r  r  r  rX   r3   r1   qc_filebrowser_paths_getr  ڀ s#     ))+!# #r3   c            	      <   t        j                  d      xs i } | j                  d      xs dj                         }t	        j
                  |      st        d      dfS | j                  d      xs dj                         }| j                  d	      xs dj                         d
d }t               }|sW|j                  |d
       t        |       t        ddt               dt	        j                  |      z         t        d|      S t        |      st        d      dfS ||t               t        j                         j!                  d      d||<   t        |       t        ddt               dt	        j                  |      z         t        d|      S )zGSet (or, with a blank URL, clear) one segment's SharePoint folder link.Tr  rH  r7   r	  r  r`  r  r   Nr  r  qc_filebrowser_path_savedzCleared SharePoint path for %sr  )rU  r  2A SharePoint folder link (http/https) is required.r  r  zSet SharePoint path for %s)r   r  r   r   r  rz  r   r  r  r  r	  rA  r  r  r  r  r  )r  rH  r  r   r  s        r1   qc_filebrowser_paths_postr   sq    4(.BDxx	"(b//1Gw'/0#55+&,"335IXXg$"++-ds3EE		'4 % }&A?!//8 9	: $e,,#QRTWWWIK"))+445IJLE'N e="=Y[7++G456 d%((r3   z/qc/filebrowser/segmentsr  c                      t               } g }t        j                         D ]E  }|d   }| j                  |      }|s|j	                  ||j                  d      xs |d   d       G t        |      S )zSegments that have a SharePoint folder configured, for the subtab bar.
    A segment with no path simply doesn't appear (no empty tab).r  r   r  r   r  )r  r  r  r   ri   r   )r  r   sdefr  r  s        r1   qc_filebrowser_segmentsr   s{    
 E
C&&( E4jiinJJc!$!1!BT']D E	E C  r3   z/qc/filebrowser/listc                  f   t         j                  j                  d      xs dj                         } t         j                  j                  d      xs dj                         }t         j                  j                  d      xs dj                         }t         j                  j                  d      xs dj                         }|r/|r-t	        ||      \  }}}|rt        |      dfS t        ||      S |sQt        j                  |       st        d	      d
fS t               j                  |       }|st        d      dfS |d   }t        |      st        d      d
fS t        |      \  }}	}
}|rt        |      dfS t	        |	|
      \  }}}|rt        |      dfS t        |||	|
d      S )a  List folders + PDFs under a segment's configured folder, or under a
    subfolder (``item_id``+``drive_id`` from a prior list), or under an explicit
    ``share_url`` (the Settings Test button, qc.settings holders). Folder failures
    degrade to a clear message, never a stack trace.rH  r7   r  r  re  r  rU  )r  r  r	  r`  z/No SharePoint path configured for this segment.r  r  )re  r  )r  r  r=  )r   rh  r   r   r  r   r  rz  r  r  _dash_graph_resolve_item)rH  r  r  re  r  r  r  r  rf  
root_drive	root_items              r1   qc_filebrowser_listr   s    ||	*0b779G!!+.4";;=I||	*0b779G  ,299;H 8/'B%s**we44 ""7+!34c99!%%g.!RSUXXX$	#QRTWWW&>y&I#CY
S!3&&+J	BGUC
S!3&&7%%/IFH Hr3   z/qc/filebrowser/filec                     ddl m}  t        j                  j	                  d      xs dj                         }t        j                  j	                  d      xs dj                         }|r|st        d      dfS dd	lm} |j                         }|st        d
      dfS dd|z   i}dt        |d      dt        |d      d}	 t        j                  ||d      }|j                  dk7  rt        d|j                  z        dfS |j                         j	                  d      xs d}	|	j!                         j#                  d      st        d      dfS dt        |d      dt        |d      d}
	 t        j                  |
|dd      }|j                  dk7  rt        d |j                  z        dfS  | |j%                  d!"      d#$      }d%|	j'                  d&d      z  |j(                  d'<   |S # t        j                  $ r"}t        t        |d            dfcY d}~S d}~ww xY w# t        j                  $ r"}t        t        |d            dfcY d}~S d}~ww xY w)(u   Stream one SharePoint PDF inline for preview. ``drive_id``+``item_id`` come
    from a prior list. PDFs only — defence in depth on the broad app-only token.r   )Responsere  r7   r  rk  r  r`  r  r  rU  r  r  rZ  r  r[  r\  r  r;  r]  NrK   r^  r9   r  r   z Only PDF files can be previewed.r_  Tr`  ra  rb  r  rc  r  r   zinline; filename="%s"r  r  )r%  r  r   rh  r   r   r   rZ  r  r  r   r  r?  rd  rg   r   rE  r|  r  r  rx   )r  re  r  r  rD  rf  rg  r  rl  r9   rh  r  r  s                r1   qc_filebrowser_filer   8 sE   
   ,299;H||	*0b779GABCGG*##%EABCGGI-
.C +U7-DFHVxbA 	}}6FGLLFFHLL 2ND::<  (?@#EE ) H2.gB0GIKV{C'02 	}}4q}}DEsJJANNgN6.0D 	 $,,sB"77 	LL&'K) ** V3C9NOPRUUUV ** V3C9NOPRUUUVs<   G; :H3 ;H0H+%H0+H03I(I#I(#I(z/qc/scan/import-sharepointc                  \   t        j                  d      xs i } | j                  d      sdt        |       vrt        d      dfS t	                t        j                  d      xs i }t        |j                  d	      xs d
      j                         }t        |j                  d      xs d
      j                         }|r|st        d      dfS ddl	m
} |j                         }|st        d      dfS dd|z   i}dt        |d
      dt        |d
      d}	 t        j                  ||d      }|j                   dk7  rt        d|j                   z        dfS t#        |j%                         j                  d      xs d      }
|
j'                         j)                  d       st        d!      dfS dt        |d
      dt        |d
      d"}t+        j,                         j.                  dd# }t0        |z  }|j3                  dd$       t+        j,                         j.                  dd# }||d z   z  }	 t        j                  ||dd%&      }|j                   dk7  r2t5        j6                  |d'       t        d(|j                   z        dfS t9        t        |      d)      5 }|j;                  d*+      D ]  }|s|j=                  |        	 ddd       ddl}	 |j9                  t        |            }|j@                  }|jC                          |d-k  r%t5        j6                  |d'       t        d.      dfS t        |||
|d/g0      S # t        j                  $ r"}	t        t        |	d            dfcY d}	~	S d}	~	ww xY w# 1 sw Y   xY w# t        j                  $ r9}	t5        j6                  |d'       t        t        |	d            dfcY d}	~	S d}	~	ww xY w# tD        $ r2}	t5        j6                  |d'       t        d,|	z        dfcY d}	~	S d}	~	ww xY w)1u  DVI-1383 P3 — the File Browser "Open in Scan Tickets" workflow. Download a
    SharePoint PDF (identified by ``drive_id``+``item_id`` from a prior list) into
    QC_SCAN_DIR and return the EXACT {scan_id, files:[{id, name, pages}]} shape
    /qc/scan/upload returns, so Scan Tickets loads it into its page queue with
    zero change to the downstream OCR/save pipeline. Requires qc.filebrowser (to
    have browsed the file) AND qc.import (Scan Tickets is qc.import-gated).r'  r   r  z2Opening in Scan Tickets requires QC Import access.r  r  Tr  re  r7   r  rk  r`  r   r  r  rU  r  r  rZ  r  r[  r\  r  r;  r]  NrK   r^  r9   r  r   z-Only PDF files can be opened in Scan Tickets.r_  r$
  rM   r`  ra  rO	  rb  r  r  rc  z%Could not read the downloaded PDF: %sr^   zThe PDF has no pages.r  r9   r  r  r  )#r   r   r#  r   r  r   r  r*   r   rZ  r  r  r   r  r?  rd  rg   r   r   rE  r|  rW  rX  rY  r  r  r  r:  r?  r  r  r=  r@  r[  ry   )r'  r  re  r  r  rD  rf  rg  r  rl  r9   rh  r  scan_dirr  rb   r  r  r  r=  r   r  s                         r1   qc_scan_import_sharepointr  c s    ;;v$"D88M"3D99QRTWWW4(.BD488J'-2.446H$((9%+,224GABCGG*##%EABCGGI-
.C +U7-DFHVxbA 	}}6FGLL1668<</A>BD::<  (LMsRR ) H2.gB0GIKjjls#GW$HNN4$N/jjls#Gw'(DV{C'02==CMM($7!81==!HI3NN#d)T" 	$b7; $HHUO$	$ QiiD	"		 qyhd345s::7!($GHJ JM ** V3C9NOPRUUUV(	$ 	$ ** Vhd33C9NOPRUUUV  Qhd3DsJKSPPQs   !M AN! N! 1N
NN! +6O0 N0NNNNN! !O-4.O("O-(O-0	P+9'P& P+&P+z/qc/scan/uploadc                     t                t        j                  j                  d      } | s&dt        j                  v rt        j                  d   g} | D cg c]  }|s|j                  s| } }| st        d      dfS | D ]Q  }|j                  j                         j                  d      r-t        dt        |j                        z        dfc S  dd	l	}t        j                         j                  d	d
 }t        |z  }|j                  dd       g }| D ]  }t        j                         j                  d	d
 }||dz   z  }|j                  t!        |             	 |j#                  t!        |            }|j$                  }	|j'                          |	dk  r=t+        j,                  |d       t        dt        |j                        z        dfc S |j/                  |t        |j                        |	d        t        ||      S c c}w # t(        $ rJ}
t+        j,                  |d       t        dt        |j                        d|
      dfcY d	}
~
c S d	}
~
ww xY w)zAccept one or more PDFs; store each and return {scan_id, files:[{id, name,
    pages}]}. The client builds an ordered page queue across the returned files.r  r  zNo PDF uploaded.r  r`  r   z'Only PDF files can be scanned (got %s).r   Nr$
  TrM   rO	  zCould not read r>  r^   z%s has no pages.r  r  )r  r   r  r
  r  r   rE  r|  r   r=  rW  rX  rY  r  r  r  r*   r?  r@  r[  ry   r  r:  ri   )r  r  r=  r  r  r   r  rb   r   r  rl  s              r1   qc_scan_uploadr   s.   
 MM!!'*EVw}},v&'21qzzQ2E2/0#55 ?zz!**62!J,QZZ8"9 :;>? ?? jjls#GW$HNN4$N/
C %**,""3B'7V+,	s4y	F))CI&CNNEIIK
 19MM($7!3,QZZ8"9 :;>? ?

'?1::+F"$ 	%!%$ 7#..= 3(  	FMM($7-ajj93"@ ABEF F	Fs0   HH+H#6H	I* =I%I*%I*z/qc/scan/pagec                  D   t        t        j                  j                  dd      t        j                  j                  dd            } | y	 t	        t        j                  j                  dd            }d}t        j                  j                  d	      rd
}n\t        j                  j                  d      r=	 t        dt        dt	        t        j                  j                  d                        }	 t	        t        j                  j                  dd            }	 t        | |||      }|yt        t        j                  |      dd      S # t
        $ r Y yw xY w# t
        $ r d}Y zw xY w# t
        $ r d}Y aw xY w# t        $ r}d| dfcY d}~S d}~ww xY w)zDRender one page of an uploaded scan PDF to PNG for the preview pane.r  r7   r  NrJ  rO  rF   r  r4  r  r  r   i`	  r  r   )rE  r  r  r  r  rn  zscan-page.pngr  )r  r   rh  r   r   r   rC  rL  r  ry   r   r>  rJ  )rb   r  rE  r  rq  rl  s         r1   qc_scan_pager	  ԁ su    gll..y"=%ll..y"=?D|#gll&&vs34
 E|| 			#		Cc',,*:*:3*?&@ABE',,""5#./4!$u#F {'RZZ_{#24 4-  #"#  	E	    4(.334sT   )E :<E# 7)E4 !F 	E E #E10E14FF	FFFFz/qc/scan/extractc            	         t        j                  d      xs i } t        t        | j	                  dd            t        | j	                  dd                  }|t        d      d	fS 	 t        | j	                  d
d            }t        j                         D cg c]  }|d   	 }}| j	                  d      xs dj                         xs d}	 t        | j	                  dd            dz  }| j	                  d      s|rt        ||t               ||      }nt        ||t               ||      }t        di |S # t        t        f$ r t        d      dfcY S w xY wc c}w # t        t        f$ r d}Y w xY w)zEOCR one page into structured QC batch fields (best-effort auto-fill).Tr  r  r7   r  NScan not found.r  r  rO  r   r  r`  r9   rH  r  r  force_wholepager  rp  rX   )r   r  r  r*   r   r   r   r   r   r  rW  r   r  r  r  )r  rb   r  r  r.  rH  r  r>  s           r1   qc_scan_extractr   sn    4(.BDc$((9b"9:!$((9b"9:<D|./442dhhvq)* !) 7 7 9:1QvY:E:xx	"(b//19TG$((5!$%+ xx!"c+'?,e> "$1BE*13V' z" 2^,c112: z" s*   *D> E!E& >EE&E:9E:z/qc/scan/regionc            
         t        j                  d      xs i } t        t        | j	                  dd            t        | j	                  dd                  }|t        d      d	fS 	 t        | j	                  d
d            }| j	                  d      xs i }	 t        |j	                  d            t        |j	                  d            }}t        |j	                  d            t        |j	                  d            }}t        t        dt        d|            t        dt        d|            f      \  }}	t        t        dt        d|            t        dt        d|            f      \  }
}|	|z
  dk  s||
z
  dk  rt        d      dfS ddlm} 	  |||||
|	|fdt                     }t        |xs d      S # t        t        f$ r t        d      dfcY S w xY w# t        t        f$ r t        d      dfcY S w xY w# t         $ r-}t"        j$                  j'                  d||       d}Y d}~d}~ww xY w)uF   Re-OCR a user-drawn rectangle on a scan page → {text} for one field.Tr  r  r7   r  Nr  r  r  rO  r   r  r`  rD  r
  r
  r  r  r  r  r  r  r  r  r  zQC region OCR failed for %s: %sr%  )r   r  r  r*   r   r   r   r   r   r  r  rC  rL  r"  r  r  ry   rm   rn   ro   )r  rb   r  rD  r
  r
  r  r  r  r  r  r  r  r&  rl  s                  r1   qc_scan_regionr   s    4(.BDc$((9b"9:!$((9b"9:<D|./442dhhvq)* 88F!rD4txx~&dhhtn(=Btxx~&dhhtn(=B s3C-s3C/EFGHCs3C-s3C/EFGHCc	UsSyE1IJCOO)dGc3S-At&5&79
 
##' z" 2^,c112 z" 4-.334  

<dCHs=   *G A(G$ H G! G!$HH	H=#H88H=z/qc/scan/template-regionsc                  F   t         j                  j                  d      xs dj                         xs d} | rt	        j
                  |       nd}|st        dt        g       S t        t        j                        }t	        j                  d      D ci c]  }|d   |d    }}t	        j                         D ci c]  }|d   |d	    }}g }|j                  d
      xs i j                         D ]  \  }}	t	        j                  |      }
t	        j                  |      }|
|j                  |
      }|s)F||j                  |      }|s\|j                  ||      }|j                  |||	d        t        |j                  d      t        |      S c c}w c c}w )a"  Field rects of the segment's assigned OCR region template, for the Scan
    Tickets overlay that visualizes where each field is read from + the padded
    skew-tolerance margin (DVI-1442 prod-verify follow-up). Returns
    {template, pad, fields:[{key,label,rect}]}; empty when no template.rH  r7   N)r  r  r  FrK  r  r9   r   r  )r)  r   rD  )r   rh  r   r   r  r  r   _QC_REGION_OCR_PADr   r  rW  r^  r   r  r  ri   )rH  r  r  r  r  r  r  r  r)  rD  r2  r  r   s                r1   qc_scan_template_regionsr  : s    ||	*0b779ATG:Ax--g6tH*<RHH(556F,,FH dGQvY HD H,4,G,G,IJqqw'
"JGJFll8,299; B	T,,S1**3/?HHSME_KK$EJJsC(EcE4@AB HLL0)&: :%HJs   F<Fz/qc/scan/savec                  z   t        j                  d      xs i } t        |       }d|d<   t        j                  || j                  d      t                     \  }}|rt        |      dfS t        d	d
t               d|d   xs d|d   z  d|d   xs dd|d   d|d   |d   dd       t        |      S )zSave one validated scanned page as an ordinary (editable) QC batch with
    source='ocr'. Gated by qc.import so the whole scan workflow stays under one
    capability (editing later uses /qc/batches, which needs base `qc`).Tr  r  r	  rP  rg  r  r`  r  rh  ri  rj  rk  r  r:  rl  rm  r  rn  z yd3) scanned for ro  )rp  r	  r  rq  )	r   r  r   r  rs  r   rA  r   r	  )r  r  rr  r  s       r1   qc_scan_saver  \ s     4(.BD$ZFF8&&vtxx/@-6[:JE3
S!3&&'y{.!8UU4[%88.!-X-uW~,! "$K59; r3   c                 H    | xs d}d|v sd|v rdt          dt          d| S |S )u]  Translate a Graph ApplicationAccessPolicy (RAOP) block into an
    actionable message. Since 2026-07-02 the tenant policy that allows togen@
    (DVI-854) blocks app-only access to the SCADA mailbox because scadalogs@
    was never added to its group — the generic 'Mail.Read not granted' text
    previously shown pointed admins at the wrong fix.r7   RAOPAccessPolicyzApp-only Graph access to zW is blocked by the tenant Exchange ApplicationAccessPolicy. An Exchange admin must add z_ to the policy's allowed group (the same policy that permits togen@, see DVI-854). Graph said: )r  )r	  rn  s     r1   _scada_raop_hintr  s sK     	"A{n)+M? ;P / "BBCF 	G Hr3   c                 *    t        t        |             S )zIHuman-readable error detail from a Graph error response, RAOP-translated.)r  r'  r  s    r1   r  r   s    0677r3   c           	         g }| xs g D ]@  }t        |j                  d            }|j                  dd      }|dk(  rIt        t        |            \  }}|xs |}|D ]&  }|j	                  t        |d         ||d   d       ( ~|dk(  st        t        |            }	|	d	   }
t        |
      d
k\  r|
dd ng }|	d   D ]  }|j                  d      }|r|j                  d      n|}|D ]Z  }|j                  |d      xs dj                         }|r|j                         dk(  r=|j	                  t        |      ||d       \  C t        j                  |d      S )a+  Parse Trigger snapshots + daily Log tables into scada.db readings.

    Idempotent (the store dedupes on sensor+ts+source), so overlapping mailbox
    windows from the collector, /scada/status, and /scada/history can all call
    this opportunistically. Returns the number of newly stored rows.
    r  rm  r7   r5  r   r  sensorr_   r  rk  r   r   r^   r  rn  r  r  r  r  r	  )r  r   r  r  ri   r  r  r  rC  r  r   rE  r  insert_readings)rf  rn  r  r!	  ro  r_   r  r!  r  rL  r"  r#  r  r  r6  rQ  s                   r1   r  r   s    D~2 B!#'')"4577-r29,-@-EFLBxJ E'9!G*'E#-'
D EE U]+,@,EFF)$D(+D	Q$q*BKf~ BWWU^>@R[[)9:h
& BC773+1r88:C#))+"6 KK+=c+B'1C!A B	BBB, &&tG<<r3   c                    d }	  || j                   j                  dd      j                               } || j                   j                  dd      j                               }|r|r||kD  ry| j                   j	                  d      }t        |      dk(  rd	|d
   v r|d
   j                  d	      }|D cg c]#  }|j                         s|j                         % }}|||dfS # t        $ r Y yw xY wc c}w )zParse date_from/date_to (YYYY-MM-DD) and sensors (CSV or repeated) from a
    request. Returns (date_from, date_to, sensors, error).c                 P    | sy t        j                  | d      j                         S Nr  )r  r  r   r  s    r1   
parse_datez,_scada_parse_report_args.<locals>.parse_date s$    &&sJ7<<>>r3   r  r7   r  )NNNrH  )NNNz'date_from must be on or before date_to.sensorsr^   r  r   N)rh  r   r   r   r
  rC  r  )reqr#  r  r  r$  r  s         r1   _scada_parse_report_argsr&   s    ?
Fsxx||K<BBDE	SXX\\)R8>>@A WW!4Jhhy)G
7|qSGAJ.!*""3'")7QQWWYqwwy7G7gw,,  FEF 8s   A C' 7C6C6'	C32C3c                     t        | xs d      } | j                  d      r2| dd j                  d      xs d}t        dt	        |      z         d	fS t        d
t	        |              dfS )z@Map a _build_scada_report error string to a JSON Flask response.r7   r  rP   NrS  zaccess deniedr  r  r  r	  rU  )r*   rw   rV  r   r  )r  r	  s     r1   _scada_report_error_responser(   sy    
ciR.C
~~eQR$77:J6:RR
 	 78H8M7NOPRUUUr3   z/scada/report/typesc                  v    t        t               D  cg c]  } | j                          c} t              S c c} w )zARegistered report types for the Log Reports picker (DVI-1095 F3).)rV  r.  )r   r)  rl   r&  r   s    r1   scada_report_typesr+  ʂ s.     ,=,?@q!&&(@13 3@s   6z/scada/reportc            	         t        t              \  } }}}|rt        |      dfS t        t        j                  j                  d            }|j                  | ||      }|d   rt        |d         S d}|j                  rDt               j                  |j                        }|j                  ||      }|d   |d   |d   d	}t        |j                  |d
   |d   |d   |d   |d   |      S )zPreview JSON for the SCADA report: per-day tables + available sensors +
    earliest available date, honoring the {date_from, date_to, sensors[]} filter.
    The optional ``report_type`` selects the registered engine (defaults to
    Kiln Temp).r  r`  report_typer  Nr  passr$  )r  r.  r$  r  r   available_sensorsearliest_dater  )r-  r  r   r/  r0  r  criteria)r&  r   r   r(  rh  r   buildr(  has_criteria_load_scada_configcriteria_config_keycriteria_summaryr  )	r  r  r$  r  r~  r6  criteria_outr   r
	  s	            r1   scada_reportr8  ҂ s    (@'H$Iw
S!3&&GLL,,];<E[[GW5Fg+F7O<<L "&&u'@'@A((5
+FOy)

 HHF^y! !45_-% r3   z/scada/report/downloadc                     t        t              \  } }}}|rt        |      dfS t        t        j                  j                  d            }|j                  | ||      }|d   rt        |d         S d}|j                  r3|j                  |t               j                  |j                              }|j                  ||      }d } ||        d ||       d}	t        |d	|	d
      S )zStream the SCADA report as an XLSX (one tab per day). The optional
    ``report_type`` selects the registered engine (defaults to Kiln Temp).r  r`  r-  r  Nc                 d    | r-| j                    d| j                   d| j                  d       S dS )Nr  z%yr  )r  r  r  r  s    r1   fmtz"scada_report_download.<locals>.fmt s2    :;!''!AEE7!AJJt$4#56FFr3   r*  rN	  TAapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetr0  )r&  r   r   r(  rh  r   r2  r(  r3  r6  r4  r5  workbookr   )
r  r  r$  r  r~  r6  r
	  r2  r;  r9   s
             r1   scada_report_downloadr>   s    
 (@'H$Iw
S!3&&GLL,,];<E[[GW5Fg+F7O<<G((&(,,U-F-FGI
..
)CG)nSWe4DT	 r3   z/scada/reportsc                     g } t         j                         rt         j                  d      D ]  }|j                         s	 |j	                         }| j                  |j                  |j                  t        j                  |j                        j                  d      |j                  d        | j                  d d       t        |       S # t
        $ r Y w xY w)zxList produced XLSX report files (written by the report scheduler),
    newest first. Used by the File Browser SCADA tab.z*.xlsxr  )r9   rd  r"  r_   c                     | d   S Nr_   rX   rx	  s    r1   r	  z$scada_reports_list.<locals>.<lambda>% 
    qw r3   Tr
  )r  )r:  r  r  r   r  r   ri   r9   r&  r  r  r  r  r  r   )r  r  r{	  s      r1   scada_reports_listrC   s    
 G!"''1 	A99;VVX NN

*88ENNO_`kk	 	 LL&L57##  s   C	CCz/scada/reports/downloadc                     t         j                  j                  dd      } | r7| t        |       j                  k7  s| j                         j                  d      st        d      dfS t        | z  j                         }	 |j                  t        j                                |j                         st        d      dfS t        |d	| d
      S # t        $ r t        d      dfcY S w xY w)zHStream a single persisted XLSX report by filename (path-traversal safe).r9   r7   rN	  Invalid report name.r  r`  zReport not found.r  Tr<  r0  )r   rh  r   r   r9   rE  r|  r   r:  r,   r{  r   r   r   r9   rb   s     r1   scada_reports_downloadrG  ) s     <<FB'D44:??*$**,2G2G2P34c99$--/D:*2245 <<>01366T	 	  :34c99:s    #C C*)C*z/scada/report-viewc                     t         j                  j                  dd      } | r7| t        |       j                  k7  s| j                         j                  d      st        d      dfS t        | z  j                  d      j                         }	 |j                  t        j                                |j                         st        d      d	fS 	 t        j                  |j!                               }t        | |      S # t        $ r t        d      dfcY S w xY w# t        j"                  t$        f$ r t        d
      dfcY S w xY w)u  Return the normalized view-model JSON for a persisted report (DVI-1163).

    The report scheduler writes a sibling ``<report>.json`` next to each
    generated ``.xlsx`` at generation time (scada_report.write_report_view_model);
    this reads it back for the in-app read-only viewer without re-parsing the
    XLSX. ``name`` is the XLSX filename (path-traversal safe). Reports generated
    before this feature shipped have no sibling JSON and 404 with a hint — their
    XLSX can still be downloaded.
    r9   r7   rN	  rE  r  r`  r  u   No saved view for this report — it was generated before the viewer was added. Regenerate the report to view it in-app (the XLSX can still be downloaded).r  zReport view is unreadable.r  )r9   
view_model)r   rh  r   r   r9   rE  r|  r   r:  r  r,   r{  r   r   r   r   r   r   r   )r9   r
  vms      r1   scada_report_viewrK  @ s1    <<FB'D44:??*$**,2G2G2P34c99"T)66w?GGII:/779: 89 ;>> 	>@ZZ	++-. ,,  :34c99:   '* @9:C??@s$   #D #D DD'EE)check_interval_minutesstale_after_minutesc                  <   i i i i i i t        t              t        i       g t        i       ddid} t        j                         r2	 t        j                  t        j                               }t        |j                  d      t               r|d   | d<   t        |j                  d      t               r|d   | d<   t        |j                  d      t               r|d   | d<   t        |j                  d      t               r|d   | d<   t        |j                  d      t               r|d   | d<   t        |j                  d	      t               r|d	   | d	<   t        |j                  d
      t               r4| d
   j                  t        D ci c]  }||d
   v r	||d
   |    c}       t        |j                  d      t               rt        |d         | d<   d|v rt        j                  |d         | d<   t        |j                  d      t               rt        |d         | d<   t        |j                  d      t               r-|d   j                  d      }dt        |t              r|ndi| d<   | S | S c c}w # t        j                  t         f$ r Y | S w xY w)zReturn shared SCADA config: {ranges: {label: {low, high}}, enabled: {label: bool}, aliases: {label: str}, reporting: {label: bool}, notify: {label: bool}, meta: {label: {type, location, unit}}, collector: {check_interval_minutes, stale_after_minutes}}.planr7   )rangesr~  rN  r  r  r  r  report_criteriareport_approversreport_emailr  rP  r~  rN  r  r  r  r  rQ  rR  rS  r  )r   SCADA_COLLECTOR_DEFAULTSr,  r*  SCADA_CONFIG_FILEr   r   r   r   r   r   r  r
  normalize_approversr*   r   r   )r  r  r  rO  s       r1   r4  r4  f s   WY!":;#8#<$& 5b 9$b\+H   "#	JJ0::<=E%))H-t4%*8_"%))I.5&+I&6#%))I.5&+I&6#%))K0$7(-k(:%%))H-t4%*8_"%))F+T2#(= %))K0$7%,,5.E+.. u[)!,,. 
 %))$56=.C+,/.*+!U**>>013 +, %))N3T:+@.),+(%))K0$7[)--f5JtS$9Dr)C% O8O+.& $$g. 	O	s&   EI> I9#CI> 9I> >JJc                 X    t         j                  t        j                  | d             y)z$Persist shared SCADA config to disk.r   r   N)rU  r   r   r   r  s    r1   _save_scada_configrX   s      F1!=>r3   r7  c                  &    g g g d d dddddg dddd	S )Nr7   Tr~  rO  layersstyleshow_legend)	
facilitiesplansstylesstyle_default_lightstyle_default_darkequipment_planvisitor_planlogistics_planoverview_dashboardrX   rX   r3   r1   _oversight_default_configrg   s5    rR#'t " /3B",.t#E
F 
Fr3   c                      ddg dddS )NTr7   rZ  rX   rX   r3   r1   _oversight_overview_defaultsri   s    R2! !r3   c           	      D   | j                  d      xs g D cg c]6  }t        |t              r$|j                  d      r|j                  d      r|8 }}|st        dddg}|| d<   |D cg c]  }|d   	 }}| j                  d      |vr|d   | d<   | j                  d      |vr| d   | d<   | j                  d	g       D ]  }t        |j                  d
      t              r|j                  d
      ni }|j	                         D ci c]-  \  }}||v r$t        |t              r|j                  d      r||/ }}}|j                  dd      }|j                  dd      |j                  dd      }
}	|r| d   |vr||	|
d|| d   <   ||d
<    | S c c}w c c}w c c}}w )u]  Normalize styles in-memory on every load (pure — no write on read).

    Seeds a "Standard" style when none exist, folds a legacy per-plan `image`
    into it, sanitizes each plan's `images` map, and repairs dangling
    light/dark default style ids. The migrated shape persists the next time
    any admin write calls _save_oversight_config.
    r`  r  r9   Standardr   r  r9   r  ra  rb  r_  imagesr  r~
  Nimg_wimg_hr  r  r}
  )r   r   r   OVERSIGHT_SEED_STYLE_IDr   r  )rJ  r  r`  r|  r  rm  r   r  rb  lwlhs              r1   _oversight_migrate_stylesrt  Ã s    ''(+1r HAAt$tv  HF H0*qQRCM"
#q1T7
#C
#
ww$%S0%(V!"
ww#$C/$'(=$> !WWWb! $.quuX$Ex2)/ Mgc2CZJr4$8RVVF^ r' M Mw%w%quuWd';Bc/0>:@rPR1SF3,-.( J'H
 $Ms   ;F+F	2Fc                  
   t               } t        j                         r	 t        j                  t        j                               }t        |j                  d      t              r7|d   D cg c]%  }t        |t              r|j                  d      r|' c}| d<   t        |j                  d      t              r7|d   D cg c]%  }t        |t              r|j                  d      r|' c}| d<   t        |j                  d      t              r|d   | d<   dD ]*  }t        |j                  |      t              s#||   | |<   , t        |j                  d      t              r|d   | d<   t        |j                  d      t              r|d   | d<   t        |j                  d      t              r|d   | d<   |j                  d	      }t        |t              rt               }t        |j                  d
d            |d
<   t        |j                  d      xs d      |d<   t        |j                  d      t              r+|d   D cg c]  }t        |      st        |       c}|d<   t        |j                  d      xs d      |d<   t        |j                  dd            |d<   || d	<   t        |       S t        |       S c c}w c c}w c c}w # t        j                  t        f$ r Y t        |       S w xY w)zJHelper-gated read of the Oversight config (never touch the file directly).r^  r  r_  r`  )ra  rb  rc  rd  re  rf  r~  TrO  r7   r[  r\  r]  )rg  OVERSIGHT_CONFIG_FILEr   r   r   r   r   r   r  r   r*   ri  r   r   r   rt  )rJ  r  r  r  r)  r  r  ls           r1   _load_oversight_configrx   s   
#
%C$$&	JJ4>>@AE%))L14805l0C %M1(21d(;d &' %ML!%))G,d3+0>  Ha#-a#6155; !"  HG%))H-t4 %hHD *eiinc2$SzCH* %))$45s;(-.>(?$%%))N3S9&+N&;N#%))$45s;(-.>(?$%/0B"d#57$(	4)@$Ay!!$RVVF^%9r!:vbffX.58:8'O1AA'OF8$"%bffWo&;"<w(,RVVM4-H(I}%,2() %S))$S))9%M H& (P $$g. 	$S))	sK   AK .*K,K *K.AK  DK K K-A
K K LLc                 X    t         j                  t        j                  | d             y)z+Helper-gated write of the Oversight config.r   r   N)rv  r   r   r   rN  s    r1   _save_oversight_configrz   s    $$TZZA%>?r3   c                     t         j                         r	 t        j                  t         j	                               } t        | t              rL| D cg c]@  }t        |t              r.|j                         r|j                         j                         B c}S 	 g S g S c c}w # t        j                  t        f$ r Y g S w xY w)z@Return the list of granted Oversight-editor emails (lowercased).)OVERSIGHT_EDITORS_FILEr   r   r   r   r   r  r*   r   rE  r   r   rV  s     r1   _load_oversight_editorsr}   s    %%'	**3==?@C#t$36 =a%a-!'') 	) = = %
 I2I	=$$g. 	I	rX  c                 :   t               g }}| xs g D ][  }t        |t              s|j                         j	                         }|s5||vs:|j                  |       |j                  |       ] t        j                  t        j                  |d             |S )zJPersist the editor list, de-duplicated and lowercased. Returns saved list.r   r   )r  r   r*   r   rE  r  ri   r|  r   r   r   rZ  s        r1   _save_oversight_editorsr   s    E2'Dl !!S!wwy D$HHTNNN4 ! %%djj&CDNr3   c                 X    | sy| j                         j                         t               v S )zGTrue if the email is on the Oversight editors list (per-request check).F)r   rE  r}  r]  s    r1   _is_oversight_editorr  ' s&    ;;= $;$===r3   c                 h    | xs i } | j                  d      ryt        | j                  dd            S )a]  Whether a user may use the Oversight Locations/plan editor (DVI-1103).

    Togen Admins always qualify; other users qualify only if their email is on
    the granted editors list. Evaluated per request, so grants take effect
    without a re-login (mirrors the DVI-1024/1052/1082 pattern). Managing the
    grant list itself stays Togen-admin-only.r   Tr  r7   )r   r  r-  s    r1   r  r  . s2     :2Dxx" 566r3   c                 N    |  dt        j                         j                  d d  S )Nr  r   )rW  rX  rY  r  s    r1   _oversight_new_idr  ; s'    XQtzz|'',-..r3   c                 T   | xs i } g }t        | j                  d      t              r| j                  d      ni }|G|j                  |      }t        |t              r|j                  d      r|j                  |d          nr|j	                         D cg c]4  }t        |t              r"|j                  d      r|j                  d      6 }}| j                  d      r|j                  | d          |D ]  }	 t
        |z  j                           yc c}w # t        $ r Y .w xY w)u   Delete a plan's image files — one style's file, or all when style_id
    is None (plan/facility delete cascade). Legacy `image` handled too.rm  Nr  r~
  )r   r   r   ri   r  OVERSIGHT_PLANS_DIRr/   r   )rO  style_idr  rm  r  r  s         r1   _oversight_delete_plan_imager  ? s    :2DE#-dhhx.@$#GTXXhRFZZ!b$BFF6NLLF$*0--/ =Br4(RVVF^  = =88GLLg' 	 5(002	=  		s   9D<D	D'&D'c                    	 | j                  dd      }t        j                  dd|t        j                        }t        j                  dd|t        j                        }t        j                  dd|t        j                        }t        j                  dd|t        j                        }t        j                  d	d|t        j                        }t        j                  d
d|t        j                        }t        j                  dd|t        j                        }|j                  d      S # t        $ r | cY S w xY w)zStrip <script>, inline on* handlers, javascript: URIs and entity decls
    from an uploaded SVG so a plan image can't carry active content.r  r  z<script[\s\S]*?</script\s*>r7   flagsz<script[^>]*/>z\son\w+\s*=\s*\"[^\"]*\"z\son\w+\s*=\s*'[^']*'zjavascript:z<!ENTITY[\s\S]*?>z<!DOCTYPE[\s\S]*?>)r  ry   rF  r\  r	  r  )r   r&  s     r1   _oversight_sanitize_svgr  U s    {{7I. 660"d"$$GD66#RRTT:D66-r4rttDD66*BBDDAD66."d"$$7D66&D=D66'T>D;;w  s   D6 6EEc                    t        j                  d| t         j                        }|rFt        t	        |j                  d                  t        t	        |j                  d                  fS t        j                  d| t         j                        }t        j                  d| t         j                        }|rH|rFt        t	        |j                  d                  t        t	        |j                  d                  fS y)Nz?viewBox\s*=\s*["\']\s*[\d.\-]+\s+[\d.\-]+\s+([\d.]+)\s+([\d.]+)r  r^   r   z\bwidth\s*=\s*["\']([\d.]+)z\bheight\s*=\s*["\']([\d.]+)r  )rF  r  r	  r   r  rb  )r&  r  r  r}
  s       r1   _oversight_svg_dimsr  f s    
		JBDD	A 	5$%s5+<'===
		0$bddCA
		14rttDAQ5$%s5+<'===r3   c                 2   | dk(  rt        |j                  dd            S 	 t        j                  t	        j
                  |            5 }t        |j                        t        |j                        fcddd       S # 1 sw Y   yxY w# t        $ r Y yw xY w)zF(width, height) of an uploaded plan image, or (None, None) if unknown.rv  r  r  Nr  )
r  r  rI  r?  r>  rJ  r   rE  rF  ry   )r  r   r  s      r1   _oversight_image_dimsr  s sy    
e|"4;;w	#BCC__RZZ-. 	1"rxx=#bii.0	1 	1 	1 s/   (B
 
*A>4	B
 >BB
 B
 
	BBc                 l    dD ]  }	 t        j                  | |      c S  y # t        t        f$ r Y /w xY w)Nrr  r  )r  r  r   r   )r_   r;  s     r1   _oversight_parse_tsr   sG    6 	!**2s33
  I& 		s   !33c                 \    t        | j                  d      xs | j                  d            S )Nr   r   r,  r-  s    r1   _oversight_scada_accessr   s$    'B488M+BCCr3   c                     t               } | j                  di       xs i }| j                  di       xs i }i }	 t        j                         j                  dg       D ]  }|j                  d      }|sd||<    	 |D ]  }d||<   	 g }t        |      D ]  }t        |j                  |      t              r|j                  |      ni }|j                  ||j                  |      xs ||xs i j                  dd      |xs i j                  d	d
      |xs i j                  dd
      d        |S # t        $ r" t
        j                  j                  d       Y w xY w)Nr  rN  r  r   Tz&Oversight: SCADA items snapshot failedrT  otherr  r7   r	  )r  r   rT  r  r	  )r4  r   r  r/  ry   rm   rn   r  r  r   r   ri   )rJ  r  rN  r  r  r[  r   r  s           r1   _oversight_scada_itemsr   sW   

C7762$"Dggi$*GDG,,.22:rB 	!A%%.C S		!  S	Ed| 't<DHHSM"[[%,W"MM&'2bj"5W"MM&"-
 	 L  G

EFGs   :D5 5D5 5(E E c                     t               } | j                  di       xs i }| j                  di       xs i }| j                  di       xs i }t        | j                  d      xs i j                  dt        d               }	 t	        j
                         }t        j                         }i }|j                  dg       D ]H  }|j                  d	      }	|	st        |j                  |	      t              r|j                  |	      ni }
t        |j                  |	      t              r|j                  |	      ni }|j                  d
      }t        |      }|dn||z
  j                         |dz  kD  }|	|j                  |	      xs |	|j                  d      |
xs i j                  dd      |
xs i j                  dd      |
xs i j                  dd      |||xs i j                  d      |xs i j                  d      d
||	<   K |S # t        $ r( t        j                  j                  d       d g d}Y w xY w)Nr  rP  rN  r  rM  z'Oversight: SCADA status snapshot failedr  r  r   r_   Tr   r  r	  r7   rT  r  r  rK  high)
r  r   r  r	  rT  r  r_   stalerK  r  )r4  r   r   rT  r  r/  ry   rm   rn   r  r  r  r   r   r  total_seconds)rJ  r  rP  rN  stale_afterr:  r  r   r  r[  r  r  r_   r  r  s                  r1   _oversight_scada_statusr   s   

C7762$"DWWXr"(bFggi$*Gsww{+1r6678MNP QK.**, 


C
CXXj"% 
eeGn't<DHHSM"!+FJJsOT!BfjjoUU4[ $
2X$$&r)99 	 [[%,UU7^W"MM&"-W"MM&'2bj"5I2??5)YBOOF+
C
, J7  .

FG"-.s   H -H>=H>c                      yr  rX   r-  s    r1   _oversight_equipment_accessr  ӄ s     r3   c                      t               } g }| j                         D ]N  \  }}t        |t              r|j	                  d      s(|j                  ||j	                  d      xs |d       P t        |d       S )Nr  r9   )r  r   c                 0    | d   xs dj                         S Nr   r7   rs  r  s    r1   r	  z,_oversight_equipment_items.<locals>.<lambda>     '
(8b'?'?'A r3   r  r  r   r   r   r   ri   r  )r  r   aidr~  s       r1   _oversight_equipment_itemsr  ل sr    "$HENN$ DS3%#''2H*IS3776?+AcBCD %ABBr3   c            
         t               } i }| j                         D ]  \  }}t        |t              r|j	                  d      s(t        |      }|D cg c]  }|j	                  d      dv s| }}|D cg c]  }|j	                  d      dk(  s| }}|j	                  d      rd}n|rd}nd}||j	                  d	      xs ||t        |      t        |      d
ddd|   d||<    |S c c}w c c}w )u3  Traffic-light state per included equipment (DVI-1124).

    green = no outstanding WO; yellow = >=1 outstanding WO (new/in_progress,
    which covers "waiting for parts" today, D2); red = out of service — the
    data source is a future project, so the record flag is read but never set
    yet (D3 stub).r  rd   r  r   r  redyellowgreenr9   z
In servicezOpen WOzOut of service)r  r  r  )r  r   ro  
open_countin_progress_countr  )r  r   r   r   r   r  rC  )	r  r   r  r~  r  r}
  open_wosr   ro  s	            r1   _oversight_equipment_statusr   s    #$H
CNN$ 
S3%#''2H*I',&T!!%%/=S*SATT"*OQaeeHo.NqOO77#$EEEWWV_+h-!$[!1+ )-//46	
C
, J% UOs   C.-C.7C3C3c                      yr  rX   r-  s    r1    _oversight_lorawan_mobile_accessr   s    r3   c                  0   t               } g }| j                         D ]j  \  }}t        |t              s|j	                  d      }|dk7  r.|j	                  d      s@|j                  ||j	                  d      xs ||d   d       l t        |d       S )z9Mobile equipment that has a DevEUI binding (DVI-1136 P3).r  r
  r  r9   r  r   r'  c                 0    | d   xs dj                         S r  rs  r  s    r1   r	  z1_oversight_lorawan_mobile_items.<locals>.<lambda> r  r3   r  r  )r  r   r  r~  r  s        r1   _oversight_lorawan_mobile_itemsr  	 s    "$HENN$ 
<S#t$WW+,>ww*+S3776?+Ac!$%8!9; 	<
< %ABBr3   c                  n   t               } 	 ddl}|j                         }|j                  dg       D ci c]  }|d   |
 }}i }| j                         D ]  \  }}t        |t              s|j                  d      dk7  r-|j                  d      xs d}|sE|j                  |      }	|	r>|	j                  d	      }
|	j                  d
      }|	j                  d      }d}|
r	 t        j                  |
j                  dd            }|j                   |j                  t        j                        }t        j                  t        j                        |z
  j                         dz  }|dk  rdn|dk  rdnd}||ddnd}||ddnd}|j                  d      xs |}|||d| d| ||	j                  d	      |||	j                  d      |	j                  d      d
||<   ||j                  d      xs |dd|d ||<    |S c c}w # t        $ r i }Y w xY w# t        $ r d}Y w xY w)!a  Last-seen tag state per mobile equipment ref (DVI-1136 P3).

    Returns a status dict keyed by asset_id mirroring the equipment layer
    contract (ref, label, state, value) plus tag telemetry fields. When
    thingpark.db is empty or unreachable the layer renders without data.r   Nr&  r'  r  r
  r  r7   r  r  r(  grayr  r  r  r   r  r	  r  r  r[   r  r]   z dBmr9   zBattery u	    · RSSI r*  r+  )
r  r   ro  r  r'  r  r  r(  r*  r+  zNo uplinks yetr  r   ro  r  r'  )r  r.  r/  r   ry   r   r   r   r  r  r  r  r  r  r  r  )r  r3  rM  rn  by_euir   r  r~  r'  uplinkr_   r  r(  ro  r  age_minbatt_strrssi_strr   s                      r1    _oversight_lorawan_mobile_statusr   sZ    #$H&'')+3<<	2+FGa!I,/GG CNN$ .S#t$77)*h6''-.4"G$K(Bjj+G::f%DE#'55bjjh6OPByy(ZZ|/?/?Z@-11,2B2BCbHWWY\^^G'.|GgPSm(Y^E -4,?'#a(SH,0,<$s4(#HGGFO*sE#H:YxjA"#ZZ4"zz%(zz%(CH /C)"CHQ.^ Jg H 2 ! #"E#s5   (H HH &BH&H H#"H#&H43H4c            
          ddt         t        t        dddt        t        t
        dddt        t        t        ddd	t        t        t        dd
dt        t        t        ddS )zLayer id -> provider descriptor. New layers plug in here without
    touching the viewer (DVI-1100 design; Equipment DVI-1124; LoRaWAN DVI-1136).scadazKiln Sensors)r  r   r  r   rd   r  r  lorawan_mobilezMobile TagsvisitorsVisitors	personnel	Personnel)r  r  r  r  r  )r  r  r  r  r  r  r  r  r  _oversight_visitor_access_oversight_visitor_items_oversight_visitor_status_oversight_personnel_access_oversight_personnel_items_oversight_personnel_statusrX   r3   r1   _oversight_layersr  [ sz    
 #-+-
  1/1
 #"646
 /-/
  1/1
;$ $r3   z/oversight/configc                  
   t         d   } t               }g }t               j                         D ]>  \  }}t	         |d   |             }|j                  ||d   ||r
 |d          ng d       @ t        |d   d       }t        |d	   d
       }g }t        |d   d       D ]  }	|	j                  d      xs i j                         D 
ci c])  \  }
}|
|j                  d      |j                  d      d+ }}
}|j                  |	d   |	j                  d      |	j                  dd      |	j                  d      |	j                  dd      t	        |      ||	j                  dg       d        t        |||||d   |d   |j                  dd      |j                  dd      |j                  dd      |j                  dt                     t        t        |       t	        | j                  d             !      S c c}}
w )"zFacilities, plans (with placements), and the layers THIS user may see.
    `items` is populated only for accessible layers (feeds the admin editor).r'  r  r   r   )r  r   r  r   r^  c                 l    | j                  dd      | j                  d      xs dj                         fS Nr  r   r9   r7   r<  rZ  s    r1   r	  z&oversight_config_get.<locals>.<lambda> ,    quuWa(155=+>B*E*E*GH r3   r  r`  c                 l    | j                  dd      | j                  d      xs dj                         fS r  r<  r  s    r1   r	  z&oversight_config_get.<locals>.<lambda> r  r3   r_  c                 l    | j                  dd      | j                  d      xs dj                         fS r  r<  r  s    r1   r	  z&oversight_config_get.<locals>.<lambda> s,    155!#4quuV}7J6Q6Q6S"T r3   rm  r  r}
  r  r}
  r  facilityr9   r7   r  r  r   
placements)r  r  r9   r  r  	has_imagerm  r  ra  rb  rc  rd  re  rf  r   )r^  r_  r[  r`  ra  rb  rc  rd  re  rf  location_presetsrB  can_manage_editors)r   rx  r  r   r   ri   r  r   r   ri  OVERSIGHT_LOCATION_PRESETSr  )r'  rJ  r[  lidprovr   r^  r`  r_  r  r   r  rm  s                r1   oversight_config_getr   s   
 6?D
 
"CF&(..0 	T~tH~d+,'](/]T']_R	
 	 LHJJ HHJF ECLTV  #$%%/"7R!>!>!@Bc2 RVVC[rvvc{;; B BD'j)EE&"%eeHoUU7A&f%%b1	
 			 jf '*+@'A&)*>&?"%''*:B"? # ;"%''*:B"?&)gg.B.J.L'N$>/5&*488M+B&CE EBs   .G?z/oversight/equipment-planc                  6   t        j                  d      xs t         j                  } | j                  d      xs dj	                         t               }r%t        fd|d   D              st        d      d	fS |d
<   t        |       t        d      S )u   Set the focused plan shown in the Equipment tool's Oversight tab
    (DVI-1124). Global, admin-only write — mirrors the SCADA View oversight
    plan selector. Validated against known plan ids; empty clears it.Tr  rO  r7   c              3   .   K   | ]  }|d    k(    ywr	  rX   r,  r  rO  s     r1   r/  z0oversight_equipment_plan_save.<locals>.<genexpr>      >A$4>   r_  Plan not foundr  r  rc  rU  rO  	r   r  rg  r   r   rx  r2  r   rz  r   rJ  rO  s     @r1   oversight_equipment_plan_saver        4(8GLLDHHV"))+D
 
"CC>W>>-.33 C3d&&r3   z/oversight/visitor-planc                  6   t        j                  d      xs t         j                  } | j                  d      xs dj	                         t               }r%t        fd|d   D              st        d      d	fS |d
<   t        |       t        d      S )u   Set the focused plan shown in the Visitor Registry tool's Oversight tab
    (DVI-1136 Phase 4). Global, admin-only write — mirrors the equipment-plan
    selector. Validated against known plan ids; empty clears it.Tr  rO  r7   c              3   .   K   | ]  }|d    k(    ywr	  rX   r  s     r1   r/  z.oversight_visitor_plan_save.<locals>.<genexpr>х r  r  r_  r  r  r  rd  r  r  r  s     @r1   oversight_visitor_plan_saver  ȅ s     4(8GLLDHHV"))+D
 
"CC>W>>-.33C3d&&r3   z/oversight/logistics-planc                  6   t        j                  d      xs t         j                  } | j                  d      xs dj	                         t               }r%t        fd|d   D              st        d      d	fS |d
<   t        |       t        d      S )u   Set the focused plan shown in the Logistics tool's Oversight → Plan
    subtab (DVI-1401 P5). Global, admin-only write — mirrors the equipment-plan
    / visitor-plan selectors. Validated against known plan ids; empty clears it.Tr  rO  r7   c              3   .   K   | ]  }|d    k(    ywr	  rX   r  s     r1   r/  z0oversight_logistics_plan_save.<locals>.<genexpr> r  r  r_  r  r  r  re  r  r  r  s     @r1   oversight_logistics_plan_saver  ؅ r  r3   z/oversight/overview-dashboardc                  d   t        j                  d      xs t         j                  } t               }t	        |j                  d      xs
 t                     }d| v rt        | j                  d            |d<   d| v rO| j                  d      xs dj                         r%t        fd|d   D              st        d	
      dfS |d<   d| v r| j                  d      }t        |t              r,|j                  d      D cg c]  }|j                          }}t        t               j!                               }t        j#                  |xs g D cg c]5  }t        |      j                         st        |      j                         7 c}      D cg c]  }||v r
|t$        vr| c}dd |d<   d| v r)| j                  d      xs dj                         dd |d<   d| v rt        | j                  d            |d<   ||d<   t'        |       t        d|      S c c}w c c}w c c}w )u@  Configure the system-owned default Oversight dashboard (DVI-1213 P2,
    Option 1). Global, admin-only write. Validates the plan id; people-tracking
    layers are silently dropped here because this dashboard is shown to every
    user and can be pushed to signage (D3 — they are never part of the shared
    default).Tr  rf  r~  rO  r7   c              3   .   K   | ]  }|d    k(    ywr	  rX   r  s     r1   r/  z4oversight_overview_dashboard_save.<locals>.<genexpr> s     BAdGtOBr  r_  r  r  r  r[  r  Nrx  r\  rS  r]  )rU  rf  )r   r  rg  rx  r   r   ri  r   r   r2  r   r   r*   r  r  r  r,  fromkeys_OVERSIGHT_PEOPLE_LAYERSrz  )	r   rJ  r  r   r  r  r  rw  rO  s	           @r1   !oversight_overview_dashboard_saver   s    4(8GLLD
 
"C	cgg*+M/K/M	NBDTXXi019~ &B--/BS\BB!12C776
4hhx c3&)iin517795C5%',,./#'==&)iRCCFLLNSV\\^C$E AaEza'??  AAD"F8 $xx(.B557<7 -!89= "C3dr22 6 DAs   H#H(6H(H-)
registeredonsitec                  T   t         j                         rn	 t        j                  t         j	                               } t        | t              r3| D cg c]'  }t        |t              s|j                  d      s&|) c}S 	 g S g S c c}w # t        j                  t        f$ r Y g S w xY w)z/Helper-gated read of the visitor registry list.r  )VISITOR_REGISTRY_FILEr   r   r   r   r   r  r   r   r   r   )r   r  s     r1   _load_visitor_registryr   s    $$&	**2<<>?C#t$#&Na*Q*=!%%+NN % I2I O$$g. 	I	/   ;B
 B'B9B=B
 B
 
B'&B'rn  c                 X    t         j                  t        j                  | d             y)z0Helper-gated write of the visitor registry list.r   r   N)r  r   r   r   rn  s    r1   _save_visitor_registryr  " s    $$TZZQ%?@r3   r  c                     dj                  d | j                  d      xs dj                         | j                  d      xs dj                         fD              }|xs | j                  d      xs dS )Nr\   c              3   $   K   | ]  }|r| 
 y wrT   rX   r,  r  s     r1   r/  z _visitor_name.<locals>.<genexpr>(       J!GH  Jr  
first_namer7   	last_namer  Visitorr  r   r   )r  r9   s     r1   _visitor_namer  ' sn    88 J|!4!: A A C!"{!3!9r @ @ B D J JD-AEE$K,9-r3   r'  exclude_visitorexclude_personc                    | xs dj                         } | syt               j                         D ]W  \  }}t        |t              s|j                  d      xs dj                         | k(  s>d||j                  d      xs |dc S  t               D ]r  }|j                  d      |k(  r|j                  d      d	k(  s-|j                  d
      xs dj                         | k(  sTd|j                  d      t        |      dc S  t               D ]]  }|j                  d      |k(  r|j                  d
      xs dj                         | k(  s?d|j                  d      t        |      dc S  y)a@  Return the owning entity if a tag (DevEUI) is already bound, else None.

    Scans every registry so a tag can't be double-assigned (Decision 2A guard).
    Equipment/personnel bindings are long-lived; a visitor binding only counts
    while the visitor is onsite (a checked-out visitor returns the tag to the
    pool).r7   Nr  r  r9   )r!	  r  r   r  rd   r  r'  visitorr  )
r   r  r   r   r   r   r  r  _load_personnel_personnel_name)r'  r  r  r  r~  r  r  s          r1   _tag_is_assignedr  - s\    }"##%G')//1 VSc4 cgg.A&B&Hb%O%O%QU\%\'cggfo>TQTUUV $% V55;/)55?h&AEE),<,B+I+I+Kw+V%aeeDkMRSDTUU	V
  Z55;.(EE)"))+w6'doVWFXYY	Z
 r3   c                 >    t        | xs i j                  d            S )u   People-tracking privacy default (§9.D): the Visitors Oversight layer is
    Togen-admin-only until an Azure group + position-retention window is agreed
    and signed off (before live positions turn on in Phase 7).r   r,  r-  s    r1   r  r  H s       /00r3   c                      g } t               D ]_  }|j                  d      dk(  s|j                  d      xs dj                         s<| j                  |d   t	        |      |d   d       a t        | d       S )	zCOnsite visitors that carry a tag (candidates for a plan placement).rd   r  r'  r7   r  r  c                 0    | d   xs dj                         S r  rs  r  s    r1   r	  z*_oversight_visitor_items.<locals>.<lambda>V r  r3   r  )r  r   r   ri   r  r  )r   r  s     r1   r  r  O sw    E#% 455?h&AEE),<,B+I+I+KLL4=3C%&y\3 44 %ABBr3   c                  :   	 ddl } | j                         j                  dg       D ci c]  }|d   |
 }}i }t	               D ]  }|j                  d      dk7  r|j                  d      xs dj                         }|s>|j                  |      }t        |      }|rS|j                  d      rB|d	   |d
d||j                  d      |j                  d      |j                  d      d||d	   <   |d	   |dd|d||d	   <    |S c c}w # t        $ r i }Y w xY w)zStatus per onsite visitor with a tag. Live positions land in Phase 7
    (blocked on DVI-1139 samples); until then a tagged onsite visitor shows a
    gray "no fix yet" marker so the layer is structurally complete now.r   Nr&  r'  rd   r  r7   r  r  r  Onsiter*  r+  r  r   ro  r  r'  r  r*  r+  r  u   Onsite · no fix yetr  )r.  r/  r   ry   r  r   r  )r3  rn  r  r   r  r'  r  r   s           r1   r  r  Y s8   &+/+?+?+A+E+EiQS+TUa!I,/UU C#% Q55?h&55#)r002G$a fjj-#$T7UW%-')/K)@#)::e#4VZZ=NPC$L
 $%T7UV%;QC$LQ  J) V s!   &D DD D DDc                  >    t        t        j                  d            S )Nr'  )r  r   r   rX   r3   r1   _visitor_can_editr  v s    w{{6233r3   z	/visitorsc                  Z    t        t               d d      } t        | t                     S )zVisitor registry, newest first. All logged-in users may view the book;
    `can_edit` tells the client whether to show the front-desk controls.c                 &    | j                  dd      S Nr  r7   r  r  s    r1   r	  zvisitors_list.<locals>.<lambda>     lB 7 r3   Tr
  )r  rB  )r  r  r   r  r  s    r1   visitors_listr  z s+    
 (*7GDD+<+>??r3   c                  
   t               st        d      dfS t        j                  d      xs t        j                  } | j                  d      xs dj                         }| j                  d      xs dj                         }|r|st        d	      d
fS t        | j                  d            }| j                  d      xs dj                         }t        j                         j                  d      }dt        j                         j                  d d  ||| j                  d      xs dj                         | j                  d      xs dj                         dd|d d t        d   j                  d      xs dj                         d}|r<|r+t!        |      }|rt        d|d|d   d|d   d      dfS d|d<   ||d<   ||d <   t#               }|j%                  |       t'        |       t        d|!      S )"NNot authorizedr  r  Tr  r  r7   r   First and last name are requiredr`  register_onsiter'  rZ	  r[	  vis_r   companyrm  r  r'  r  )r  r  r  r   rm  rd   r'  r  checked_in_atchecked_out_atr	  Tag  is already assigned to r   r:  r!	  r7  r9  r  rd   r!  rU  r	  )r  r   r   r  rg  r   r   r   r  r  r  rW  rX  rY  r   rE  r  r  ri   r  )	r   r&  r  r  r'  r  r~  rL  rn  s	            r1   visitors_creater&   s    -.334(8GLLDXXl#)r002EHH[!'R..0D?@#EE$((,-.Fxx	"(b//1G




(
()
(
<CTZZ\%%cr*+,$HHY'-244688H%+2242DDv**739r@@BC $W-E")5>5=&J KLOP P H I"O!#DKK4 dC((r3   z/visitors/<vid>/register-onsitec           	      "    t               st        d      dfS t        j                  d      xs t        j                  }|j                  d      xs dj                         }t               }t         fd|D        d       }|t        d	      d
fS |r-t        |       }|rt        d|d|d   d|d   d      dfS d|d<   ||d<   t        j                         j                  d      |d<   d |d<   t        |       t        d|      S )Nr  r  r  Tr  r'  r7   c              3   L   K   | ]  }|j                  d       k(  s|  ywr	  r  r,  r  vids     r1   r/  z+visitors_register_onsite.<locals>.<genexpr>       6a155;##56r  Visitor not foundr  )r  r#  r$  r   r:  r!	  r7  r9  r  rd   rZ	  r[	  r!  r"  r%  )r  r   r   r  rg  r   r   r  r  r  r  r  r  r  )r*  r   r'  rn  r~  rL  s   `     r1   visitors_register_onsiter-   s    -.334(8GLLDxx	"(b//1G!#D
646
=C
{01366 #>%uW~uV}"F GHKL LCMC	N)--/9999MC C4 dC((r3   z/visitors/<vid>/checkoutc                 $    t               st        d      dfS t               }t         fd|D        d       }|t        d      dfS d|d<   d	|d
<   t	        j
                         j                  d      |d<   t        |       t        d|      S )Nr  r  r  c              3   L   K   | ]  }|j                  d       k(  s|  ywr	  r  r)  s     r1   r/  z$visitors_checkout.<locals>.<genexpr>ǆ r+  r  r,  r  r  rd   r7   r'  rZ	  r[	  r"  Tr%  )r  r   r  r  r  r  r  r  )r*  rn  r~  s   `  r1   visitors_checkoutr0   s     -.33!#D
646
=C
{01366 CMC	N*..0::I:NC4 dC((r3   z/visitors/<vid>c                    t               st        d      dfS t               }|D cg c]  }|j                  d      | k7  s| }}t	        |      t	        |      k(  rt        d      dfS t        |       t        d      S c c}w )	Nr  r  r  r  r,  r  TrY  )r  r   r  r   rC  r  )r*  rn  r  new_rowss       r1   visitors_deleter3  ц s~     -.33!#D6a155;##56H6
8}D	!013668$d	 7s   BBc                  T   t         j                         rn	 t        j                  t         j	                               } t        | t              r3| D cg c]'  }t        |t              s|j                  d      s&|) c}S 	 g S g S c c}w # t        j                  t        f$ r Y g S w xY w)z/Helper-gated read of the personnel roster list.r  )PERSONNEL_FILEr   r   r   r   r   r  r   r   r   r   )r   r  s     r1   r
  r
   s    	**^5578C#t$#&Na*Q*=!%%+NN % I2I O$$g. 	I	r  c                 X    t         j                  t        j                  | d             y)z0Helper-gated write of the personnel roster list.r   r   N)r5  r   r   r   r  s    r1   _save_personnelr7   s    djja89r3   r  c                     dj                  d | j                  d      xs dj                         | j                  d      xs dj                         fD              }|xs | j                  d      xs dS )Nr\   c              3   $   K   | ]  }|r| 
 y wrT   rX   r  s     r1   r/  z"_personnel_name.<locals>.<genexpr> r   r  r  r7   r  r  Personr  )r  r9   s     r1   r  r   sn    88 J|!4!: A A C!"{!3!9r @ @ B D J JD,AEE$K+8,r3   c                     t        | t        t        f      sg S | D ch c]  }t        |xs d      j	                         ! }}t
        D cg c]	  }||v s| c}S c c}w c c}w )zHKeep only known designation ids, de-duplicated, in canonical order (D7).r7   )r   r  r	  r*   r   _PERSONNEL_DESIGNATION_IDS)r   r  r  rn  s       r1   _sanitize_designationsr=   sX    cD%=)	*-.QCRL .D.1?!Q$YA?? /?s   $A	A!A!r  designationre  c                 l    |t         vry|du }|r
t               n|}t         fd|D        d      }|yt        |j	                  d            }d}|r||vr|j                  |       d}n|s||v r|j                  |       d}t         D cg c]	  }||v s| c}|d<   |r|rt        |       |S c c}w )u/  Add/remove one designation on a Personnel record (server-side write used
    by the Logistics driver routes under the logistics.resources gate — D7).
    Returns True if the roster was changed. Reuses the shared load/save helpers
    so the record shape stays identical to the admin /personnel routes.FNc              3   L   K   | ]  }|j                  d       k(  s|  ywr	  r  r  s     r1   r/  z-_personnel_set_designation.<locals>.<genexpr> r+  r  designationsT)r<  r
  r  r=  r   ri   r  r7  )	r  r>  re  rn  ownr~  currentr  rn  s	   `        r1   _personnel_set_designationrD   s     44
$,C #?D
646
=C
{$SWW^%<=GG	k({#K7*{#&@QAL1QC3N Rs   	B1B1c                 >    t        | xs i j                  d            S )u   People-tracking privacy default (§9.D): admin-only until the Azure group
    + position-retention window is signed off (before Phase 7 live positions).r   r,  r-  s    r1   r  r   s       /00r3   c                      g } t               D ]J  }|j                  d      xs dj                         s'| j                  |d   t	        |      |d   d       L t        | d       S )z<Personnel who carry a tag (candidates for a plan placement).r'  r7   r  r  c                 0    | d   xs dj                         S r  rs  r  s    r1   r	  z,_oversight_personnel_items.<locals>.<lambda>+ r  r3   r  )r
  r   r   ri   r  r  )r   r  s     r1   r  r  $ sh    E 4EE)"))+LL4?13E%&y\3 44 %ABBr3   c                     	 ddl } | j                         j                  dg       D ci c]  }|d   |
 }}i }t	               D ]  }|j                  d      xs dj                         }|s)|j                  |      }t        |      }|rS|j                  d      rB|d   |dd	||j                  d      |j                  d
      |j                  d      d||d   <   |d   |dd|d||d   <    |S c c}w # t        $ r i }Y w xY w)zStatus per tagged person. Live positions land in Phase 7 (blocked on
    DVI-1139 samples); until then a gray "no fix yet" marker keeps the layer
    structurally complete now (mirrors the visitors layer).r   Nr&  r'  r7   r  r  r  zOn siter*  r+  r  r  z
No fix yetr  )r.  r/  r   ry   r
  r   r  )r3  rn  r  r   r  r'  r  r   s           r1   r  r  . s%   &+/+?+?+A+E+EiQS+TUa!I,/UU C G55#)r002G$"fjj-#$T7UW%.7)/K)@#)::e#4VZZ=NPC$L
 $%T7UV%1gGC$LG J% V s!   &C7 C2C7 2C7 7DDz
/personnelc                  H    t        t               d d      } t        |       S )zBPersonnel roster, newest first (Togen Admin -> Users, admin-only).c                 &    | j                  dd      S r  r  r  s    r1   r	  z personnel_list.<locals>.<lambda>N r  r3   Tr
  )r  )r  r
  r   r  s    r1   personnel_listrK  I s$     /#7GDT""r3   c            
         t        j                  d      xs t         j                  } | j                  d      xs dj	                         }| j                  d      xs dj	                         }|r|st        d      dfS | j                  d	      xs dj	                         }|r+t        |      }|rt        d
|d|d   d|d   d      dfS t        j                         j                  d      }dt        j                         j                  d d  ||| j                  d      xs dj	                         j                         |t        | j                  d            |t        d   j                  d      xs dj                         d}t!               }|j#                  |       t%        |       t        d|      S )NTr  r  r7   r  r  r  r`  r'  r#  r$  r   r:  r!	  r7  r9  rZ	  r[	  per_r   r  rA  r'  r  r  r  r  r'  rA  r  r	  rU  person)r   r  rg  r   r   r   r  r  r  r  rW  rX  rY  rE  r=  r   r
  ri   r7  )r   r&  r  r'  rL  r  r~  rn  s           r1   personnel_createrQ  R s    4(8GLLDXXl#)r002EHH[!'R..0D?@#EExx	"(b//1G )%uW~uV}"F GHKL L




(
()
(
<CTZZ\%%cr*+,$((7#)r00288:.txx/GHv**739r@@BC DKKDd3''r3   z/personnel/<pid>c           	          t        j                  d      xs t         j                  }t               }t	         fd|D        d      }|t        d      dfS d|v rW|j                  d      xs d	j                         }|r-t        | 
      }|rt        d|d|d   d|d   d      dfS ||d<   d|v r8|j                  d      xs d	j                         }|st        d      dfS ||d<   d|v r8|j                  d      xs d	j                         }|st        d      dfS ||d<   d|v r4|j                  d      xs d	j                         j                         |d<   d|v r:t        |j                  d            |d<   d|d   vr	 t        j                          t        |       t        d|      S # t        $ r Y #w xY w)zFEdit a person's name/email and assign/clear their tag (partial merge).Tr  c              3   L   K   | ]  }|j                  d       k(  s|  ywr	  r  r  s     r1   r/  z#personnel_update.<locals>.<genexpr>v r+  r  NPerson not foundr  r  r'  r7   )r  r#  r$  r   r:  r!	  r7  r9  r  zFirst name cannot be blankr`  r  zLast name cannot be blankr  rA  r  rO  )r   r  rg  r
  r  r   r   r   r  rE  r=  logistics_storeremove_driverry   r7  )r  r   rn  r~  r'  rL  r&  r  s   `       r1   personnel_updaterW  p s    4(8GLLDD
646
=C
{/0#55D88I&,"335$WSAE")5>5=&J KLOP P It,'-2446!=>CC!Ld%+224!<=sBBK$)/R668>>@G4TXXn5MNN 3~..--c2 Dd3''  s   F: :	GGc                 *   t               }|D cg c]  }|j                  d      | k7  s| }}t        |      t        |      k(  rt        d      dfS t	        |       	 t        j                  |        t        d      S c c}w # t        $ r Y w xY w)Nr  rT  r  r  TrY  )r
  r   rC  r   r7  rU  rV  ry   )r  rn  r  r2  s       r1   personnel_deleterY   s     D6a155;##56H6
8}D	!/0#55H%%c* d 7  s   BB B 	BBc                  Z    t        j                  d      xs i j                  d      xs dS r7  r  rX   r3   r1   _logistics_actorr[   s&    KK%2**739r9r3   c                  ^    t        t        j                  d      xs i       } d| v d| v d| v dS )zSPA reveal flags for the Resources/Settings surfaces. The route
    decorators are the real boundary; a hidden control is never the gate.r'  r  r  r  )r   rx  r  )r#  r   r   rx  s    r1   _logistics_capsr]   s>     $GKK$7$=2
>C"c)*c1(C/ r3   c                  h    t               D  ci c]  } | j                  d      s| d   |  c} S c c} w r  )r
  r   r  s    r1   _logistics_person_mapr_   s+     / 1A1QUU4[AdGQJAAAs   //c                     |j                  | d         xs i }t        |       }|rt        |      n| d   |d<   |j                  dd      |d<   t        |       |d<   |S )zCMerge a logistics driver row with its Personnel record for display.	person_idr9   r  r7   missing_person)r   r   r  r   )r  peopler  r   s       r1   _logistics_driver_viewrd  Ç sd    

6+&'-2A
v,C()/!$vk/BCK55"%CL $QKCJr3   z/logistics/metar  c                     t               } t        | t        j                  D cg c]  }|d   |d   d c}t        j                  D cg c]  }|d   |d   d c}t
        D cg c]  }|d   |d   d c}      S c c}w c c}w c c}w )Nr   r^   r  )rU  truck_featurestrailer_featuresrA  )r]  r   rU  TRUCK_FEATURESTRAILER_FEATURES_PERSONNEL_DESIGNATIONS)rU  r  rn  s      r1   logistics_metark  ͇ s     D!0!?!?A  !tad3 A $3#C#CE "#1!5 E  78 qTAaD1 89 9AE8s   A?
	B%B	z/logistics/driversc            	         t               } t        j                         D cg c]  }t        ||        }}|j	                  d        |D ch c]  }|d   	 }}| j                         D cg c]*  }|d   t        |      |j                  dd      |d   |v d, }}|j	                  d        t        ||	      S c c}w c c}w c c}w )
Nc                 0    | d   xs dj                         S r;  rs  r  s    r1   r	  z(logistics_drivers_list.<locals>.<lambda> r  r3   r  ra  r  r  r7   r  r9   r  	is_driverc                 0    | d   xs dj                         S r;  rs  r  s    r1   r	  z(logistics_drivers_list.<locals>.<lambda> s    qyB557 r3   )driversrc  )	r_  rU  list_driversrd  r  r  r  r   r   )rc  rn  rq  
driver_idsr  rosters         r1   logistics_drivers_listru  ݇ s     #$F'4468 &a0 8G 8LL8L9*12Q!K.2J2
 }}(  tWoa&8gr*dGz13 (F ( KK7K876228 3(s   B>C-/Cr  c            	        	 t        j                  d      xs i } | j                  d      xs dj                         	t	               }	rAt        	fd|D        d      }|t        d      d	fS t        	d
d|       t        |       n| j                  d      xs dj                         }| j                  d      xs dj                         }|r|st        d      dfS t        j                         j                  d      }dt        j                         j                  dd  ||| j                  d      xs dj                         j                         dd
g|t!               d}|j#                  |       t        |       |d   	t%        j&                  	| t!                     \  }}|rt        |      dfS t)        ddt!               dt+        |      z  d	i       t-               }t        t/        ||            S )zAdd a driver. Either link an existing Personnel record (`person_id`) or
    create a new one from `first_name`/`last_name` (D7). Both paths stamp the
    Driver designation + create the logistics driver row.Tr  ra  r7   c              3   L   K   | ]  }|j                  d       k(  s|  ywr	  r  )r,  r  ra  s     r1   r/  z*logistics_driver_create.<locals>.<genexpr> s      @!quuT{i'?A@r  NzPerson not found.r  r  r  r  r  r  z+Pick a person or enter first and last name.r`  rZ	  r[	  rM  r   r  rN  r  rg  r  logistics_driver_addedzLogistics driver %s addedr  r  )r   r  r   r   r
  r  r   rD  r7  r  r  r  rW  rX  rY  rE  r[  ri   rU  upsert_driverr	  r  r_  rd  )
r   rn  r~  r&  r  r  r  r  rc  ra  s
            @r1   logistics_driver_creater{   s    4(.BD+&,"335ID@t@$G;!45s::"9h4H,'-2446%+224D!NOQTTT  ",,i,@))#2./0dhhw'-2446<<>%J*,
 	CI	!//	46F6HJKFC
S!3&&=":)+69MM&	24 #$F0@AAr3   z/logistics/drivers/<person_id>c                 P   t        j                  |       st        d      dfS t        j                  d      xs i }t        j
                  | |t                     \  }}|rt        |      dfS t        dd	t               d
| i       t        t        |t                           S )NDriver not found.r  r  Tr  rg  r`  r  logistics_driver_updatedra  r		  r	  ry  )
rU  
get_driverr   r   r  rz  r[  r	  rd  r_  )ra  r   r  r  s       r1   logistics_driver_updater   s     %%i0013664(.BD!//	46F6HJKFC
S!3&&="<)+[)4LN09N9PQRRr3   c                     t        j                  |       }t        | dd       |st        d      dfS t	        ddt               d| i	       t        d
      S )zbRemove the driver row + clear the Driver designation. Never deletes the
    Personnel record (D7).r  Fr}  r  r  r  logistics_driver_removedra  r  TrY  )rU  rV  rD  r   r	  r[  )ra  r  s     r1   logistics_driver_deleter  * sZ    
 ++I6Gy(E:01366="<)+[)4LNdr3   z/logistics/trucksc                  >    t        t        j                               S )N)trucks)r   rU  list_trucksrX   r3   r1   logistics_trucks_listr  : s     /55788r3   c            	          t        j                  d      xs i } t        j                  | t	                     \  }}|rt        |      dfS t        ddt	               d|d	   z  d
|d   i       t        |      S )NTr  rg  r  r`  r  logistics_truck_addedzTruck %s addedr9   truck_idr  r  r  r   r  rU  
save_truckr[  r   r	  )r  r  r  s      r1   logistics_truck_creater  @ s~     4(.BD ++D8H8JKJE3
S!3&&="9)++eFm;%uT{35 r3   z/logistics/trucks/<int:tid>c                     t        j                  d      xs i }t        j                  || t	                     \  }}|rt        |      |dk(  rdfS dfS t        dd	t	               d
| i       t        |      S )NTr  r*  r		  r  
Not found.r  r`  r  logistics_truck_updatedr  r  r  r  )r   r  r  r  s       r1   logistics_truck_updater  N s     4(.BD ++Dc2B2DFJE3
S!3,+>CHHCHH=";)+Z4EGr3   c                     t        j                  |       st        d      dfS t        ddt	               d| i       t        d	      S )
NzTruck not found.r  r  r  logistics_truck_removedr  r  TrY  )rU  delete_truckr   r	  r[  r   s    r1   logistics_truck_deleter  [ sI     '',/0#55=";)+Z4EGdr3   z/logistics/trailersc                  >    t        t        j                               S )N)trailers)r   rU  list_trailersrX   r3   r1   logistics_trailers_listr  e s     O99;<<r3   c            	          t        j                  d      xs i } t        j                  | t	                     \  }}|rt        |      dfS t        ddt	               d|d	   z  d
|d   i       t        |      S )NTr  rg  r  r`  r  logistics_trailer_addedzTrailer %s addedr9   
trailer_idr  r  trailerr   r  rU  save_trailerr[  r   r	  )r  r  r  s      r1   logistics_trailer_creater  k s~     4(.BD"//<L<NOLGS
S!3&&=";)+-?'79 7##r3   z/logistics/trailers/<int:tid>c                     t        j                  d      xs i }t        j                  || t	                     \  }}|rt        |      |dk(  rdfS dfS t        dd	t	               d
| i       t        |      S )NTr  r  r  r  r  r`  r  logistics_trailer_updatedr  r  r  r  )r   r  r  r  s       r1   logistics_trailer_updater  y s     4(.BD"//#6F6HJLGS
S!3,+>CHHCHH="=)+\34GI7##r3   c                     t        j                  |       st        d      dfS t        ddt	               d| i       t        d	      S )
NzTrailer not found.r  r  r  logistics_trailer_removedr  r  TrY  )rU  delete_trailerr   r	  r[  r   s    r1   logistics_trailer_deleter   sI     ))#.12C77="=)+\34GIdr3   z/logistics/eventsc                  >    t        t        j                               S )N)r:  )r   rU  list_event_typesrX   r3   r1   logistics_events_listr   s     /::<==r3   r  c            	          t        j                  d      xs i } t        j                  |       \  }}|rt	        |      dfS t        ddt               d|d   z  d	|d
   i       t	        |      S )NTr  r  r`  r  logistics_event_addedzEvent type %s addedr9   event_idr  r  r  r   r  rU  save_event_typer   r	  r[  )r  r  r  s      r1   logistics_event_creater   sx     4(.BD 006JE3
S!3&&="9)+05=@%uT{35 r3   z/logistics/events/<int:eid>c                     t        j                  d      xs i }t        j                  ||       \  }}|rt	        |      |dk(  rdfS dfS t        dd	t               d
| i       t	        |      S )NTr  eidr  r  r  r`  r  logistics_event_updatedr  r  r  r  )r  r  r  r  s       r1   logistics_event_updater   sw     4(.BD 003?JE3
S!3,+>CHHCHH=";)+Z4EGr3   c                     t        j                  |       st        d      dfS t        ddt	               d| i       t        d	      S )
NzEvent type not found.r  r  r  logistics_event_removedr  r  TrY  )rU  delete_event_typer   r	  r[  r  s    r1   logistics_event_deleter   sI     ,,S145s::=";)+Z4EGdr3   c                      t               } t        j                  d      D ci c]  }|d   |
 }}t        j                  d      D ci c]  }|d   |
 }}| ||fS c c}w c c}w )NTinclude_deletedr  )r_  rU  r  r  )rc  r   r  r  s       r1   _logistics_resource_mapsr  È s|    "$F"1"="=d"STQagqjTFT(66tLN $
 NH N68## UNs   A
A#c                     | dk(  r |j                  |      }|rt        |      S |S 	 t        |      }| dk(  r|n|j                  |      }|r|d   S | d|S # t        t        f$ r |cY S w xY w)Nr  r  r9   z #)r   r  r   r   r   )r~  rK  rc  r  r  r  r*  r  s           r1   _logistics_resource_labelr  ˈ s    JJsO%&q!/C/#h G#66s;A1V98E3 78 z" 
s   A A,+A,c           
         t        |       }| j                  dd      }| j                  d      }|r$t        j                  |d      }|r|d   nd|d<   nd|d<   g }| j                  d	g       D ]  }	|	d
   |	d   }}
|
|t	        |
||||      d}|
dk(  rt        j
                  |      }t        j                  || j                  d      xs d| d         }|r|d   nd|d<   |r|d   nd|d<   |d   |d<   |d   |d<   |d   |z   |d<   |d   |z   |d<   t        |xr |d   duxr |d   |d   dz  kD        |d<   t        |xr |d   duxr |d   |d   dz  kD        |d<   |j                  |        ||d	<   |S )zAdd display names to a load's assignments + a per-driver hours readout
    (D3 planning aid: computed duration + already-assigned time that day/week vs
    the driver's max).duration_minutesr   ltl_load_idTr  r9   Nltl_load_namer  resource_typeresource_id)r  r  r9   r  r  r7   r  exclude_load_idmax_hours_daymax_hours_weekr  other_day_minutesweekother_week_minutesday_minutesweek_minutesr   over_day	over_week)	r   r   rU  get_ltl_loadr  r  driver_assigned_minutesr   ri   )r  rc  r  r  r   durltl_idbuiltr)  r  r~  rK  r   rn  r  s                  r1   _logistics_decorate_loadr  ׈ s    t*C
((%q
)C XXm$F,,VTJ05uV}4O#OGXXmR( '=)9s"'25#vv4<>? H**3/A#;;TXXl+1r4:OE;<Q%7$E/"=>a(8&9DE"#).uE%&*/-E&'#(<##5E- $)&MC$7E.! $ Ca(4 C-(1_+=+BB!DE* "& Ea()5 E.)A.>,?",DD"FE+ 	u+, !CJr3   z/logistics/loadsc            	         t         j                  j                  dd      dk7  } t               \  }}}t	        j
                  |       D cg c]  }t        ||||       }}t        |t        t        j                              S c c}w )Ninclude_cancelledrE   rF   )r  )r   statuses)
r   rh  r   r  rU  
list_loadsr  r   r  LOAD_STATUSES)r  rc  r  r  ldr   s         r1   logistics_loads_listr    s      (()<cBcI79FFH&11#467 &b&&(C 7E 7 o.K.K)LMM7s   
Bz/logistics/loads/<int:lid>c                     t        j                  |       }|st        d      dfS t               \  }}}t        t	        ||||            S )NLoad not found.r  r  r  )rU  get_loadr   r  r  )r  r  rc  r  r  s        r1   logistics_load_getr   sN     ##C(D./4479FFH0vvxPQQr3   z/logistics/availabilityc            	      >   t         j                  j                  dd      t         j                  j                  dd      t         j                  j                  dd      t         j                  j                  dd      d} 	 t        t         j                  j                  d      xs d      xs d	}t               \  }}}i }t        j                  D ]  }t        j                  || |
      }|D ]  }t        |t        |d         |||      |d<   |dk(  s't        |d         |v|d<   t        j                  t        |d         | j                  d      xs d|
      }	|	d   |d<   |	d   |d<    |||dz   <    t        dd| i|S # t        t
        f$ r d	}Y w xY w)zEvery driver/truck/trailer with its availability for the given window.
    The Add Load picker lists available resources by default; the client greys
    the unavailable ones (with the conflicting load/OOS reason) behind a
    'show unavailable' toggle.r  r7   
start_timer  end_time)r  r  r  r  r  r   Nr  r  r9   r  rb  r  assigned_day_minutesr  assigned_week_minutesr  windowrX   )r   rh  r   r   r   r   r  rU  RESOURCE_TYPESresources_availabilityr  r*   r  r   )
r  r  rc  r  r  r   r~  rn  r  assigneds
             r1   logistics_availabilityr   s    ll&&|R8ll&&|R8LL$$Z4LL$$Z4	Fgll&&y16Q7?4  89FFH
C //  55eVFMO 		>A1s1]+,ffhHAfI &)!M*:&;6&I"#*BB-()6::l+C+Ir$+- -5UO()-5f-=)*		>  ECK  (&(C((% z" s   0F FFc                     g }| j                   D ]4  }|j                  |d   |d   t        |d   |d   |||      |d   d       6 t        d|      dfS )z\Turn a LoadConflictError into an actionable 409 body naming each blocked
    resource + why.r  r  	conflicts)r  r  r9   r  z>One or more resources are no longer available for this window.)r  r  r9  )r  ri   r  r   )rl  rc  r  r  blockedr  s         r1   _logistics_conflict_responser  9 s     G]] /]+-/"Am$4ffhP;
 	  (3:<=@A Ar3   r  c            	         t        j                  d      xs i } t               \  }}}	 t        j                  | t                     \  }}|rt        |      dfS t        ddt               d|d	   xs d
|d   z  z  d|d   i       t        t        |g|||f       S # t        j                  $ r}t        ||||      cY d }~S d }~ww xY w)NTr  rg  r  r`  r  logistics_load_addedzLoad %s addedr9   rk  r  load_idr  r  )r   r  r  rU  	save_loadr[  LoadConflictErrorr  r   r	  r  )r  rc  r  r  r  r  rB  s          r1   logistics_load_creater  I s     4(.BD79FFHI#--d:J:LM	c S!3&&="8)+*d6l.Ped4j>PQ$d4j13 0 F&&:B:D F G G ,, I+AvvxHHIs   "B" "C5CCCc                    t        j                  |       st        d      dfS t        j                  d      xs i }t               \  }}}	 t        j                  || t                     \  }}|rt        |      |dk(  rdfS dfS t        d	d
t               d| i       t        t        ||||            S # t         j                  $ r}t        ||||      cY d }~S d }~ww xY w)Nr  r  r  Tr  )r  r		  r  r`  r  logistics_load_updatedr  r  r  )rU  r  r   r   r  r  r  r[  r  r  r	  r  )r  r  rc  r  r  r  r  rB  s           r1   logistics_load_updater  \ s     ##C(./444(.BD79FFHI#--dC4D4FH	c S!3,+>CHHCHH=":)+Y4DF0vvxPQQ ,, I+AvvxHHIs   #B9 9C$CC$C$z!/logistics/loads/<int:lid>/statusc                    t        j                  d      xs i }t        |j                  d      xs d      j	                         j                         }|t        j                  vrt        d      dfS t        j                  | |      }|st        d      d	fS t        d
dt               | |d       t               \  }}}t        t        ||||            S )NTr  rd   r7   zInvalid status.r  r`  r  r  r  logistics_load_status)r  rd   r  r  )r   r  r*   r   r   rE  rU  r  r   set_load_statusr	  r[  r  r  )r  r  rd   r  rc  r  r  s          r1   r   r   o s     4(.BD(#)r*00288:F_222./44**37D./44="9)+&)V<>  89FFH0vvxPQQr3   c                     t        j                  |       st        d      dfS t        ddt	               d| i       t        d	      S )
Nr  r  r  r  logistics_load_removedr  r  TrY  )rU  delete_loadr   r	  r[  r  s    r1   logistics_load_deleter   sI     &&s+./44=":)+Y4DFdr3   z/logistics/ltl-loadsc                  >    t        t        j                               S )N)	ltl_loads)r   rU  list_ltl_loadsrX   r3   r1   logistics_ltl_listr
   s     _;;=>>r3   c            	          t        j                  d      xs i } t        j                  | t	                     \  }}|rt        |      dfS t        ddt	               d|d	   z  d
|d   i       t        |      S )NTr  rg  r  r`  r  logistics_ltl_addedzLTL load %s builtr9   r  r  r  ltl_loadr   r  rU  save_ltl_loadr[  r   r	  )r  r  r  s      r1   logistics_ltl_creater   s~     4(.BD ..t;K;MNJE3
S!3&&="7)+.v>(%+68 E""r3   z/logistics/ltl-loads/<int:lid>c                     t        j                  d      xs i }t        j                  || t	                     \  }}|rt        |      |dk(  rdfS dfS t        dd	t	               d
| i       t        |      S )NTr  )r  r		  r  r  r  r`  r  logistics_ltl_updatedr  r  r  r  )r  r  r  r  s       r1   logistics_ltl_updater   s     4(.BD ..tC5E5GIJE3
S!3,+>CHHCHH="9)+]C4HJE""r3   c                     t        j                  |       st        d      dfS t        ddt	               d| i       t        d	      S )
NzBuilt load not found.r  r  r  logistics_ltl_removedr  r  TrY  )rU  delete_ltl_loadr   r	  r[  r  s    r1   logistics_ltl_deleter   sI     **3/45s::="9)+]C4HJdr3   >   r  r0  r/  largesmallrY  mediumxlarge>   r7   clipcyclescrollc                     t        | t              si } d } || j                  d      ddd       || j                  d      ddd      dS )u   Custom-size span (F3): width 1–12 grid columns × height 1–6 rows.
    Malformed/missing values fall back to the medium preset's 6×1.c           	      p    	 t        |t        |t        |                   S # t        t        f$ r |cY S w xY wrT   rC  rL  r   r   r   )r  lohidflts       r1   _numz!_sanitize_tile_span.<locals>._numՉ s7    	r3r3q6?++:& 	K	   ! 55r"  r^   r-  r   rn  )r"  rn  r
  )r   r&  s     r1   _sanitize_tile_spanr(  ω sP     c4  !R3!Q24 4r3   c                 p    t        | xs d      j                         j                         } | t        v r| S dS r  )r*   r   rE  _DASHBOARD_TILE_OVERFLOWSr  s    r1   _sanitize_tile_overflowr+  ߉ s4    AG""$A..16B6r3   c                     t        | d      S )u   Per-tile zoom percentage (DVI-1275 P2), 50–200, default 100 (no scaling).
    Applied as CSS `zoom` on the tile body, composing multiplicatively with the
    display-level zoom. Reuses the shared display-zoom clamp.rd  )_signage_zoom_clampr  s    r1   _sanitize_tile_zoomr.   s     sC((r3   c           
      t    	 t        dt        dt        | xs d                  S # t        t        f$ r Y yw xY w)u6   Per-tile list cap (B1): 0 = no cap, else 1–50 items.r   r	  r"  r  s    r1   _dash_max_itemsr0   s:    1c"c#(m,--z" s   "% 77r  r  c                  `	   i ddddddddddddd	dgd
dddddddddddddddddddddgdddddddddddddddd d!d"dd#d$dd%d&dd'd(dd)d*dgdd+dd,d-d.dd/d0dd%d1dd2d3dd4d5dgdgd
ddd6d7ddddd8dd9dd:dd;d-d<dd=d=dd>d>dd?d?dd@d@dgddAddBd/d0dd%d1dd2d3dgdgdCd6ddDdEdddddFddGddHdIdJddKdIdLddMddNdOdPddQdRdgddSddTdUdVddWdXddYdZdgdd[dId\dgd]dDdd^d_ddddd^dd_d`daddbdcddddedgddfddgdhdiddjdkddldmdd-dndgddoddpd/d0dd%d1dd2d3ddqdrdgddsddtdudvddwdxddydzdgdgd
d^dd{d|ddddd}dd~ddSdddddddddddgdddIdddddddddddddddddddgdgd
d{dddddddddddddddddddddddddddddddddddgdddddddddddddddddgdddIddg	dddddddddddddddddddddddIddgdddddddddddddddddddddddddddddgdddddddIddgdddddddddddddgdddddddddddddddddddddgdddddddddddddddddgdddIddgddddddddddddddddddddddddddddddddddddddddddgdddddg	dddddddddddddddddddddgdddddddd dddddgddddddddd	dd
dddddgddddddddddddddddgdddddddIddddddgdddddddddddddddddddd ddddd!d"dddddd	dd
ddgdd#dId$dddIddddddgd%ddd&d'ddddddddd(dd)dd*dd+dd,dd-dd.dd/dddd0dgd1d&dd2d3ddddd4dd5dd6dd7dd8dd9d:dd;d<dd=d>dd
d?dd@dAddBdCddDdEdddFdg	ddGddHddIddJddKddLddMddNdddIdOdddddg	dPd2ddQdRddddd4ddSdddIdTdgdUdQddVdWdddXdd:dd;d-d<dd=d=dd>d>dd?d?dd@d@dgddYddZdgd[dVdiS (\  u&  Tile-type registry (D5): each type bundles {label, config_fields,
    provider, renderer}. P1 registers only Text (client-rendered from config,
    so provider is None). P2 data tiles register descriptors here — no new
    routes needed, GET /dashboards/tile-data will dispatch on `provider`.r&  r  re  zTile title (optional))r)  rT  r   textareamarkdowncheckboxzEnable MarkdownN)r   config_fieldsproviderrendererrU   rK
  r   z)Timezone (IANA name; blank = this device)time_formatselectzTime format12hz12-hourr  24hz24-hour)r)  rT  r   r  show_secondszShow seconds	show_zonezShow timezone label	show_datezShow the datedate_formatzDate formatfullzWeekday, Month D, YYYYlongzMonth D, YYYYr  z
Wkd, Mon DshortzM/D/YYYYr  z
YYYY-MM-DDr  z	Font sizer   zAuto (fill the tile)r  SmallrH  r  Larger  zExtra larger  zStat / Metricsrn  zEMetric rows (list of {label,value,sub,accent}; managed by the editor)r   ColumnsAutorE   23rJ  
value_sizez
Value size_dash_provider_statweatherWeatherlocation_labelr	  latituder  Latitude	longitude	LongitudeunitsUnitsuszFahrenheit / mphmetriczCelsius / km-hrO  ShowrC  zCurrent conditionsforecastzForecast stripcompactzCompact (temp + icon)forecast_daysz"Forecast days (forecast mode; 1-7)_dash_provider_weatherlogoLogoicastziCast / Infrastructure PrecastrZ  TogenbrandingzFollow interface brandingr  Coloras_iszAs-is (brand colors)r	  zWhite (for dark backgrounds)rz  zDark (for light backgrounds)zAuto (follow theme)rd  Sizer	  zFill the tiler6
  	Alignmentr   Leftr   Centerr   Rightr~
  r   rm  z1Images (list of {id,name}; managed by the editor)Moder
  zSingle image	slideshowzSlideshow (cycle images)galleryzGallery (thumbnail grid)intervalz#Slideshow interval, seconds (5-120)fadezFade between imagesshufflezShuffle orderr>  FitcontainzFit whole image (letterbox)coverzFill the tile (crop edges)work_order_viewzWork Order View
status_newNewstatus_in_progressIn progress
status_duezDue for completionstatus_scheduled_pmszScheduled PMsr  Ranger  Allr	  Today	this_weekz	This weekoverdue_onlyzOnly overdue WOsgroup_byzGroup into sectionsr   zSingle listrd   z	By statusrT  zBy work order type	max_itemszMax items shown (0 = no cap)_dash_provider_work_order_viewr0
  zRepair Partsawaiting_partszWOs awaiting partsawaiting_approvalszParts awaiting approvaldelivered_awaiting_wou#   Delivered parts — WO not complete_dash_provider_parts
scada_viewr  r$  z-Sensors (comma-separated labels; blank = all)show_oorzHighlight out-of-range readings
oor_windowzOut-of-range windowLast 24 hours48hzLast 48 hours	last_week	Last weekshow_schedulerz#Show latest scheduled report statuszMax sensors shown (0 = no cap)_dash_provider_scada_viewr  r  rH  z*Production segment id (blank = all plants)_dash_provider_batch_productionr  r  r	  Sourcer+  zWork Order calendarmyzMy calendarre  zShared calendar (mailbox)r[  z.Shared mailbox address (for the shared source)
this_monthz
This monthzMax events shown (0 = no cap)_dash_provider_calendarr  r  rO  zPlan idr[  zLayer ids (comma-separated)r\  zStyle id (blank = auto)r]  zShow legendinteractivezAllow pan / zoom / detailallow_peoplez6Allow people-tracking layers on shared / signage viewszPlan fitzFit whole plan (letterbox)zFill the tile (crop plan edges)lock_aspectz+Lock tile height to the plan's aspect ratio_dash_provider_oversightidenciaIdenciadatasetDatar   r  r  zProject progress	shipments	ShipmentsGroup byr  	item_typez	Item typer  Projectr{  Displaystatus_barszStatus barsbarz	Bar graphpiez	Pie chartkpizBig numbers (KPI)last_7dLast 7 dayslast_30dLast 30 dayslast_90dLast 90 daysfiltersz9Filters (e.g. project:"Bridge A" status:Produced,Shipped)
max_sliceszMax slices shown (0 = no cap)_dash_provider_idenciapowerbi_datazPower BI Dataworkspace_idzWorkspace (GUID)
dataset_idzDataset (GUID)daxz	DAX queryr  Tablemax_rowszMax table rows shown (0 = 50)_dash_provider_powerbi_datapowerbi_reportzPower BI Report	report_idzReport (GUID)rO  z/Page (section name; blank = the report default)show_filter_panezShow the filter paneshow_page_navzShow page navigationzAllow interaction_dash_provider_powerbi_reportr   Chartr6  zData source (report id)
chart_typez
Chart typezBar (grouped)bar_stackedzBar (stacked)rs   Liner:  AreaPiedonutDonutprogresszProgress bargaugeGaugez
KPI / stat	label_colz#Label column (blank = first column)
value_colsz+Value columns (blank = auto-detect numeric)r  zTarget (gauge / progress)include_total_rowzInclude the report's totals rowz!Max categories shown (0 = no cap)_dash_provider_chartreport_tablezReport TablezReport source (report id)zMax rows shown (0 = no cap)_dash_provider_report_tablerb  zGroup / PanelzPanel title (optional)childrenz8Panel tiles (list of child tiles; managed by the editor)_dash_provider_grouprX   rX   r3   r1   rf  rf   sE   
J:QR
VD"JARS
 	
J 	  :QR"FEG%x-$y9$y9
 '
^T#ZBWX#Z/R%x-%0HI%@',?&<$|< $X%0FG&9'(;&9'-@): I%
Jb 	%  :QRac!8i$v6!C0C#2N!C0C#2NU 
 %h%8&:%8$ .? 
cJd 	  :QR(&:N"HzJ#XLG"-?@&1ABQ  6'2FG(3CD'2IJO 
 (>@" 1!3
eJZ 	  :QR6%0PQ%8(3NOO 
  G%0FG%0NO$/MN$/DE	Q  6%8&:%8$?	O   K$v6&:%8U '2 E#
[Jb 	  :QR &MO6&@)4NO'2LMO 
 #H?A
=RS!:Px%'2OP%0LMM $ 5
cJZ 	&:QR$j5I,j=Y$jCWX.
_]!$u5&9*[A/ '
EWX"H/%>'+>%0DE= $X8:', 9)3
[JP 	#:QR(*.0,j35/?A#X8:
 /
QJp 	#:QR!6IK"J;=$h/$?$?*[A= )*?A#X:<" 4$)
qJ\ 	'  :QR!6FH
 :*
]Jt 	:QR (X"-BC"];&1LMS 
 "6JLG%8)K@*\BQ 
 $X9;" 2")
uJ` 	   :QRC &;XY:ST%zMR%zD_`&
RTx*'2NO%0QRR  &zGI  3#/
aJR	 	  :QR!8f%8(3EF)K@R 
 #Hz&:)K@')<W 
 "8i+mD#k:#k:#.AB	U   G#e4'-@(>B(>B	Q  "6UW$h9;%zMR9< 1!K&
S	J`
 	$  :QR&AST$f?OPzKH!8i%8#.AB#k:#k:	U  #H9;$h9;%zMR" 6&5
a
JX 	&  :QR&AST#VoNKM*J02'02%zDWX 8('
YJN 	:QR &;TU$h$?,G%7%7$u5&9)NC&9$|<
 $V?A$fGI &57+Z;=$h=?%zMR36 /=
OJN 	#:QR &;VW#X79 6&

OJd 	$  :RS!8i$v6!C0C#2N!C0C#2NU 
 #FTV	 /-
eJ Jr3   c                      i } t               j                         D ]P  \  }}|j                  d|      |j                  dg       |j                  d|      |j                  d      du d| |<   R | S )zHClient-facing view of the registry: label + config fields, no callables.r   r5  r7  r6  N)r   r5  r7  client_only)rf  r   r   )r   r   r  s      r1   _dashboard_tile_types_publicr  ̋ sx    
C*,224 A	T!XXgs3%)XXor%B $S 9 $(88J#74#?ACA Jr3   c                  J    t        j                         j                  d      S )NrZ	  r[	  )r  r  r  rX   r3   r1   	_dash_nowr  ً s    ))9)==r3   c                  v    t        j                  d      xs i j                  d      xs dj                         S r7  )r   r   rE  rX   r3   r1   _dash_user_emailr  ݋ s/    [[ &B++G4:AACCr3   c                      t         j                         r=	 t        j                  t         j	                               } t        | t              r| S 	 i S i S # t        j                  t        f$ r Y i S w xY w)zaAll dashboards keyed by id. Malformed store degrades to empty (never
    raises at request time).)	DASHBOARDS_FILEr   r   r   r   r   r   r   r   r  s    r1   _load_dashboardsr   so      	**_6689C#t$
 % I2I $$g. 	I	r  c                 X    t         j                  t        j                  | d             y r   )r  r   r   r   r  s    r1   _save_dashboardsr   s    tzz$q9:r3   rL  c           	      p    t               }dt        j                         j                  d d  | |||g g ddS )Ndash_r   F)r  r9   rL  r  r  r  shared_userssignage)r  rW  rX  rY  )r9   rL  r  s      r1   _new_dashboardr   sD    
+Cdjjl&&s+,-u r3   dash_system_oversight
__system__c                  h    t               j                         D  cg c]  } | t        vr|  c} S c c} w )u~  Layers shown on the system Oversight dashboard when the admin hasn't
    picked any. All layers EXCEPT the people-tracking ones (D3 privacy: the
    default dashboard is a shared/read-only view — visitors/personnel stay off
    unless an admin explicitly opts in). Each layer is still re-gated per user
    by the provider, so this only widens what a user *may* see, never grants.)r  r,  r  r  s    r1   _overview_default_layersr   s8     -.335 4C22  4 4 4s   /ocfgr  c                    | j                  d      xs g }|D ci c]&  }|j                  d      s|j                  d      |( }}|xs dj                         }|r||v r|S | j                  d      xs dj                         }|r#|j                  |i       j                  d      r|S t        |d       D ]&  }|j                  d      s|j                  d      c S  yc c}w )a*  Pick the plan for the system Oversight dashboard. Honor an explicit admin
    pick when it still exists; otherwise auto-resolve to the admin's Equipment
    focus plan (if it has an image) and then to the first imaged plan. Returns
    "" only when no imaged plan exists at all (nothing to render).r_  r  r7   rc  rm  c                 l    | j                  dd      | j                  d      xs dj                         fS r  r<  r  s    r1   r	  z(_resolve_overview_plan.<locals>.<lambda> s0    !%%*;+,55=+>B*E*E*G*I r3   r  )r   r   r  )r  r  r_  r  by_ideqs         r1   _resolve_overview_planr   s    
 HHW#E%*:aeeDkQUU4[!^:E:"))+JjE)
((#
$
*	1	1	3B	eiiB##H-	E  I J 55?55;  ;s
   C#C#c                     t               } | j                  d      xs i }|j                  dd      syt        | |j                  d      xs d      }|sy|j                  d      xs g D cg c]  }|s|	 c}xs
 t               }dd	d
dt	        d	d|||j                  dd      |j                  dd      ddd      d}t
        dt        ddd|gg dd	S c c}w )u  Synthesize the system default Oversight dashboard from the admin config
    (oversight_config.overview_dashboard). Available to EVERY user by default
    (DVI-1213 board follow-up) — when the admin hasn't configured a plan/layers
    we auto-resolve a sensible default (first imaged plan + all non-people
    layers). Returns None only when an admin has explicitly disabled it or there
    is no imaged plan to show. Shown read-only; "Make a copy" clones it into an
    editable personal dashboard.rf  r~  TNrO  r7   r[  tile_sys_oversightr  r  r   r\  r]  F)re  rO  r[  r\  r]  r  r  )r  rT  rd  r  rc  r  )	r  r9   rL  r|  r  r  r  r  r  )rx  r   r  r  rg  SYSTEM_OVERSIGHT_DASH_IDSYSTEM_DASHBOARD_OWNER)r  r  plan_idrw  r[  tiles         r1   _system_oversight_dashboardr  # s     "#D	&	'	-2B66)T"$T266&>+?R@G&&*0b7AQa7U;S;UF"'VVGR(66-6!6
 D  ''	 	! 8s   ,C4C>   r  gegtleltnerG  between>   r  bluer  r	  amberr  purplec           
      >   g }t        | t        t        f      s|S | D ]  }t        |t              st	        |j                  d      xs d      j                         j                         }t	        |j                  d      xs d      j                         j                         }|t        vs|t        vr|j                  |t	        |j                  d      xs d      dd t	        |j                  d      xs d      dd |d       t        |      d	k\  s |S  |S )
u   Coerce a row's color_rules to [{op, value, value2, color}]. Drops rules
    with an unknown op/color; caps at 12 (defence in depth — the editor also
    validates). Values are bounded strings (numeric parse happens at render).opr7   r  r  Nr   value2)r  r  r  r  r-  )r   r  r	  r   r*   r   r   rE  _DASH_COLOR_RULE_OPS_DASH_COLOR_RULE_COLORSri   rC  )r   r   r|	  r  r  s        r1   _dash_sanitize_color_rulesr  X s
    CcD%=)
 "d##$**,224BFF7O)r*00288:))U:Q-Q

.B/4"&&*0b1#26	
 	 s8r>J Jr3   c                     i }t        | t              s|S t        | j                               dd D ]J  \  }}t	        j
                  ddt        |      j                               dd }|s:t        |      dd ||<   L |S )u   Coerce a metric binding's params to a small string→string map (bounded
    keys/values, ≤10 entries). Each metric interprets its own params.Nr   rR  r7   rS  r  )r   r   r  r   rF  r\  r*   rE  )r   r   r  r  r)  s        r1   _dash_sanitize_metric_paramsr  q s     Cc4 
SYY[!#2&  1ff]BA7<q6$3<C	 
 Jr3   ttypec                 .(   t        |t              si }| dk(  rh d}t        |j                  d      xs d      }h d}t        |j                  d      xs d      }h d}t        |j                  d	      xs d      }t        |j                  d
      xs d      dd t        |j                  d      xs d      dd t	        |j                  d            ||v r|nd||v r|nd||v r|dS ddS | dk(  r*h d}t        |j                  d      xs d      }	h d}t        |j                  d      xs d      }t        j                  ddt        |j                  d      xs d      j                               dd }
t        |j                  d
      xs d      dd |
t        |j                  d            dk(  rdndt	        |j                  dd            t	        |j                  dd             t	        |j                  d!d             |	|v r|	nd||v r|d"S dd"S | d#k(  rAh d$}t        |j                  d%      xs d      }h d&}t        |j                  d'      xs d(      }h d}g }|j                  d)      }t        |t        t        f      r|D ]  }t        |t              st        |j                  d*      xs d      dd+ }t        |j                  d,      xs d      dd- }t        |j                  d.      xs d      dd+ }t        |j                  d	      xs d      }t        j                  d/dt        |j                  d0      xs d      j                         j                               dd1 }|rt        |j                  d2            ni }t        |j                  d3            }|j                         s$|j                         s|j                         s|sT|||||v r|ndd4}|r
||d0<   ||d2<   |r||d3<   |j                  |       t        |      d5k\  s n t        |j                  d
      xs d      dd ||v r|nd||v r||d6S d(|d6S | d7k(  rDh d8}t        |j                  d9      xs d:      }h d;}t        |j                  d<      xs d=      }t        |j                  d
      xs d      dd t	        |j                  d>d             t	        |j                  d?d             t	        |j                  d@d            t	        |j                  dAd            ||v r|nd:t	        |j                  dBd            ||v r|nd=t	        |j                  dCd            t	        |j                  dDd            t        |j                  dE            dFS | dGk(  rt        |j                  d
      xs d      dd t	        |j                  dHd             t	        |j                  dId             t	        |j                  dJd            t        |j                  dE            dKS | dLk(  rh dM}t        |j                  dN      xs d      }t        |j                  dO      xs d      }|j!                  dP      D  cg c]#  } | j                         s| j                         % c} ddQ }!t        |j                  d
      xs d      dd |!t	        |j                  dRd             ||v r|ndt	        |j                  dSd             t        |j                  dE            dTS | dUk(  rQt        |j                  d
      xs d      dd t        |j                  dV      xs d      j                         dd dWS | dXk(  rh dY}"t        |j                  dZ      xs d[      }#h d\}t        |j                  d9      xs d]      }t        |j                  d
      xs d      dd |#|"v r|#nd[t        |j                  d^      xs d      j                         dd ||v r|nd]t        |j                  dE            d_S | d`k(  r|j                  da      }$t        |$t              r>|$j!                  dP      D  cg c]#  } | j                         s| j                         % }%} nYt        |$t        t        f      rA|$D  cg c]5  } t        |       j                         st        |       j                         7 }%} ng }%t#        t%               j'                               }&t        j)                  |%      D 'cg c]	  }'|'|&v s|' c}'ddb }%t        |j                  d
      xs d      dd t        |j                  dc      xs d      j                         ddd |%t        |j                  de      xs d      j                         dd1 t	        |j                  dfd             t	        |j                  dgd             t	        |j                  dhd            t        |j                  di            djk(  rdjndkt	        |j                  dld            dm	S | dnk(  rCh do}(t        |j                  dp      xs dq      })h dr}*t        |j                  d<      xs ds      }+h dt},t        |j                  du      xs dv      }-h dw}.t        |j                  d9      xs d:      }	 t+        dxt-        dbt/        |j                  dy      xs dx                  }/t        |j                  d
      xs d      dd |)|(v r|)ndq|+|*v r|+nds|-|,v r|-ndv||.v r|nd:t        |j                  dz      xs d      j                         dd{ |/t	        |j                  dfd             d|S | d}v rd~ }0| dk(  r(h d},t        |j                  du      xs d      }-	 t+        dxt-        dbt/        |j                  dy      xs dx                  }/	 t+        dxt-        dQt/        |j                  d      xs dx                  }1t        |j                  d
      xs d      dd  |0|j                  d             |0|j                  d            t        |j                  d      xs d      j                         dd |-|,v r|-nd|1|/t	        |j                  dfd             dS t        |j                  d
      xs d      dd  |0|j                  d             |0|j                  d            t        |j                  d      xs d      j                         dd t	        |j                  dd            t	        |j                  dd            t	        |j                  dgd             dS | dk(  r-h d}2t        |j                  d      xs d      }3t        |j                  d
      xs d      dd t5        |j                  d            t7        |j                  d2            |3|2v r|3ndt        |j                  d      xs d      j                         dd t9        |j                  d      dd      t;        |j                  d            t	        |j                  dd            t        |j                  dy            t	        |j                  dfd             d
S | dk(  rnt        |j                  d
      xs d      dd t5        |j                  d            t7        |j                  d2            t        |j                  dE            dS | dk(  r6d }4dd0h}5t        |j                  d      xs d      }6h d}7t        |j                  d      xs d      }8h d}"t        |j                  d      xs d      }9	 t+        dt-        dt/        |j                  d      xs d                  }:t        |j                  d
      xs d      dd |9|"v r|9ndt        |j                  d      xs d      j                         dd+  |4|j                  d      dd       |4|j                  d      dd      |6|5v r|6nd|8|7v r|8|:dS d|:dS | dk(  rh d};t        |j                  d      xs d      }<h d}=t        |j                  d      xs d      }>h d}?t        |j                  d      xs d(      }@h d}t        |j                  d      xs d      }t        |j                  d
      xs d      dd |<|;v r|<nd|>|=v r|>nd@|?v r@nd(||v r|dS ddS | dk(  r|j                  d      }Ag }Bt#               }Ct        |At        t        f      rAD ]  }t        |t              st        j                  ddt        |j                  d      xs d            dd }D|DrDCv rQCj=                  D       Bj                  |Dt        |j                  d      xs d      dd d       t        B      t>        k\  s n h d}7t        |j                  d      xs d«      }8	 t+        dt-        d+t/        |j                  dë      xs dī                  }Et        |j                  d
      xs d      dd B|8|7v r|8ndEt	        |j                  dd            t	        |j                  dd            t        |j                  di            djk(  rdjdǜS dkdǜS | dk(  reh d$}t        |j                  d%      xs d      }t        |j                  d
      xs d      dd ||v r|ndtA        |j                  dɫ            dʜS i S c c} w c c} w c c} w c c}'w # t0        t2        f$ r dx}/Y w xY w# t0        t2        f$ r dx}/Y w xY w# t0        t2        f$ r dx}1Y w xY w# t0        t2        f$ r d}:Y w xY w# t0        t2        f$ r d}EY vw xY w)u   Coerce a tile's config to the shape its type expects (defence in depth —
    the client also validates). Text stores plain fields; markdown rendering /
    escaping happens client-side at render time.r&  >   r  r  r  r  r.  r  r.  >   r   r   r   r6
  r   >   r  r  r  r	  r  r  r  r.  accentre  r7   NrK   i N  r3  )re  r&  r3  r  r6
  r   rU   >   r  r@  rA  rB  r  r?  r@  >   r   r  r  r  r  r   z[^A-Za-z0-9_+\-/]r   rX  r8  r;  r:  r<  Fr=  Tr>  )re  r   r8  r<  r=  r>  r?  r  r  >   rE   rG  rH  rJ  r   r   >   r  r  r  rI  r  rn  r   r  r  r   r\  rR  rU  rS  r  color_rules)r   r  r\  r   r   )re  r   rI  rn  rp  >   r  r	  rz  r  r  >   r   rT  rd   r|  r   rq  rs  ru  rv  r{  pm_todayshow_equipment_statusr}  )re  rq  rs  ru  rv  r  r{  r|  r  r  r}  r0
  r  r  r  )re  r  r  r  r}  r  >   r;  r  r  r  r$  r  r	  r  r  )re  r$  r  r  r  r}  r  rH  )re  rH  r  >   r  r+  re  r	  r+  >   r	  rz  r  rz  r[  )re  r	  r[  r  r}  r  r[  rx  rO  r   r\  r]  r  r  r>  ro  rn  r  )	re  rO  r[  r\  r]  r  r  r>  r  r  >   r   r  r  r  r   >   rd   r  r  rd   >   r  r  r  r  r{  r  >   r  r  r  r  r   r  r  r  )re  r  r|  r{  r  r  r  r]  )r  r  c                 l    t        j                  ddt        | xs d      j                               d d S )N[^0-9A-Za-z-]r7   rS  r]  r  s    r1   	_pbi_guidz(_sanitize_tile_config.<locals>._pbi_guidV s.    66*BAG0B0B0DEcrJJr3   r  >   r  r  r  r  r  r  r  r  r    )re  r  r  r  r{  r  r  r]  r  rO  rd  r  r  )re  r  r  rO  r  r  r  r   >	   r  r  r  r:  rs   r  r  r  r  r  r  r6  r  r  r-  r  r  )
re  r6  r  r  r  r  r  r  r  r]  r  )re  r6  r  r}  rK  c                     	 t        |t        |t        t        |       d                  S # t        t
        f$ r Y y w xY w)Nr   )rC  rL  rj   r  r   r   r  r#  r$  s      r1   _wcoordz&_sanitize_tile_config.<locals>._wcoord s=    2s2uU1Xq'9:;;z* s   (+ ==rT  rR  >   rX  rC  rW  rO  rC  >   r  my_locationorg_defaultlocation_sourcer  r^   r   rY  r   rM  rN  ir  rP  iLr;  )re  r  rM  rN  rP  rR  rO  rY  r[  >   r]  rZ  r_  r]  >   r   rz  ra  r	  r  ra  >   r	  r  r  r  rd  r   )re  r[  r  rd  r6
  r~
  rm  rV  r  r9   r  >   r
  ri  rh  r
  rj  r  rk  rl  )re  rm  rO  rj  rk  rl  r>  rb  r  )re  r   r  )!r   r   r*   r   r   rF  r\  r   r  r	  rE  r  r  ri   rC  r0  r  r  r  r,  r  rC  rL  r   r   r   _dash_report_id_dash_report_params_dash_str_list_dash_opt_floatr  DASHBOARD_IMAGE_MAX_PER_TILE_sanitize_group_children)Fr  r   valid_fsr  valid_alignalvalid_accentacvalid_dfdfr  
valid_colsr"  valid_vsvsrn  raw_rowsr|	  r   r  r\  rU  r  r  r  valid_ranger  valid_groupgrp	valid_ooroor_wraw_sensorsr  r$  valid_sourcer$  
raw_layersr[  r  rw  valid_dsdsvalid_gbgb
valid_dispdisp	valid_rngr  r  r  valid_ctr9  r
  valid_unitsrR  
valid_moderO  r	  fdays
valid_logolgvalid_colorr6  
valid_sizesz
raw_imagesrm  r  ru  rj  sF                                                                         r1   rg  rg   s    c4  E%231!+V,]"/i0)/R0#6-2.v6SWWZ01!X~9,R&,.b
 	
 5>
 	
 ='162A%/0 VV("c#''*2E2K.L.R.R.TUVYWYZ)/R0#6$'(>$?5$H5e !?@cggk489cggk489!#x2V!X~	
 		
 4:	
 		
  2
3779%/0/&2(31776?hu. "!"d+BFF7O1r24C8BFF7O1r23B7"&&--2.t4)6Y7 r #BFF8$4$: ; A A C I I KMMPbRKQ5bffX6FGWY89NO #))+""$&,$6bI	 $*CM$*CM)4C&C t9?E"H )/R0#6#z1tv "h"	
 	
 5=	
 	
 !!3#'''"+e,
 1#''*%/0)/R0#6sww|T:;"&sww/CT'J"Ksww|U;<$(1G)O$P;.SE !?@"k1v SWWZ78%)#''2I5*Q%R()=>
 	
  )/R0#6"377+;T#BC"&sww/CT'J"K%)#''2I5*Q%R()=>
 	
 /	CGGL)2U3#''),23&1&7&7&<J	1779J3BO)/R0#6SWWZ67#(I#5%5"377+;T#BC()=>
 	
 "")/R0#63779-34::<SbA
 	
 
-#''(#+t,:#'''"1k2)/R0#6 L0cd3779-34::<TcB;.SK()=>
 	
 WWX&
j#&)3)9)9#)>LA!'')aggiLFL
T5M2.8KCFLLNc!fllnKFKF%',,./!]]62Aa5j!A#2F)/R0#6-2.446s;)/R0668"=t <=t <= !?@ "#''%.1W<7)u =>
 	
 	5#.w/5$019
3779%67>	#'''"+e,	QBCGGL,A,FQ(G HIJ )/R0#68^r Hn(#z1t}9,S% 3779-34::<TcB$t <=
 	
 22	KN"7Jswwy)4W5D CC0E0J,K$LM
 q#b#cggj.A.FQ*G"HI SWWW-34Tc: )#''.*A B'(=>3775>/R0668$?#':#547$(#CGGM4$@A	 	 )/R0#6%cggn&=>"377;#78-2.446t< $SWW-?%G H!#''/5"ABt <=
 	
 0&/%0)/R0#6%cggh&78)#''(*;< "h"E SWW[17R8>>@#F()>CH%cggh&78 "&cgg.A5&I!J)#'',*?@t <=
 	
  )/R0#6%cggh&78)#''(*;<()=>	
 	
 		
 X&CGGG$,-7
3776?/i0 @SWW./;8<	3q#cggo&>&C!"DEFE )/R0#6)/<)?vX!#''*:";"ArBHHJ4CP
 3S"= !5tSA#{2U J.D"	
 		
 5>"	
 		
 3
+G,8#'''"-g.9
,H-1!-X.)/R0#6
*B;.SG
*B,R
 	

 3;
 	
  WWX&
uj4-0  
!"d+ff-r3rvvd|7Ir3JKCRPcTkS'*266&>+?R'@#'FH Iv;">>
 8
3776?.h/	1c#s377:+>+C!'DEFH )/R0#6 J.DH /0CGGIu56!#''%.1W<7
 	
 CL
 	
  2
3779%/0)/R0#6#z1tv01DE
 	

 I] K: MK B6 :& 	J	4 z* 
 z* H :& 	E	h :& 	H	s   AN/ANAN&ANAN4AN	ANAN72AN "2AN7 2AO 2AO' I+2AO? NAN4N3AN4N7AOOAOOAO$O#AO$O'AO<O;AO<O?APPAPc                    t        | t              sg S t               }g }t        | dd       D ]V  \  }}t        |t              s|j                  d      xs dj                         }|dk(  s||vrE|j                  d      xs dj                         }|t        vrd}|j                  d      rt        |j                  d            dd	 n#d
t        j                         j                  dd z   |||t        ||j                  d      xs i       t        |j                  d            d}|dk(  rt        |j                  d            |d<   t        |j                  d            }|dk7  r||d<   |j!                  |       Y |S )u4  Normalize a Group tile's child list (DVI-1305 P4). Same per-tile shape as
    the dashboard store / _report_sanitize_tiles, but nested groups are dropped
    (one level of nesting only) and the list is capped small — a panel, not a
    wall. Each child gets a stable id so the provider can key its payload.Nr  rT  r7   rb  rd  r  r  rS  tile_r   rc  overflowr  rT  rd  r  rc  r:  rY  spanrP  rd  r   r  rf  r  r   r   r   _DASHBOARD_TILE_SIZESr*   rW  rX  rY  rg  r+  r(  r.  ri   )	r   r  r   r  r   r  rd  r  r  s	            r1   r  r   sj   
 c4 	!#E
C#bq'" 1!T"v$"++-GuE1f)002,,D,-EE$K3quuT{#CR($**,"2"23B"77+E155?3HbI/j0AB
 8/f>E&M v/9E&M

5/0 Jr3   c                     i }| j                  d      xs g D ]&  }|j                  d      }|st        |||      ||<   ( d|iS )a  Run a Group tile's children through the SAME provider dispatch a report
    tile uses (_report_run_tile) and return {children:{child_id: payload}}. Each
    child keeps its per-source D8 gate + the D3 oversight people-strip (a panel is
    a shareable container), and client-rendered children return {} (rendered from
    config). source_range is injected into date-filterable children on the report
    path only (None on a live dashboard / signage poll).r  r  )r   _report_run_tile)rJ  r'  source_ranger   r  r  s         r1   _dash_group_payloadrB   sX     Cggj!'R ;eeDk#At\:C	;
 r3   c                     t        | |d      S )zProvider for the Group / Panel tile (DVI-1305 P4). Returns all child
    payloads in one call so the SPA/kiosk auto-refresh loops fetch a panel like
    any other data tile.N)rB  )rJ  r'  s     r1   r  r  * s     sD$//r3   c                 l    t        j                  ddt        | xs d      j                               dd S )zScrub a bound report id (it keys _auditor_report_types(); charset-limited
    as defence in depth). Report ids are [a-z0-9_] plus the powerbi_q_<hex> and
    dotted forms already minted by the registry.z[^A-Za-z0-9_.\-]r7   Nrd  r]  r  s    r1   r  r  2 s0     66%r3qwB<+=+=+?@#FFr3   c                     t        | t              si S i }t        | j                               dd D ]@  \  }}t	        j
                  ddt        |            dd }|s,t        ||nd      dd ||<   B |S )zIStored values for a bound report's declared params (string map, bounded).Nrx  rV  r7   rS  r  )r   r   r  r   rF  r\  r*   )r  r   r  rQ  r)  s        r1   r  r  9 s    a	
Cqwwy/#2& A3ff%r3q623B7#/3r:4C@CHA Jr3   max_nmax_lenc                     t        | t        t        f      sg S g }t        |       d | D ]6  }t        ||nd      j	                         d | }|s&|j                  |       8 |S r  )r   r  r	  r*   r   ri   )r  rF  rG  r   r  s        r1   r  r  E sh    a$'	
C!WVe_ Q]+113HW=JJqM Jr3   c                     |  t        | t              r| j                         sy 	 t        t        |       j	                  dd      j                               S # t
        t        f$ r Y y w xY w)Nr  r7   )r   r*   r   r  r  r   r   r  s    r1   r  r  P sZ    yZ3'	SV^^C,22455z" s   1A A)(A)rr  c                     t               }t               }|j                  |       }|r*|j                  d      xs dj                         |k(  r||fS |dfS )z(store, dashboard) when dash_id exists and is owned by the current user,
    else (store, None). Owner check is the P1 access boundary.rL  r7   N)r  r  r   rE  )rr  r  r	  rn  s       r1   _dashboard_ownedrK  Y sU     EE		'AaeeGn"))+u4ax$;r3   c                 `   t               }t               }|j                  |       }|s|dfS |j                  d      xs dj                         |k(  r||fS |j                  d      xs g D cg c]  }t	        |      j                          }}t
        |v r|r||fS |r||v r||fS |dfS c c}w )u  (store, dashboard) when dash_id exists and the current user is the owner
    OR the dashboard is shared with them (view-only); else (store, None).

    This is the P3 *view* boundary used by GET /dashboards/tile-data. All
    mutation routes stay owner-only via _dashboard_owned — a shared viewer can
    read tile data (each provider still enforces its own source-tool role gate)
    but cannot change the dashboard.NrL  r7   r  )r  r  r   rE  r*   _DASH_SHARE_ALL_SENTINEL)rr  r  r	  rn  r  re  s         r1   _dashboard_viewablerN  d s     EE		'Ad{	g"##%.ax'(uu^'<'BDc!fllnDFD  6)eax&ax$; Es   - B+c                 F   t        | t              sy| j                         D ch c]H  }t        |t              r6|j                  d      dk(  r"|j                  d      r|j                  d      J }}|sy	 t	               }d}|D ]Y  }|j                  |      }|st        |j                  d      xs g       }t        |vs>|j                  t               ||d<   d}[ |rt        |       yyc c}w # t        $ r" t        j                  j                  d       Y yw xY w)	a6  Ensure every dashboard referenced by a dashref item (DVI-1347 P3) carries
    the "*" share-to-all sentinel in shared_users, making an admin-embedded
    dashboard org-viewable (D5). Fire-and-forget: a missing dashboard or a write
    error is swallowed so it never fails the tool-order save that triggered it.NrT  r   rm  Fr  Tz$dashref org-share application failed)r   r   r  r   r  r  rM  ri   r  ry   rm   rn   r  )r   r  dash_idsr	  r  didrn  re  s           r1   r#  r#  } s   
 eT"/4||~ a!a&155=I+E!%%P^J_ n% aH aE " 	C		#A!%%/526F'v567$*.!	 U# !a$  E

CDEs   AC07AC5 +C5 5(D D c           	         g }t        | t              rt        j                  d|       }nKt        | t        t
        f      r5| D ]0  }|j                  t        j                  dt        |                   2 g t               }}|D ]M  }|j                         j                         }|rd|vs||v r,|j                  |       |j                  |       O |dd S )zParse a list, or a comma/semicolon/whitespace-separated string, of email
    addresses into a lowercased, de-duplicated, order-preserving list. Light
    validation only (must contain '@'); capped at 200.z[,;\s]+r  NrK   )r   r*   rF  r  r  r	  r  r  r   rE  r  ri   )r   r0
  r	  r   r  r  rB  s          r1   _dash_normalize_emailsrS   s     E#sS)	C$	' 	:DLL*c$i89	:CEC GGIOOCqLAI

1 t9r3   rn  c                 @    t        |       }|j                  dd       |S )zA shared-with-me view of a dashboard: everything needed to render it, but
    with the shared_users recipient list stripped (a viewer shouldn't see who
    else it's shared with).r  N)r   r  )rn  r   s     r1   _dash_public_sharedrU   s      q'CGGND!Jr3   r
	  r	  c          	         t        j                  d      xs i }|j                  d      xs
 t               }t        |xs i       }|j	                  d|j                  d             t        d| |||j                  d      xs d|       y)	u  Capture one personal-dashboard lifecycle event under tool ``dashboards``
    (DVI-1228 P2). Actor is the acting session user, ref is the dashboard id.

    Scope is lifecycle + sharing/signage/default only (decision D2a) — tile
    add/remove/reorder/config edits are deliberately NOT captured to keep the
    log low-noise; the P3 config inspector answers "what is in it now".
    Fire-and-forget (record_history never raises).r'  r9   dashboard_namer  r  r7   r	  N)r   r   r  r   r}  r	  )r  dashr
	  r	  r'  r		  rn  s          r1   _record_dashboard_historyrZ   sv     ;;v$"DHHV2 0 2EV\rALL!488F#34<ug,"a9r3   c                     t               } g g }}| j                         D ]p  \  }}t        |t              s|j	                  d      xs |}|j	                  d      r|j                  |       |j	                  d      s`|j                  |       r |j                  d        |j                  d        t        |      t        |      |dd |dd d	S )
uK  Facility-wide equipment down / offsite counts (DVI-1302).

    Source: the manual ``out_of_service`` (down) and ``offsite`` flags on each
    Equipment Registry record (wor_equipment.json), set by an admin from the
    Equipment tool. These are deliberately independent of a WO's status — a
    machine can have an open WO and still be running, so "has an open WO"
    (the Oversight yellow state) is NOT the same signal as "down". Counts every
    registry record regardless of the Oversight opt-in, since down/offsite is a
    maintenance fact, not an Oversight-display setting.
    r9   r  r  c                 "    | j                         S rT   rs  r  s    r1   r	  z+_equipment_service_counts.<locals>.<lambda> s    AGGI r3   r  c                 "    | j                         S rT   rs  r  s    r1   r	  z+_equipment_service_counts.<locals>.<lambda> s    qwwy r3   Nr   )downr  
down_itemsoffsite_items)r  r   r   r   r   ri   r  rC  )regr^  r  r  r~  r9   s         r1   _equipment_service_countsrb  Ў s     
C'DIIK !S#t$wwv%#77#$KK779NN4 ! 	II%I&LL(L)D	w<3Bi "	 r3   c                   () |j                  d      s1|j                  d      s t        |j                  dd            sdddS t        |j                  d      xs |j                  d            )|j                  dd      })st        |      n	t               (t	        j
                         j                         }|t        d      j                  d	
      z   }t               }d }d }()fd}t        | j                  dd            }	g }
|	rt        d      }dD ]q  }|j                  |      }|r|j                         s(|j                  d      D ]5  }|j                  j                  d      r  ||      } ||      s1 ||j                  d      xs |j                  d            }|4 ||j                  |j                        xs i j                  d            }|* ||j                  d      xs |j                  d            }|||kD  r|
j                  |j                  t!        |j                  d|      |d|j#                         ||k  |j                  d      |j%                         j&                  d       8 t nvt               }| j                  dd      r|j)                  d       | j                  dd      r|j)                  d       | j                  d d      s| j                  d!d      r3|j)                  d       |j)                  d       |j)                  d"       | j                  d#d$      }t        | j                  d%d            }d&D ]  }t        |      }|j+                         D ]z  \  }}|d'v r||vr|j                         s"|j                  d      D ]D  }|j                  j                  d      r  ||      } ||      s1| j                  d!d      r3| j                  dd      s!| j                  dd      s|j                  |vrv ||j                  d      xs |j                  d            }|duxr ||k  xr |dv }|r|s|d(k(  r||k7  r|d)k(  r|
||k  s||kD  r|
j                  |j                  t!        |j                  ||      |||r|j#                         nd||j                  d      |j%                         j&                  d       G }  |
j-                  d* +       |
D ]  }|d,=  t/        |
      }t1        | j                  d-            }| j                  d.d/      }|d0v r%|d1k(  rg d2}d3d4d5d2}d6 }ng d&}d7d8d9d:d&}d; }i }|
D ]"  } ||      }|j                  |d<      d=z   ||<   $ |r|
d| n|
} i }!| D ])  }|!j3                   ||      g       j                  |       + t        |      }"t5        |      |D cg c]	  }||"vs| c}z   }#g }$|#D ]\  }|j                  |d<      }%|%d<k(  r|!j                  |g       }&|$j                  ||j                  ||      |&t/        |&      |%d>       ^ dd||$t/        |       |d?}'n|r|
d| }
d|
t/        |
      |d@}'|	rd|'d<   | j                  dAd      rt7               |'dB<   |'S c c}w )CuI  Provide WO summary data filtered per the tile's config.

    Gate: same as wor_approver_required (wor_admin / togen_admin / any approver
    dept). Approver-only callers see only their actionable RtS queue — but for
    the tile we give them the same restricted view the /wor-admin-list route
    returns, which already applies that filter.

    PM-today mode (DVI-1302): when cfg["pm_today"] is set, the feed is the
    precise "preventive-maintenance work orders scheduled or due today" set —
    open (new/in_progress) *preventive*-type WOs whose effective scheduled/due
    date is today or earlier (past-due PMs are flagged is_overdue rather than
    hidden, since they're part of today's plate). Effective date resolves in
    order: Desired Completion Date → Date Promised → the WO's recurring
    schedule next_run → the sidecar submission date. WOs with no resolvable
    date are excluded (they can't be "today"). This replaces the previous
    best-available approximation (range="today" across all four WO types,
    matching only on Desired Completion / Date Promised).
    r   r   r  r7   Fz8Work Order Admin or Authorized Approver access required.r  rm  r   r   r  c                     	 t        j                  | xs dd d d      j                         S # t        t        f$ r Y y w xY w)Nr7   r   r  )r  r  r   r   r   r  s    r1   _parse_datez3_dash_provider_work_order_view.<locals>._parse_date sD    	!**AGSb>:FKKMMI& 		s   *- ??c                     | j                  d      }|j                         r(	 t        j                  |j	                               xs i S i S # t
        t        f$ r i cY S w xY w)Nr  )r  r   r   r   r   r   r   )r  r  s     r1   _load_sidecarz5_dash_provider_work_order_view.<locals>._load_sidecar s^    --(??zz'"3"3"56<"< 	 ( 	s   &A A A c                     ry| j                  d      xs i }|j                  d      dk7  ryt        fdt        |      D              S )zAApprover-only viewers see only WOs awaiting an RtS dept they own.Tr  rd   r
  Fc              3   &   K   | ]  }|v  
 y wrT   rX   )r,  rn  viewer_deptss     r1   r/  zG_dash_provider_work_order_view.<locals>._approver_ok.<locals>.<genexpr>$ s     I1$Ir  )r   r2  r  )r  r  rk  viewer_is_admins     r1   _approver_okz4_dash_provider_work_order_view.<locals>._approver_ok sG    ffVn"778 33I.CC.HIIIr3   r  r   r  r+  r
  r  r@
  Nr_  r   r3  r  )r9   re  rd   r  due_date
is_overduer  r	  rq  Tr$  rs  r   ru  rv  r   r  r  r{  r#  )r   r	  rz  c                     | d   rdnd| d   fS )Nro  r   r^   r	  rX   r  s    r1   r	  z0_dash_provider_work_order_view.<locals>.<lambda>z s    Q|_!QwZH r3   r  r	  r}  r|  r   )rd   rT  rd   )r$  r   r   rr  rt  ra  c                 ,    | j                  d      xs dS )Nrd   r7   r  rx	  s    r1   r	  z0_dash_provider_work_order_view.<locals>.<lambda> s    quuX4" r3   r  r  r  r  c                 ,    | j                  d      xs dS )Nr  r7   r  rx	  s    r1   r	  z0_dash_provider_work_order_view.<locals>.<lambda> s    quuZ06B r3   r   r^   )r)  r   r   r  r@  )r  groupedr|  sectionsr  r@  r  r   r  r@  r  r  )r   rv  r   r  r  r  r   
__import__r   r  r  r  r  r9   r|  ri   r  r  r  r  r  r   r  rC  r0  r}  r  rb  )*rJ  r'  viewer_emailr	  week_endr  rf  rh  rm  r  r  r  rv  r  r  r  rH  wanted_statusesrange_filterr{  r  rn  ro  r  r@  rx  r|  r  r  key_offull_countsr  cappedbucketsr  section_keysrt  	sec_total	sec_itemsr  rk  rl  s*                                           @@r1   r~  r~   s   & HH[!TXXm%<'"(=>+eff488K0KDHH]4KLO88GR(LAP+L9VYV[L!!#((*Ez*-77Q7??H')HJ CGGJ./HGl+0 	J,II$4$4$6^^G, 66??:."1%#B'!"&&)B"C"^rvvoG^_;%x||AFF';'Ar&F&Fz&RSC;%bffVn&Q?P8QRC;#+FF/bI( , #"%+"$&&"7VVX..	  			8 %77<&&77'..77<'3773I5+Q&.,www.CGGNE:;P %	H!(+D)- #%
I/_4 '')"0 Avvz2 &q)B'+ ww5u=cggl\`Fajmjqjq  sG  IM  kN661$*2662K+L+gPRPVPVWfPghH"*$"6 #K8e;K #K&04J&J #J #w.8u3D #{28HHW\L\`hks`s NN !!3AFFHb!I",$,<DH$6$6$8$&0&(ff]&;!"!2!2	$ 	'#%	N LLHLI gJLE
#''+.
/C wwz6*H%%x7E"={[F4FLE%2EU (FF6F 	7Aq	A(__Q2Q6KN	7 #&#7 	8Avay"-44Q7	8 5zE{%NAa%NN 	A#1-IA~Ar*IOOAq)"Y" 	 4X$s6{UL dsmG3w<RWXZ
ww&.57[K1 &Os    	[
[c                    t        |      sdddS t        | j                  dd            }t        | j                  dd            }t        | j                  dd            }t               }|d   |d	   g }t        D ]  }t        |      j                         D ]a  \  }d
k(  r|j                         s|j                  d      D ]/  }	|	j                  d      }
|
j                         s&	 t        j                  |
j                               xs i }t!        |
|      }|s]t#        fd|D              }t%        d |D              }t%        fd|D              }d}|r|sd}|r|rd}|r|rd}|s|j'                  |	j(                  t+        |	j(                  ||      |t-        |      t/        fd|D              t/        d |D              |	j1                         j2                  d       2 d  |j5                  d d       |D ]  }|d=  t-        |      }t7        | j                  d            }|r|d| }d|t-        |      |dS # t        t        f$ r Y w xY w)zProvide Repair Parts Manager data filtered per the tile's config.

    Gate: same as rpm_required (togen_admin always; wor_admin when RPM enabled;
    DVI-1496 P3 keys the "enabled" flag off the Equipment RPM sub-tool).
    F%Repair Parts Manager access required.rd  r  Tr  r  r  r  r   r+  r  c              3   :   K   | ]  }t        |        y wrT   r  r,  r  r  r  rv  s     r1   r/  z'_dash_provider_parts.<locals>.<genexpr>ڏ s$      " +1j&.Q"s   c              3   @   K   | ]  }|j                  d          yw)r  Nr  rp  s     r1   r/  z'_dash_provider_parts.<locals>.<genexpr>ޏ s     $Jqz):%:$Js   c              3   F   K   | ]  }t        |      xr d v  yw))r   Nr  r  s     r1   r/  z'_dash_provider_parts.<locals>.<genexpr>ߏ s8      1  +1j&.Q 9".891rI  c              3   >   K   | ]  }t        |      rd   ywr  r  r  s     r1   r/  z'_dash_provider_parts.<locals>.<genexpr> s'      )1!ZX )s   c              3   D   K   | ]  }|j                  d       rd  yw)r  r^   Nr  rp  s     r1   r/  z'_dash_provider_parts.<locals>.<genexpr> s     +V!AEE*DUA+V     )r9   re  r  rd   r  stocked_countunapproved_countr	  c                     | d   S rn  rX   r  s    r1   r	  z&_dash_provider_parts.<locals>.<lambda> s
    qz r3   r
  r	  r}  Nru  )r$  r   r   r  rp  r  r   r  r  r  r   r   r   r   r   r   r  r  r2  ri   r9   r  rC  r  r  r  r  r0  )rJ  r'  want_awaiting_partswant_awaiting_approvalswant_delivered_awaiting_wor  r  r  r  r  r  r   r0
  all_stockedany_unapprovedany_delivered_not_completer  r  r@  rx  r  r  rv  s                       @@@r1   r  r   s    +RSSsww'7>?"377+?#FG!%cgg.Eu&M!N!#H23F9:NG& 0%3H%=%C%C%E /	!J	[(##%^^G, *--0(::g&7&7&9:@bD &gt4! """  "%$JE$J!J-0 1 #1 .*
 &{D*~D-2LDFF/$G (("%e*%( )#() & ),+Vu+V(VVVX..  =*/	0b LL)4L8 gJLE
#''+.
/C
$3-Ws7|eTTY #G, s   4'I  I3	2I3	r  c                     t        | xs i j                  d      xs d      j                         j                         }|t        v r|S dS )z6Normalize an optional WO-type filter param ("" = any).r  r7   )r*   r   r   rE  _DASH_WO_METRIC_TYPES)r  r   s     r1   _dash_wo_type_paramr   sE    V\ry)/R0668>>@A**122r3   c                     t        | j                  d      xs. | j                  d      xs t        | j                  dd                  S )z^Same gate as _dash_provider_work_order_view (wor_admin / togen_admin /
    any approver dept).r   r   r  r7   )r   r   rv  r-  s    r1   _dash_wo_gater   sH     % C-)@ C+DHHWb,ABD Dr3   c                    | j                  d      s| j                  d      sg S t        j                         j                         dz
  }g }t        D ]  }t        |      j                  d      }|r|j                         s0|j                  d      D ]Q  }|j                  j                  d      r	 |j                         j                  |k  r>	 |j                  d|i       S  |S # t        $ r Y cw xY w)u%  WOs completed in the last 24h, as [{wor_type}]. Only admins (wor_admin /
    togen_admin) can see completed WOs — an approver-only viewer's WO feed never
    includes completed items (matches _dash_provider_work_order_view), so this
    returns [] for them, consistent with the WO View tile.r   r   r  r   r+  r
  r  )r   r  r  rv
  r  r  r  r  r9   r|  r  r  r   ri   )r'  r   r   r  r  r  s         r1   _dash_wo_completed_24hr  ! s    
 HH[!TXXm%<	""$..058F
C) /"8,00=		 0 0 2( 	/Avvz*668$$v- . JJ
H-.	/	/ J  s   3C++	C76C7r  c                    d|v r|d   S t        |       sddi|d<   |d   S t        dddddd|       }t        dddd	|       }d|j                  d
      xs g |j                  d
      xs g t        |       d|d<   |d   S )zBuild (once per stat-provider call, memoized in ctx) the shared WO data
    the built-in Work Order metrics derive their counts from. Scans the WO feed
    a bounded number of times regardless of how many WO rows a tile binds.r+  r  FTr  r   r   )rq  rs  r  r}  r|  )r  r}  r|  r   )r  
open_itemspm_itemscompleted_24h)r  r~  r   r  )r'  r  open_respm_ress       r1   _dash_wo_metric_contextr  : s     s{4yu%D	4y-4%V	-.24H ,v>FF ll7+1rJJw'-2/5	CI t9r3   r  c                 2    dt        |       t        |       dS )NTrU  	value_strr  )r*   r  r  s    r1   _dash_metric_count_resultr  Q s    SVuQx@@r3   c                 Z   t        ||      }|j                  d      sddddS t        |       }d}|d   D ]i  }|r|j                  d      |k7  r|dk(  r|j                  d	      dk7  r4|d
k(  r|j                  d	      d
k7  rN|dk(  r|j                  d      se|dz  }k t        |      S )zShared runner for the WO count metrics. `want` filters the open feed:
    'open' (all), 'new', 'in_progress', or 'overdue'. Applies the optional
    wo_type param.r  FWork Order access required.rU  r  r  r   r  r  r$  rd   r   r'  ro  r^   )r  r   r  r  )r  r'  r  r  r+  wtyper  r|	  s           r1   _dash_metric_run_wo_countr  U s     
!s	+B66(u8: 	:'E	A 	RVVJ'505=RVVH-6= RVVH%5%F9RVVL%9	Q	 %Q''r3   c                     t        ||      }|j                  d      sddddS t        |       t        fd|d   D              }t	        |      S )Nr  Fr  r  c              3   N   K   | ]  }r|j                  d       k(  rd  yw)r  r^   Nr  )r,  r|	  r  s     r1   r/  z0_dash_metric_run_wo_completed.<locals>.<genexpr>r s+      9"BFF:.%7  9s   "%r  )r  r   r  r  r  )r  r'  r  r+  r  r  s        @r1   _dash_metric_run_wo_completedr  l s]    	 s	+B66(u8: 	:'E 9?+ 9 	9A$Q''r3   c                 v    t        ||      }|j                  d      sddddS t        t        |d               S )Nr  Fr  r  r  )r  r   r  rC  )r  r'  r  r+  s       r1   _dash_metric_run_pms_due_todayr  w s@    	 s	+B66(u8: 	:$SJ%899r3   c                     t        ddddd|      }|j                  d      du rdd|j                  d      xs ddS t        t        |j                  d	      xs d            S )
NTFr   )r  r  r  r}  r  rm  zRepair Parts access required.r  r@  )r  r   r  r   )r  r'  r  r  s       r1   _dash_metric_run_awaiting_partsr   ss    
u"'a	9:>@C wwxE!u778,O0OQ 	Q$S)9)>Q%?@@r3   c                     |j                  d      }|t               }||d<   t        t        |j                  |      xs d            S )Nequipr   )r   rb  r  r   )r  r'  r  r)  r$  s        r1   _dash_metric_run_equipmentr   sC    WWWF~*,G$SC)=A%>??r3   c                     t        | j                  d      xs# t        | xs i       t        t	                     z        S )u   QC (Batch Production) data gate — any qc leaf grant (Togen Admins pass).
    Matches the QC tile's scoped visibility (any qc.* grant).r   )r   r   r#  r  r  r-  s    r1   _dash_qc_gater   s>     ' V-djb9C@Q@S<TTW Wr3   c                 \    t        | j                  d      xs | j                  d            S )ux   Kiln Metrics data gate — the togen_scada role (Togen Admins pass). Mirrors
    _km_role_ok / the SCADA-view tile gate.r   r   r,  r-  s    r1   _dash_kiln_gater   s&     'B488M+BCCr3   c                      dddg} 	 t        j                         D ]  }| j                  |d   |d   d        	 ddd| d	S # t        $ r Y w xY w)
zA live 'segment' select param (blank = all plants) from the QC segment
    registry, so a QC stat tile can scope to one production line.r7   
All plantsr  r  r   rH  zProduction segmentr9  r)  r   rT  r  )r  r  ri   ry   )r  r  s     r1   _dash_qc_segment_paramr   st     <01D'') 	AAKK!D'AgJ?@	A ';X   s   3A 	AAc                 0   | j                  d      xs dj                         xs d}d|xs dz   }||vr'	 t        j                  t	               |      ||<   ||   S ||   S # t
        $ r+ t        j                  j                  d       d||<   Y ||   S w xY w)zMemoized qc_store.daily_totals(today, segment) per (segment) per provider
    call. Returns None on any store error (the metric soft-degrades).rH  r7   Nz
qc_totals:r1  z#qc stat metric: daily_totals failed)	r   r   r  r  _qc_today_isory   rm   rn   r  )r  r  r  r)  s       r1   _dash_qc_today_totalsr   s     ::i &B
-
-
/
74C
#*
%C
#~	,,]_cBCH s8O3s8O  	JJ  !FGCHs8O	s   !A! !-BBc                 L    t        d | j                  d      xs g D              S )zOut-of-range material-line count for a daily_totals result. A batch
    captures EITHER an aggregate OR its constituents (never both), so summing
    every row's n_out_of_range does not double-count.c              3   X   K   | ]"  }t        |j                  d       xs d       $ yw)r  r   N)r   r   r  s     r1   r/  z%_dash_qc_oor_count.<locals>.<genexpr>Đ s,      9 155)*/a0 9s   (*rN  )r  r   )totalss    r1   _dash_qc_oor_countr   s,      9K06B9 9 9r3   c                 v    t        | |      }|dddS t        t        |j                  d      xs d            S )NFQC data unavailable.r  r  r   )r  r  r   r   r  r'  r  r   s       r1   _dash_metric_run_qc_batchesr  Ȑ s=    fc*Ay(>??$S})=)B%CDDr3   c                     t        | |      }|dddS t        |j                  d      xs d      }dt        |      |dS )NFr  r  r  r  Tr  )r  r  r   _dash_fmt_num)r  r'  r  r   yds        r1   _dash_metric_run_qc_yardsr  ϐ sI    fc*Ay(>??	quu]#*s	+B]2%6"EEr3   c                 P    t        | |      }|dddS t        t        |            S )NFr  r  )r  r  r  r  s       r1   _dash_metric_run_qc_oorr  א s0    fc*Ay(>??$%7%:;;r3   c                     d| vr	 t               | d<   | d   S | d   S # t        $ r+ t        j                  j	                  d       d| d<   Y | d   S w xY w)zrMemoized _oversight_scada_status() per provider call (returns None on
    error so the kiln metrics soft-degrade).r  z%kiln stat metric: scada status failedN)r  ry   rm   rn   r  )r  s    r1   _dash_kiln_snapshotr  ސ sn     C	&!8!:C }3}  	&JJ  !HI!%C}	&s    -AAc                    d}| j                         D ]T  }	 t        |j                  d            }|j                  d      |j                  d      }}|||k  s	|J||kD  sP|dz  }V |S # t        t        f$ r Y jw xY w)Nr   r  rK  r  r^   )r  r  r   r   r   )rd   oorr  fvr#  r$  s         r1   _dash_kiln_oor_nowr   s    
C]]_ 	quuW~&B uquuV}BNrBwBNrBw1HC J :& 		s   A,,A>=A>c                 N    t        |      }|dddS t        t        |            S )NFKiln data unavailable.r  )r  r  r  r  r'  r  r{	  s       r1   _dash_metric_run_kiln_oorr   s.    	S	!B	z(@AA$%7%;<<r3   c                 f   t        |      }|dddS g }|j                         D ]X  }	 t        |j                  d            }|j                  d      dk(  s3|j                  ||j                  d      xs df       Z |sT|j                         D ]A  }	 |j                  t        |j                  d            |j                  d      xs df       C |sd	d
dS t        |d       \  }}t        |      }|r|d|z   z  }d	||dS # t        t
        f$ r Y w xY w# t        t
        f$ r Y w xY w)NFr  r  r  rT  temperaturer	  r7   Tr	  )rU  r  c                     | d   S Nr   rX   r*  s    r1   r	  z0_dash_metric_run_kiln_max_temp.<locals>.<lambda> s
    ! r3   r  r\   r  )	r  r  r  r   r   r   ri   rC  r  )	r  r'  r  r{	  tempsr  r  r	  r  s	            r1   _dash_metric_run_kiln_max_tempr   sC   	S	!B	z(@AA EYY[ 4	quuW~&B 55=M)LL"aeeFm1r234  	AeAEE'N3QUU6]5HbIJ	
 //5n-HB	r	B
cDjR266! :& 		 z* s#   D	>D	DDD0/D0c                 x    t        |      }|dddS t        t        d |j                         D                    S )NFr  r  c              3   D   K   | ]  }|j                  d       sd  ywr  r^   Nr  r&  s     r1   r/  z._dash_metric_run_kiln_stale.<locals>.<genexpr> s     (Rq155>(Rr  )r  r  r  r  r  s       r1   _dash_metric_run_kiln_staler   s9    	S	!B	z(@AA$S(RBIIK(R%RSSr3   >   r  r  r  >   r  r  r
  >   avgrC  rL  r  r  r  r&  fixedlookup>   lateston_daterz  last_n_dayson_or_beforemonth_to_date>   exactr	  days_ago	yesterday
days_ahead>   r  rC  rL  r  r  r    )	r  %m/%d/%Yz%m/%d/%yz%m-%d-%Yz%Y/%m/%dz%d-%b-%Yz%b %d %Yz	%b %d, %Yz%d %b %Yr  c                  d   dg i} t         j                         rr	 t        j                  t         j	                               }t        |j                  d      t              r)|d   D cg c]  }t        |t              r| c}dd | d<   | S | S c c}w # t        j                  t        f$ r Y | S w xY w)zAReturn the admin-configured data sources with defaults filled in.sourcesNrK   )DASHBOARD_SOURCES_FILEr   r   r   r   r   r   r  r   r   r   )rJ  r  r  s      r1   _load_dashboard_sourcesr  Q s    b/C%%'	JJ5??ABE%))I.5-29-= ":%/4%8 #$ "::>3"@I J3J	":$$g. 	J	s$   AB 'B 	B B B/.B/c                     t         j                  t        j                  | d             	 t         j	                  t
        j                  t
        j                  z         y # t        $ r Y y w xY wr   )	r  r   r   r   r  r  r  r  r   rN  s    r1   _save_dashboard_sourcesr  _ sM    %%djjQ&?@$$T\\DLL%@A    4A! !	A-,A-zdashboard_source_values.dbc                      t        j                  t        t              d      } t         j                  | _        | j                  d       | j                  d       | S )z]Open the durable manual-value store, creating the schema on first use.
    Caller must close.FrD  rF  a  
        CREATE TABLE IF NOT EXISTS source_values (
            id         INTEGER PRIMARY KEY AUTOINCREMENT,
            source_id  TEXT NOT NULL,
            name       TEXT,
            value      TEXT,
            ts         TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_sv_source_ts
            ON source_values (source_id, ts);
        CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
    )rG  rH  r*   DASHBOARD_SOURCE_VALUES_DBrJ  rK  rL  rM  rN  s    r1   _dash_values_dbr  s sM     ??39:eTD{{DLL*+  	 Kr3   c                 V    | j                  d|f      j                         }|r|d   S dS )zCThe most recently observed value for a source (None if never seen).zSSELECT value FROM source_values WHERE source_id=? ORDER BY ts DESC, id DESC LIMIT 1r  Nr  )rO  	source_idr  s      r1   _dash_values_lastr   s7    
,,	-/8l<<DHJ  3w<(D(r3   c                 X    | j                  d||f      j                         }|r|d   S dS )zThe value a source held at/at-or-before ``instant_iso`` (carry-forward).
    Returns None when the source had no observation by that instant.z]SELECT value FROM source_values WHERE source_id=? AND ts<=? ORDER BY ts DESC, id DESC LIMIT 1r  Nr  )rO  r  instant_isor  s       r1   _dash_values_atr   s=     ,,	-/8+.FHHP
  3w<(D(r3   c           	         | j                  d      j                         ryg }	 ddl}t               }	 |j                  d      j	                         }|j                          |D ]  }	 |d   r|j                  |d         ni }t        |t              r|j                  d      sA|d   dk(  r|j                  d	      d
k7  sd|vrb|j                  |j                  d      |j                  dd      |j                  dd      |d   f        	 i }|D ]?  \  }}	}
}|j                  |      |
k(  r| j                  d||	xs d|
dn|
|f       |
||<   A 	 t        j                         j                  d      }t        t!               d         D ]r  }|j                  d	      d
k7  r|j                  dd      }|j                  |d         |k7  sB| j                  d|d   |j                  dd      ||f       |||d   <   t 	 | j                  d       | j#                          y# |j                          w xY w# t        $ r i }Y w xY w# t        $ r g }Y Mw xY w# t        $ r Y fw xY w)a  Idempotent first-run backfill: reconstruct the value time-series from the
    still-present dashboard_source_value_set / dashboard_source_added rows in
    history.db, then anchor each current manual source's present value. Marked
    done via a meta flag so it runs once.z-SELECT value FROM meta WHERE key='backfilled'Nr   zSELECT action, detail, created_at FROM tool_history WHERE action IN ('dashboard_source_value_set','dashboard_source_added') ORDER BY created_at ASC, id ASCr	  r  r  r  rT  r  r  r9   r7   r  DINSERT INTO source_values (source_id,name,value,ts) VALUES (?,?,?,?)r  r  zAINSERT OR REPLACE INTO meta (key,value) VALUES ('backfilled','1'))rL  ru  r   r  r  r[  r   ry   r   r   r   ri   r  r  r  _dash_sanitize_sourcesr  rZ  )rO  r:  r  r}
  rn  r  rn  r  r   r9   r  r_   r  r  rQ  s                  r1   _dash_values_ensure_backfillr   s   
 ||CDMMOFM	9934 5=HJ	  GGI 	AA01(EKK(, a&aeeDk {6655=H,q0@MM155;fb(955"-q@ A	A  D & T5"88C=E! )+.
162E2+O	P S	##%../CD'(?(A)(LM 	$AuuV}(%%$Cxx$ C' 1gquuVR'8#sCE !$QtW	$ 	LL . /KKMU GGI    .  sf   I H( I (H=BI A:I! 3I! (H::I =II II II!	I-,I-c                    | sy|dn
t        |      }|xs# t        j                         j                  d      }	 t	               }	 t        |       t        ||       |k(  r	 |j                          y|j                  d| |xs d||f       |j                          |j                          y# |j                          w xY w# t        $ r Y yw xY w)zAppend one manual-value observation (best-effort, never raises). Deduped:
    an identical consecutive value (e.g. an admin whole-list save that didn't
    change this source) adds no row.Nr7   r  r  )r*   r  r  r  r  r  r  r[  rL  rZ  ry   )r  r9   r  r_   rO  s        r1   _dash_values_recordr  ؑ s     -BSZE		E~$$&//0DEB 	(. y1U:
 JJL	 LL -/8$*"eR.PRKKMJJLDJJL s5   
C B- !C 2*B- C -B??C 	CCc                    g t               }}| xs g dd D ]  }t        |t              st        |j	                  d      xs d      j                         j                         }|t        vrd}t        |j	                  d      xs d      j                         dd }|s	 t        |j	                  d      xs t              }t        t        t        t        |            }t        |j	                  d	      xs d
      j                         j                         }|t         vrd
}|dk(  rt#        |j	                  d            ng }d|||||d}	|dk(  r&t%        |j	                  d            }
|
s^t        |j	                  d      xs d      j                         j                         }|t&        vrd}t        |j	                  d      xs d      j                         dd }|dk7  r|s|	j)                  |
t+        |j	                  d            ||t        |j	                  d      xs d      j                         dd t        |j	                  d      xs d      j                         dd d       n|dk(  rF|	j)                  dt        |j	                  d      xs d      j                         dt,         i       nt        |j	                  d      xs d      j                         dd }t        |j	                  d      xs d      j                         dd }t        |j	                  d      xs d      j                         dd }d|v r\|j/                  d      \  }}}|j                         r7|s"|j                         j                  d       dd }|j                         dd }|r|st        |j	                  d!      xs d"      j                         j                         }|t0        vrd"}|	j)                  ||||d#       |d$k(  rNt        |j	                  d%      xs d      j                         dd }|s7t        |j	                  d&      xs d'      j                         j                         }|t2        vrd'}t        |j	                  d(      xs d      j                         dd }|d)k7  r|st        |j	                  d*      xs d+      j                         j                         }|t4        vrd+}	 t        |j	                  d,      xs d-      }t        d-t        d.t7        |                  }t        |j	                  d/      xs d      j                         j                         }|t8        vrd}	 t        |j	                  d0      xs d1      }t        d1t        d2|            }|	j)                  |||||t        |j	                  d3      xs d      j                         dd4 ||t        |j	                  d5      xs d      j                         dd t        |j	                  d6      xs d      j                         dd d7
       n|sVt;        j<                  d8dt        |j	                  d9      xs d      j                               dd: }|r||v r$d;t?        j@                         jB                  dd< z   }|jE                  |       ||	d9<   |jG                  |	        |S # t        t        f$ r
 t        }Y Zw xY w# t        t        f$ r d-}Y 
w xY w# t        t        f$ r d1}Y w xY w)=a  Validated data-source list. Strict (a source becomes a runnable metric +
    its fields land in Graph URL paths / report ids): entries missing their
    required fields are dropped, ids are minted when blank + de-duplicated +
    charset-scrubbed to [a-z0-9_] (they become metric ids, which the Stat
    sanitizer scrubs the same way), TTL is clamped, and visibility falls back
    to 'all'. Two source types: `sharepoint_cell` (P2) and `report_cell` (P3).NrK   rT  r
  r9   r7   rd  	cache_ttlr  r  r  allow_emailsr  r9   rT  r	  r  r
  r  r6  row_moder&  columnr  r  	match_colmatch_valuer6  r  r  r  r  r  r  r  r    r<  r	  r   !rd
  select_moder  r  r<  r	  r  r  	value_col	date_ruler  date_colr  date_anchorr	  date_offsetr   iB  reduce
header_rowr^   rs	  
fixed_daterx  
data_ranger  
r  r  r  r  r  r  r  r  r  r  rR  r  rS  src_r-  )$r  r   r   r*   r   r   rE  _DASH_SOURCE_TYPESr   _DASH_SOURCE_TTL_DEFAULTr   r   rC  _DASH_SOURCE_TTL_MINrL  _DASH_SOURCE_TTL_MAX_DASH_SOURCE_VISIBILITYrS  r  _DASH_REPORT_ROW_MODESr  r  _DASH_MANUAL_VALUE_MAX
rpartition_DASH_SELECT_MODES_DASH_DATE_RULES_DASH_DATE_ANCHORSabs_DASH_LOOKUP_REDUCErF  r\  rW  rX  rY  r  ri   )r   r   r  r  r  r9   ttlr  rA  r   r6  rO  r  r  r<  r	  headr  r&  r  r  r	  r  anchorr  reduce_moder  r   s                               r1   r  r   s    CECiR# s!T"AEE&M6%67==?EEG**%E155=&B'--/5	+aeeK(D,DEC &,@#(FG!%%%./557==?--CBE-'n(=>UWT5s"F< M!$QUU8_5FquuZ(3G4::<BBDD11x.B/557=F wvLL -aeeHo> d {!3!9r:@@B4CH"155#7#=2>DDFtL  h LLQUU7^1r288:;R<RS  AEE+.4"5;;=etDIg,"-335ds;EquuV}*+113CR8D d{ $ 4a::<  $

 2 23 7 =::<,D%aeeM2=g>DDFLLNK"44%LLy5$)46 7h&k 2 8b9??A$3G	 155-?@FFHNNP//)DquuZ06B7==?E8#HQUU=1<W=CCEKKM!33$F }!5!:;F QD#f+ 67!!%%/"<W=CCEKKM&99")K#!$QUU<%8%=A!>J !Cj$9:
!*!%f#)"%aeeL&9&?R"@"F"F"H""M)"%aeeL&9&?R"@"F"F"H""MaeeDk/R0668"=  ff]BAEE$K,=2(>(D(D(FGLcTk4::<++CR00Cd

5gsh JQ :& 	+*C	+R ":. F ":. #!"J#s6   ""\\"\:\\"\76\7:]]r	  c                 r   |j                  d      ry| j                  d      xs d}|dk(  ry|dk(  rT|j                  d      xs d	j                         j                         }|r |t        | j                  d
      xs g       v sy| j                  d      dk(  r| j                  dd	      t	        |      v S y)u>  Per-source D8 gate. Admins always pass. 'all' = any logged-in user (the
    Dashboards tool is login-gated); 'admins' = togen_admin only; 'list' = the
    email allowlist. A session with no email (signage display identity) can only
    ever satisfy 'all', so a gated source is never resolved onto a wall. A
    report_cell source ALSO keeps the bound report's own reporting gate (D8) —
    the viewer must both pass the source's visibility and hold access to the
    report, so a report-derived row is never resolved for a viewer who couldn't
    run that report directly.r   Tr  r  r  Fr  r  r7   r
  rT  r  r6  )r   r   rE  r  _auditor_reporting_allowed)r	  r'  r  r  s       r1   _dash_source_accessr3  n s     xx
**\
"
+eC
h
f}'"(b//1779%3vzz.'A'GR#HHzz&]*zz(B'+Ed+KKKr3   c                    | j                  d      | j                  d      | j                  d      | j                  d      | j                  d      | j                  d      | j                  d      | j                  d      | j                  d	      | j                  d
      | j                  d      | j                  d      | j                  d      | j                  d      | j                  d      fS )Nr  r  r<  r	  r  r  r  r  r  r  r  r  r  r  r  r  r  s    r1   _dash_source_sigr5   s     JJtfjj5JJwF!3JJ}%vzz+'>JJz"FJJ{$;JJ}%vzz-'@JJ|$fjj&:JJ|$fjj&>JJt r3   r  c                     dt        j                  | j                  d            j                  d      j	                  d      z   S )zEncode a SharePoint/OneDrive sharing URL into the Graph ``/shares/{id}``
    token: ``u!`` + unpadded base64url of the UTF-8 URL (per the Graph shares
    API). Same encoding used by the SharePoint sharing-link resolver above.r  r  r  rC  )r  r  r  r8   r  )r  s    r1   _graph_share_idr7   s?     &**!##)6$<w@ @r3   c           	         ddl m} |j                         }|sydd|z   i}	 t        j                  dt        |       z  |d      }|j                  dk7  r#d	d	d	d|j                  d|j                  d	d fS |j                         }|j	                  d      }|j	                  d      xs i j	                  d      }|r|sy|||d	fS # t        j                  $ r}d	d	d	t        |d
      fcY d	}~S d	}~ww xY w)u   Resolve a SharePoint sharing URL to the drive item the workbook API
    needs. Returns (auth_header, drive_id, item_id, error) — the header is
    returned so a caller can reuse the same token for the range read.r   r  )NNNr  r  r  zOhttps://graph.microsoft.com/v1.0/shares/%s/driveItem?$select=id,parentReferencer  r;  Nr]  rK   zfile lookup failed (r  r  r  driveId)NNNz&could not resolve the SharePoint file.)rZ  r  r  r  r   r7  r?  rd  rg   r&  r   )	r  r  rD  rf  r  rl  r	  r  re  s	            r1   r  r   s    +##%E7I-
.CT*,;I,FG% 	}}T4%&]]AFF4CL#B C 	C668DhhtnG*+1r66yAHI'4'' ** TT4!7=R!SSSTs   $C C5C0*C50C5c                    t        | d         \  }}}}|rdd|fS t        | d         j                  dd      }d|d|dt        |d	
      dt        t        | d         d	
      d	}	 t	        j
                  ||d      }|j                  dk7  r"ddd|j                  d|j                  dd fS |j                         }	d }
 |
|	j                  d            } |
|	j                  d            }||y|dvrt        |      n|d	n
t        |      }d}t        |t        t        f      rt        |t              st        |      }nt        |      }||dfS # t        j                  $ r}ddt        |d      fcY d}~S d}~ww xY w)u   Read a single cell from a SharePoint-hosted XLSX via the Graph workbook
    range API — no full-file download (the range endpoint returns just the cell,
    which matters when the workbook is large). Returns (value_str, number, error).
    r  Nr<  rd
  rp  rZ  r[  /workbook/worksheets('r7   r  z')/range(address='r	  z')r  r;  r]  rK   zcell read failed (r  c                 >    	 | d   d   S # t         t        f$ r Y y w xY wr  )
IndexErrorr   r  s    r1   _cell0z'_dash_source_fetch_cell.<locals>._cell0̒ s,    	71:I& 		s   
 r&  r  r7   NNr  )r  r*   r  r   r  r   r?  rd  rg   r&  r   r   r   r  r   _dash_chart_num)r	  rf  re  r  r  r<  rJ  r2rl  r   r>  r&  rQ  r{  r  s                  r1   _dash_source_fetch_cellrB   s   
 #;6+;N"OC7C
T3w ((d3E E#U3vf~+>R%HJCNS"= 
~~T!~~rwwt}> ? 	?779D
 $((6"#D
(#
$C
{t|z1c$iks3x 
C#U|$ZT-BCjg&C/ ** NT1#7LMMMNs   +E F/F=FFc                     t        | xs d      j                         j                         } | r| j                         syd}| D ]2  }d|cxk  rdk  s y  y|dz  t	        |      t	        d      z
  dz   z   }4 |dz
  S )zoExcel column letters -> 0-based index ("A"->0, "B"->1, "AA"->26).
    None when `sel` is not a pure A-Z string.r7   Nr   Ar  rN
  r^   )r*   r   rB  isalphaord)selr  rU  s      r1   _dash_col_letters_to_indexrH   s     ciR.


 
&
&
(Cckkm
C 2r S  !Bh#b'CH,q012 7Nr3   c                 n    t        | |      }||S t        |      }|d|cxk  rt        |       k  r|S  yy)u   Resolve a column selector to a 0-based index. Tries a header label /
    key / 1-based ordinal (via _dash_report_col_index) first — so a header
    literally named "B" wins — then an Excel column letter. None if unmatched.Nr   )_dash_report_col_indexrH  rC  )r   rG  r  rU  s       r1   _dash_lookup_col_indexrK   sF     	w,A}"3'A}a.#g,. /r3   c                     	 t        |       } | dk  s| dkD  ry	 t        ddd      t	        t        |             z   j                         S # t        t        f$ r Y yw xY w# t        t        f$ r Y yw xY w)zmConvert an Excel date serial to a date (1900 date system, locale-free).
    None if out of a plausible range.Nr^   i ik  r-  r  r  )r  r   r   r  r  r   r   OverflowErrorr  s    r1   _dash_excel_serial_to_daterN    s    !H 	1uF
tR,SV,-.2df	5 z"  :& s"   A
 0A 
AAA10A1c                    t        |t        t        f      rt        |t              st	        |      }|r|S t        | dvr| n|dvr|nd      j                         }|sy|j                  dd      j                  d      d   }t        D ](  }	 t        j                  ||      j                         c S  	 t        j                  |      j                         S # t        $ r Y Zw xY w# t        $ r Y yw xY w)u   Parse a date from a sheet cell. Prefers a real Excel serial (from the
    `values` grid — locale-independent); falls back to parsing the formatted
    `text` across common US/ISO formats. None when unparseable.r  r7   NrP  r\   r   )r   r   r  r   rN  r*   r   r  r  _DASH_LOOKUP_DATE_FORMATSr  r  r   r   r  )	text_cell
value_cellrn  r  r.  r;  s         r1   _dash_parse_date_cellrS   s     *sEl+Jz44P&z2H*4I ** <*"	GGLuw 99S#$$S)!,D( 	!**45::<<
++A.3355  		  s$   #C4"C& 	C#"C#&	C21C2c                    t        | xs d      j                         } | r0	 ddlm} t	        j
                   ||             j                         S t	        j
                         j                         S # t        $ r Y -w xY w)zsThe 'today' date for a lookup (D3). An optional IANA `tz` overrides the
    default (the prod server's local date).r7   r   r  )r*   r   r  r  r  r  r   ry   )r  r  s     r1   _dash_lookup_todayrU  ( sq     
RX2			B		)!%%hrl388:: $$&&  		s   .A2 2	A>=A>c                    | xs g } |xs g }t        dt        |xs d            dz
  }|t        |       k\  rg g g ddfS | |   xs g }g }t        |      D ]A  \  }}|dvrt	        |      j                         nd|dz   z  }|j                  d|z  |d       C | |dz   d D 	cg c]  }	t        |	       }
}	|r||dz   d D 	cg c]  }	t        |	       c}	ng }||
|ddfS c c}	w c c}	w )	zNormalize a Graph range (`text` + `values` 2D grids) into
    {columns:[{key,label}], rows:[text row], value_rows:[values row]} using a
    1-based header row. Returns (table, error).r^   )r   rn  
value_rowsz*the header row is beyond the sheet's data.r  	Column %dc%dr  N)rC  r   rC  r  r*   r   ri   r  )	text_gridvalues_gridr  hidxry  r   rU  r	  r   r  rn  vrowss               r1   _dash_range_to_tabler^  5 s    RI#Kq#joA&'!+Ds9~"=<> 	>t_"FGV$ ;4&**&<T"!QU+ 	uqy59:; 'taxy12DG2D29Dk$()45T!W5"EEBDHH 35s   #C"C'c                    | j                  d      xs d}	 t        | j                  d      xs d      }t	        |      }|dk(  r|t        d      z
  dfS |d	k(  r|t        |      z
  dfS |d
k(  r|t        |      z   dfS |dk(  r!t        | j                  d            }|sy|dfS |dfS # t        t        f$ r d}Y w xY w)zHResolve the anchor date for on_date / on_or_before. Returns (date, err).r  r	  r  r   r  r^   r  Nr  r  r  r  )Nz4the exact date could not be parsed (use YYYY-MM-DD).)r   r   r   r   r+  r  rS  )r	  r	  r!	  r  r  s        r1   _dash_lookup_anchorr`  J s    ::m$/DVZZ.3!4 [F{A..44zF33T99|F33T99w"6::l#;<O4x$; z" s   B, ,C ?C c                    | j                  d      xs g }| j                  d      xs g }| j                  d      xs g }|j                  d      xs d}d|i}|dk(  rt        ||j                  d            }g }	t        |      D ]8  }
||t        |
      k  st	        |
|         j                         d
k7  s5|
g}	 n |	s|r|d   g}	t        |	      |d<   |	|d	fS t        ||j                  d            }|g |dfS g }t        |      D ]^  \  }}
|t        |
      k  r|
|   nd
}|t        |      k  r|t        ||         k  r||   |   nd	}|j                  t        ||             ` t        ||      \  }}|rg ||fS |j                         |d<   g }|dk(  r&t        ||      D 
cg c]  \  }
}||k(  s|
 }}
}n|dk(  rGd\  }}t        ||      D ]  \  }
}|	||k  s|||k\  s||
}} ||g}|j                         |d<   n|dv r|dk(  r|t        |j                               z
  }nR|dk(  r;t        dt        |j                  d      xs d            }|t        |dz
        z
  }n|j!                  d      }t        ||      D 
cg c]  \  }
}|||cxk  r|k  rn n|
 }}
}|j                         |j                         g|d<   ng |dfS t        |      |d<   ||d	fS c c}}
w c c}}
w )zPick the row(s) matching the source's date rule. Returns
    (rows_subset, context, error). `context` records what was picked for the
    Test button (anchor/window/matched count/picked date).r   rn  rW  r  r  r	  r  r  Nr7   r  r  r  z+the date column was not found in the sheet.r/  r  r  picked_date)rz  r  r  rz  r  r  r^   r  r  r  zunknown date rule.)r   rK  reversedrC  r*   r   r  ri   rS  r`  r  r  r  r  rC  r   r  )r  r	  r	  r   rn  r]  r	  r  vidxr_  r  didxrL  r  tcvcr/  aerrr\  rn  best_dbest_rrq   r  s                           r1   _dash_select_rows_by_daterl  ` sL    ii	"(bG99V"DIIl#)rE::k"4nD4.Cx%gvzz+/FG$ 	AD3q6Mc!D'l6H6H6Jb6P	 $2hZFVIsD  !'6::j+ABD|3EEEF$ 51s1vQtW2 !CJ4#eAh-3GeAhtn 	+B34	5 'vu5LFD3}$$&CM
Cy v.>TQ!v+q>>		#f% 	&DAq}f&.AK!"A	& (C!'!1!1!3C	>	>;_&..2BCCE]"As6::m49:;A_!a%88ENNqN)E v. :TQ-EQ$8&$8  : :*F,<,<,>?H3,,,XC	NT>/ ?":s   -K3;K3K9with_contextc           
         fd}t        | d         \  }}}}|r |dd|i       S t        | d         j                  dd      }t        | j                  d      xs d      j	                         }d	|d
|dt        |d      d}	|r|	dt        |d      z  z   }
n|	dz   }
	 t        j                  |
|d      }|j                  dk7  r( |ddd|j                  d|j                  dd i       S |j                         }|j                  d      xs g }|j                  d      xs g }t        | j                  d      xs d      }|t        z   }t        |      |kD  }|r
|d| }|d| }t        |||      \  }}|d   D cg c]  }|d   	 c}|d}|r |dd||      S t!        | j                  d            }|j#                         |d<   t%        || |      \  }}}|j'                  |       |r |dd||      S t)        |d   | j                  d            }| |ddd |      S |s |ddd|      S | j                  d!      xs d"}|d"k(  rd#n|}t+        |d   |d$t        |dz         |d%      \  }}} ||d&<    |||| |      S # t        j                  $ r} |ddt        |d      i       cY d}~S d}~ww xY wc c}w )'u*  Resolve a date-relative SharePoint source: one bounded range read ->
    normalize -> pick rows by date rule -> reduce to a scalar. Returns
    (value_str, number, error) — or (value_str, number, error, context) when
    with_context (the Test button, so the admin sees which date/row it picked).c                     r| |||fS | ||fS rT   rX   )r{  r  r  r  rm  s       r1   _retz'_dash_source_fetch_lookup.<locals>._ret s     +7c3'PgsC=PPr3   r  Nr<  rd
  rp  r  r7   rZ  r[  r;  r  z')/z8range(address='%s')?$select=text,values,rowCount,addressz@usedRange(valuesOnly=false)?$select=text,values,rowCount,addressrv  r;  r]  rK   zrange read failed (r  r&  r  r  r^   r   r   )r   r  r  r	  r  z,the value column was not found in the sheet.r  r  r  r   rn  )r  r  r  )r  r*   r  r   r   r   r  r?  rd  rg   r&  r   r   _DASH_LOOKUP_ROW_CAPrC  r^  rU  r  rl  r  rK  _dash_report_extract_scalar)!r	  rm  rp  rf  re  r  r  r<  r  r  rJ  r  rl  r   rZ  r[  r  	max_totalr  r  terrr  r  r	  r\  sctxserrre  r0  rO  r{  r  rerrs!    `                               r1   _dash_source_fetch_lookupry   s   
Q #;6+;N"OC7C
D$R((w ((d3EVZZ-34::<J#+WeE6KMDPz+, ,  7 7LsC< 	}}D$45MM166$3<P 	 668D &BI((8$*KVZZ-23J11II*Ijy)	!*9-&y+zJKE4+0+;<aqz<!#CD$c**vzz$/0E??$CL/vuEOCtJJtD$c**!%	"2FJJ{4KLD|D$ N 	 BdC((**X&1'K G+6D4)$c2tax=d35GS$ CdC((U ** LD$*30EFL 	LL$ =s$   $J K J=J82J=8J=c                 4   t        |xs d      j                         }|sy| D cg c]-  }t        |j                  d|j                  dd                  / }}| D cg c]-  }t        |j                  d|j                  dd                  / }}|j                         r%t	        |      dz
  }d|cxk  rt        |       k  r|S  t        t        |             D ]2  }|||   k(  s%|j                         ||   j                         k(  s0|c S  yc c}w c c}w )u   Resolve a column selector to a 0-based index. Accepts a column key, a
    case-insensitive label, or a 1-based ordinal ("1", "2", …). None when the
    selector is blank or doesn't match a column.r7   Nr   r)  r^   r   )r*   r   r   r	  r   rC  r  rE  )r   rG  r  r  r,  r  s         r1   rJ  rJ   s     ciR.


 C=DEc!%%ub!123EFE;BCaCeQUU7B/01CDC
{{}HqL CL H !3w<  $q'>SYY[F1IOO,==H  FCs   2D2Dc                     | yt        | t              r$| j                         rt        t	        |             S t        t        | d            S )zgFormat a computed aggregate for display (integers without a decimal
    tail, everything else to 2 dp).r7   r   )r   r  
is_integerr*   r   rj   r  s    r1   r  r   s=     	y!U3q6{uQ{r3   r6  c                    | j                  d      xs g }| j                  d      xs g D cg c]  }t        |       }}|j                  d      xs d}|j                  d      }|rt        ||      }|yt        |j                  d      xs d	      j	                         j                         }|D cg c]?  }|t        |      k  r/t        ||         j	                         j                         |k(  r|A }}|d
k(  r"t        |      }	t        |	      t        |	      dfS t        ||j                  d            }
|
y|sy|dk(  r8|
t        |d         k  r|d   |
   nd	}|d	n
t        |      }|t        |      dfS |dk(  r8|
t        |d         k  r|d   |
   nd	}|d	n
t        |      }|t        |      dfS |D cg c]  }|
t        |      k  st        ||
         ! }}|D 	cg c]  }	|	t        |	       }}	|sy|dk(  rt        |      }n@|dk(  rt        |      t        |      z  }n#|dk(  rt        |      }n|dk(  rt        |      }nyt        |      |dfS c c}w c c}w c c}w c c}	w )aX  Reduce a report ({columns, rows}) to one scalar per the source's column +
    row_mode (+ optional match_col/match_value row filter). Returns
    (value_str, number, error). Aggregates (sum/avg/min/max) coerce cells with
    _dash_chart_num and skip non-numeric ones; count returns the row count;
    first/last return that row's cell verbatim.r   rn  r  r&  r  N)NNz-the match column was not found in the report.r  r7   r  r  )NNz0the selected column was not found in the report.r?  r   r  r  r  r  rL  rC  )NNzunknown row selector.)r   r  rJ  r*   r   rE  rC  r  r@  r  rL  rC  r  )r6  r	  r   r  rn  rO  r  mimvr  col_idxr	  rn  numsr  s                  r1   rs  rs   s    jj#)rG$jj06B8DG8D8::j!,WD 

;'I#GY7:NM*0b1779??A EaA;3qu:#3#3#5#;#;#=#C  E E wI1vuQx%%$Wfjj.BCGM w#*Sa\#9tAwwr,BCI/$'--v~$+c$r(m$;tBx ,BCI/$'--15JA3q69IOAgJ'JDJ"4amE!H4D4u}I	ID	!	I	I2Q$$c 9E2 K4s$   I%6AI*I/I/,I44I4c                    | j                  d      | j                  d      xs i d}t        ||      \  }}|C|j                  d      du rdd|j                  d      xs dfS dd|j                  d	      xs d
fS t        ||       S )a  Run the bound report under the viewer's identity (keeping its D8 gate) and
    reduce it to one scalar. Returns (value_str, number, error). A no-access /
    unavailable report surfaces as an error string (the caller serves the
    last-good reading or a redacted row).r6  r  r6  r  Nr  Frm  z no access to this report source.r  zthe report returned no data.)r   _dash_report_source_runrs  )r	  r'  rJ  r6  r  s        r1   _dash_source_fetch_report_cellr  = s    
 ZZ)VZZ5I5OR
PC-c48OFG;;x E)H 5 !F#EG GTGKK	2 >=? 	?&vv66r3   c                    | j                  d      dk(  rd| j                  d      |j                  d      xs dj                         | j                  d      | j                  d      | j                  d	      | j                  d
      | j                  d      t        t        | j                  d      xs i j	                                     f	S t        |       S )a"  Memo/last-good key. A SharePoint cell is org-data (app-only token) so it
    keys on the cell config alone; a report_cell runs under the viewer's
    identity (a report may resolve differently per user), so its key is
    per-user + per-config to avoid serving one viewer's data to another.rT  r  rcr  r  r7   r6  r  r  r  r  r  )r   rE  r	  r  r   r5  )r	  r'  s     r1   _dash_source_cache_keyr  M s    
 zz&]*fjj&'):)@b(G(G(I

8$fjj&:

:&

;(?

=)ffjj28b??ABC	E 	E
 F##r3   c                    | j                  d      dk(  r@t        | j                  d      xs d      }d|t               d}t        |      }|||d<   |S t	        | |      }t        j
                         }t        j                  |      }|r|d   |kD  rt        |d	         S 	 | j                  d      d
k(  rt        | |      \  }	}}
n3| j                  d      dk(  rt        |       \  }	}}
nt        |       \  }	}}
|
r1t        j                  |      }|rt        |      }d|d<   |S dd|
z   dS d|	t               d}|||d<   t!        | j                  d      xs t"              }||z   |ft        |<   |t        |<   t        |      S # t        $ r8 t        j                  j                  d| j                  d             d\  }	}}
Y w xY w)a  Resolve a configured source to a Stat-row result (value_str + optional
    number for coloring). Live-first with a per-source TTL memo + a never-expire
    last-good fallback so a render never blocks on the source and a transient
    blip serves the last reading with a 'Cached' badge.rT  r  r  r7   T)rU  r  as_ofr  r   r^   r  r  r  zdata source %s read failedr  )NNthe value could not be read.r  FzData source unavailable: r  r	  )r   r*   r  r@  r  rU   _DASH_SOURCE_MEMOr   r  ry  rB  ry   rm   rn   r  _DASH_SOURCE_LAST_GOODr   r!  )r	  r'  r  r   r  r  r)  r  memor{  r  r  r   r-  s                 r1   _dash_source_runr  [ s    zz&X%&**W%+,ikBc"?CM

 
.C
))+C  %DQ#DG}	G::f. >vt LGS#ZZ&(2 9& AGS# 7 ?GS# %))#.t*C CMJ(Cc(IJJGik
BC
H
fjj%A)A
BC!Ci-c"%39!  G

96::d;KLFcGs   #AF >GGc                  v    i } t        t               d         D ]  }|d   dg |fdd|fdd| d|d	   z   <    | S )
zEach admin-configured source as a metric-registry entry (folded into
    _dash_metric_types). Sanitized here as defence in depth (hand-edited config
    lands in metric ids / Graph URL paths).r  r9   zData sourcesc                     t        ||       S rT   )r3  )r  _ss     r1   r	  z-_dash_source_metric_entries.<locals>.<lambda> s    ':2q'A r3   z*You don't have access to this data source.c                     t        |||      S rT   )r  )r  r  r  r  s       r1   r	  z-_dash_source_metric_entries.<locals>.<lambda> s    *:2q!*D r3   r   rb  r  r  access_messager:  datasrc_r  )r  r  )r  r  s     r1   _dash_source_metric_entriesr   s^     G#$;$=i$HI 
vY#$%AJ'(D)

QtW$%
 Nr3   r  zWork order typer9  zAny typer  r  c                  L   ddt         gt        dd dddt         gt        dd dddt         gt        dd	 dd
dt         gt        dd dddt         gt        dt        dddg t        dt        dddg d dt        dddg d d dddg d d dddt               gt        dt        dddt               gt        dt        dddt               gt        dt        dddg t        d t        dd!dg t        d t        dd"dg t        d t        dd#} | j                  t                      | S )$a  Metric registry (DVI-1312 P1). Each entry:
        {label, group, params:[{key,label,type,options?}],
         access(user)->bool, access_message, run(params,user,ctx)->result}
    where result is {ok, value_str, number?, access?, message?, as_of?, cached?}.
    `ctx` is a per-provider-call scratch dict for cross-row memoization (the WO
    feed / equipment counts are gathered at most once per tile).z$Open work orders (new + in progress)zWork Ordersr  c                     t        | ||d      S )Nr?  r  r  r  r  s      r1   r	  z$_dash_metric_types.<locals>.<lambda> s    #<Q1f#M r3   r  zNew work ordersc                     t        | ||d      S )Nr$  r  r  s      r1   r	  z$_dash_metric_types.<locals>.<lambda> s    #<Q1e#L r3   zIn-progress work ordersc                     t        | ||d      S )Nr   r  r  s      r1   r	  z$_dash_metric_types.<locals>.<lambda> s    #<Q1m#T r3   zOverdue work ordersc                     t        | ||d      S )Nr'  r  r  s      r1   r	  z$_dash_metric_types.<locals>.<lambda>ɔ s    #<Q1i#P r3   zCompleted in last 24hzPMs due todayzWork orders awaiting partsc                     t        |       S rT   )r$  r  s    r1   r	  z$_dash_metric_types.<locals>.<lambda>ܔ s
    Q r3   r  zEquipment down (out of service)r  c                      yr  rX   r  s    r1   r	  z$_dash_metric_types.<locals>.<lambda>     r3   c                     t        | ||d      S )Nr^  r  r  s      r1   r	  z$_dash_metric_types.<locals>.<lambda> s    #=aAv#N r3   )r   rb  r  r  r:  zEquipment offsitec                      yr  rX   r  s    r1   r	  z$_dash_metric_types.<locals>.<lambda> r  r3   c                     t        | ||d      S )Nr  r  r  s      r1   r	  z$_dash_metric_types.<locals>.<lambda> s    #=aAy#Q r3   zBatches captured todayr  &QC (Batch Production) access required.zYards produced todayz!Out-of-range material lines todayzKiln sensors out of range nowr  &Togen SCADA group membership required.zMax kiln temperature nowzStale kiln sensors)wo_openwo_newwo_in_progress
wo_overduewo_completed_24hwo_pms_due_todaywo_awaiting_partsequipment_downequipment_offsiteqc_batches_todayqc_yards_todayqc_oor_todaykiln_oor_nowkiln_max_temp_nowkiln_stale_now)_DASH_WO_TYPE_PARAMr  r  r  r  r  r  r  r  r  r  r  r  r  r  r  )ra  s    r1   _dash_metric_typesr   s    <"/B.C#;M
 '"/B.C#;L
 /"/B.C#;T
 +"/B.C#;P
 -"/B.C#;0
 %"b#;1
 2"b/E2
 7 B$N	
 ) B$Q	
 .'4J4L3M#F.
 ,'4J4L3M#F,
 9'4J4L3M#F*
 5#r%F,
 0#r%F1
 *#r%F.
GjC\ JJ*,-Jr3   c                      i } t               j                         D ]>  \  }}|j                  d|      |j                  dd      |j                  dg       d| |<   @ | S )zClient-facing view of the metric registry: label + group + params, no
    callables. Feeds the Stat tile editor's source picker (SPA only).r   rb  r7   r  )r   rb  r  )r  r   r   )r   r2  r  s      r1   _dash_metric_types_publicr    sd     C')//1 
	TXXgs+XXgr*hhx,
C
 Jr3   c                    | j                  d      xs g }t               }i }i }t        |      D ]  \  }}t        |t              s|j                  d      xs dj                         }|s=|j                  |      }	|	ddd|t        |      <   b|	j                  d      }
t        |
      r. |
|      s&dd|	j                  d      xs d	d
|t        |      <   	  |	d   |j                  d      xs i ||      }t        |t              r|nddi|t        |      <    d|dS # t        $ r( t        j                  j                  d|       ddd}Y Yw xY w)u  Resolve any metric-bound Stat rows (DVI-1312 P1).

    Returns {"access": True, "metrics": {row_index: result}} where result carries
    the resolved scalar (or a per-row {ok:false}/{access:false} soft state — the
    D8 gate is per source, so a row the viewer can't access redacts that ROW,
    never the whole tile). Unbound rows are absent from `metrics` and render from
    their typed value. A fully-manual tile returns an empty metrics map.rn  rU  r7   FzUnknown metric.r  r  r  zNo access to this data source.r  r:  r  zstat metric %s failedzThis metric failed to run.rU  T)r  metrics)r   r  r  r   r   r   r*   r  ry   rm   rn   r  )rJ  r'  rn  ra  metrics_outr  r  r  r2  r  accr  s               r1   rJ  rJ  - si    776? bD

CK
CD/ N3#t$wwx &B--/wws|<).;L"MKAhhx C=Tu88$45Y9Y#[KA 	I$u+cggh/52tSAC &0T%:cuCF+N, {33	  	IJJ  !8#>+GHC	Is    D%%.EEc                 4   |j                  d      s|j                  d      sdddS t               }|j                  di       }|j                  di       }|j                  di       }t        j                         }|j                  d	      xs g }|j                  d
      }| j                  d      xs g D 	cg c]  }	|	s|		 }
}	t	        | j                  dd            }| j                  dd      }t	        | j                  dd            }g }|D ]  }|j                  dd      }|
r||
vr|j                  |i       }|j                  |      }|r|rt        ||j                  d            nd}|j                  ||j                  |d      |j                  d      |j                  dd      |j                  dd      |j                  d      ||d        g }|r|
rddddj                  |d      }	 t        j                         t        d      j                  |      z
  }t        j                  |
xs d|j                  d             }|D ]U  }|j                  dd      }|j                  |      }|s)t        ||j                  d            sE|j                  |       W t        t        j                  |            }d}|r{t#        j$                         }|D cg c]  }|j                  d!      d"k7  s| }}|rAt'        |d# $      }|j                  d%      |j                  d&      |j                  d'      d(}t)        |      }t+        | j                  d)            }|r|d| }d||||||d*S c c}	w # t         $ r Y w xY wc c}w )+zProvide SCADA live snapshot + scheduler status per the tile's config.

    Gate: same as scada_required (togen_scada or togen_admin).
    r   r   Fr  rd  rP  r  rN  r  rU   r$  r  Tr  r;  r  r   r7   r  r	  rT  r_   )r   aliasr  r	  rT  r_   r  r  r   0      )r;  r  r  r   r)  Nr  )r$  sincer	  r  c                 &    | j                  dd      S )Nr  r7   r  rT  s    r1   r	  z+_dash_provider_scada_view.<locals>.<lambda> s    j"0E r3   r  r  r  r  )r  r  r  r}  )r  	snap_timer  r@  oor_in_windowr  scheduler_status)r   r4  r  r/  r   _scada_value_out_of_rangeri   r  r  rv  r   r  r  r  r   r  ry   r  r  rC  rC  r0  ) rJ  r'  	scada_cfgrP  r  rN  r:  readings_rawr  r  wanted_sensorsr  r  r  r  r  r   r  r  r  r  window_hourssince_dtr  r[  r  rk  rB  rs  r  r@  rx  s                                    r1   r  r  R ss   
 HH]#txx'>+STT"$I]]8R(F==$DmmIr*G&&(D88J'-2L I"%'')"4":AAqaANACGGJ-.Hu-J#''"2D9:NH gr"e>9HHUBjj@HS'QUU7^<V[[[+UU7^EE&"%EE&"%%%+	
 		& MN!"3?CCJPRS	%,,.J1G1Q1QXd1Q1eeH --&.$''(;<F  .eeGR(jjo4S!%%.I!((-	.
 !}!=>M $113 #C1quuX('BQC	Cy&EFD#xx6 HHZ0 $ 8  ME
#''+.
/C
DS>& , w BP  		 Ds7   9NN:BN ?N 1N (NN	NNc                     | |t        |j                  d      xs d      t        |j                  d      xs d      t        |      dS )zCOne production-summary section from a qc_store.daily_totals result.r  r   r  r  )rH  r   r  r  r  )r   r   r  r  )seg_idr   r  s      r1   _batch_prod_sectionr   sH     6::m49:VZZ6=#>!&) r3   c           	         t        |      sdddS | j                  d      xs dj                         }t               }g }	 |rIt	        j
                  ||      }|j                  t        |t	        j                  ||      |             |}n{t	        j                         D ]O  }t	        j
                  ||d         }|j                  d      s.|j                  t        |d   |d   |             Q t	        j
                  |      }d
||t        |j                  d      xs d      t        |j                  d      xs d      t!        |      |dS # t        $ r( t        j                  j                  d	       d
dddcY S w xY w)u  QC Batch Production daily summary tile (DVI-1524 P5) — sibling of the
    Kiln Metrics (scada_view) tile. Shows today's per-segment batches / yards /
    out-of-range material lines from qc_store. Gate: any qc leaf (Togen Admins
    pass); a viewer without QC access gets {access:false} and the tile is redacted
    (never data), consistent with every other data tile provider.

    Config: `segment` (blank = all plants -> per-segment breakdown).Fr  rd  rH  r7   r  r  r   z batch_production provider failedTr  r  r  r  r   r  r  )r  r   rH  r  r  r  rt  )r  r   r   r  r  r  ri   r  r  r  ry   rm   rn   r  r   r  r  )	rJ  r'  r  r	  rt  r   overallr  r{	  s	            r1   r  r   su    +STT779#
*
*
,COEH3%%eS1AOO/X++C5q: ;G++- R**5!D':66-(OO$7$7R$PQR ++E2G 7;;}5:;W[[7>3?!'* 	  3

?@U13 	33s   BE 9E .FF	range_keyc                    ddl }t        j                         j                         }| dk(  r||fS | dk(  rq|j                  dk(  r!|j                  |j
                  dz   dd      }n*|j                  |j
                  |j                  dz   d      }|||j                  d      z
  fS |||j                  d|j                         z
        z   fS )	u  Return (start_date, end_date) inclusive for a calendar tile range.

    Windows begin at *today* — a glanceable tile shows upcoming entries, not
    past ones. this_week runs today → Sunday of the current week; this_month
    runs today → the last day of the current month.
    r   Nr	  r  r-  r^   r  r   )r   r  r  r   r  yearr   r  )r  r  r	  r  s       r1   _dash_calendar_windowr   s     !!#((*EGe|L ;;"((5::>1a0C((5::u{{Q:CcCMMqM1111%#--a%--/.A-CCCCr3   r[  	start_isoend_isoc                 :   t               }|sg dddfS ||dddd}	 t        j                  t         d|  d	d
| dd|d      }|j                  dk(  rg dddfS |j                  dk7  rg dd|j                   ddfS g }|j                         j                  dg       D ]  }|j                  |j                  d      |j                  d      xs d|j                  d      xs i j                  d      |j                  d      xs i j                  d      t        |j                  d             |j                  d!      xs i j                  d"      xs d#d$d%        |d&fS # t        $ r) t
        j                  j                  d       g dddfcY S w xY w)'u"  Fetch M365 calendarView events for ``mailbox`` via the app-only Graph
    token. Returns (events, err): ``err`` is a dict {reason, message} on soft
    failure (missing config / 403 policy / API error) with events == [];
    otherwise err is None. Mirrors /calendar/events — never raises.rc  r  )rm  r  z0id,subject,start,end,isAllDay,location,organizerr  rd  r  r]  r  r  r  r  r   r  z#Dashboard calendar tile fetch errornetworkr  r  rj  z(Calendar not available for this mailbox.rK   r  zCalendar service error ().r  r  r  r  rq   r  r  r  r  r  r7   r  )r  r  rq   r  r  r  r	  N)rY  r  r   r^  ry   rm   rn   r  rg   r   ri   r   )r[  r  r  rD  r  r  r:  r-  s           r1   _dash_graph_calendarr   s   
  EhDF F 	F #E$F	H!!m77)=9(/w&7792	' 3lIK K 	K3g!9$:J:J9K2NP P 	PFiikoogr* 	&&,vvi(8LffWo+00<FF5M'R,,Z8BFF:./&&,277FL"!
 		 4<-  H

BCiFH H 	HHs   )E( (/FFc                    | j                  d      xs d}| j                  d      xs d}t        |      \  }}|dk(  rit        |      sdddS t        ||      }|j	                  d 	       |d
d }t        |      }t        | j                  d            }|r|d
| }ddd|||dS |dk(  r-| j                  d      xs dj                         }	|	s(dd|dddS |j                  dd      xs d}	|	sdd|dddS |j                  d      }
|j                  d      }t        |	|
|      \  }}|dd||	|d   |d   dS t        |      }t        | j                  d            }|r|d
| }dd||	|||dS )u+  Calendar tile data provider (D6).

    * ``wo``     — Work Order calendar entries; gated like /calendar/wo-events
                   (_user_can_access_pm). No access → {"access": false}.
    * ``my``     — the *viewer's own* M365 mailbox (per-viewer, so sharing a
                   dashboard never leaks the owner's calendar).
    * ``shared`` — an explicitly configured mailbox address.

    Graph modes always return {"access": true, "available": bool}; on a soft
    failure available is false with a message the renderer shows as a chip.
    r	  r+  r  rz  Fz0Work Order Admin or Togen Admin access required.rd  c                 ,    | j                  d      xs dS )Nrq   r7   r  rT  s    r1   r	  z)_dash_provider_calendar.<locals>.<lambda>> s    155>#7R r3   r  Nrd  r}  T)r  r  r	  r  r:  r@  re  r[  r7   rc  z+No shared mailbox configured for this tile.r  r  r	  rm  r  r  z(No mailbox associated with your session.z%Y-%m-%dT00:00:00z%Y-%m-%dT23:59:59rm  r  )r  r  r	  r[  rm  r  )r  r  r	  r[  r  r:  r@  )
r   r  r  r  r  rC  r0  r   r  r  )rJ  r'  r	  r  r  r  r:  r@  rx  r[  r  r  r  s                r1   r  r  ) s    WWX&$F /KI.y9Iw~"4(#PR R$Y889Fcggk23DS\FTT"fuF 	F 779%+224"&&LN N ((7B'-2"&&IK K ""#67I23G&w	7CKFC
Uf"c(my>+ 	+ KE
#''+.
/C
f r3   c                    | j                  d      xs dj                         t               }sdddddS t        fd|d	   D        d
      }|dddddS | j                  d      xs g D cg c]  }|s|	 }}t	               }t               i i }	}}|D ]G  }
|j                  |
      }|r |d   |      s"|j                  |
        |d          ||
<   |d   |	|
<   I g }|j                  dg       D ]  }|j                  d      }
|
t        k(  rg|j                  |j                  d      |
|j                  d      |j                  d      |j                  d      |j                  dd      dd
d       |
|vr|j                  |
i       }|j                  d      }|j                  |j                  d      |
||j                  d      |j                  d      |j                  dd      ||v |j                  |      d        |j                  d      xs i j                         D ci c])  \  }}||j                  d      |j                  d      d+ }}}t        |d   d       D cg c]  }|d   	 }}dt        |      |rdnd|d   |j                  dd      |j                  d       |d!|t        |      D 
cg c]
  }
|
|	|
   d" c}
| j                  d#      xs d|d$   |d%   |t        | j                  d&d            t        | j                  d'd            d(S c c}w c c}}w c c}w c c}
w ))aL  Resolve the configured plan + accessible layer subset and return plan
    image meta + server-gated marker placements in one round trip (mirrors
    /oversight/data). Returns {"available": false, ...} with a message when the
    plan is unconfigured / missing / imageless, so the tile renders a note
    rather than a broken viewer.rO  r7   TFrc  u=   No plan selected for this tile — edit the tile to pick one.)r  r  rm  r  c              3   4   K   | ]  }|d    k(  s|  ywr	  rX   r,  r  r  s     r1   r/  z+_dash_provider_oversight.<locals>.<genexpr>z s     @qQtW-?@r  r_  Nz3The plan configured for this tile no longer exists.r[  r  rd   r   r  layerr  r  r  r|
  r  r  r  r  r|
  r   r  r   rm  r  r}
  r  r`  c                 l    | j                  dd      | j                  d      xs dj                         fS r  r<  r  s    r1   r	  z*_dash_provider_oversight.<locals>.<lambda> r  r3   r  z$This plan has no image uploaded yet.r9   r  )r  r9   r  rm  r  r\  ra  rb  r]  r  )r  r  r  rO  r  r[  r\  ra  rb  r`  r]  r  )r   r   rx  r  r  r  r  OVERSIGHT_LOCATION_LAYERri   r   r  r   )rJ  r'  r  rO  rw  
req_layersr  
accessiblelayer_statuslayer_labelsr  r  r  plr{	  r  r   r  rm  r  styles_orderedr  s                        @r1   r  r  o ss    wwv$"++-G!#DUhZ\ 	\@DM@$GD|UhPR 	R "ggh/52<!!<J< "H-0UBlJ *||C >4>$/s*DN,S MS* Jhh|R( ffWo**ffTlSVVC[rvvc{,tT 
 j c2&ffUm&&,S266#;VVGR(BYs	
 	* #hhx06B==?A#r 266#;77 AF A'-XH(J K!ag KN K &\2%KDz488FB+?!XXj1VE $Z02 S(9: 2 '''"(b#$9:"#78 CGGM489CGGM489! K =@AK2s   0L18L14.L68L<Mz/dashboardsc                     t               } t               }|j                         D cg c]+  }|j                  d      xs dj	                         | k(  s*|- }}|j                  d        |j                         D cg c]f  }|j                  d      xs dj	                         | k7  r>| |j                  d      xs g D cg c]  }t        |      j	                          c}v r|h }}}|j                  d        t        |       j                  d      xs d}||D ch c]  }|j                  d       c}vrd}t               }|s|r|d   }t        |||D cg c]  }t        |       c}|t               t               t               t        t        d	   j                  d
            d	      S c c}w c c}w c c}}w c c}w c c}w )zfList the current user's dashboards + their default pick + the tile-type
    registry the editor needs.rL  r7   c                 H    | j                  d      xs dj                         S r;  r<  r  s    r1   r	  z!dashboards_list.<locals>.<lambda> s    QUU6]0b779 r3   r  r  c                 H    | j                  d      xs dj                         S r;  r<  r  s    r1   r	  z!dashboards_list.<locals>.<lambda>Ŗ     quuV}299; r3   default_dashboardr  r'  r   T)	r  
default_idshared_with_mesystem_dashboard
tile_typesmetric_typesdisplay_defaultscan_manage_sourcescan_edit_manual_values)r  r  r  r   rE  r  r*   r  r  r   rU  r  r  _load_display_defaultsr   r   )r  r	  rn  miner  re  r  r|  s           r1   dashboards_listr   s   
 EE||~Q!!%%.*>B)E)E)G5)PAQDQII9I: TA%%.&B--/58QUU>5J5PbRqCFLLNRR  TF T KK;K<$U+//0CDJJt4!!%%+44
(*F &D\
dzCI"Ja#6q#9"J$*:< 9 ;
 %;$<&*76?+>+>}+M&N +/0 0 R
 ST
 5 #Ks0   +F6F6?AG  F;"G #GG;G r  c                    | d   | d   | d   | d   | d   | d   d}| d   dk(  r}|j                  | j                  d	d
      | j                  d      xs i | j                  dd
      | j                  dd      | j                  dd
      | j                  dd
      d       |S | d   dk(  r%|j                  d| j                  dd
      i       |S |j                  | j                  dd
      | j                  dd
      | j                  dd
      | j                  dd      d       | j                  d      dk(  r|j                  | j                  dd
      | j                  dd
      | j                  dd      | j                  dd       | j                  d!d"      | j                  d#d
      | j                  d$d      | j                  d%d&      | j                  d'd
      | j                  d(d
      d)
       |S )*zAdmin-facing source shape (full config incl. gating). Same fields the
    editor POSTs back. Type-specific keys are included per source type.r  r9   rT  r	  r  r
  r  r  r6  r7   r  r  r  r&  r  r  r  r  r  r  r<  r	  r  r  r  r  r  r  r  r  r  r	  r  r   r  r  r  r^   r  r  r  )r  r   )r  r   s     r1   _dash_source_publicr   ߖ s    4!F)QvY+ao^,.C 	yM!

aeeHb1eeHo3eeHb1 !j' :!"{B!7#$55#;= 	>. J# 
6h	

GQUU7B/01  J 	

{B!7UU7B/vr9J#$55#@B 	C 558+JJQUU;%;$%EE*b$9%&UU;%G'(uu]G'D'(uu]A'>&'eeL"&="#%%'":&'eeL!&<&'eeL"&=eeD"o	/ 	0 Jr3   z/dashboards/sourcesc                  ~    t        t               d         } t        | D cg c]  }t        |       c}      S c c}w )zBAdmin: list the configured Stat/Metric data sources (DVI-1312 P2).r  )r  )r  r  r   r   )r  r  s     r1   dashboards_sources_listr    s6     %%<%>y%IJGGDq/2DEEDs   :c                    i }t        | j                               t        |j                               z  D ]R  }|dk(  r	| j                  |      |j                  |      k7  s-| j                  |      |j                  |      d||<   T |S )zField-level change map between two sanitized source dicts (excludes id),
    same {field: {from, to}} shape as the Assets asset_edited detail.r  )r2  rX  )r  r,  r   )r  r$  r  r  s       r1   _dash_source_diff_fieldsr   sz     G_s388:. @9771:#"%''!*CGGAJ?GAJ	@
 Nr3   c                    | D ci c]  }|j                  d      s|d   | }}|D ci c]  }|j                  d      s|d   | }}|j                         D ]8  \  }}||vr||j                  dd      |j                  dd      d}|j                  d      dk(  r|j                  dd      |d<   t        dd	|
       |j                  d      dk(  s}t        ||j                  dd      |j                  dd             t	        ||   |      }|st        dd||j                  dd      |j                  dd      |d
       |j                  d      dk(  sd|v st        ||j                  dd      |j                  dd             ; |j                         D ]<  \  }}||vst        dd||j                  dd      |j                  dd      d
       > yc c}w c c}w )au  DVI-1382 P1 (D3): diff the admin whole-list Sources save into per-source
    added / edited / deleted history events so SharePoint/report source edits are
    auditable field-by-field (in addition to the bulk dashboard_sources_saved
    summary). A manual value change is also mirrored into the durable value
    store so the point-in-time report picks up admin-side edits.r  r9   r7   rT  r  r9   rT  r  r  r  r  r	  r  )r  r9   rT  r  r  N)r   r   r	  r  r  )old_sourcesnew_sourcesr  old_bynew_byr   r	  r  s           r1   _dash_record_sources_historyr   s    #.=Qtagqj=F="-=Qtagqj=F=,,. <Qfvr):eeFB/1FuuV}("#%%"4w=*B6RuuV}(#Cvr):AEE'2<NO.vc{A>G}.G-0!%%:K/0uuVR/@29';< 55=H,G1C'QUU62->()gr(:<#<& ,,. ?Qf=*D),aeeFB6G+,55+<#>??+ >=s   G'G'G,G,c            	      t   t        j                  d      xs i } t        t               d         }t        | j	                  d            }t        d|i       t        j                          t        dddt        |      i       t        ||       t        d|D cg c]  }t        |       c}      S c c}w )	u   Admin: replace the whole data-source list (the editor manages it client
    side and POSTs the full array — same shape as the Power BI query library).Tr  r  r  r  r  r  rU  r  )r   r  r  r  r   r  r  rR  r	  rC  r  r   r   )r   r  r  r  s       r1   dashboards_sources_saver  6 s    
 4(.BD()@)B9)MNK$TXXi%89GY01 =";"CL13 g6dW$M%8%;$MNN$Ms   B5
z /dashboards/sources/report-typesc            	          t               j                         D  cg c]  \  } }| |d   |d   |d   d }} }|j                  d        t        d|      S c c}} w )	zAdmin: registered Auditor/Reporting report types the report_cell editor
    binds to (id/label/params). Admins hold every report resource.r   r	  r  r  r   r	  r  c                 (    | d   j                         S Nr   rs  r*  s    r1   r	  z1dashboards_sources_report_types.<locals>.<lambda>P s    qz//1 r3   r  TrU  r  )_auditor_report_typesr   r  r   )rK  r   r  s      r1   dashboards_sources_report_typesr  H sl     34::<>3 AgJ!H+H+' >G > LL1L2dG,,	>s   Az/dashboards/sources/testc                  8   t        j                  d      xs i } t        | g      }|st        dd      dfS |d   }|d   d	k(  r5t	        |j                  d
      xs d      }t        d|t        |            S |d   dk(  r|d   |j                  d      xs i d}	 t        |t        d         \  }}|5|j                  d      xs |j                  d      xs d}t        d|      S |j                  d      xs g D cg c]-  }t	        |j                  d|j                  dd                  / }	}t        ||      \  }
}}|rt        d||	      S t        d|
||	      S |j                  d      dk(  rI	 t        |d      \  }
}}}|j                  d      xs g }	|rt        d||	|"      S t        d|
||	|#      S 	 t        |      \  }
}}|rt        d|      S t        d|
|      S # t        $ r/ t        j                  j                  d       t        dd      cY S w xY wc c}w # t        $ r- t        j                  j                  d        ddd!i f\  }
}}}Y w xY w# t        $ r( t        j                  j                  d$       d%\  }
}}Y w xY w)&a/  Admin: resolve a single (possibly unsaved) source live, bypassing the
    cache, so the editor's Test button proves the config before saving. Never
    persists anything. For a report_cell source it also returns the report's
    column names so the admin can see what to bind the target/match column to.Tr  Fz"Fill in the required fields first.r  r`  r   rT  r  r  r7   r  r  r6  r  r  r'  zdata source report test failedzthe report could not be run.Nrm  r  zthe report is unavailable.r   r   r)  )rU  r  r   )rU  r  r  r   r  r  )rm  zdata source lookup test failedr  )rU  r  r   picked)rU  r  r  r   r  zdata source test failed)NNzthe cell could not be read.)r   r  r  r   r*   r   r@  r  r   ry   rm   rn   r  rs  ry  rB  )r   r  r$  r   rJ  r6  r  r  r  r"  r{  r  r  s                r1   dashboards_sources_testr  T s    4(.BD$dV,G%ACDGH 	H
!*C
6{h#'''"(b)$#oc6JKK
6{m#X#''(2C2IrJ	K5c76?KOFG ;;x( 3GKK	,B 32 e3// **Y/528 AEE'155#345 8 87Dce3==$'#tLL
ww})	T%>$&("GS#s
 wwy!'Re3SII$'##C1 	1F3C8c %s++dgc::A  	KJJ  !ABe+IJJ	K8  	TJJ  !AB%)41OQS%S"GS#s	T  F

67EcFs<   G/ 2H*:H/ I( /5H'&H'/3I%$I%(.JJc                 \    | d   | d   | j                  dd      | j                  dd      dS )Nr  r9   r  r7   r  r  )r  r9   r  r  r  r  s    r1   _dash_manual_publicr   s4    D'1V9quuWb7I%%e46 6r3   z/dashboards/sources/manualc                      t        t               d         } | D cg c]"  }|j                  d      dk(  st        |      $ }}t	        d|      S c c}w )z|Any signed-in user: list the manual data sources with their current
    values (feeds the Data sources dialog's Manual tab).r  rT  r  Tr  )r  r  r   r  r   )r  r  r  s      r1   dashboards_sources_manual_listr   sR    
 %%<%>y%IJG.5Sv(9R!!$SFSdF++ Ts
   AAc            
      :   t        j                  d      xs i } t        | j                  d      xs d      j	                         dd st        dd	      d
fS t        | j                  d      xs d      j	                         dt         }t               }t        |j                  d      xs g       }|j                  d|dt        d       t        |      }t        d|i       t        j                          t        fdt!        |      D        d      }|xs i j                  dd      }t#        ddd||d       t%        ||       t        d|rt'        |      nd|D cg c]!  }|j                  d      dk(  rt'        |      # c}      S c c}w )zAny signed-in user: quick-add a manual value source (name + optional
    starting value). Creates a `manual`-type source visible to all; refine its
    visibility later on the admin Sources tab if needed.Tr  r9   r7   Nrd  FGive the manual value a name.r  r`  r  r  r  r  )rT  r9   r  r  r	  c              3   r   K   | ].  }|j                  d       dk(  r|j                  d      k(  r| 0 yw)rT  r  r9   Nr  )r,  r  r9   s     r1   r/  z0dashboards_sources_manual_add.<locals>.<genexpr> s9      L!uuV}0QUU6]d5J  Ls   47r  r  r  )rT  r9   r  r  r  rT  rU  r	  r  )r   r  r*   r   r   r   r&  r  r  ri   r!  r  r  r  rR  r  rd  r	  r  r  )	r   r  rJ  r   r  r  
created_idr  r9   s	           @r1   dashboards_sources_manual_addr#   s    4(.BDtxx%2&,,.t4D%'FGLL!'R(..01H2HIE
!
#C
swwy!'R
(CJJ$#2JL M$S)GY01 Lx0 LMQSG-R$$T2.J=":#+T!+e=>
 
D%0d7#6w#?PT<C :q uuV}8 02 :; ;:s   *&Fz%/dashboards/sources/<source_id>/valuec           
        
 t        j                  d      xs i }t        |j                  d      xs d      j	                         dt
         }t        j                  ddt        | xs d      j                               dd 
t               }t        |j                  d      xs g       }d}|D ]`  }t        |t              st        j                  ddt        |j                  d	      xs d      j                               dd }|
k(  s^|} n |t        d
d      dfS t        |j                  d      xs d      dk7  rt        d
d      dfS ||d<   t        |      }t        d|i       t         j#                          t%        
fd|D        d      }	t'        dd
|	xs |j                  dd      |d       t)        
|	xs |j                  dd      |       t        d
|	xs |j                  dd            S )zAny signed-in user: update one manual source's value in place (save on
    exit each field, per Q2-A). Read-modify-write so it never clobbers an
    admin's unsaved structural draft; scoped to `manual` sources only.Tr  r  r7   NrR  rS  r  r  FUnknown data source.r  r  rT  r  z&Only manual values can be edited here.r`  c              3   L   K   | ]  }|j                  d       k(  s|  ywr	  r  r,  r  r   s     r1   r/  z.dashboards_source_set_value.<locals>.<genexpr>ߗ       ;ds(:!;r  r  r  r9   )r  r9   r  r  )rU  r  r  )r   r  r*   r   r   r&  rF  r\  rE  r  r  r   r   r   r  r  r  rR  r  r	  r  )r  r   r  rJ  r   r  r   r  r  r  r   s             @r1   dashboards_source_set_valuer)  ė s    4(.BD!'R(..01H2HIE
&&C	R$8$>$>$@
A#2
FC
!
#C
swwy!'R
(CF %&ff]BEIIdO,Ar(B(H(H(JKCRP#:F ~%'=>CC
6::f#$0%'OPRUUUF7O$S)GY01;W;TBE=">!$u.C.CFB.O$)+,
 eov2262>Fds5?F*?*?*LMMr3   c           
      J   t        j                  ddt        |xs d      j                               dd }| xs g D ]b  }t	        |t
              st        j                  ddt        |j                  d      xs d      j                               dd }||k(  s^||fc S  d|fS )zLocate a raw source entry by its (scrubbed) id. Returns (entry, sid) with
    entry None when not found; `sid` is the scrubbed id used for matching (ids
    are scrubbed to [a-z0-9_] the same way the sanitizer mints them).rR  r7   NrS  r  )rF  r\  r*   rE  r   r   r   )r   r  r   r   r  s        r1   _dash_find_source_entryr+   s     &&C	R$8$>$>$@
A#2
FC) %&ff]BEIIdO,Ar(B(H(H(JKCRP#:#: 9r3   z&/dashboards/sources/<source_id>/renamec           
      *  
 t        j                  d      xs i }t        |j                  d      xs d      j	                         dd }|st        dd	      d
fS t               }t        |j                  d      xs g       }t        ||       \  }
|t        dd	      dfS t        |j                  d      xs d      dk7  rt        dd	      d
fS t        |j                  d      xs d      }||d<   t        |      }t        d|i       t        j                          t        
fd|D        d      }t        dd
||d       t        d|rt        |      nd|D 	cg c]!  }	|	j                  d      dk(  rt        |	      # c}	      S c c}	w )u$  Any signed-in user: rename one manual source in place (DVI-1370 prod-verify
    follow-up). Read-modify-write so it never clobbers an admin's unsaved
    structural draft; hard-scoped to `manual` sources only — a non-admin can
    never rename a SharePoint/report source through here (400).Tr  r9   r7   Nrd  Fr  r  r`  r  r%  r  rT  r  z'Only manual values can be renamed here.c              3   L   K   | ]  }|j                  d       k(  s|  ywr	  r  r'  s     r1   r/  z+dashboards_source_rename.<locals>.<genexpr> r(  r  r  r   )r  r2  rX  r  r!  )r   r  r*   r   r   r   r  r  r+  r  r  r  rR  r  r	  r  )r  r   r9   rJ  r   r  r  r  r  r  r   s             @r1   dashboards_source_renamer.   s    4(.BDtxx%2&,,.t4D%'FGLL
!
#C
swwy!'R
(C)#y9KFC~%'=>CC
6::f#$0%FHILM 	M
fjj &B
'CF6N$S)GY01;W;TBE="<!$c>@d8=-e44<C :q uuV}8 02 :; ;:s   "&Fz/dashboards/sources/<source_id>c           	      V   t               }t        |j                  d      xs g       }t        ||       \  }}|t	        dd      dfS t        |j                  d      xs d      dk7  rt	        dd	      d
fS t        |j                  d      xs d      }|D cg c]	  }||us| }}t        |      }t        d|i       t        j                          t        dd||dd       t	        d|D cg c]!  }|j                  d      dk(  rt        |      # c}      S c c}w c c}w )u  Any signed-in user: delete one manual source (DVI-1370 prod-verify
    follow-up). Read-modify-write; hard-scoped to `manual` sources only — a
    non-admin can never delete a SharePoint/report source through here (400);
    structural management of other source types stays on the admin Sources tab.
    A Stat tile bound to a deleted source simply shows no value on its next
    poll (same as an admin removing it via the Sources tab).r  Fr%  r  r  rT  r7   r  z'Only manual values can be deleted here.r`  r9   r  r  r  r  Tr  )r  r  r   r+  r   r*   r  r  r  rR  r	  r  )	r  rJ  r   r  r   r9   rB  r  r  s	            r1   dashboards_source_deleter0   s7    "
#C
swwy!'R
(C)#y9KFC~%'=>CC
6::f#$0%FHILM 	Mvzz&!'R(D
-Qf_1
-C
-$S)GY01="<!$dHEGdW %C()f(A &9%; %C D D .%Cs   	D!%D!3&D&
c                  d   t        j                  d      xs t         j                  } | j                  d      xs dj	                         xs dd d }t               }t        |t                     }|||d   <   t        |       t        d|d	|j                  d       d
       t        d|      S )NTr  r9   r7   zUntitled dashboardr  r  r  zCreated dashboard 'rd
  r
	  rU  	dashboard)r   r  rg  r   r   r  r  r  r  rZ  r   )r   r9   r	  rY  s       r1   dashboards_creater5  4 s     4(8GLLDXXf#**,D0DdsKDE$ 0 23DE$t*Ui(;DHHV<L;MQ&OQdd++r3   z/dashboards/copy-systemc                  R   t               } | t        d      dfS t               }t               }t	        d|      }g }t        | j                  d      xs g       D ]  \  }}|j                  dd      }|j                  d	      t        v r|j                  d	d
      nd
}dt        j                         j                  dd  |||t        ||j                  d      xs i       t        |j                  d            d}	|dk(  rt        |j                  d            |	d<   t        |j                  d            }
|
dk7  r|
|	d<   |j                  |	        ||d<   |||d   <   t!        |       t#        d|d|j                  d       ddt%        |      i       t        d|      S )zClone the system Oversight dashboard into an editable personal dashboard
    (DVI-1213 P2 "Make a copy"). Tiles get fresh ids and re-sanitized config so
    the copy is fully owner-editable and independent of the central config.Nz,No system Oversight dashboard is configured.r  r  zOversight (my copy)r  rT  r7   rd  r  r9  r   rc  r:  r;  rY  r<  rP  rd  r  zcopied-from-systemzCopied system Oversight into 'r9   rd
  
tile_countrV  Tr3  )r  r   r  r  r  r  r   r>  rW  rX  rY  rg  r+  r(  r.  ri   r  rZ  rC  )r|  r  r	  rY  r  r  r   r  rd  r  _tzs              r1   dashboards_copy_systemr9  B s    )*F~KLcQQEE/7DE&**W-34 1fb!*+%%-;P*PquuVX&V^$**,**3B/01+E155?3HbI/j0AB
 8.quuV}=DL!!%%-0#:DLT!" DME$t*U2D(FtxxPVGWFXXY&Z&2CJ%?A dd++r3   z/dashboards/<dash_id>c                 V   t        j                  d      xs t         j                  }t        |       \  }}|t	        d      dfS d|v r;|j                  d      xs dj                         }|st	        d	      d
fS |dd |d<   t               |d<   t        |       t	        d|      S )z#Partial update (rename this phase).Tr  NDashboard not foundr  r  r9   r7   Name cannot be blankr`  r  r  r3  )	r   r  rg  rK  r   r   r   r  r  )rr  r   r	  rY  r9   s        r1   dashboards_updater=  i s     4(8GLLD"7+KE4|23S88~ &B--/!78#==DSzV"DUdd++r3   c                    t               }t        |       \  }}|t        d      dfS t        d|d|j	                  d       ddt        |j	                  d	      xs g       i
       || = t        |       t        |      }|j	                  d      | k(  r|j                  dd        t        ||       t        d      S )Nr;  r  r  r	  zDeleted dashboard 'r9   rd
  r7  r  rV  r  TrY  )
r  rK  r   rZ  r   rC  r  r  r  r  rr  r  r	  rY  r  s        r1   dashboards_deleter@  { s     E"7+KE4|23S88i(;DHHV<L;MQ&O&2C8I8OR4P%QS 	gU"5)H||'(G3($/E8,dr3   z/dashboards/<dash_id>/defaultc                     t               }t        |       \  }}|t        d      dfS t        |      }| |d<   t	        ||       t        d|d|j                  d       d	       t        d
|       S )zHPersist the user's default-dashboard pick in user_settings/<email>.json.r;  r  r  r  zset-defaultr  r9   z' as default dashboardr2  T)rU  r  )r  rK  r   r  r  rZ  r   r?  s        r1   dashboards_set_defaultrB   s     E"7+KE4|23S88"5)H$+H !x(mT(-dhhv.>-??U&VXdw//r3   z/dashboards/fullscreen-rememberc                  P   t        j                  d      xs i } t        | j                  d      xs d      j	                         }|st        d      dfS |t        k(  rt               }nt        |      \  }}|t        d	      d
fS t               }t        |      }|j                  d      }t        |t              si }| j                  d      dv }|rt        | j                  d            t        | j                  d            d||<   t        |      t         kD  rKt#        |j%                               dt           D ]  }|j'                  |d        n|j'                  |d       ||d<   	 t)        ||       t        d|j                  |            S # t*        $ r Y 'w xY w)u  DVI-1321 P1 (plan B6/D2): persist the user's "Remember these settings"
    Fullscreen choice per dashboard so the next Fullscreen opens directly with
    them and the dialog prefills. Durable across the user's browsers; the live
    same-browser transport (kiosk boot + Update) rides localStorage/Broadcast
    channel, so this store deliberately carries only the layout + use-interface
    flag (never a resolved interface snapshot — that is recomputed client-side
    from the user's live prefs per D5).Tr  rm  r7   zdashboard_id requiredr  r`  Nr;  r  fullscreen_rememberrememberTr^   rE   rd  Truere  r  use_interfacer  rH  )rU  
remembered)r   r  r*   r   r   r   r  r  rN  r  r  r   r   _signage_sanitize_layoutr   rC  _FULLSCREEN_REMEMBER_CAPr  r,  r  r  r   )	r  rr  rY  _storer  r  remember_mapre  r  s	            r1   dashboards_fullscreen_rememberrO   s    4(.BD$((>*0b1779G45s:: ***,*73|23S88E"5)H<< 56LlD)	*	!E	EB	 /txx/AB!$((?";<	!
W |77l//123M5M4MN .  -. 	$'&2H"#E8, d|'7'7'@AA  s   1F 	F%$F%z/display/defaultsc                  ,    t        dddit               S )us   DVI-1321 P2: read the org default display settings (Admin → Agents →
    Display → Default Display Settings).rU  TrX   )r   r  rX   r3   r1   display_defaults_getrQ  ՘ s    
 7d74677r3   c                     t        j                  d      xs t         j                  } t        | j	                  d      | j	                  d      d       t        dddt               i       t        d
d	dit               S )zDVI-1321 P2: persist the org default display settings. Layout is
    sanitized; a field left at its neutral value is a no-op in resolution, so
    the admin explicitly opts a value in by choosing a non-neutral setting.Tr  r  use_interface_defaultr  rS  r  display_defaults_savedr  rU  rX   )	r   r  rg  _save_display_defaultsr   r	  _org_default_layoutr   r  r  s    r1   display_defaults_saverX  ݘ st     4(8GLLDdhhx&859XX>U5VX Y=":#%8%:;=7d74677r3   z/user-settings/displayc                     t               } t        |       }t        j                  d      xs t        j                  xs i }|j                  d      dv r|j                  dd       n8t        |j                  d            t        |j                  d            d	|d<   	 t        | |       t        d|j                  d            S # t        $ r t        d
      dfcY S w xY w)u  DVI-1321 P3: the caller's own default display settings — the baseline for
    their Fullscreen views and their own Virtual Display Agents (never a shared
    physical display). ``clear:true`` removes the block so the user falls back to
    the org default.Tr  rR  rF  display_settingsNr  rH  rI  Could not save settings.r  r  )rU  rZ  )r  r  r   r  rg  r   r  rK  r   r  r   r   )r  r  r   s      r1   user_display_settings_saver\   s     E"5)H4(>GLL>BDxx@@'. /txx/AB!$((?";<(
#$>E8, dX\\:L-MNN  >78#==>s   !C	 	C#"C#z/togen-admin/regionalc                  p    t               } t        d| t               t        t              t                     S )u   Read the org default regional preferences (Togen Admin → General →
    Regional). ``resolved`` carries the effective org-level values (system
    baseline with the org overrides applied) for the panel to preview.T)rU  orgr  date_formatsr|  )_load_regional_settingsr   r  r  _REGIONAL_DATE_FORMATS_system_regional_baseline)r^  s    r1   regional_settings_getrc   s2     "
#Cd.?.A $%; <357 7r3   c                  0   t        j                  d      xs t         j                  xs i } t        t	        | j                  d      t              r| j                  d      n|        t               }t        ddd|i       t        d|t                     S )	zPersist the org default regional preferences. Each field left blank means
    'use the system baseline'; a chosen value becomes the org default for all
    users unless they override it in their own settings.Tr  r}  r  regional_settings_savedr^  r  )rU  r^  r  )r   r  rg  _save_regional_settingsr   r   r   r`  r	  r   r  )r   r^  s     r1   regional_settings_saverg   su     4(>GLL>BDJtxx
?SUY4ZDHHZ0`de
!
#C=";UCLQd.?.ABBr3   z/user-settings/regionalc                  b   t               } t        |       }t        j                  d      xs t        j                  xs i }|j                  d      dv r|j                  dd       nxt        t        |j                  d      t              r|j                  d      n|      t        fddD              sj                  d	      r|d<   n|j                  dd       	 t        | |       t        d|j                  d      t        |             S # t        $ r t        d
      dfcY S w xY w)u   The caller's own regional override (User Settings → Account → Regional).
    Each field blank = inherit the company default. ``clear:true`` removes the
    whole block so the user falls back to the org/system defaults.Tr  rR  rF  r}  Nc              3   @   K   | ]  }j                  |        y wrT   r  )r,  r  r  s     r1   r/  z.user_regional_settings_save.<locals>.<genexpr>- s     Puyy|Pr  r8  r   r?  r  r[  r  r  )rU  r}  r  )r  r  r   r  rg  r   r  _sanitize_regional_layerr   r   r2  r  r   r   r  )r  r  r   r  s      @r1   user_regional_settings_saverl   s    E"5)H4(>GLL>BDxx@@Z&($.txx
/CT$JDHHZ PTV P%OPP99Z(#(HZ LLT*>E8, dX\\*%=-e46 6  >78#==>s   "D D.-D.z/dashboards/<dash_id>/sharec                    t        j                  d      xs t         j                  }t        |       \  }}|t	        d      dfS d|v sd|v rk|j                  d      }||j                  d      }t        |      }|j                  d      xs d	j                         }|D cg c]
  }||k7  s	| c}|d<   d
|v rt        |j                  d
            |d
<   t               |d<   t        |       t        |j                  d      xs g       }t        d|d|j                  d       d| d|j                  d
      rdnd	z   |t        |j                  d
            d       t	        d|      S c c}w )u   Owner-only sharing settings (P3): set the view-only share list and/or the
    "Available for signage" flag. Partial — only keys present in the body are
    touched. Sharing is view-only; the owner is never added to their own list.Tr  r;  r  r  r  rA  rL  r7   r  r  re  zShared 'r9   z' with z user(s)z, signage on)shared_countr  rV  r3  )r   r  rg  rK  r   r   rS  rE  r   r  r  rC  rZ  )	rr  r   r	  rY  r   rA  rL  rB  n_shareds	            r1   dashboards_sharerp  : sh    4(8GLLD"7+KE4|23S88$H$4hh~&;((8$C','"(b//1+1@aQ%Z@^Dtxx	23Y"DU488N+1r2H$DHHV,-WXJhG&*hhy&9NrC (T$((9:M5NO	Q
 dd++  As   %
E40E4z/dashboards/<dash_id>/tilesc           
         t        j                  d      xs t         j                  }t        |       \  }}|t	        d      dfS |j                  d      xs dj                         }|t               vrt	        d      d	fS |j                  d
      xs dj                         }|t        vrd}t        ||j                  d      xs i       }t        |d   D cg c]  }|j                  dd       c}dgz         dz   }dt        j                         j                  d d  ||||t        |j                  d            d}	|dk(  rt        |j                  d            |	d<   t!        |j                  d            }
|
dk7  r|
|	d<   |d   j#                  |	       t%               |d<   t'        |       t	        d|	|      S c c}w )NTr  r;  r  r  rT  r7   Unknown tile typer`  rd  r  rc  r  r  r   r  r^   r9  r   r:  r;  rY  r<  rP  rd  r  rU  r  r4  )r   r  rg  rK  r   r   r   rf  r>  rg  rC  rW  rX  rY  r+  r(  r.  ri   r  r  )rr  r   r	  rY  r  rd  rc  r   r  r  r8  s              r1   dashboards_add_tilert  X s    4(8GLLD"7+KE4|23S88XXf#**,E)++01366HHV(//1D(("5$((8*<*BCFDM:qw":bTABQFE$**,**3B/0155F/0DEGD x*488F+;<V
dhhv.
/C
czVM"DUd66 ;s   Gz%/dashboards/<dash_id>/tiles/<tile_id>c                    t        j                  d      xs t         j                  }t        |       \  }}|t	        d      dfS t        fd|d   D        d      }|t	        d	      dfS d
|v r>|j                  d
      xs dj                         }|t        vrt	        d      dfS ||d
<   |j                  d
      dk(  r;d|v sd|vrEt        d|v r|j                  d      n|j                  d            |d<   n|j                  dd       d|v rt        |j                  d            |d<   d|v r7t        |j                  d            }|dk7  r||d<   n|j                  dd       d|v r2t        |j                  dd      |j                  d      xs i       |d<   t               |d<   t        |       t	        d||      S )z;Update a tile's size and/or type-specific config (partial).Tr  Nr;  r  r  c              3   L   K   | ]  }|j                  d       k(  s|  ywr	  r  r,  r   tile_ids     r1   r/  z)dashboards_update_tile.<locals>.<genexpr>} s      DqQUU4[G-CDr  r  Tile not foundrd  r7   zInvalid sizer`  rY  r<  r:  rP  rd  rc  rT  r  rs  )r   r  rg  rK  r   r  r   r   r>  r(  r  r+  r.  rg  r  r  )rr  rx  r   r	  rY  r  rd  r8  s    `      r1   dashboards_update_tilerz  u s    4(8GLLD"7+KE4|23S88DDMDdKD|-.33~ &B--/,,0#55V xx8#T>V4/.6T>txx/?48HHV4DFDL 	T2488J3GHZ~!$((6"23#:DLHHVT"4.txx/C040B0HbJX"DUd66r3   c                 B   t        |       \  }}|t        d      dfS t        |d         }|d   D cg c]  }|j                  d      |k7  s| c}|d<   t        |d         |k(  rt        d      dfS t	               |d<   t        |       t        d|	      S c c}w )
Nr;  r  r  r  r  ry  r  Tr3  )rK  r   rC  r   r  r  )rr  rx  r	  rY  beforer   s         r1   dashboards_delete_tiler}   s     #7+KE4|23S88gF $WH1t1GQHDM
4=V#-.33"DUdd++ Is   BBz/dashboards/<dash_id>/reorderc                    t        j                  d      xs t         j                  }t        |       \  }}|t	        d      dfS |j                  d      }t        |t              st	        d      dfS t        |      D ci c]  \  }}||
 }}}|d	   D ]!  }|j                  d
      |v s||d
      |d<   # |d	   j                  d        t               |d<   t        |       t	        d|      S c c}}w )zEReassign tile order from a client-supplied id list (drag-to-reorder).Tr  r;  r  r  r  z order must be a list of tile idsr`  r  r  c                 &    | j                  dd      S Nr  r   r  r*  s    r1   r	  z$dashboards_reorder.<locals>.<lambda> s    QUU7A%6 r3   r  r  r3  )r   r  rg  rK  r   r   r   r  r  r  r  r  )	rr  r   r	  rY  r  r  r   r  r   s	            r1   dashboards_reorderr   s     4(8GLLD"7+KE4|23S88HHWEeT"?@#EE )% 0
1fa36
1C
1'] &55;#QtWAgJ& 	M67"DUdd++ 2s   C8img_idc                     t        j                  dd| xs d      }|syt        j                  |dz         D ]  }|j	                         s|c S  y)zThe on-disk file for an image id, or None if absent/invalid. The id is
    scrubbed to a safe filename token so it can never escape the store dir.rV  r7   Nr1  )rF  r\  DASHBOARD_IMAGE_DIRr  r   )r  r  r  s      r1   _dashboard_image_pathr  Ǚ sR     66"B"5D!&&td{3 ::<I r3   z/dashboards/image-uploadc                     t         j                  j                  d      } | | j                  st	        d      dfS d| j                  v r-| j                  j                  dd      d   j                         nd	}|t        vrt	        d
      dfS | j                  t        dz         }t        |      t        kD  rt	        d      dfS t        j                  dd       dt        j                         j                  dd z   }t        | d| z  j!                  |       | j                  j                  dd      d   j                  dd      d   dd }t	        d||d|z         S )zStore one uploaded image and return its id/name/url. The caller stages the
    returned id into the tile config; the tile is saved through the normal
    add/update-tile route (I1: config carries ids only, bytes live on disk).r~
  NrP  r  r`  r  r^   r  r7   z3Unsupported image type. Use PNG, JPEG, WebP or SVG.zImage is too large (5 MB max).TrM   dashimg_r$
  r6   r  rK   z/dashboards/image/)rU  r  r9   rJ  )r   r  r   r  r   rj  rE  DASHBOARD_IMAGE_EXT_MIMESr  DASHBOARD_IMAGE_MAX_BYTESrC  r  r  rW  rX  rY  r-   )r  r  rG  r  r9   s        r1   dashboards_image_uploadr  ә sP    ==W%D|4==/0#5569T]]6J$--

sA
&r
*
0
0
2PRC
++RSUXXX99.23D
4y,,=>CCdT:$**,**3B//FfXQse,,99$?==Q'+224;B?EDdvD+f46 6r3   z/dashboards/image-deletec                      t        j                  d      xs t         j                  } t        | j	                  d      xs d      }|	 |j                          t        d      S # t        $ r Y w xY w)u   Remove an image from the disk store (called when the editor drops one).
    Best-effort — a missing file is not an error. The tile config no longer
    referencing it is saved separately through the update-tile route.Tr  r  r7   rY  )r   r  rg  r  r   r/   r   r   )r   r  s     r1   dashboards_image_deleter   sh     4(8GLLD	txx~3	4B	~	IIK d  		s   
A& &	A21A2z/dashboards/image/<img_id>c                 T   t        j                  d      xs i j                  d      st               }t        |t	                     yt        |       }|yt        j                  |j                  j                  d      j                         d      }t        t        |      |d      S )	u(  Serve an Image-tile image. Dual-auth: a logged-in session (SPA / Report
    Viewer / fullscreen preview) OR a valid display token (signage kiosk, which
    has no session) — the same exposure model as /display/plan-image. Images are
    author-placed dashboard content, not a gated data source.r'  r  r  r  rJ  r  r  r  rL  )r   r   _load_signage_agents_signage_agent_by_token_signage_bearer_tokenr  r  r!  rV  rE  r   r*   )r  r	  r  r:  s       r1   dashboards_imager   s     KK%2**73$&"5*?*ABJ(	v	&B	z$(()9)9#)>)D)D)F)CEDSWtU;;r3   c                     t        | t              r| j                  d      nd}t        |t              r%|j                  d      rd|j                  d      dS i S )zSurface an Auditor report's cached/as_of meta as the tile's cached badge
    (Idencia/Power BI reports carry it; internal reports don't).r  Nr  Tr  )r  r  r
  )r6  r  s     r1   _dash_report_meta_badger   sK     ",FD!96::ftD$$((8"4'):;;Ir3   c           
         t        | j                  d            }|sddddfS t               j                  |      }|sddddfS |t        |      vrddddfS t	        | j                  d	      t
              r| j                  d	      ni }|d	   D ci c]8  }|d
   t        |j                  |d
         xs d      j                         dd : }}	  |d   ||      \  }}|rdd|dfS t	        |t
              sddddfS |dfS c c}w # t        $ r* t        j                  j                  d|       ddddfcY S w xY w)u  Run the Auditor report bound to a Chart / Report Table tile.

    Returns (report, payload) — exactly one is non-None. `payload` is a
    structured tile payload (access=False / available=False) to return verbatim
    when the source can't produce data; `report` is the {columns, rows, meta}
    dict when it can. Soft-fails like every other tile provider (a bad source
    never raises out to a 500).r6  NFz*This tile has no data source selected yet.r  r  z,The selected report source no longer exists.-You do not have access to this report source.rd  r  r)  r7   r  r:  z.dashboard report-source tile: report %s failedz!This report source failed to run.z$This report source returned no data.)r  r   r  r2  r   r   r*   r   ry   rm   rn   r  )	rJ  r'  rK  
descriptorr4  r  r  r6  r  s	            r1   r  r   s    #''(+
,C5!MO O 	O&(,,S1J5!OQ Q 	Q ,T22 OQ Q 	Q",SWWX->"ESWWX2F!(+- hFJJqx06B7==?EE -F -F'j'5
 5S999fd#5!GI I 	I4<-  F

MsS5!DF F 	FFs   =DD 0D>=D>c                    t        | |      \  }}||S |j                  d      xs g }|D cg c]-  }t        |j                  d|j                  dd                  / }}|j                  d      xs g D cg c]  }t        |       }}t	        |      }	t        | j                  d            }
|
r
|	|
kD  r|d|
 }|||	d}|j                  t        |             |S c c}w c c}w )	zReport Table tile: a registered Auditor report dropped onto a dashboard
    as a tile, preserving its per-source gate (D8). max_items caps the rows the
    tile shows (0 = all); `total` carries the pre-cap count for a "+N more".Nr   r   r)  r7   rn  r}  )r   rn  r@  )r  r   r*   r  rC  r0  r  r  )rJ  r'  r6  r  r   r  r"  r  rn  r@  r}  r   s               r1   r  r  B s     .c48OFGjj#)rG;BCaCgquuUB/01CDC$jj06B8DG8D8IE 45IUY&JYD5
9CJJ&v./J D8s   2C8C$c                 x    t        | |      \  }}||S t        | |      }|j                  t        |             |S )zChart tile: run the bound report source, then shape its {columns, rows}
    into a normalized chart model (categories + numeric series) the SVG toolkit
    draws identically in the SPA, the signage kiosk, and Print-to-PDF.)r  _dash_chart_shaper  r  )rJ  r'  r6  r  r   s        r1   r  r  U sB     .c48OFG
C
(CJJ&v./Jr3   c                 
   t        | t              ryt        | t        t        f      r| S | y	 t        t	        |       j                  dd      j                  dd      j                               S # t        t        f$ r Y yw xY w)zBest-effort numeric coercion of a report cell (handles "1,234" / "56%" /
    None). Returns a float/int or None when the cell isn't numeric.Nr  r7   r  )	r   r   r   r  r*   r  r   r   r   r  s    r1   r@  r@  a sx     !T!c5\"ySV^^C,44S"=CCEFFz" s   AA0 0BBc                    |j                  d      xs g }|D cg c]-  }t        |j                  d|j                  dd                  / c}|D cg c]-  }t        |j                  d|j                  dd                  / c}t              |j                  d      xs g D cg c]  }t        |       }}|rS| j                  d      sB|d   rt        |d   d         nd}t	        j
                  d	|t        j                        r|d
d }fd} || j                  d            }|d}g }	| j                  d      xs g D ])  }
 ||
      |k7  s|	vs|	j                         + |	s<t              D ].  |k(  r	t        fd|D              s|	j                         0 |D cg c]   }|t        |      k\  rdnt        ||         " }}|	D cg c]4  }|   |D cg c]   }|t        |      k  rt        ||         nd
" c}d6 }}}d}t        | j                  d            }|r<t        |      |kD  r.t        |      |z
  }|d
| }|D cg c]  }|d   |d   d
| d }}| j                  d      xs d}|||r|   nd|d}|dv r|r|d   d   ng D cg c]  }||	 }}t        |      }| j                  d      }|t        |t        t        f      r|dkD  r|n|rt!        |      ndt        |t        t        f      xr |dkD  |r|d   d   n|d   d|d<   |S c c}w c c}w c c}w c c}w c c}w c c}}w c c}w c c}w )u  Tabular report -> chart model. The author may pin the label column and
    value column(s); otherwise the first column is the label and every numeric
    column becomes a series. A trailing "…(total)" row is dropped by default
    (a Total category dwarfs every real bar/slice) unless include_total_row is
    set. Categories are head-capped at max_slices (reports arrive pre-sorted, so
    the head is the top-N); the dropped count rides in `truncated`.r   r   r)  r7   rn  r  r  r   r@  Nc                     t        | xs d      j                         } | sy t              D ]2  }| |   k(  s%| j                         |   j                         k(  s0|c S  y r  )r*   r   r  rE  )rG  r  r,  r  ncolss     r1   	col_indexz$_dash_chart_shape.<locals>.col_index s_    #)n""$u 	Ad1g~q	0A!A	 r3   r  r  c              3   \   K   | ]#  }t        |      k  st        |         d u % y wrT   )rC  r@  )r,  r  r  s     r1   r/  z$_dash_chart_shape.<locals>.<genexpr> s)     Qa#a&j?1Q4(4Qs   ,,)r9   r  r  r9   r  r  r  )r  r	  r  x_labelr  )r  r  r  r  )r  r  
has_targetr   r  )r   r*   rC  r  rF  r  r  ri   r  r2  r@  r0  r  r   r   r  rC  )rJ  r6  r   r  r  rn  
first_cellr  	label_idx
value_idxsrG  r	  r  r  r  r  r  ctyper   r  valsr  tgtr,  r  r  s               `          @@@r1   r  r  p s    jj#)rG=DEc!%%ub!123EF;BCaCeQUU7B/01CDKE$jj06B8DG8D8CGG/0)-bSb!%r
99Xz2==99D #''+./I	J%+ !cN=Q)^0Ca ! u 	%AI~QdQQ!!!$		% OSSc!f,2#a	l2CCSJS "# aySWXaAAJ?1Q40DHXZ #F # I !67Jc*o
2
Oj0	,
!# V9(KZ0HI # # GGL!*UE (-6)$2C %%39F1Ih/rTaamTTD	ggh(sEl;ac\`sSWyfg$S3,7CC!G+1fQi's9~	
G JI FC8@ TX## UsA   2L>2M.M>%M*M7%MM)M6M">M"Mz/dashboards/tile-datac                     t         j                  j                  d      xs dj                         } t         j                  j                  d      xs dj                         | rst	        d      dfS | t
        k(  rt               }nt        |       \  }}|t	        d      d	fS t        fd
|j                  d      xs g D        d      }|t	        d      d	fS |j                  dd      }t               }|j                  |      }|t	        d      dfS |j                  d      }|st	        i       S t               j                  |      }t        |      st	        d      dfS t        j                  d      xs i }	|j                  d      xs i }
|dk(  rz|j                  d      xs dj                         t               k(  }|sJ|
j                  d      s9t        |
      }
|
j                  d      xs g D cg c]  }|t         vr| c}|
d<   	  ||
|	      }t	        |      S c c}w # t"        $ r1 t$        j&                  j)                  d|       t	        d      dfcY S w xY w)u  Dispatch to the registered provider for a single tile.

    Query params: dash_id, tile_id.
    Owner OR shared-with-me (P3) — mutations stay owner-only elsewhere.
    Each provider inline-checks its own role gate (D5) — a user without
    the role gets a structured {"access": false} payload that the client
    renders as a "no access" tile, never as an error or as data. This holds
    for shared viewers too: sharing grants view of the layout, never elevated
    data access.
    rr  r7   rx  z dash_id and tile_id are requiredr  r`  Nr;  r  c              3   L   K   | ]  }|j                  d       k(  s|  ywr	  r  rw  s     r1   r/  z'dashboards_tile_data.<locals>.<genexpr>ٚ       Pqt9OPr  r  ry  rT  rr  r6  r  Provider not availabler  r'  rc  r  rL  r  r[  z'dashboards/tile-data provider %s failedProvider error)r   rh  r   r   r   r  r  rN  r  rf  globalsr  r   rE  r  r   r  ry   rm   rn   r  )rr  rY  rM  r  r  r  r  provider_nameprovider_fnr'  rJ  is_ownerrw  r>  rx  s                 @r1   dashboards_tile_datar   sI    ||	*0b779G||	*0b779G'?@#EE** +,*73|23S88PTXXg.4"PRVWD|-.33HHVR E$&H<<D|01366HHZ(MB)--.KK 56;;;;v$"D
((8

"C HHW%+2248H8JJ 7s)C),):)@b C1 !)A A  CCM4S$' C  4

FV-.334s   H1	H6 67I0/I0z/tools/subpage/<surface>c                 (   | t         vrt        d      dfS t        j                  d      xs i }|j                  dd      }t	        | |      }t        | |d   |d   |d	   t        |       t        | |      t        |j                  d
                  S )a  Return the effective tiles-page layout for a surface (DVI-1496 P1, D7-A).

    The layout = admin default (or the catalog-seeded default when none is
    stored, D8-A) overlaid by the caller's own overrides, sanitized against this
    surface's catalog. Unknown surface -> 404.
    Unknown surfacer  r  r'  r  r7   r  r  r   r   )r  r  r  r   r  r'  can_edit_default)r  r   r   r   r  r  r  r   )r  r'  r  r  s       r1   tools_subpage_getr   s     n$./44;;v$"DHHWb!E/F
 7&/$\2&/.w7,We<$(-)@$A	C Cr3   c                 x   | t         vrt        d      dfS t        d   j                  dd      }t	        j
                  d      xs i }t        | |j                  d	      |j                  d
      xs i |j                  d      xs i d      }t        |      }|j                  d      }t        |t              r|ni }|d	   s|d
   s|d   s|j                  | d       n||| <   |r||d<   n|j                  dd       t        ||       t        | |      }t        d| |d	   |d
   |d   t        | |            S )u  Save the CURRENT USER's per-surface layout override (DVI-1496 P4, D7-A).

    Body {order, separators, items}. Stored under user_settings/<email>.json ->
    tool_subpages[surface], sanitized against this surface's catalog (cross-surface
    token rejection, D9). An empty order AND empty separators/items clears the
    override so the user cleanly inherits the admin default — this is the storage
    path behind the sub-page edit loop's "Reset my layout" (D10-A). The home save
    path (/user-settings) is untouched.
    r  r  r  r'  r  r7   Tr  r  r  r   r  r  N)rU  r  r  r  r   r'  )r  r   r   r   r   r  r  r  r   r   r  r  r  r  )r  r  r  rF  r  r  r  s          r1   tools_subpage_postr   s<    n$./44FO,E4(.BD$W'"hh|,2'"(b/ E
 #5)H<<(DdD)4rD>%"5eGn$W$(!_d+x(/FdG6'?$\2&/,We<> >r3   z$/togen-admin/tools/subpage/<surface>c           
         | t         vrt        d      dfS t        j                  d      xs i }t	        | |j                  d      |j                  d      xs i |j                  d      xs i d	      }t               }||| <   	 t        |       t        |d          t        d| |d   |d   |d   t        |             S # t        $ r}t        d
|       dfcY d}~S d}~ww xY w)a  Save a surface's ADMIN-DEFAULT layout (DVI-1496 P4, D7-A). Body
    {order, separators, items}, sanitized against the catalog and persisted in
    tool_subpages.json. This is the Default-scope edit; it is what every user with
    no personal override inherits.r  r  r  Tr  r  r  r   r  r;  r  N)rU  r  r  r  r   r  )r  r   r   r  r  r   r  r  r   r#  r  )r  r  rF  r   rl  s        r1   togen_admin_subpage_postr  = s     n$./444(.BD$W'"hh|,2'"(b/ E
 "#GGG<G$ U7^,dG5>#L1w.w79 9	  </u56;;<s   C 	C&C!C&!C&c                     t        j                         }t        t        j                               }dj	                  ||dk(  rdnd      }|r|dj	                  ||dk(  rdnd      z  }||rddS ddS )	Nz{:,} asset{}r^   r7   r  u    · {} part{} lowr  rU  r&  tone)r  asset_countrC  r  r	  )r'  r  rK  r&  s       r1   _summary_equipment_assetsr  ` su      "A
l**,
-C  !q&Bc:D
#**3cQhCHHC&::T::r3   c                 `    t        t                     }dj                  ||dk(  rdnd      ddS )Nz{} manual{} indexedr^   r7   r  rU  r  )rC  r  r	  r'  r  s     r1   _summary_equipment_manualsr  i s1     A)00!q&BcJTXYYr3   c                 z   t        dddd|       }|j                  d      sy |j                  d      xs g }t        d |D              }t        d |D              }g }|r |j                  dj	                  |             |r |j                  d	j	                  |             |sd
ddS dj                  |      ddS )NTF)r  r  r  r  r   c              3   j   K   | ]+  }|j                  d d      |j                  dd      k  rd - yw)r  r   r  r^   Nr  r  s     r1   r/  z)_summary_equipment_rpm.<locals>.<genexpr>u s7      RrFF?A6a9PP  Rs   13c              3   L   K   | ]  }|j                  d d      dkD  sd  yw)r  r   r^   Nr  r  s     r1   r/  z)_summary_equipment_rpm.<locals>.<genexpr>w s#     ObRVV4F-JQ-NOs   $$z{} awaiting partsz{} to approvezNo open partsrU  r  r	  r  )r  r   r  ri   r	  r  )r'  r   r   r  awaiting_apprr0
  s         r1   _summary_equipment_rpmr  n s    tV[\D 88HHHW#E RU R RNOEOOME(//?@_++M:;'66KK&77r3   c                 $   t               }|j                  dd      |j                  dd      }}|s|sdddS g }|r |j                  dj                  |             |r |j                  dj                  |             d	j	                  |      d
dS )Nr^  r   r  zAll equipment in servicerU  r  z{} downz
{} offsiter	  r  )rb  r   ri   r	  r  )r'  r  r^  r  r0
  s        r1   _summary_equipment_oversightr   s    !#Afa !%%	1"5#D2DAAEY%%d+,
\((-.KK&77r3   c                  H    t        j                         j                  d      S r"  )r  r  r  rX   r3   r1   r  r   s    ((44r3   c                 "   t        j                  t                     }t        |j	                  d      xs d      }|sdddS dj                  ||dk(  rdnd	      }|j	                  d
      }|r|dj                  t        |            z  }|ddS )Nr  r   zNo batches todayrU  r  z{} batch{} todayr^   r7   esr  u    · {:g} yd³)r  r  r  r   r   r	  r  )r'  r   r  r&  r  s        r1   _summary_qc_batchesr   s    mo.AAEE- %A&A*D99$$Qa1f$?D	
}	B	&&uRy11$''r3   c                     t        j                  t                     }|sdddS dj                  ||dk(  rdnd      ddS )NzNone submitted todayrU  r  z{} segment{} submitted todayr^   r7   r  )r   daily_production_submitted_countr  r	  r  s     r1   _summary_qc_dailyr   sG    11-/BA.==299!16RsS r3   c                     t        j                         }|sdddS t        |j                  d      xs d      }dj	                  |d   ||dk(  rd	nd
      ddS )NzNo imports yetrU  r  r  r   u   Last import {} · {} batch{}ro  r^   r7   r  )r  last_importr   r   r	  )r'  rl   r  s      r1   _summary_qc_importr   sg    !D($77DHHW"#A299\AQ!Vr7@DF Fr3   c                 `    t        j                         }|sdddS dj                  |      ddS )NzNo batches yetrU  r  zData through {})r  last_batch_dater	  )r'  rn  s     r1   _summary_qc_reportsr   s4      "A($77%,,Q/>>r3   c                 4   t        j                         xs g }t        |      D ]n  }t        |t              s|j                  d      s&|j                  d      xs |j                  d      xs dd d }|rdj                  |      ddc S d	ddc S  d
ddS )Nr  r  r  r7   r   zLast report {}rU  r  zReport generatedzNo reports generated yet)r  r  rd  r   r   r   r	  )r'  rk  r~  r  s       r1   _summary_km_reportingr   s    
 
-
-
/
52C} >c4 SWW]%;77:&M377=+A+GR"*MD 0 7 7 =tLL.==> /==r3   c                 b   t               }|sy t        d |j                         D              }d}|j                         D ]T  }	 t        |j	                  d            }|j	                  d      |j	                  d      }}|||k  s	|J||kD  sP|dz  }V |s)|s't        |      }dj                  ||dk(  rdnd	      d
dS g }	|r |	j                  dj                  |             |r |	j                  dj                  |             dj                  |	      ddS # t
        t        f$ r Y w xY w)Nc              3   D   K   | ]  }|j                  d       sd  ywr  r  r&  s     r1   r/  z(_summary_km_oversight.<locals>.<genexpr>Λ s     =aaeeGn=r  r   r  rK  r  r^   z{} sensor{} reportingr7   r  rU  r  z{} out of rangez{} staler	  r  )r  r  r  r  r   r   r   rC  r	  ri   r  )
r'  rd   r  r  r  r  r#  r$  r  r0
  s
             r1   _summary_km_oversightr  ʛ s,   $&F=6==?==E
C]]_ 	quuW~&B uquuV}BNrBwBNrBw1HC K/66qQ"CP 	E
&--c23Z&&u-.KK&77 :& 		s   DD.-D.)
)r  r  )r  r   )r  r  )r  r  )r  r  )r  r  )r  r  )r  r  )r  r  )r  r  c                     t        |       }|si S i }|d   D ]R  }|d   }t        j                  | |f      }|s"t        | ||      s0	  ||      }|s<|j                  d      sN|||<   T |S # t        $ r Y bw xY w)zD4-A live summary lines for a surface's visible catalog tiles, keyed by
    catalog id -> {text, tone}. Best-effort: a builder that raises (or returns
    None / no text) simply yields no summary for that tile.r  r  r&  )r  _SURFACE_SUMMARY_BUILDERSr   r6  ry   )r  r'  r  r   r(  r  builderr  s           r1   _surface_tile_summariesr   s     J'G	
Cy! $i+//S0AB
C6	A vCH J	  		s   A..	A:9A:z/tools/tile-datac                  T   t        j                  d      xs i } | j                  dd      }t        j                  j                  d      }|r(|t        v r t        ||      j                  d      xs i }nd}t        |      }i }i }|j                         D ]h  \  }}|j                  d      }|dk(  r8|j                  d	      |j                  d
      xs i d}	t        |	| d      ||<   T|dk(  sZt        ||       ||<   j t        ||      }
|rt        ||       |
d<   t        di |
S )aM  Run every dashboard-tile home item (DVI-1346 P2) under the VIEWER identity
    and return {tiles:{item_id: payload}}.

    Each tile is dispatched through the shared _report_run_tile, so every provider
    enforces its own source gate (D3): a per-source access failure yields a
    {access:false} payload that redacts just that tile, never the whole page. The
    D3 people-layer guard applies (home tiles are a non-owner context). Mirrors
    the dashboards polling contract; the home grid re-renders each body in place.

    DVI-1496 P5 (D4-A): an optional ?surface=<id> switches the item source to that
    tile surface's per-user effective layout (so a dashboard tile a user added on
    the Equipment sub-page polls live) and adds a `summaries` map with the live
    catalog-tile summary lines. The home path (no surface) is unchanged.
    r'  r  r7   r  r   NrT  r   ra  rc  rT  rc  r   )r  dashrefs	summariesrX   )r   r   r   rh  r  r  r  r   r@  _resolve_dashref_payloadr   r  r   )r'  r  r  r   r   r  ru  r   ptyper  r  s              r1   tools_tile_datar   s'   " ;;v$"DHHWb!Elly)G7n,#GU377@FB%e,
CHkkm B
U		&!G!IIk2eii>Q>WUWXD'dD9CHi4UDAHSMB x0G6wEWr3   c                 R   | j                  d      xs d}t        |      \  }}|||vrdddS dddS t        |j                  d	      xs g d
       }| j                  d      | j                  d      }}t        |t              r"t        |t              r|dt        d||z         }g i }	}|D ]  }
|
j                  d      }|s||
j                  d      |
j                  d      xs d|
j                  d      xs i d}|
j                  d      r|
d   |d<   |
j                  d      r|
d   |d<   |j                  |       t        |
j                  d      |
j                  d      xs i d|d      |	|<    d|j                  dd      |t        v r|nd|t        v r|nd||	dS )u-  Resolve an embedded-dashboard home item (DVI-1347 P3) for the VIEWER.

    Returns the referenced dashboard's structure (ordered tiles: layout + config)
    plus each tile's provider payload, run under the viewer identity through the
    shared _report_run_tile so every per-source gate (D3) holds — a tile the
    viewer can't access soft-degrades to {access:false} and redacts just that
    tile. If the whole dashboard isn't viewable (no share / "*" sentinel), the
    section is redacted as {access:false}; a stale reference is {available:false}.
    rm  r7   NFz This dashboard no longer exists.r  z(You don't have access to this dashboard.)r  r  r  c                 &    | j                  dd      S r  r  r*  s    r1   r	  z*_resolve_dashref_payload.<locals>.<lambda>D s    AEE'1<M r3   r  r   rn  r^   r  rT  rd  r  rc  )r  rT  rd  rc  r<  rP  r  Tr9   r   )r  r9   r   rn  r  r   )
r   rN  r  r   r   rC  ri   r@  ro  rp  )r   r'  rr  r	  rY  r  r"  rn  	out_tilesr   r   r   r   s                r1   r  r  3 s    ii'-2G%g.KE4|%!&3UVV,VWWDHHW%+2MNE 9%uyy'8$D$D#!6+Atd{+,"tI 1eeDkAEE&M155=;TH55?0b255=fIE&M55=fIE&M$aeeFmquuXG\Z\%]%)41S	1 DHHVR$8#'>>tF $88Df. .r3   z/tools/dashref-optionsc                     t        j                  d      xs i } | j                  d      xs dj                         }t        | j                  d            }t	               }g }|j                         D ]  \  }}t        |t              s|j                  d      xs dj                         }|j                  d      xs g D cg c]  }t        |      j                          }	}||k(  }
|s|
s|r||	v s	t        |	v s|j                  ||j                  d      xs d|j                  d      xs d|
d	        |j                  d
        t        ||      S c c}w )af  List the dashboards the caller may embed on the Tools page (DVI-1347 P3).

    A Togen Admin may embed ANY dashboard (an admin embed makes it org-viewable
    via the "*" sentinel on add). A non-admin may embed a dashboard they own or
    that's shared with them. Returns [{id, name, owner, mine}], newest first is
    not meaningful here so ordered by name.r'  r  r7   r   rL  r  r9   z
(untitled))r  r9   rL  r  c                 4    | d    | d   j                         fS )Nr  r9   rs  rx	  s    r1   r	  z'tools_dashref_options.<locals>.<lambda>w s    &	M1V9??+<= r3   r  )r  can_embed_any)r   r   rE  r   r  r   r   r   r*   rM  ri   r  r   )r'  r  r  r	  r   rQ  rn  rL  r  re  r  s              r1   tools_dashref_optionsr  ^ s<    ;;v$"DXXg$"++-EDHH]+,HE
C++- 
BQ!T"w%2,,.+,55+@+FBHQ#a&,,.HH~DUu+v5

#quuV}'DUU7^1r4A 	B
B HH=H>c:: Is    Ez/dashboards/weather/geocodec                      t         j                  j                  d      xs dj                         } | st	        g       S t        |       \  }}|rt	        g |      S t	        |      S )u   Resolve a place name → candidate lat/lon for the Weather tile editor
    (DVI-1277 D2). Open-Meteo geocoding, no API key. The editor stores the
    resolved lat/lon + label on the tile config so render-time never geocodes.r  r7   )r  )r  r  )r   rh  r   r   r   _weather_geocode)r  r  r  s      r1   dashboards_weather_geocoder  { s]     
		#		$"++-Ar""#A&LGS
r--7##r3   c                 f    | sy| j                  d      rydt        | j                  dd            v S )zTrue when the caller may use the dashboard-config inspector: a Togen
    Admin, or an Auditor whose per-user grant includes dashboards.inspector
    (re-checked per request, so Access changes apply without re-login).Fr   Tr  r  r7   r   r  r-  s    r1   _dashboards_inspector_grantr   s5     xx!%8'29N%OOOr3   c                     i } 	 t        t        j                  d            }|D ]w  }	 t	        j
                  |j                               }t        |t              r|j                  d      nd}|sM| j                  |g       j                  |j                         y | S # t        $ r g }Y w xY w# t        j                  t        f$ r Y w xY w)a  Map dashboard id -> [user identifiers] who have it as their default.

    Scans the per-user settings files; the file stem is the user's email for
    standard addresses (the settings-path slug is lossy only for exotic
    characters, which real work emails don't use).r  r  N)r  r  r  r   r   r   r   r   r   r   r   r}  ri   r  )r   r  r  r   rQ  s        r1   _dashboard_default_mapr   s     C(--h78  4	::blln-D 0:$/Edhh*+4NN3#**27734 J  
 $$g. 		s"   B  #B1 B.-B.1CCc                  ,   i } t               j                  d      xs i }|j                         D ]  \  }}|j                  d      xs |}|j                  d      }|r/| j                  |g       j	                  ||dt        |      d       t        |      }|sk|j                  d      s}| j                  |d   g       j	                  ||d|j                  d      xs |j                  d      |j                  d	      t        |      d
        | S )zMap dashboard id -> [assignment dicts] from the signage registry:
    passively-assigned displays and live (unexpired) present overrides.r  r9   rm  r  )
display_idr  r!	  online_status
presentingpresenter_name	presenterr.  )r  r  r!	  r  r.  r  )r  r   r   r}  ri   _signage_status_signage_present_active)r   r  r  r  r9   rQ  r  s          r1   _dashboard_signage_mapr   s    C"$((28bFlln ;
Uyy 'Cii'NN3#**"D*"1%"8:; $E*~&NN1^,b188"D,ee$45K{9K uu\2"1%"8:;; Jr3   c                 B   | j                  d      xs g }g }|D ]0  }|j                  d      xs d}|s||vs |j                  |       2 | j                  d      | j                  d      | j                  d      | j                  d      | j                  d      t        |      |t        | j                  d	      xs g       t        | j                  d
            |j                  | j                  d      g       |j                  | j                  d      g       dS )uA   Metadata row for one dashboard (structure only — no tile data).r  rT  r7   r  r9   rL  r  r  r  r  )r  r9   rL  r  r  r7  r  rn  r  default_forr  )r   ri   rC  r   )rY  default_mapsignage_mapr  rV  r   tts          r1   _dashboard_inspector_summaryr  Ɯ s    HHW#EE UU6] b"E/LL
 hhtn '"hh|,hh|,%jDHH^4:;+,"txx~r:"txx~r: r3   z/auditor/dashboardsc                  d   t        j                  d      xs i } t        |       s
t               S t	               }t               }t               }|j                         D cg c]  }t        |||       }}t               }|r#t        |||      }d|d<   |j                  |       |j                  d        t        j                  j                  d      xs dj                         j                         }|rY|D cg c]N  }||j                  d      xs dj                         v s%||j                  d	      xs dj                         v r|P }}t!        |t#        |      t%               j'                         D 	
ci c]  \  }	}
|	|
j                  d
|	       c}
}	      S c c}w c c}w c c}
}	w )u   Read-only list of EVERY user's dashboards for troubleshooting and tuning
    (DVI-1228 P3). Metadata only here — the per-dashboard config is served by
    /auditor/dashboards/<id>. Admin/auditor(dashboards.inspector)-gated.r'  Tr|  c                     | j                  d      xs dj                         | j                  d      xs dj                         fS )NrL  r7   r9   r<  r  s    r1   r	  z)auditor_dashboards_list.<locals>.<lambda> s:    quuW~3::< uuV}299;= r3   r  r  r7   r9   rL  r   )r  r@  tile_type_labels)r   r   r  r  r  r  r  r  r  r  ri   r  r   rh  r   rE  r   rC  rf  r   )r'  r	  r  r  rn  r   r|  r  r  r  r  s              r1   auditor_dashboards_listr	  ݜ s    ;;v$"D&t,  E(*K(*Klln& *![+F &E & )*F(kJ(Q	JJ =J >			#		$"++-335A! 9q!%%--24466!%%..B5577  9 9 e3u:1F1H1N1N1P%R)-A &'gq(9%9 %RS S&9%Rs   F"<AF'<F,z/auditor/dashboards/<dash_id>c                 V   t        j                  d      xs i }t        |      s
t               S | t        k(  rt               }nt               j                  |       }|t        d      dfS t               }t               }t        |||      }g }t        |j                  d      xs g d       D ]  }|j                  |j                  d      |j                  d	      |j                  d
      |j                  d      |j                  d      |j                  d      |j                  d      xs i d        t        i |t        |j                  d      xs g       |dt               j                         D 	ci c]  \  }}	||	j                  d|       c}	}      S c c}	}w )zFull read-only config of one dashboard (tiles, sizes/spans, overflow,
    per-tile config, sharing, signage) + its signage assignment / live present
    state (DVI-1228 P3). No mutation. Admin/auditor(dashboards.inspector)-gated.r'  r;  r  r  r  c                 &    | j                  dd      S r  r  r  s    r1   r	  z*auditor_dashboard_detail.<locals>.<lambda> s    155!;L r3   r  r  rT  rd  r<  r:  r  rc  )r  rT  rd  r<  r:  r  rc  r  )r  r  r   )r4  r  )r   r   r  r  r  r  r  r   r  r  r  r  ri   r  rf  r   )
rr  r'  rY  r  r  r
	  r  r   r  r  s
             r1   auditor_dashboard_detailr   s    ;;v$"D&t,  ***,!%%g.|23S88(*K(*K*4kJGEDHHW%+1LM 	%%+EE&MEE&MEE&Mj)UU7^eeHo+
 		 #W ##'(@(FB#G!# '<&=&C&C&EG"a QUU7A.. G	H HGs   ?F%c                     t        | t              syi i d}t        | j                  d      t              r| d   |d<   t        | j                  d      t              r| d   |d<   |S )zaNormalize a parsed store into ``{enroll_tokens, agents}`` or None if it is
    not a usable dict.Nenroll_tokensr  r  r  r
  r   s     r1   _signage_coerce_storer  , sb     c4 2.D#''/*D1 #O 4_#''(#T*XXKr3   c                 R   | j                         sy	 t        t        j                  |             S # t        j                  $ r Y nw xY w	 t        j
                         j                  | j                               \  }}t        |      S # t        j                  t        f$ r Y yw xY w)u  Parse store JSON, self-healing the concurrent-write corruption seen in
    DVI-1253: two writers racing produced a file that is a valid JSON document
    followed by trailing bytes (e.g. a stray ``}``). ``json.loads`` rejects the
    whole file ("Extra data"), but the *leading* document still holds every
    enrolled agent — so on a strict-parse failure we salvage it with
    ``raw_decode`` rather than degrading to empty and losing all device tokens.N)	r   r  r   r   r   JSONDecoder
raw_decoderV  r   )r&  obj_ends      r1   _signage_parse_store_textr  9 s     ::<$TZZ%566 $$&11$++-@	T !%%   *- s!   1 AA4B
 
B&%B&c                      t         t         j                  t         j                  dz         fD ]5  } | j                         s	 | j	                         }t        |      }|3|c S  i i dS # t
        $ r Y Hw xY w)u  Signage registry {enroll_tokens, agents}.

    Reads the store, falling back to the last-good ``.bak`` when the primary file
    is missing or malformed (e.g. truncated by an interrupted write, or corrupted
    by racing writers). A malformed-but-salvageable file (valid JSON prefix +
    trailing junk — the DVI-1253 concurrent-write signature) is recovered via
    ``raw_decode`` instead of being discarded, so a single bad write can never
    silently drop every enrolled agent (DVI-1245/DVI-1253: the physical Display
    Agents were lost when a corrupt store degraded to empty and the next save
    clobbered it). Never raises at request time..bakr  )SIGNAGE_AGENTS_FILEro  r9   r   r   r   r  )rb   r&  r   s      r1   r  r  M s     %$../B/G/G&/PQS 
||~	>>#D ).K
  2..  		s   A**	A65A6c                 F   t        j                  | d      }t        j                  t        j                  dz         }t        j                  t        j                  dz         }t        t        j                        }t        |d      5 }	 t        j                  |t        j                         	 t        j                         rt        t        j                               }|t        j                   |d      \  }}t#        j$                  |d      5 }	|	j'                  t        j                  |d             |	j)                          t#        j*                  |	j-                                ddd       t#        j.                  ||       t        j                   |d	      \  }
}	 t#        j$                  |
d      5 }|j'                  |       |j)                          t#        j*                  |j-                                ddd       t#        j.                  |t               	 ddd       y# t        $ r Y w xY w# 1 sw Y   xY w# t        $ r Y w xY w# 1 sw Y   XxY w# t0        $ r' 	 t#        j2                  |        # t        $ r Y  w xY ww xY w# 1 sw Y   yxY w)
un  Persist the signage registry durably under a cross-process lock.

    Three failure modes have wiped enrolled agents in production, all rooted in
    concurrent writes (heartbeats from every display + the two gunicorn workers,
    a rate that grows with each new Display Agent — DVI-1253):

    1. A shared ``.tmp`` filename let two writers interleave into one file, so
       ``os.replace`` published corrupt JSON (a valid doc + a stray trailing
       ``}``). Fixed by writing to a per-write unique temp via ``mkstemp``.
    2. The ``.bak`` was refreshed with an unconditional ``copy2`` of the primary,
       so once the primary was corrupt the next save copied that corruption over
       the last-good backup (both files ended up byte-identical and unreadable).
       Fixed by refreshing ``.bak`` only from a primary that still parses.
    3. Two saves could race the whole read-modify-write. Fixed by serializing
       every save on an exclusive ``flock`` so writes never overlap.

    ``os.replace`` remains the atomic publish step; ``fsync`` flushes the temp
    before it is swapped in so a crash can't leave a torn file.r   r   r  z.lockr  Nz.signage-bak-)r  r  z.signage-tmp-)r   r   r  ro  r9   r*   r  r?  fcntlflockLOCK_EXr   r   r  r   r  r  r  r  r  r9  fsyncfilenor  r  r/   )r   r&  bak	lock_pathr  lock_fhr  
tmp_bak_fdtmp_bak_namebfhr  tmp_namer  s                r1   _save_signage_agentsr'  f s"   & ::d1%D

'
'(;(@(@6(I
JC#--.A.F.F.PQI'../I	i	 "	KK/	"**,/0C0M0M0OP?/7/?/?%o0?,J:s3 /s		$**S";<		./ JJ|S1  ''IoNH	2s# &r
%& JJx!459" "  		/ /
  		& &
  			(#   	;" "s   	J$H-0A#I	AH=-I	J&I$<AI"I$-	H:6J9H::J=I	I			IJIJI!	I$$	J.JJ	JJJJJJ c                      t         j                  j                  dd      } | j                  d      r| dd j	                         S t         j
                  j                  d      xs dj	                         S )zDevice token from the Authorization: Bearer header or a ?token= query
    param (the kiosk browser passes it as a query param; a headless agent uses
    the header).r  r7   r  r   NrD  )r   rx   r   rw   r   rh  )r  s    r1   r  r   s\     ??3Dy!ABx~~LLW%+2244r3   r	  rD  c                     |sy| j                  d      xs i j                         D ]/  }|j                  d      |k(  s|j                  d      dk7  s-|c S  y)u   The active agent record matching a bearer agent_token, or None. A revoked
    agent never matches — its token is dead the moment an admin revokes it.Nr  r  rd   r  )r   r  )r	  rD  r  s      r1   r  r   sZ     ii!'R//1 555(QUU8_	-IH r3   r  c                 N    t        | t              xr | j                  d      dk(  S )NrT  virtualr
  )r  s    r1   _signage_is_virtualr,  ŝ s"    eT"Euyy'8I'EEr3   r  c                     t        |       }|rt        nt        }| j                  d      }|s|rdS dS t	        j                         |z
  |k  rdS dS )zonline / stale / (never|offline), derived from last_seen_at vs the stale
    window. Virtual displays use a shorter window and report 'offline' (a browser
    tab that was never open reads better than a device that 'never' checked in).r"  offlineneverr  r  )r,  _SIGNAGE_VIRTUAL_STALE_AFTER_S_SIGNAGE_STALE_AFTER_Sr   rU   )r  r+  r  r  s       r1   r  r  ɝ sT     "%(G4;0AWK99^$D#y00		d*{:8GGr3   c           	         dD ci c]  }|| j                  |       }}t        |       |d<   t        | j                  d            |d<   t        | j                  d      xs g       }d|v |d<   |D cg c]
  }|dk7  s	| c}|d<   t	        |       |d<   t        | j                  d      xs i       |d<   t        | j                  d	      xs i       |d	<   t        | j                  d
      xs g       |d
<   t        | j                  d            |d<   t        D ci c]$  }|t        | j                  d      xs d|      du& c}|d<   t        | j                  d      xs d      j                         j                         }|t        v r|nd|d<   |d   dk(  rt        | j                  d            |d<   |S c c}w c c}w c c}w )u   Admin-facing view of an agent — everything the Agents tab needs, but never
    the raw agent_token (a device credential, shown only once at enroll).)r  r9   r  r"  rm  r!  local_ip	public_ipr  screencurrent_dashboardrd   r  presentableallowed_presentersr1  publish_anyonepresent	interfacesplashallowed_editorsr  r  r7   N	bg_imagesplatformlinuxwindowsr  )r   r  r   r  _signage_present_publicr   rK  _SIGNAGE_BG_TARGETS_signage_bg_image_pathr*   r   rE  _SIGNAGE_PLATFORMS_signage_sanitize_window)r  r  r   _presentersrB  r   plats          r1   _signage_public_agentrI  ՝ s   % q1eiil? C  +51Ceii67C uyy!56<"=K;.C,7 Dq18 DC,U3C	N EIIk28b9C8,23CM "%)),=">"D"EC -UYYx-@ACM!46 1%))D/2GRKSWWW 6C uyy$/0668>>@D"&88dgC
O
:)#081DEHJA !E6s   F>4
G?G)G)rz  r{  )r;  r<  c                    t        | t              si } i }t        | j                  d      xs d      j	                         j                         }|t        v r|nd|d<   t        | j                  d      xs d      j	                         j                         }|t        v r|nd|d<   t        | j                  d            }|||d<   t        | j                  d            }|||d<   |S )zCoerce an admin-posted interface dict to the stored shape. Invalid values
    fall back to the kiosk defaults (dark theme, company-default background).ry  r7   rz  rf  r\  side_padding)
r   r   r*   r   r   rE  _SIGNAGE_IFACE_THEMESr  r  r  )r   r   ry  rO  r  r  s         r1   _signage_sanitize_interfacerM  	 s     c4 
C &B'--/557E!%::5CLswwy!'R(..0668D!]2TC	N	CGGL1	2B	~L
#CGGN$;
<C
!NJr3   c                     t        | t              si } dt        | j                  d      xs d      j	                         dt
         i}t        | j                  d      t              rt        | d         |d<   |S )zCoerce an admin-posted splash config: display text + an optional full
    interface override (the splash can be themed independently of dashboards).r&  r7   Nr;  )r   r   r*   r   r   _SIGNAGE_SPLASH_TEXT_MAXrM  )r   r   s     r1   _signage_sanitize_splashrP   sm     c4 3swwv,"-3356O7OP
QC#''+&-6s;7GHKJr3   )scaler  r  )r   	landscapeportraitr   )r  r  r^   c                    | yt        |       j                         j                         }|dv ry	 t        t	        t        |                   }t        t        t        t        |            S # t        t        f$ r Y yw xY w)u6  DVI-1288 P1: coerce a display grid column count. 'auto'/blank/invalid →
    the string ``"auto"`` (kiosk resolves 12 landscape / 6 portrait as before);
    an explicit value is clamped to an int in [2, 24]. Returning a string vs an
    int is deliberate — the kiosk distinguishes 'auto' from a fixed count.r   )r7   r   )r*   r   rE  r   rj   r  r   r   rC  _SIGNAGE_COLUMNS_MINrL  _SIGNAGE_COLUMNS_MAXr   r  r  s      r1   _signage_columns_clamprX  E sy    
 {C ALeCj!" #S)=q%ABB z" s   A, ,A>=A>c                    | yt        |       j                         j                         }|dv ry	 t        t	        t        |                   }|dk  ryt        t        t        t        |            S # t        t        f$ r Y yw xY w)u/  DVI-1288 P2: coerce a grid row-count target. 'auto'/blank/0/invalid →
    the string ``"auto"`` (kiosk lets content flow naturally); an explicit value
    is clamped to an int in [1, 24]. Mirrors _signage_columns_clamp's string-vs-int
    return so the kiosk can distinguish 'auto' from a fixed count.r   )r7   r   rF   r   )r*   r   rE  r   rj   r  r   r   rC  _SIGNAGE_ROWS_MINrL  _SIGNAGE_ROWS_MAXrW  s      r1   _signage_rows_clampr\  V s    
 {C AeCj!" 	Av #&7";<<	 z" s   A2 2BBc                     	 t        t        t        |                   }t        t        t        t        |            S # t        t        f$ r |cY S w xY w)zCoerce a display/tile zoom percentage to an int in [50, 200]; anything
    unparseable falls back to ``default`` (100 = no scaling). Shared by the
    display layout sanitizer (DVI-1275 P1), the per-tile zoom (P2), and the
    present-time override (P3).)	r   rj   r  r   r   rC  _SIGNAGE_ZOOM_MINrL  _SIGNAGE_ZOOM_MAX)r   r.  zs      r1   r-  r-  i sM    
eCj!"  #&7";<< z" s   = AAc                 X   t        | t              si } t        | j                  d      xs d      j	                         j                         }t        | j                  d      xs d      j	                         j                         }|t        v r|nd|t        v r|ndt        | j                  d      d      t        | j                  d             d	 t        | j                  d
      xs d      j	                         j                               t        | j                  d            dS )u
  Coerce admin-posted layout options; invalid/missing values fall back to
    the defaults (fit=scale, orientation=auto, zoom=100). Also the resolver for
    agents that predate the layout key — legacy displays get fit-to-screen (D2a)
    at 100% zoom (DVI-1275 P1).r>  r7   r	  rQ  r   rP  rd  r   c                     | t         v r| S dS )Nr  )_SIGNAGE_FLOW_MODESrZ  s    r1   r	  z*_signage_sanitize_layout.<locals>.<lambda> s    A)<$<q % r3   flowrn  r>  r	  rP  r   rd  rn  )r   r   r*   r   r   rE  _SIGNAGE_FIT_MODES_SIGNAGE_ORIENTATIONSr-  rX  r\  )r   r>  orients      r1   rK  rK  u s    
 c4 
cggen"
#
)
)
+
1
1
3C'-2.446<<>F!333%+/D%D6& (= .cggi.@A
 HCGGFO)r*00288:<'8: :r3   rQ  re  r  overlayc                     t        |       }t        j                         D ]!  \  }}|j                  |      }||k7  s|||<   # |S )uK  Overlay a (sanitized) layout onto a base: a field in ``overlay`` that is
    non-neutral (differs from the system default) wins over base; a neutral field
    leaves base's value in place. Both args must already be sanitized. This is the
    D3 precedence primitive — an org/user default only fills what a context left
    unset.)r   _SYSTEM_NEUTRAL_LAYOUTr   r   )r  ri  r   r  neutralr  s         r1   _merge_layout_overrm   sM     t*C,224 
7KKN<CF Jr3   c                  J   i } t         j                         r:	 t        j                  t         j	                               }t        |t              r|} t        | j                  d            t        | j                  d            dS # t        j                  t        f$ r Y Pw xY w)ua  Org-level default display settings (DVI-1321 P2). Shape:
    ``{layout: {fit,orientation,zoom,columns,flow,rows}, use_interface_default: bool}``.
    Missing file / bad JSON → all-neutral layout + use_interface_default False, so
    the defaults are a no-op until an admin sets one. Always returns a sanitized
    layout so callers can merge directly.r  rS  rT  )DISPLAY_DEFAULTS_FILEr   r   r   r   r   r   r   r   rK  r   r   r   r  s     r1   r  r   s     C$$&	JJ4>>@AE%& /swwx/@A%)#''2I*J%KM M $$g. 		s   9B B"!B"c           
          t         j                  t        j                  t	        | xs i j                  d            t        | xs i j                  d            dd             y)zMPersist org default display settings (layout sanitized + use-interface flag).r  rS  rT  r   r   N)ro  r   r   r   rK  r   r   rf  s    r1   rV  rV   sS    $$TZZ+X^,@,@,JK"&B';';<S'T"U	W& r3   c                      t               d   S )Nr  )r  rX   r3   r1   rW  rW   s    !#H--r3   c                     t        |       xs i j                  d      }t        |t              }|si }t	        |j                  d            t        |j                  d            |dS )aD  Per-user default display settings (DVI-1321 P3). Shape mirrors the org
    store: ``{layout: {...}, use_interface: bool, set: bool}``. ``set`` is True
    only when the user has an explicit ``display_settings`` block (so a fullscreen
    dialog can prefer the user default over the org default). Layout always
    sanitized.rZ  r  rH  )r  rH  r  )r  r   r   r   rK  r   )r  r(  r  s      r1   _user_display_settingsrt  ƞ sb     e
$
*	/	/0B	CBb$D.rvvh/?@!"&&"9: r3   r]  c                    t               }|rt        |      }|d   rt        ||d         }t        |t        |             S )u  The layout a kiosk/preview should render, applying the D3 precedence:
    org default (base) → the user's own default (only when ``email`` names a
    user-owned context: their Fullscreen preview or their own virtual display) →
    the context's explicit stored layout (wins). Neutral fields fall through, so
    this equals ``_signage_sanitize_layout(stored)`` until a default is set.r  r  )rW  rt  rm  rK  )r4  r  r  uds       r1   _resolve_display_layoutrw  ՞ sC      D#E*e9%dBxL9Dd$<V$DEEr3   )r:  r;  )month_day_year	month_day	mdy_slash	dmy_slashr  r:  rx  zBeaver Dam, KYgA`вB@g\(Ur   rN  rP  r8  r   r?  r  c                 x    | sy	 ddl m}m} 	  ||        y# |t        t        f$ r Y yw xY w# t
        $ r Y yw xY w)zTrue if *name* is a resolvable IANA timezone. Uses the stdlib zoneinfo
    database (the same one used elsewhere for schedule timezones).Fr   )r  ZoneInfoNotFoundErrorT)r  r  r  r   KeyErrorry   )r9   r  r  s      r1   _is_valid_timezoner   sK     <	TN%z8< 		 s    -  *- *- 	99c                 ~   t        | t              sy	 t        | j                  d            }t        | j                  d            }d|cxk  rdk  rn yd|cxk  rdk  sy yt        | j                  d      xs d	      j                         dd
 t        |d      t        |d      dS # t        t
        f$ r Y yw xY w)zA location dict {label, latitude, longitude} or None (=inherit). Coords
    are validated + clamped + rounded to 4dp (matches the Weather-tile store).NrN  rP  g     Vg     V@g     fg     f@r   r7   r  r   r|  )	r   r   r  r   r   r   r*   r   rj   )r   r*  lons      r1   _sanitize_regional_locationr   s     c4 CGGJ'(CGGK() S D  &,s%;e%; &<)/R0668#>c1E#qMC C	 z" s   4B* *B<;B<c                    t        | t              si } t        | j                  d      xs d      j	                         j                         }t        | j                  d      xs d      j	                         j                         }t        | j                  d      xs d      j	                         }|t        v r|ndt        |      r|nd|t        v r|ndt        | j                  d            dS )zSanitize one regional layer (org store, or a user's ``regional`` block).
    Each field is either a valid value or blank ("" / None) meaning inherit from
    the layer below. Never raises; unknown values become blank.r8  r7   r?  r   r  r}  )
r   r   r*   r   r   rE  _REGIONAL_TIME_FORMATSr  ra  r  )r   tfr  r  s       r1   rk  rk   s     c4 	SWW]#)r	*	0	0	2	8	8	:B	SWW]#)r	*	0	0	2	8	8	:B	SWWZ &B	'	-	-	/B#99rr,R0Bb#99rr/
0CD	 r3   c                     t        |       }dD ]  }|j                  |      }|s|||<    |j                  d      r|d   |d<   |S )u   Overlay a sanitized regional layer onto *base*: a non-blank overlay field
    wins; a blank field leaves base in place. This is the D3-style precedence
    primitive (system ← org ← user).rj  r  )r   r   )r  ri  r   r  r  s        r1   _merge_regional_overr  / sV     t*C7 KKNCF {{:!*-JJr3   c                      i } t         j                         rE	 t        j                  t         j	                               }t        |t              r|} t        |       S t        |       S # t        j                  t        f$ r Y t        |       S w xY w)u   Org-level regional preferences (sanitized layer; blank fields = inherit
    the system baseline). Missing file / bad JSON → all-blank (a no-op overlay,
    so the system baseline shows until an admin sets one).)
REGIONAL_SETTINGS_FILEr   r   r   r   r   r   r   r   rk  rp  s     r1   r`  r`  = s     C%%'	JJ5??ABE%& $C((#C(( $$g. 	#C((	s   9A' 'BBc                 j    t         j                  t        j                  t	        |       d             y)zEPersist org regional preferences (sanitized; blank fields = inherit).r   r   N)r  r   r   r   rk  rf  s    r1   rf  rf  L s%    %%

+H5a@Br3   c                     t        |       xs i j                  d      }t        |t              }t	        |r|ni       |dS )zPer-user regional override. Shape: ``{layer: {...}, set: bool}``. ``set``
    is True only when the user has an explicit ``regional`` block (so a panel can
    show 'using company default' vs an explicit personal value).r}  )r  r  )r  r   r   r   rk  )r  rsr  s      r1   _user_regional_settingsr  R s>     e
$
*	/	/
	;Bb$D-DbbA$OOr3   c                  N    t        t              } t        t        d         | d<   | S )zIA fresh copy of the concrete system baseline (never mutate the constant).r  )r   _SYSTEM_REGIONAL_DEFAULTS)r  s    r1   rb  rb  [ s'    )*D5jABDKr3   c                     t        t               t                     }| rt        |       }|d   rt        ||d         }|S )u   The effective regional preferences for a viewer: system baseline → org
    default → the user's own override. Always returns concrete values (every
    field populated) so the SPA can format directly from ``window._regional``.r  r  )r  rb  r`  r  )r  r  r  s      r1   r  r  b sC     $$=$?$;$=?H#E*U8+HajAHOr3   )r@  rA  )r\  bottomwindowed	wallpaperr$
  i  c           	         t        | t              si } t        | j                  d      xs d      j	                         j                         }|t        vrd}| j                  d      }|*t        |      j	                         j                         dk(  rd}n&	 t        dt        t        t        |                  }|}d }| j                  d      xs i }t        |t              si } ||j                  d	d
      t        t               ||j                  dd
      t        t               ||j                  dd      dt               ||j                  dd      dt              d}|||dS # t        t        f$ r d}Y w xY w)u  Coerce an admin-posted window settings block for Windows displays.

    Contract (frozen per DVI-1227 D4-A — do not rename fields without
    coordinating with the Windows Engineer on the parent issue):
      layer:    "top" | "bottom" | "windowed" | "wallpaper"  (default "top")
      monitor:  "primary" | <int 1-N>                        (default "primary")
      windowed: {x, y, width, height}  — int-clamped; only meaningful when
                layer == "windowed".
    r  r7   r\  monitorrK  r^   c           	      p    	 t        |t        |t        |                   S # t        t        f$ r |cY S w xY wrT   )rC  rL  r   r   r   r	  s      r1   _clamp_geomz-_signage_sanitize_window.<locals>._clamp_geom s7    	r3r3q6?++I& 	I	r'  r  r  r   r|
  rE  i   rd  rF  rz
  )r  r|
  rE  rF  )r  r  r  )r   r   r*   r   r   rE  _SIGNAGE_WIN_LAYERSrC  rL  _SIGNAGE_WIN_MONITOR_MAXr   r   r   _SIGNAGE_WIN_GEOM_MIN_SIGNAGE_WIN_GEOM_MAX)r   r  mon_rawr  mon_intr  windowed_rawr  s           r1   rF  rF  | sf    c4  &B'--/557E''ggi G#g,,,.446)C	 !S!93w<HIGG 77:&,"LlD)l..s;?TVkll..s;?TVkll..w=sVkll..x=sVkl	H wHEE% I& 	 G	 s   %E, ,F ?F agent_idr  c                     dj                  t              }t        j                  d|  d| d      D ]5  }|j                  j                  d      j                         t        v s3|c S  y)zStored custom-background path for a display. The 'signage_' prefix keeps
    these apart from the per-user email-slug files in the same directory.|signage_r  r1  r  N)r  r!  r  r  r!  rV  rE  )r  r  extsr  s       r1   rD  rD   se     88'(D(8*AfXR @A 88??3%%'+>>H r3   whichc                    t               }t        | xs i j                  d      xs i       }|dk(  rE| xs i j                  d      xs i j                  d      }t        |t              r|rt        |      }|j                  d      t        v r|j                  d      nd}|j                  d      xs d}|t
        vr|j                  d      xs d}|d}}| r8| j                  d	      r't        | d	   |      }||dk(  rdt        | d	   d      }}|d
k(  r|d}t        |j                  d            }	|	%t        |j                  |dk(  rdnd            xs d}	t        |j                  d            }
|
d}
|||	|
|dud}|.||d<   	 t        |j                         j                        |d<   |S |S # t        $ r	 d|d<   Y |S w xY w)u  Effective interface options the kiosk applies: stored values over company
    branding defaults. which='splash' uses the splash override when present,
    else the display's dashboard interface. bg_mode is fully resolved here
    ('' company default → the admin branding mode) so the kiosk needs no
    branding knowledge; a 'custom' mode without an uploaded image degrades to
    togen_default rather than a blank background.r;  r<  ry  rz  rf  r7   rc  Nr  rY  r\  rh  rg  rd  rK  r   )ry  rf  r\  rK  	custom_bgcustom_bg_targetcustom_bg_v)rd  r   r   r   rL  r  rD  r  r  r   r  r  r   )r  r  r_  ifacesplash_ifacery  rO  	bg_targetbg_filerc  paddingr   s               r1   _signage_resolved_interfacer   s    '(H%+2"";/526E"))(39r>>{KlD)l&E"'))G"48M"MEIIgSYE99Y%2D= ||I&9/wI4(te<?u0!,.DU4[R].^wIxGO <!89G$LLevo*CUVX _[^ 	'		.(ABGd'"1DFC"+	#!$W\\^%<%<!=C J3J  	#!"CJ	#s   &F5 5GG>   r  r  r   c                     | j                  d      }t        |t              r"|j                  d      s|j                  d      sy|j                  d      }|t        j                         |kD  ry|S )u   The active (unexpired) present override for an agent, else None. Pure —
    never mutates/saves the store; callers that already save (display_config)
    lazily drop an expired override.r:  rm  r6  Nr.  )r   r   r   rU   )r  r  r/  s      r1   r  r   s^     			)A aquu^'<h
%%
C
499;,Hr3   c                 .   t        |       }|sy|j                  d      |j                  d      t        |j                  d      t              rl|j                  d      xs i j                  d      |j                  d      xs i j                  d      |j                  d      xs i j                  d      dnd|j                  d	      |j                  d
      |j                  d      |j                  d      t	        |j                  d            dS )u   Public shape of the active override (None if none/expired). Safe to show
    to an admin or an allowed presenter — no device credential.Nrm  rX  r6  r  rf  template_name)r  rf  r  r  r  rq  r.  r;  )rm  rX  r6  r  r  rq  r.  use_my_interface)r  r   r   r   r   )r  r  s     r1   rB  rB   s     	 &AEE.1ee$45 %QUU8_d; )*h(=2'B'B='Q#$55?#8b"="=h"G*+%%/*?R)D)D_)UW BF{+ee$45%%-%%- $QUU;%7 8: :r3   c                     | syt         j                  d|  d      D ]  }	 |j                           y# t        $ r Y "w xY w)zRemove the presenter's custom background copied for a present's duration
    (DVI-1220 P3, plan D4a). Called wherever the override ends: manual stop,
    replacement by a new present, and the lazy expiry on the config poll.Nr  z
_present.*)r  r  r/   r   )r  r  s     r1   _signage_present_bg_cleanupr   sO     (8*J ?@ 	HHJ  		s   4	A A 
theme_hintc                    | j                  d      xs dj                         }t        |      }t               }|j                  d      }|t        vr7t        |xs d      j                         j                         }|t        v r|nd}|j                  d      xs |j                  d      xs d}|t        vrd}||dd	}	|d
k(  rt        |      }
|
't        d| d|
j                  j                          z  nd}	 |
t        d      t        |       t        j                  |
|       |	j                  ddt!        |j#                         j$                               t'        |j                  d|             }|t'        |j                  d|             }t)        |j                  d            }|t)        |j                  d            }|dn||	d<   |dn||	d<   |	S # t        $ r d|	d<   Y w xY w)u  Snapshot of the presenter's own interface options for a present override
    (DVI-1220 P3, plan B5) — theme, background mode + opacity, side padding —
    in the same fully-resolved shape /display/config emits, so the kiosk's
    applyInterface consumes it unchanged. Resolution mirrors what the presenter
    sees in the SPA: their User Settings override, else the company branding
    default. Snapshotted at present-start; later settings edits never chase a
    live present. If their mode is a custom image, the file is copied into the
    display background store for the present's duration (plan D4a) and removed
    by _signage_present_bg_cleanup when the present reverts.r  r7   ry  rz  r  rf  rc  F)ry  rf  r  rY  Nr  _presentzno custom background uploadedTr:  )r  r  r  bg_opacity_rj  rd  r\  r   rK  )r   rE  r  rd  rL  r*   r   r  r"  r  r!  r   r  r  copyfiler  r   r  r  r  r  )r'  r  r  r  r  r_  ry  hintrO  r   r$  r  rc  r  s                 r1   _present_my_interfacer   s    XXg$"++-E"5)H&(HLL!E)) :#$**,224 556<<*+Yx||I/FY/D= d
?Cxe$ (8CJJ<L<L<N;OPP$( 	
	-{=>>'1OOC&JJ	#&tyy{';';#<  > !E7.C!DEG$X\\Kw2G%HI'5H(IJG+HLL9L,MN&GC&!GCJ  	- -C	N	-s   =A#G& &G76G7c                    | j                  d      dk(  ry|j                  d      xs dj                         }t        |       r)|r'| j                  d      xs dj                         |k(  ry| j                  d      sy|j                  d	      ry|sy| j                  d
      xs g D cg c]  }t        |      j                          }}d|v ry||v S c c}w )uh  Whether `user` may present to `agent`: the display must be `presentable`
    AND the user must be listed in `allowed_presenters` — or be a Togen Admin
    (who can present to, and force-revert, any presentable display), or the OWNER
    of a virtual display (who may always present to their own fullscreen window).
    A revoked display is never presentable.rd   r  Fr  r7   owner_emailTr7  r   r8  r1  r   rE  r,  r*   )r  r'  r  rB  r   s        r1   _signage_can_presentr  L s     yyi'XXg$"++-E 5!eYY}%+224=99]#xx(-		2F(G(M2O!s1v||~OGO g~G Ps   + Cpresenter_emailc                 H    t               }| xs dj                         |d<   |S )u  Synthetic identity for rendering a *presented* dashboard's tiles.

    Same broad display-scoped role grant as _signage_display_user (plan D4:
    tile-data auth "follows the same display-scoped path as the assigned
    dashboard"), but carries the presenter's email so a "My calendar" tile
    resolves to the presenter's own mailbox — presenting one's own dashboard is
    the deliberate act D4 permits, so `my` is NOT hard-blocked for a present
    override (unlike a passively-assigned signage dashboard).r7   r  )_signage_display_userrE  )r  r  s     r1   _signage_present_userr  k s(     	A!'R..0AgJHr3   c                      ddddddS )u  The synthetic identity a signage display renders as (plan D4/D5).

    A display is NOT a user session; a Togen Admin's assignment of a
    signage-flagged dashboard is the authorization for the role-gated data
    (WO / SCADA / Parts) shown on the wall. So this identity is granted every
    source-tool role the tile providers gate on — but carries NO email, which
    is the mechanism that keeps a personal ("my") M365 calendar off a display:
    the calendar provider's `my` branch has no mailbox to resolve and soft-
    degrades. The display tile-data route also hard-blocks `my` on top of this.
    Mutation routes are never reachable with a device token (they are
    login_required + owner-only), so this broad read grant carries no write risk.
    r7   T)r  r   r   r   r  rX   rX   r3   r1   r  r  y s     d4D2 2r3   c                 l    t        | j                  d            }| j                  d      dv rdg|z   }|S )u*  Build an allowed_presenters list from an update payload: normalized email
    addresses plus the '*' publish-to-anyone sentinel when `publish_anyone` is
    set (the sentinel can't ride the email list — _dash_normalize_emails drops
    non-'@' entries — so it is carried by a separate boolean).r8  r9  rF  r1  )rS  r   )r   r   s     r1   _signage_collect_presentersr   s;    
 !*>!?
@Cxx !%IIeckJr3   c                    t        | j                  d      xs g       }| j                  d      | j                  d      | j                  d      xs d| j                  d      | j                  d      t        |       t        | j                  d            d	|v |D cg c]
  }|d	k7  s	| c}t	        |       t        | j                  d
      xs i       t        | j                  d      xs i       t        D ci c]$  }|t        | j                  d      xs d|      du& c}t        | j                  d            dS c c}w c c}w )u   Owner-facing view of a virtual display for the User Settings → Agents tab
    (never a device credential — virtual displays have none, D5 session auth).r8  r  r9   rm  r7   r  r"  r7  r1  r;  r<  Nr  )r  r9   rm  r  r"  r  r7  r9  r8  r:  r;  r<  r>  r  )	r  r   r  r   rB  r   rC  rD  rK  )r  
presentersrB  r   s       r1   _signage_virtual_publicr   s$    eii 45;<Jiio		&!		.17Rii-		.1(/EIIm45+*4AQSqA*51 %))K06B7uyy*0b124 /		$0E2qIQUUU 4*599X+>?%  B4s   
E(E:)Ec                     | j                  d      xs i j                  |      }t        |      r:|j                  d      xs dj                         |xs dj                         k(  r|S y)zThe caller's own virtual agent by id, or None. The ownership gate for the
    owner-facing virtual routes (login_required, not togen_admin).r  r  r7   N)r   r,  rE  )r	  r  r  r  s       r1   _signage_virtual_ownedr   s^     
8		"''1A1155#7#=2"D"D"F5;TVJ]J]J_"_r3   c                 d   |xs i }|j                  d      ry|j                  d      xs dj                         }|syt        |       r&| j                  d      xs dj                         |k(  S | j                  d      xs g D cg c]  }t        |      j                          }}||v S c c}w )aO  DVI-1322: may this user edit the given display's settings?

    A Togen Admin may edit any display. Otherwise: the OWNER of a virtual
    display, or a user on a physical display's `allowed_editors` list (the
    delegated "Allow Edit" grant). Revoke/Delete and managing the editor list
    itself remain admin-only regardless of this.r   Tr  r7   Fr  r=  r  )r  r'  r  rB  editorss        r1   _signage_can_editr   s     :2DxxXXg$"++-E5!		-(.B5575@@(-		2C(D(JL!s1v||~LGLG Ms    B-c                    d|v r.|j                  d      xs dj                         }|sy|dd | d<   d|v rX|j                  d      xs dj                         }|r.t               j                  |      }|y|j                  d      sy	|| d<   d
|v rt        |j                  d
            | d
<   d|v sd|v rt	        |      | d<   d|v rt        |j                  d            | d<   d|v rt        |j                  d            | d<   d|v rt        |j                  d            | d<   d|v rt        |j                  d            | d<   y)a  Apply the display-settings fields common to the admin edit route and the
    DVI-1322 delegated-editor /settings route: name, dashboard assignment,
    Present gating, interface, splash, layout, and the Windows `window` block.
    Returns (error_message, http_status) on a validation failure, else None.
    Never touches admin-only fields (allowed_editors, platform, revoke/delete).r9   r7   )r<  r`  Nr  rm  )r;  r  r  )z4That dashboard is not flagged Available for signage.r`  r7  r8  r9  r;  r<  r  r  )	r   r   r  r   r  rM  rP  rK  rF  )r  r   r9   rQ  rn  s        r1   $_signage_apply_shared_display_fieldsr  ˠ sW    ~ &B--/0Tc
fxx'-2446 "&&s+Ay355#T #n#DHH]$;<mt#'74'?&A$&G"#d8+9NOk42488H3EFh42488H3EFh42488H3EFhr3   z/signage/agents/enrollc            	         t        t        j                               } t        j                         }t	               }|j                  d      xs i j                         D ci c]1  \  }}|j                  d      s|j                  d      xs d|kD  r||3 c}}|d<   ||t        z   dt        j
                  d      xs i j                  dd      d	|d   | <   t        |       t        d
dt        j
                  d      xs i j                  d      d       t        j                  j                  d      }|dz   }d| d| d|  }t        | t        |dz   |dz   |dz   ||      S c c}}w )zIssue a short-lived signage enrollment token. The device installer
    exchanges it (once) for a permanent per-device agent_token.r  r  r.  r   Nr'  r  r7   )	issued_atr.  r  	issued_bysignage-agentsenroll-token-issuedr9   zSignage enrollment token issuedr  r6   /signage/install.shzcurl -fsSL z | sudo bash -s -- --host z	 --token /signage/agents/register/signage/checkin/display)r  
expires_inregister_urlcheckin_urldisplay_urlinstall_urlinstall_command)r*   rW  rX  rU   r  r   r   _SIGNAGE_ENROLL_TTL_Sr   r'  r	  r   r  r8   r   )rD  r  r	  r   r  r  r  r  s           r1   signage_agents_enrollr   s   
 

E
))+C "E  ))O4:AACJAuu]#|)<)AS(H 	
1JE/ .C(Ckk&)/R44WbA%CE/5! #%:!++f-388@<> ""3'D..K
 k] #y	)  E3 $'A A#&88#j0*#24 4'Js   "6E()
checkin.shxsessionkiosk-launch.shztogen-signage-checkin.serviceztogen-signage-checkin.timerztogen-signage-kiosk.service
install.sh)r  r  r  r  __TOGEN_SIGNAGE_EOF__c                     g d} t         D ]  }t        |z  j                         }t        |v rt	        d|       |j                  d      s|dz  }| j                  d| dt         d       | j                  |j                  d             | j                  t               | j                  d        dj                  d	 t        D              }| j                  d
|        | j                  d       dj                  |       dz   S )a  Assemble a self-contained bootstrap installer from the signage_agent/
    files, so `curl -fsSL <host>/signage/install.sh | sudo bash -s -- ...` works
    on a fresh Pi with no repo checkout. The agent files are the single source
    of truth: this stages each one verbatim (quoted heredoc = no expansion) into
    a temp dir, then execs install.sh, which reads its siblings from that dir.)	z#!/usr/bin/env bashzN# Togen signage kiosk bootstrap installer (served by GET /signage/install.sh).zi# Auto-assembled from togen/signage_agent/*; do not edit the served copy -- edit the source files. Usage:zo#   curl -fsSL https://HOST/signage/install.sh | sudo bash -s -- --host https://HOST --token <enrollment_token>zset -euo pipefailr7   zSTAGING="$(mktemp -d)"ztrap 'rm -rf "$STAGING"' EXITr7   zheredoc delimiter collides in r0  zcat > "$STAGING/z" <<'rd
  r7   r\   c              3   (   K   | ]
  }d | d  yw)z
"$STAGING/r  NrX   )r,  r  s     r1   r/  z+_build_signage_bootstrap.<locals>.<genexpr>< s     L1z!A.Ls   z	chmod +x z$exec bash "$STAGING/install.sh" "$@")
_SIGNAGE_AGENT_FILESSIGNAGE_AGENT_DIRr   _SIGNAGE_HEREDOCRuntimeErrorr|  ri   r8   r  _SIGNAGE_EXEC_FILES)r0
  r9   r&  chmod_targetss       r1   _build_signage_bootstrapr   s    E % 	!D(335t#!?vFGG}}T"DLD'vV4D3ERHIT[[&'%&R	 HHL8KLLM	LL9]O,-	LL7899Ud""r3   r  c                      	 t               } | dddd	fS # t        $ r) t        j                  j	                  d       ddddifcY S w xY w)
aj  Serve the self-contained device-agent bootstrap installer.

    Public by design (no session): the Pi has no Togen login, and the script
    holds no secrets -- the operator passes the enrollment token as an argument
    (see the copy-paste command in Togen Admin -> Agents -> Display). Returns 500
    only if the agent source files are missing from the deploy.zsignage_agent files missingze# Signage agent files are not present on this Togen server.
# Deploy togen/signage_agent/ and retry.
r  r  rm  rK   z!text/x-shellscript; charset=utf-8rn  ro  )r  FileNotFoundErrorrm   rn   r  )rz  s    r1   signage_install_scriptr  B sj    =)+ 3;#    =

:;=?B ;<= 	==s   
 /AAc                      t         j                  j                  d      xs dj                         j	                  d      } | r| S t
        j                  s"t
        j                  j                  d      dk(  rdnd}| dt
        j                   S )z`Server base URL for embedding in display bootstrap scripts.
    Mirrors _vision_server_base_url.TOGEN_BASE_URLr7   r6   X-Forwarded-ProtorB  rA  r>  )	r  r  r   r   r8   r   	is_securerx   r$  )cfg_baserH  s     r1   _display_server_base_urlr  ] su     

/06B==?FFsKH))W__-@-@AT-UY`-`WflFXS''r3   z/enroll/display-bootstrap.ps1c                  :   t               } t        j                  j                  d      xs dj	                         }|sdddddfS |  d}|j                  d	d
      }| j                  d	d
      }dt        j                  d       d| d| d| d| d}|dddddfS )u  Render a PowerShell onboarding script for Windows display agents.

    ?token=<enrollment_token> embeds the per-display enrollment token (quick-
    enroll path; the token comes from Admin → Agents → Display → Enroll).
    The script is semantically identical to the Vision bootstrap but targets
    the display-agent zip and register endpoint.

    Auth: token-gated (same as Vision bootstrap). Unauthenticated token-less
    requests get a parseable PS error (avoids SSO-HTML getting iex'd — DVI-674).
    rD  r7   zWrite-Host 'ERROR: This URL needs a ?token= enrollment token. Generate one in Togen Admin -> Agents -> Display -> Generate enrollment token.' -ForegroundColor Red
exit 1
r  rm  rn  ro  /enroll/display-agent.ziprd
  rp  u.   # Togen Display agent bootstrap — generated rr  zi
# Run as Administrator.  Downloads the display agent bundle and delegates to install.ps1.
# Usage: iwr 'zq/enroll/display-bootstrap.ps1?token=<tok>' -UseBasicParsing | iex
$ErrorActionPreference = 'Stop'
$Server     = 'rs  a  '
$Zip        = Join-Path $env:TEMP 'togen-display-agent.zip'
$ExtractDir = Join-Path $env:TEMP 'togen-display-agent'

# -- Download (no auth required; ZIP contains no secrets) -------------------
# DVI-850 pattern: retry up to 3 times; validate size > 1 MB (real bundle expected ~5+ MB).
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { }
$ZipSize = [long]0; $ZipOk = $false
for ($dlTry = 1; $dlTry -le 3; $dlTry++) {
    if ($dlTry -gt 1) { Write-Host "Retrying download (attempt $dlTry/3, last size: $ZipSize B)..."; Start-Sleep -Seconds 15 }
    Remove-Item $Zip -Force -ErrorAction SilentlyContinue
    Write-Host "Downloading Togen Display agent (attempt $dlTry)..."
    try {
        Invoke-WebRequest -Uri 'a  ' -OutFile $Zip -UseBasicParsing -TimeoutSec 300
        $ZipSize = [long](Get-Item $Zip -ErrorAction SilentlyContinue).Length
        if ($ZipSize -ge 1048576) { $ZipOk = $true; break }
        Write-Host "ERROR: Download too small ($ZipSize B) on attempt $dlTry." -ForegroundColor Yellow
    } catch {
        Write-Host "ERROR: Download attempt $dlTry failed: $_" -ForegroundColor Yellow
        $ZipSize = [long](Get-Item $Zip -ErrorAction SilentlyContinue).Length
    }
}
if (-not $ZipOk) {
    Write-Host "ERROR: Display agent bundle download failed after 3 attempts (last size: $ZipSize B)." -ForegroundColor Red
    Write-Host 'Check network connectivity and retry.'
    Remove-Item $Zip -Force -ErrorAction SilentlyContinue; exit 1
}

# -- Extract ----------------------------------------------------------------
if (Test-Path $ExtractDir) { Remove-Item -Recurse -Force $ExtractDir }
try {
    Expand-Archive -Path $Zip -DestinationPath $ExtractDir -Force
} catch {
    Write-Host "ERROR: Extraction failed (ZIP may be corrupt): $_" -ForegroundColor Red
    Write-Host "Downloaded size was $ZipSize B. Try running the script again." ; exit 1
}
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 display 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
rK   z*attachment; filename=display-bootstrap.ps1rt  )r  r   rh  r   r   r  rU   r  )r  rD  rw  rx  ry  rz  s         r1   display_enroll_bootstrapr  g s     $%D\\g&,"335E- 8:V
 	
 /0GmmC&Gll3%G?Na@b?c di y y ! ")	 #*%5Fl 33K#  r3   r  c                      t         t        z  } | j                         st        ddt         dt          d      dfS t	        t        |       dt              S )	aO  Serve the frozen Windows display agent bundle built by the Windows
    Engineer (P2) and dropped in togen/display_agent_win/.

    No auth required: the ZIP contains only the agent exe + install.ps1 with no
    embedded secrets. An enrollment token is still required to register.
    404 with guidance until the P2 artifact is present.z'Display agent bundle not available yet.rh  z in display_agent_win/ (ub   ); the Windows Engineer (P2) builds and drops the ZIP there — the route lights up when it lands.ri  r  Trn	  )DISPLAY_AGENT_WIN_DIRrb  r   r   r   r*   rj  s    r1   display_enroll_agent_zipr     sl     !#99D<<>; 67 8-. /UV

  	 SYd#9; ;r3   z/enroll/display-upgrade.ps1c                  v   t         j                  j                  d      xs dj                         } t	        t        j                  d      xr t
        d   j                  d            }|s| rt               }t        ||       du}|s`t         j                  j                  dd      }|j                  d      r|dd j                         nd}|rt               }t        ||      du}|sd	d
dddfS t               }|j                  dd      }| d}dt        j                  d       d| d| d}|d
ddddfS )u  Render an upgrade-in-place PowerShell script for already-enrolled Windows
    display agents. Stops the agent task, replaces the exe from the latest ZIP,
    and restarts.

    Auth: requires Togen Admin session or ?token=<agent_token> from an enrolled
    display agent — same pattern as Vision upgrade.ps1 (DVI-920 / DVI-1007).
    Returns HTTP 200 with a PS error body when unauthenticated, so
    `iwr ... | iex` never gets an HTML 302 (DVI-674).

    Honors the staged .new-swap pattern: installs the new exe as
    display_agent.exe.new, then a helper task does the hot-swap (ensures the
    running process is not replaced under itself).
    rD  r7   r'  r   Nr  r  r   zWrite-Host 'ERROR: display-upgrade.ps1 requires a Togen Admin session or ?token=<device-agent-token>. Log into Togen Admin and re-download.' -ForegroundColor Red
exit 1
rK   rm  rn  ro  rd
  rp  r  u3   # Togen Display agent upgrade script — generated rr  z
# Upgrades an already-enrolled Windows Display agent in-place.
# Run as Administrator.  No enrollment token is required.
$ErrorActionPreference = 'Stop'
$Server      = 'aQ  '
$TaskName    = 'TogenDisplayAgent'
$SwapTask    = 'TogenDisplayAgentSwap'
$InstallDir  = Join-Path $env:ProgramFiles 'Togen\Display'
$LogDir      = Join-Path $env:ProgramData  'Togen\Display\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 }

# Download new bundle ---------------------------------------------------------
$Zip        = Join-Path $env:TEMP 'togen-display-agent.zip'
$ExtractDir = Join-Path $env:TEMP 'togen-display-agent'
Write-Step 'Downloading latest display agent bundle...'
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { }
Invoke-WebRequest -Uri 'u=	  ' -OutFile $Zip -UseBasicParsing -TimeoutSec 300
$ZipSize = [long](Get-Item $Zip).Length
if ($ZipSize -lt 1048576) { Write-Host "ERROR: Download too small ($ZipSize B)." -ForegroundColor Red; exit 1 }
Write-OK "Downloaded $ZipSize B"

if (Test-Path $ExtractDir) { Remove-Item -Recurse -Force $ExtractDir }
Expand-Archive -Path $Zip -DestinationPath $ExtractDir -Force
Remove-Item $Zip -Force -ErrorAction SilentlyContinue

# Stage new exe as .new (DVI-920 pattern: hot-swap via a helper task) ---------
# DVI-1007: the Scheduled Task re-registration gotcha — unregister the swap
# task before rewriting it so a stale task definition can't interfere.
Write-Step 'Staging new exe for hot-swap...'
$NewExe = Join-Path $ExtractDir 'display_agent.exe'
if (-not (Test-Path $NewExe)) {
    Write-Host "ERROR: display_agent.exe missing from bundle." -ForegroundColor Red; exit 1
}
$TargetNew = Join-Path $InstallDir 'display_agent.exe.new'
Copy-Item -Path $NewExe -Destination $TargetNew -Force
Write-OK "Staged $TargetNew"

# Register / re-register the swap task ----------------------------------------
Write-Step 'Registering swap task...'
Unregister-ScheduledTask -TaskName $SwapTask -Confirm:$false -ErrorAction SilentlyContinue
$SwapScript = Join-Path $InstallDir 'swap_agent.ps1'
if (-not (Test-Path $SwapScript)) {
    # Inline minimal swap: stop agent task, replace exe, restart.
    @"
Stop-ScheduledTask -TaskName '$TaskName' -ErrorAction SilentlyContinue
Start-Sleep -Seconds 3
Move-Item -Path '$TargetNew' -Destination (Join-Path '$InstallDir' 'display_agent.exe') -Force
Start-ScheduledTask -TaskName '$TaskName' -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName '$SwapTask' -Confirm:`$false -ErrorAction SilentlyContinue
"@ | Set-Content -Path $SwapScript -Encoding UTF8
}
$SwapAction  = New-ScheduledTaskAction -Execute 'powershell.exe' `
    -Argument ('-NonInteractive -ExecutionPolicy Bypass -File "' + $SwapScript + '"')
$SwapTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(10)
Register-ScheduledTask -TaskName $SwapTask -Action $SwapAction -Trigger $SwapTrigger `
    -RunLevel Highest -Force | Out-Null
Write-OK "Swap task registered — agent will restart within ~15 s"

Write-Host ''
Write-Host 'Upgrade staged.' -ForegroundColor Green
Write-Host 'The running agent will be replaced and restarted automatically.'
z(attachment; filename=display-upgrade.ps1rt  )r   rh  r   r   r   r   r  r  rx   rw   r  r  rU   r  )	rD  authedr	  auth_hdrbearerr  ry  rw  rz  s	            r1   display_enroll_upgrader   ѡ sa    \\g&,"335E'++f%L'&/*=*=m*LMFe$&(6dB??&&;)1)<)<Y)G!"##%R(*E,UF;4GF- 8:V
 	
 $%Dll3%G/0GDT]]SfEgDh i 	  !	 -"'@FB 33I#  r3   r  c                     t        j                  d      xs i } t         j                  j                  dd      }| j                  d      xs& |j	                  d      r|dd j                         ndj                         }|st        d	
      dfS | j                  d      xs dj                         xs d}t        j                         }t               }|j                  d      xs i j                  |      }|st        d
      dfS |j                  d      rt        d
      dfS ||j                  d      xs dkD  rt        d
      dfS dt        j                         j                  dd  }t        t        j                               }t        | j                  d      xs d      j                         j                         }	|	t        v r|	nd}
i d|d|xs dd|d|dddd d!dd|d"dd#dd$dd%dd&dd'dd(g d|
|d)   |<   ||d<   t        |       t!        d*d+|xs |d,| d-|.       t        ||d /      S )0u  Device presents its enrollment_token; receives a permanent agent_token.

    Mirrors /vision/agents/register but signage-only: token-bound admin
    enrollment lands the display 'active' immediately — only admins can mint an
    enrollment token, so the token itself is the authorization (no separate
    pending/approval step). Accepts the credential in the body or a Bearer header.
    Tr  r  r7   r  r  r   Nzenrollment_token required.r  r`  r!  r  r  r  r  r  r.  r   r  disp_r   r?  r@  r  r9   zNew displayr  r  r"  rd   rv  rm  r3  r4  r  r5  r6  r7  r8  r  r  r  Display agent z registeredrh  )r  r  rd   )r   r  rx   r   rw   r   r   rU   r  rW  rX  rY  r*   rE  rE  r'  r	  )r  r  r  r!  r  r	  r  r  r  plat_rawr?  s              r1   signage_agent_registerr
   ? s    4(.BD??3D((-. K'+y'A48>>#rEEG 9:C??$*113;tH
))+C "E99_%+
0
0
<C893>>
ww}=>CC
cggl#(q)893>>tzz|'',-.Hdjjl#K 488J'27399;AACH#'99xwH!h!)M! 	{! 	s	!
 	! 	(! 	! 	H! 	D! &t! 	!  (! 0CD! 	t!  	b!!" 	H#!E(OH& C#\!-X+H:[AxQ {XhOOr3   r  c                  t   t               } | st        d      dfS t        j                  d      xs i }t	               }t        ||       }|t        d      dfS t        j                         |d<   d	D ]K  }|j                  |      }t        |t              s%|j                         s6|j                         dd
 ||<   M |j                  d      }t        |t              r(|j                  d      xs t        j                  |      }t        |t              r&|j                         r|j                         dd
 |d<   d|v rIt        |j                  d      xs d      j                         j                         }|t        v r|nd|d<   t!        |       t        d|j                  d      xs dd      S )zDevice heartbeat (Bearer agent_token). Refreshes last_seen_at + the host
    facts that drive the online/stale indicator in the Agents tab. Returns the
    currently-assigned dashboard id so a headless agent knows its target.r  r  r  Tr  Nr  r"  )r!  r3  r4  r  r6  rK   r5  r	  r?  r@  rm  r7   r  )rU  rm  poll_seconds)r  r   r   r  r  r  rU   r   r   r*   r   r   r   r   rE  rE  r'  )rD  r  r	  r  r)  r  r5  r	   s           r1   signage_checkinr   y s   
 "#E:;S@@4(.BD "E#E51E}>?DD IIKE.% )HHSMa!'')4CE#J	)
 XXhF&$L)?TZZ-?&#6<<> ,,.#.h Ttxx
+6w7==?EEG(04F(FHGjd>)B)Hb "$ $r3   z/signage/agentsc                     t               } | j                  d      xs i j                         D cg c]  }t        |      st	        |       }}|j                  d        t               }|j                         D cg c]F  }|j                  d      r3|j                  d      |j                  d      |j                  d      dH }}|j                  d	        |j                         D ci c]#  }|j                  d      |j                  d      % }}|D ])  }|j                  |j                  d
            xs d|d<   + t        ||t              S c c}w c c}w c c}w )zEnrolled display agents + the dashboards assignable to them. Only
    signage-flagged dashboards are renderable/assignable (plan D4).r  c                 H    | j                  d      xs dj                         S r;  r<  )r  s    r1   r	  z%signage_agents_list.<locals>.<lambda> r  r3   r  r  r  r9   rL  )r  r9   rL  c                 H    | j                  d      xs dj                         S r;  r<  r  s    r1   r	  z%signage_agents_list.<locals>.<lambda>     155=+>B*E*E*G r3   rm  r7   rX  )r  signage_dashboardsstale_after_seconds)	r  r   r  r,  rI  r  r  r   r1  )r	  r  r  dboardsrn  r   r.  s          r1   signage_agents_listr    sS   
 !"E 2781D1J0R0R0T -1(+ $A& -F -
KK;K< G !7QUU9%5 uuT{AEE&MAEE'NK7 7  GH181ABAQUU4[!%%-'BEB E#iin(=>D"
E &5G'=? ?-7 Cs   E>AE0(E$z/signage/agents/<agent_id>c           	         t        j                  d      xs t         j                  }t               }|j	                  d      xs i j	                  |       }|t        d      dfS t        ||      }|rt        |d         |d   fS d	|v rt        |j	                  d	            |d	<   d
|v rIt        |j	                  d
      xs d      j                         j                         }|t        v r|nd|d
<   t        |       t        ddt        j                  d      xs i j	                  d      d|j	                  d       d|        t        dt        |            S )zRename and/or (re)assign a dashboard. Only a signage-flagged dashboard
    may be assigned; assigning '' clears the assignment (display shows a splash).Tr  r  Display agent not foundr  r  r   r^   r=  r?  r@  r  r  r'  r9   r   z updatedrh  rU  r  )r   r  rg  r  r   r   r  rS  r*   r   rE  rE  r'  r	  r   rI  )r  r   r	  r  r  r	   s         r1   signage_agent_updater    sK   
 4(8GLLD "EYYx &B++H5E}67<< /ud
;C
SV$c!f,, D #9$((CT:U#V Ttxx
+6w7==?EEG(04F(FHGj#Y!++f-388@+EIIf,=+>hGXW d"7">??r3   z#/signage/agents/<agent_id>/settingsc           	      v   t        j                  d      xs t         j                  }t               }|j	                  d      xs i j	                  |       }|t        d      dfS t        |      rt        d      dfS t        j                  d	      xs i }t        ||      st        d
      dfS t        ||      }|rt        |d         |d   fS t        |       t        dd|j	                  d      xs |j	                  d      d|j	                  d       d|        t        dt        |            S )u  DVI-1322: a delegated editor (a user on the display's `allowed_editors`
    list) saves a physical display's settings from User Settings → Agents, as if
    it were their own (D1-C full parity minus revoke/delete): name, dashboard,
    Present gating, interface, splash, layout, and — for Windows displays — the
    `window` layer/monitor/geometry (D2). Managing the editor list itself and the
    platform tag stay admin-only; there is no revoke/delete path here. Shared
    record, last-write-wins; each save is attributed in history.Tr  r  r   r  r  zNot a physical display agentr`  r'  z#Not authorized to edit this displayr  r   r^   r  zsettings-editedr9   r  r   z settings edited by delegaterh  r   )r   r  rg  r  r   r   r,  r   r  r  r'  r	  rI  )r  r   r	  r  r'  r  s         r1   signage_agent_settingsr   Ѣ s&    4(8GLLD "EYYx &B++H5E}67<<5!;<cAA;;v$"DUD)BCSHH
.ud
;C
SV$c!f,,#%6&)>TXXg->+EIIf,=+>>Z[! d"7">??r3   z!/signage/agents/<agent_id>/revokec           	      H   t               }|j                  d      xs i j                  |       }|t        d      dfS d|d<   t        |       t	        ddt        j                  d      xs i j                  d	      d
|j                  d	       d|        t        d      S )zCRevoke a display: its agent_token stops authenticating immediately.r  r   r  r  r  rd   r  r'  r9   r   z revokedrh  TrY  )r  r   r   r'  r	  r   r  r	  r  s      r1   signage_agent_revoker    s     !"EYYx &B++H5E}67<<E(O#Y!++f-388@+EIIf,=+>hGXW dr3   c                    t               }| |j                  d      xs i vrt        d      dfS |d   |    j                  d      }|d   | = t        |       t	        ddt        j                  d      xs i j                  d      d	| d
|        t        D ]"  }t        | |      }|	 |j                          $ t        |        t        d      S # t        $ r Y Gw xY w)Nr  r   r  r  r9   r  r	  r'  r   z deletedrh  TrY  )r  r   r   r'  r	  r   rC  rD  r/   r   r  )r  r	  r9   r  r  s        r1   signage_agent_deleter     s     !"E		(+1r267<<?8$((0Dh!#Y!++f-388@+D6:J & "8V4=
	  )d  s   +C	C C z%/signage/agents/<agent_id>/background)r	  r  r	  c           
        
 t               }|j                  d      xs i j                  |       

t        d      dfS t        j                  d      xs i }t	        
|      st        d      dfS t
        j                  j                  d      xs d	j                         j                         t        vrt        d
      dfS 
fd}t
        j                  dk(  r`t        |       }|yt        t        |      t        j                  |j                  j!                  d      j                         d            S t
        j                  dk(  r`t        |       }|	 |j#                           |       }|j                  d      dk(  rd|d<   t'        |       t        dt)        
            S t
        j*                  j                  d      }||j,                  st        d      dfS d|j,                  v r-|j,                  j/                  dd      d   j                         nd}|t        vrt        d      dfS |j1                  t2        dz         }t5        |      t2        kD  rt        d      dfS t6        j9                  dd       t        |       }	|		 |	j#                          t6        d|  d  d| z  j;                  |       d |       d<   t'        |       t=        d!d"t        j                  d      xs i j                  d#      d$
j                  d#       d% d&| '       t        dt)        
            S # t$        $ r Y w xY w# t$        $ r Y w xY w)(u  Custom background image for a display (DVI-1217).

    Authorization (DVI-1248 + DVI-1322): a Togen Admin may manage any display's
    background; the OWNER of a virtual display may manage their own; and a
    delegated editor (on a physical display's allowed_editors list) may manage
    that physical display's background (User Settings → Agents).
    ?target=interface|splash picks which of the two slots. POST (multipart field
    'background') mirrors the per-user custom-bg validation (extension +
    8 MB cap) and flips the matching interface's bg_mode to 'custom'; DELETE
    removes the file and reverts a now-imageless 'custom' mode to company
    default; GET serves the stored image for the modal preview.r  r   r  r  r'  r  r  r  r;  zUnknown background targetr`  c                      dk(  rBj                  dddi      } | j                  dt        j                  d      xs i             S j                  dt        i             S )Nr<  r&  r7   r;  )r}  rM  r   )r<  r  r  s    r1   _iface_dictz-signage_agent_background.<locals>._iface_dict2 sh    X%%h=F$$[2M		+&,"3. / /-H-LMMr3   r	  rJ  r  r  r   r	  rf  rY  r7   Tr   r  rP  r^   r  z8Unsupported image type. Use PNG, JPEG, WebP, GIF or SVG.zImage is too large (8 MB max).rM   r  r  r  r  r9   r   r\   z background updatedrh  )r  r   r   r   r  r   rh  r   rE  rC  ra   rD  r   r*   r!  r!  rV  r/   r   r'  rI  r  r  rj  r  r  rC  r  r  r-   r	  )r  r	  r'  r#   r  r  r  r  rG  r  r  r  s             @@r1   signage_agent_backgroundr$    s+    !"EYYx &B++H5E}67<<;;v$"DUD)-.33llx(7K>>@FFHF((893>>N ~~"8V49#Q*=*A*AHHOOC &&(*D+F G 	G ~~!"8V4=
 99Y8+!E)U#$&;E&BCC==\*D|4==/0#5569T]]6J$--

sA
&r
*
0
0
2PRC
%%WXZ]]]99(1,-D
4y&&=>CCt4
 6
2C
	JJL xz6(!C599FFtL'KM)#Y!++f-388@+EIIf,=+>axGZ[! d"7">??A  ,  		s$   $M	 !M 		MM	M%$M%z/display/backgroundc                  *   t               } t        | t                     }|t               \  }}|yt        j
                  j                  d      xs dj                         j                         }|t        dz   vryt        |j                  d      xs d|      }|$|dk(  rt        |j                  d      xs dd      }|yt        t        |      t        j                  |j                  j                  d	      j                         d
            S )u   Custom background image for the kiosk page (device token — the kiosk has
    no session). ?which=interface|splash; falls back splash→interface the same
    way _signage_resolved_interface does.r  r  r;  )r:  rJ  r  r7   r<  r  r  r   )r  r  r  "_display_virtual_agent_for_sessionr   rh  r   r   rE  rC  rD  r   r*   r!  r!  rV  )r	  r  r  r  r  s        r1   display_backgroundr'   i s    
 !"E#E+@+BCE}
 78	E}$\\g&5+<<>DDFE ',66uyy4"e<AyUh&"599T?#8b+FySV&9&=&=	""$&@'B C Cr3   z/signage/agents/virtualc                  |   t               } t               }|j                  d      xs i j                         D cg c]I  }t	        |      r<|j                  d      dk7  r(|j                  d      xs dj                         | k(  r|K }}|D cg c]  }t        |       }}|j                  d        t               }|j                         D ci c]#  }|j                  d      |j                  d	      % }}g }|j                         D ]  }|j                  d
      xs dj                         }	|j                  d      xs g D 
cg c]  }
t        |
      j                          }}
|	| k(  s| sg| |v sl|j                  |j                  d      |j                  d	      |	| k7  d        |j                  d        g }|j                  d      xs i j                         D ]  }t	        |      s|j                  d      dk(  r#|j                  d      xs g D cg c]  }t        |      j                          }}| s_| |v sdt        |      }|j                  |j                  d            xs d|d<   |j                  |        |j                  d        |j                         D cg c]6  }|j                  d      r#|j                  d      |j                  d	      d8 }}|j                  d        t        ||||t        t              S c c}w c c}w c c}w c c}
w c c}w c c}w )zjThe caller's own virtual displays + the dashboards they may attach one to
    (owned or shared with them).r  rd   r  r  r7   c                 H    | j                  d      xs dj                         S r;  r<  r  s    r1   r	  z&signage_virtual_list.<locals>.<lambda>     AEE&M/R668 r3   r  r  r9   rL  r  )r  r9   re  c                 H    | j                  d      xs dj                         S r;  r<  r  s    r1   r	  z&signage_virtual_list.<locals>.<lambda> s    f 3::< r3   r=  rm  rX  c                 H    | j                  d      xs dj                         S r;  r<  r  s    r1   r	  z&signage_virtual_list.<locals>.<lambda> s    v!4" ; ; = r3   r  r  c                 H    | j                  d      xs dj                         S r;  r<  r  s    r1   r	  z&signage_virtual_list.<locals>.<lambda> r   r3   )r  r  editable_displayseditable_signage_dashboardsr   max_per_user)r  r  r   r  r,  rE  r  r  r  r*   ri   rI  r   r0  _SIGNAGE_VIRTUAL_MAX_PER_USER)r  r	  r  r  r   r   rn  dnameschoicesrL  r  re  editablerB  r  pubr   s                    r1   signage_virtual_listr6    s   
 E "E		(+1r99; ?!"1%!%%/Y*F}%+224=  ?D ? 04
4!"1%
4C
4HH8H9 G29..2BCQaeeDk155=(CFCG^^ 7w%2,,.+,55+@+FBHQ#a&,,.HHE>eNN!%%+quuV}&+un6 7	7 LL<L=
 Hii!'R//1 !q!QUU8_	%A,-EE2C,D,JLa3q6<<>LLUg%'*C$*JJquu^/D$E$KC !OOC ! MM=M>#*>>#3IquuY7G "#taeeFmD I I GH#'%-/A'E =	? ?A? 5 D I MIs$   AL L%(L*
 L/# L47;L9c                     t        j                  d      xs t         j                  } t               }|st	        d      dfS | j                  d      xs dj                         }|st	        d      d	fS | j                  d
      xs dj                         }|rt        |      \  }}|t	        d      dfS t               }|j                  d      xs i j                         D cg c]I  }t        |      r<|j                  d      dk7  r(|j                  d      xs dj                         |k(  r|K }}t        |      t        k\  rt	        dt         d      d	fS dt        j                         j                   dd  }	t#        j"                         }
|	d|dd |||
dddg i t%        d      d|d   |	<   t'        |       t	        dt)        |d   |	               S c c}w )zECreate a virtual display for one of the caller's viewable dashboards.Tr  zNot signed inr  r  r9   r7   r<  r`  rm  N-Dashboard not found or not accessible to you.r  r  rd   r  r  zYou can configure at most z virtual displays.vdisp_r   r+  r  rv  F)r  rT  r9   r  rm  r  r"  rd   r7  r8  r;  r  r   )r   r  rg  r  r   r   r   rN  r  r  r,  rE  rC  r1   rW  rX  rY  rU   rK  r'  r  )r   r  r9   rQ  r  rn  r	  r  r  r  r  s              r1   signage_virtual_creater:    s    4(8GLLDE_-s22HHV"))+D34c9988N#)r
0
0
2C
#C(A9!PQSVVV "E		(+1r99; ?!"1%!%%/Y*F}%+224=  ?D ? 4y119 =>>PR STWX 	X

(("-./H
))+C	4:c48B#;D#A!E(OH d"9%/(:S"TUU!?s   (AG z"/signage/agents/virtual/<agent_id>c                 <   t        j                  d      xs t         j                  }t               }t	        || t                     }|t        d      dfS d|v r;|j                  d      xs dj                         }|st        d	      d
fS |dd |d<   d|v rH|j                  d      xs dj                         }|rt        |      \  }}|t        d      dfS ||d<   d|v rt        |j                  d            |d<   d|v rt        |j                  d            |d<   d|v rt        |j                  d            |d<   d|v rt        |j                  d            |d<   d|v sd|v rt        |      |d<   t        |       t        dt!        |            S )zOwner edits their own virtual display: name, dashboard, interface, layout,
    and the P2 presentation settings (presentable / allowed_presenters / publish
    to anyone).Tr  NVirtual display not foundr  r  r9   r7   r<  r`  r  rm  r8   r;  r<  r  r7  r8  r9  r   )r   r  rg  r  r  r  r   r   r   rN  rM  rP  rK  r   r  r'  r  )r  r   r	  r  r9   rQ  r  rn  s           r1   signage_virtual_updater=   ߣ s    4(8GLLD "E"5(4D4FGE}893>>~ &B--/!78#==Tc
fxx'-2446',EBy%TUWZZZ #nd8+9NOk 42488H3EFh42488H3EFh#DHH]$;<mt#'74'?&A$&G"#d"9%"@AAr3   c                     t               }t        || t                     }|t        d      dfS |d   | = t	        |       t        |        t        d      S )Nr<   r  r  r  TrY  )r  r  r  r   r'  r  r   s      r1   signage_virtual_deleter?    s[     !"E"5(4D4FGE}893>>h!)dr3   z/signage/agents/virtual-checkinc                  @   t        j                  d      xs t         j                  } t               }t	        || j                  d      xs dj                         t                     }|t        d      dfS | j                  d	      d
v rd|d<   nt        j                         |d<   |j                  d      r;t        |      0|j                  dd       t        |j                  d      xs d       t        |       t        dt        |      t        |            S )u;  Heartbeat from the owner's open fullscreen window (called on load, on a
    ~30 s interval, and — with offline:true, via sendBeacon — on unload so the
    display flips to offline the moment the tab closes). Returns the live present
    override so the window can react without waiting for the next config poll.Tr  r  r7   Nr<   r  r  r.  rF  r"  r:  r  )rU  r  r:  )r   r  rg  r  r  r   r   r  r   rU   r  r  r  r'  r  rB  )r   r	  r  s      r1   signage_virtual_checkinrA    s     4(8GLLD "E"5488J+?+E2*L*L*N#3#57E}893>>xx	BB $n $		nyy 7 > F		)T"#EIIdO$9r:d/%*@259; ;r3   z/display/present/targetsc            
         t        j                  d      xs i } t               }g }|j                  d      xs i j                         D ]U  }t	        ||       s|j                  |j                  d      |j                  d      t        |      t        |      d       W |j                  d        d}| j                  d	      xs d
j                         }|rt        |      j                  d      }t        |t              r|j                  d      r	 t        |j                  d      xs d      }t!        |j                  d            |t#        |j                  d            t        |j                  d      xs d      t!        |j                  d      xs d
      t!        |j                  d      xs d
      d}t%        ||      S # t        t        f$ r d}Y w xY w)u$  Displays the current user may present to, with each display's live present
    state so the picker can show a "presenting to X — Stop" affordance.

    A display qualifies when it is `presentable` AND lists the user in
    `allowed_presenters`; a Togen Admin sees every presentable display.r'  r  r  r9   )r  r9   r  r:  c                 H    | j                  d      xs dj                         S r;  r<  r  s    r1   r	  z)display_present_targets.<locals>.<lambda>C r*   r3   r  Nr  r7   present_rememberr  r  r   r  rP  r   rd  r  r  r  rP  r   rd  )displaysrJ  )r   r   r  r  r  ri   r  rB  r  rE  r  r   r   r   r   r   r*   r   r   )r'  r	  r   r  rJ  r  r  r  s           r1   display_present_targetsrG   2 s    ;;v$"D "E
Cii!'R//1 <#At,

!%%+quuV}%4Q%76q9; 	<< HH8H9 JXXg$"++-E&**+=>a155#6!%% 238q9 ),AEE,,?(@.1.21559K3L.M #&aeeFm&8q"9%(y)9)?R%@"%aeeFm&9r":<J CJ77 z* s   G G#"G#c                 2   t        |j                  d      xs d      j                         j                         }|r|dk7  rt	        |      | d<   t        |j                  d      xs d      j                         j                         }|t
        v r|| d<   yy)a  DVI-1288 P3: fold an optional present-time columns/flow override onto a
    present record from the POST body. Blank/"keep" leaves the display's own
    layout untouched; an explicit value stores it for the present's duration.
    Shared by the dashboard and report present routes.r   r7   keeprd  N)r*   r   r   rE  rX  rc  )r:  r   pcolspflows       r1   _present_apply_layout_overriderL   [ s    
 #)r*00288:E&3E:	 &B'--/557E## $r3   z/display/presentc                  b   t        j                  d      xs t         j                  } | j                  d      xs dj	                         }| j                  d      xs dj	                         }t               }|j                  d      xs i j                  |      }||j                  d      d	k(  rt        d
      dfS t        j                  d      xs i }t        ||      st        d      dfS |j                  d      xs dj                         }t               j                  |      }|&|j                  d      xs dj                         |k7  rt        d      dfS 	 t        | j                  d      xs d      }t        j                         }	|t        v r|	|dz  z   nd}
t!        |       ||j                  d      ||j                  d      xs ||	|
d|d<   t#        | j                  d      xs d      j	                         }|r|dvrt%        |d      |d   d<   t'        |d   |        | j                  d      dv }|r"t)        ||| j                  d            |d   d<   t+        |       d | v rt-        |      }| j                  d       dv rs||t/        |      t        |d   j                  d      xs d      t#        |d   j                  d!      xs d      t#        |d   j                  d"      xs d      d#|d$<   n|j1                  d$d       	 t3        ||       t7        d%d&|j                  d      xs |d'|j                  d       d(|j                  d       |)       t        dt9        |      *      S # t        t        f$ r d}Y w xY w# t4        $ r Y w xY w)+ax  Start (or replace) a Present override on a display.

    Authorization (all enforced here): the display must be `presentable` and the
    user an allowed presenter or a Togen Admin (`_signage_can_present`), and the
    dashboard must be one the user OWNS. Duration is 15/30/60 minutes or, for any
    other value, "until stopped" (expires_at=None). Expiry is enforced on read.Tr  r  r7   rm  r  Nrd   r  Display not foundr  r  r'  /You are not allowed to present to this display.r  r  rL  z)You can only present a dashboard you own.r  r   r   r9   )rm  rX  r  r  rq  r.  r:  rP  rF   rI   rd  r  rF  r  r;  rE  r   rd  rE   rD   r  present-startzPresenting '' to rh  rU  r:  )r   r  rg  r   r   r  r   r   r  rE  r  r   r   r   rU   _SIGNAGE_PRESENT_DURATIONSr  r*   r-  rL   r  r'  r  r   r  r  r   r	  rB  )r   r  rm  r	  r  r'  r  rY  r  r  r.  _pzuse_myr  s                 r1   display_present_startrW   h s    4(8GLLD((<(.B557JHH^,299;L "EYYx &B++J7E}		(+y801366;;v$"Dt,NOQTTTXXg$"++-E!!,/D|)/R668EAHI3NN$((-.3!4 ))+C%(,F%F#b.DJ
+$((6*((6*3e E) dhhv$"
%
+
+
-C
s-'#6sC#@i 
 #5#3T: XX()-QQF(=*dhh|4)6i% T&u-88J#GG(c$(LE),008=A>uY/33I>D"EE),008>B?,AH'( LL+T2	x0 #_&)2U)$((6*:);56AR@ST!# d$;E$BCCs z" f  		s$   'N
 !N" 
NN"	N.-N.z/display/present/stopc            	      n   t        j                  d      xs t         j                  } | j                  d      xs dj	                         }t               }|j                  d      xs i j                  |      }|t        d      d	fS |j                  d
      xs i }|j                  d      s|j                  d      st        d      S t        j                  d      xs i }|j                  d      xs dj                         }|j                  d      s4|j                  d      xs dj                         |k(  st        d      dfS |j                  d
d       t        |       t        |       t        dd|j                  d      xs |d|j                  d       |       t        d      S )zManually revert a Present override. Allowed for the presenter who started
    it, or a Togen Admin (force-revert). Stopping when nothing is presenting is a
    no-op success.Tr  r  r7   r  NrN   r  r  r:  rm  r6  rY  r'  r  r   r  z2Only the presenter or a Togen Admin can stop this.r  r  zpresent-stopr9   zStopped presenting to rh  )r   r  rg  r   r   r  r   r   rE  r  r  r'  r	  )r   r  r	  r  r  r'  r  s          r1   display_present_stoprY    st    4(8GLLD((<(.B557J "EYYx &B++J7E}01366		)"AEE.!QUU8_$;;v$"DXXg$"++-EHH]#k(:(@b'G'G'IU'RQRTWWW	IIi
+#^&)2U3EIIf4E3FG!# dr3   c                    t        | t              r| j                  d      nd}t        |t              r|j                  d      sy	 t        j                  |d         S # t
        $ r" t        j                  j                  d       Y yw xY w)zLoad the saved run (with its stored view-model) referenced by a report
    present override, or None if it's not a report present / the run is gone.r6  Nrf  zpresent report: get_run failed)	r   r   r   reports_storerR  ry   rm   rn   r  )r:  r  s     r1   _present_report_runr\    st     $.gt#<'++h
$Cc4 (9$$S]33 

=>s   A (B
	B
c           	         | xs i j                  d      xs i }g }t        |j                  d      xs g       D ]  \  }}|j                  d      |j                  d      |j                  d      xs d||j                  d      xs d|j                  d	      xs i d
}|j                  d      r|j                  d      |d<   |j                  d      r(|j                  d      dk7  r|j                  d      |d<   |j                  |        dt        |j                  d      xs | xs i j                  d      xs d      z   |j                  d      xs dd|j                  d      xs i |dS )a  Build a kiosk-renderable dashboard-shaped object from a presented report
    run's stored view-model. Tiles carry layout + config only (no payload); the
    display fetches each tile's snapshot payload from /display/tile-data, exactly
    like an ordinary signage dashboard.rI  r  r  rT  rd  r  r:  r7   rc  )r  rT  rd  r  r:  rc  r<  rP  rd  report:r  r  ReportT
date_range)r  r9   r6  r`   r  )r   r  ri   r*   )r:  rJ  r  r  r   r  s         r1    _present_report_dashboard_publicra    sK   
 )	&	,"BE"&&//R0 1EE$KveeFm/x!%%
+1rQUU8_=RPRT 55=vBvJ55=QUU6]c1vBvJR c"&&"7"_CI2??=;Y"_]_``FF?+7x"&&*>*D" r3   c                     | xs i j                  d      xs i }|j                  d      xs g D ]7  }|j                  d      |k(  st        |j                  d      xs i       c S  t        d      dfS )	u   Serve one tile's snapshot payload for a presented report (baked into the
    stored view-model at run time). Never runs a live provider — the presented
    artifact is fixed for the present's duration. Returns a Response.rI  r  r  r  r  ry  r  r  )r   r   )r:  rx  rJ  r   s       r1   _present_report_tile_responserc    ss     )	&	,"BffWo# :55;'!y!1!7R99: )*C//r3   c           
         t        |       }|t        |      }n7t               j                  | j                  d            }|rt	        |      nd}|]|j                  dd| j                  d      | j                  d      |       t        | j                  d      t              r| d   r| d   |d<   | j                  d      }|rOt        |j                  d	      t              r0t        |d	   t        ||d	   j                  dd
                  |d	<   | j                  d      }|;t        |j                  d	      t              rt        |d	   t        |            |d	<   | j                  d      }|t        v r2t        |j                  d	      t              rt        |d	   |      |d	<   |S | j                  d      rdnd|d<   |S )a
  Apply an active Present override to a /display/config response dict,
    handling both an ordinary dashboard present and a DVI-1259 report present
    (which carries a saved reports_store run rendered as a dashboard-shaped
    payload). Mutates and returns ``resp``.Nrm  Tr  r.  )r  r:  r  present_expires_atr4  r;  rP  r  rd  )rP  r   )r   rd  )rd  r6  z,The presented report is no longer available.z)The presented dashboard no longer exists.rm  )r\   ra   r  r   rU  r  r   r   r-  rX  rc  )r:  r  r:  dash_pubrY  pzpcr  s           r1   _present_config_applyri    s   
 g
&C
3C8!%%gkk.&AB04&t,$T4#*;;/?#@'.{{<'@& 	 	( gkk+.5'+:N ' 4D [[ *TXXh/6!$x.':2tH~?Q?QRXZ]?^'_aDN [[#>j();TB!$x.:PQS:TUDN[[ $$DHHX4F)M!$x.r:DN
 K %[[2 IJ 	X Kr3   z/display/present-reportc                  <   t        j                  d      xs t         j                  } | j                  d      xs dj	                         }| j                  d      xs dj	                         }| j                  d      xs dj	                         }t               }|j                  d      xs i j                  |      }||j                  d	      d
k(  rt        d      dfS t        j                  d      xs i }t        ||      st        d      dfS t        |      st        d      dfS t        j                  |      }|t        d      dfS |j                  d      xs dj                         }|r<t        j                  |      }	|	|	j                  d      |d   k7  r~t        d      dfS t        ||| j                  d            }
t        j                  |d   |d   ||
|
j                  d            }	|	d   }t!        ddd|d   z  |d   |d   ||d       	 t#        | j                  d      xs d       }t)        j(                         }|t*        v r||d!z  z   nd}t-        |       ||d   |d   d"|d   ||j                  d      xs |||d#|d$<   t/        | j                  d%      xs d      j	                         }|r|d&vrt1        |d'      |d$   d%<   t3        |d$   |        | j                  d(      d)v r"t5        ||| j                  d*            |d$   d+<   t7        |       t!        d,d-|j                  d      xs |d.|d   d/|j                  d      |0       t        dt9        |      1      S # t$        t&        f$ r d }Y Uw xY w)2uv  Present a report to a Display Agent (DVI-1259, decision D6).

    Body: {display_id, template_id, run_id?, duration_minutes, use_my_interface,
    theme_hint}. Authorization mirrors dashboard present — the display must be
    presentable and the caller an allowed presenter/admin (`_signage_can_present`)
    — plus the caller must be able to use the Report Viewer. A chosen historical
    `run_id` (which must belong to the template) is presented as-is; otherwise a
    fresh live view-model is built under the presenter's identity and snapshotted
    (D4-A) so the presented artifact is immutable for the present's duration.Tr  r  r7   r  rf  r  Nrd   r  rN   r  r  r'  rO   r  z,You do not have access to the Report Viewer.zReport template not found.r  r  z Run not found for this template.r`   r  r`   r  report_run_savedzPresented a run of report '%s'r9   )r  rf  presented_tor
	  r  r	  r  r   r   )rf  r  r  )r6  rX  r  r  rq  r.  r:  rP  rP   rd  r  rF  r  r;  r  rQ   zPresenting report 'rR   rh  rS   )r   r  rg  r   r   r  r   r   r  _reporting_can_use_viewerr[   get_templaterE  rR  _report_build_view_model
create_runr	  r   r   r   rU   rT   r  r*   r-  rL   r  r'  rB  )r   r  r  rf  r	  r  r'  r~  r  r:  rJ  r  r  r.  rU   s                  r1   display_present_reportrs   A s    4(8GLLD((<(.B557J88M*0b779Khhx &B--/F "EYYx &B++J7E}		(+y801366;;v$"Dt,NOQTTT$T*KLcQQ

$
$[
1C
{9:C??XXg$"++-E##F+;#''-0CI=!CDcII
 &c4,1GH&&s4y#i.%24&&2FHT}&8?#f+Mt9.1$i6/9;	<
$((-.3!4 ))+C%(,F%F#b.DJ
+#CI$'K1 f+((6*3e 
E)  dhhv$"
%
+
+
-C
s-'#6sC#@i  #5#3T:xx"#'KK(=*dhh|4)6i%#_&)2U!&k599V+<>!	#
 d$;E$BCCI z" s   'N NNc                  F   t         j                  j                  d      xs dj                         } t	        j                  d      xs i j                  d      xs dj                         }| r|syt               }t        || |      }||j                  d      dk(  ry||fS )u  Resolve ?agent=<id> to a virtual display the current session may render.

    DVI-1245: a virtual display is the OWNER's own fullscreen browser window, so
    authorization is session-cookie based (D5) — the caller must be the owner.
    Returns (store, agent) or (None, None).r  r7   r'  r  r  rd   r  )r   rh  r   r   r   rE  r  r  )r  r  r	  r  s       r1   r&   r&    s       )/R668Hkk&!'R,,W5;BBDE5 "E"5(E:E}		(+y8%<r3   c                  D   t               \  } }|yt        j                         |d<   |j                  d      r;t        |      0|j	                  dd       t        |j                  d      xs d       t        |        |j                  d      |j                  d      ddd	d
t               t        |      |j                  d      xs i j                  d      xs dt        |d      dt        |j                  d      |j                  d      xs d      d
}t        |      }|rt        t        ||            S |j                  d      xs d}|r>t        |      \  }}|rd	|d<   t        |      |d<   t        |      S d|d<   t        |      S d|d<   t        |      S )u  /display/config for a virtual display (?agent=). Bumps last_seen_at (the
    open window IS the heartbeat), lazily expires a present override, and returns
    the presented dashboard (if any) else the owner's configured dashboard. The
    configured dashboard need NOT be signage-flagged — it is the owner's own
    window, gated only by view access. Returns a Response, or None when the
    session/agent don't resolve (caller falls through to the ?dash= preview).Nr"  r:  r  r7   r9   r  FTr@  r<  r&  r&  r;  r  r  r]  )
r  
agent_namer   r  r+  r?  r  r;  r<  r  rm  r  r4  z1Your configured dashboard is no longer available.rm  z)No dashboard configured for this display.)r&   rU   r   r  r  r  r'  r  r  rw  r   ri   rN  rU  )r	  r  r  r:  rQ  r  rY  s          r1   _display_virtual_configrx    s    67LE5} IIKE.yy 7 > F		)T"#EIIdO$9r:		$uyy7HEd684U; !&		( 3 9r>>vFL"$?x$PR
 .eii.A6;ii6N6TRTWXD &e,G,Wd;<<
))N
#
)rC
&s+D#D 3D 9D
 4= QDN 4= EX4=r3   c           	      z   t               \  }}|yt        |      }|rxt        |      }|t        ||       S t	               j                  |j                  d            }|t        d      dfS t        || t        |j                  d            d      S |j                  d      xs d	}|rt        |      nd
\  }}|t        d      dfS t        j
                  d      xs i }|j                  d      xs d	j                         |j                  d      xs d	j                         k(  }	t        || |d|	      S )ui  /display/tile-data for a virtual display (?agent=). A present override
    renders as the presenter (their own dashboard, `my` calendar allowed); the
    owner's own configured dashboard renders under the owner's REAL identity
    (same as the ?dash= preview — no synthetic display grant). Returns a
    Response, or None when the session/agent don't resolve.Nrm  No dashboard presentedr  r  r  F)block_myr7   r  zNo dashboard configuredr'  rL  r  r{   owner_people)r&   r  r\   rc   r  r   r   _display_dispatch_tiler  rN  r   rE  )
rx  r	  r  r:  r:  rY  rQ  r  r'  r}   s
             r1   _display_virtual_tiler   إ s:    67LE5}%e,G!'*?0g>>!%%gkk.&AB<!9:C??%'0[1IJ 	 ))N
#
)rC+."3'LHB|67<<;;v$"DHHW%+224'9J9Pb8W8W8YYL!$/;= =r3   c                  z   t         j                  j                  d      xs dj                         } | r(t	        j                  d      xs i j                  d      sy| t
        k(  r
t               S t        |       \  }}|;t        t	        j                  d      xs i       rt               j                  |       }|S )a)  Resolve the ?dash=<id> fullscreen-preview target for a logged-in session
    (DVI-1217). Returns the dashboard dict when the caller may VIEW it (owner,
    sharee, or the system default), else None. Providers still run under the
    real session user, so preview never grants elevated data access.rY  r7   r'  r  N)
r   rh  r   r   r   r  r  rN  r  r  )rr  rM  rY  s      r1   _display_preview_dashboardr    s    
 ||'-2446G7;;v.4"99'B***,,&w/LFD|3GKK4G4M2N  !%%g.Kr3   z/display/configc                  \   t               } t               }t        ||       }|t               }||S t	               }|ct        dddddt        |      t        d      t        dt        j                  d      xs i j                  d      xs d      t               		      S t        d
      dfS t        j                         |d<   |j                  d      r;t        |      0|j                  dd       t        |j                  d      xs d       t!        |       t#        |j                  d      xs d      j%                         j'                         }|t(        vrd}|j                  d      |j                  d      ddt               t        |      t        |j                  d            |j                  d      xs i j                  d      xs dt        |d      d|d	|dk(  rdt+        |j                  d            ini }t        |      }|rt        t-        ||            S |j                  d      xs d}|rat/               j                  |      }	|	r/|	j                  d      rd|d<   t        |	      |d<   t        |      S |	rd|d <   t        |      S d!|d <   t        |      S )"a^  Device-token-authed dashboard config for the kiosk page.

    Returns the assigned, signage-flagged dashboard (public shape, recipient
    list stripped) + the tile-type registry the renderer needs. Bumps
    last_seen_at so an actively-rendering display shows online even if the
    headless heartbeat is down. Never returns a non-signage dashboard.Nr7   Previewr  Tr'  r  r]  )	r  rw   r   r  rL  r4  r;  r  r  !Invalid or revoked display token.r  r  r"  r:  r  r?  r@  r9   Fr  r<  r&  rv   )	r  rw   r   r  r  r;  r  r<  r?  rA  r  rm  r  r  r4  z:The assigned dashboard is no longer available for signage.rm  z(The assigned dashboard no longer exists.)r  r  r  rx   r   r   rU  r  rw  r   r   r  rU   r  r  r  r'  r*   r   rE  rE  rF  ri   r  )
rD  r	  r  vresprL  
_disp_platr  r:  rQ  rY  s
             r1   display_configr    s    "#E "E#E51E} ()L -.B92$($%8%A%@%F
 #:#GKK,?,E2+J+J7+S+YWY#[&B&D
F 
F @A3FF IIKE. yy 7 > F		)T"#EIIdO$9r:UYYz*5g6<<>DDFJ++
		$uyy7HE68 5U;
 .eii.AB %		( 3 9r>>vFL"$?x$PR
 #!4$ 	) 2599X3FGH/1%4D, &e,G ,Wd;<<
))N
#
)rC
!%%c*DHHY'#D 3D 9D
 4=	 YDN 4= HDN4=r3   z/display/tile-datac            	      p   t               } t               }t        ||       }t        j                  j                  d      xs dj                         }||t        |      }||S t               }|t        d      dfS t        ||t        j
                  d      xs i d|j                  d	      xs dj                         t               k(  
      S t        |      }|rnt        |      }|t!        ||      S t#               j                  |j                  d            }|t        d      dfS t%        |j                  d            }d}	n_|j                  d      xs d}
|
rt#               j                  |
      nd}||j                  d      st        d      dfS t'               }d}	t        ||||	      S )u  Device-token-authed tile data for the kiosk (plan D4/D5).

    Resolves the display's assigned signage dashboard from its agent_token —
    never a user session — and dispatches to the same provider registry as
    /dashboards/tile-data, but under the synthetic signage identity (admin
    assignment = authorization). Calendar `my` is hard-blocked here so a
    personal M365 calendar can never render on a wall.rx  r7   Nr   r  r  r'  FrL  r|   rm  rz   r  r  r  zNo signage dashboard assignedT)r  r  r  r   rh  r   r   r   r   r   r~   r   rE  r  r  r\   rc   r  r  r  )rD  r	  r  rx  r   rY  r:  r:  display_userr{   rQ  s              r1   display_tile_datar   c s    "#E "E#E51E||	*0b779G}%g.L *+<!DEsJJ%dG&-kk&&9&?R%48HHW4E4K3R3R3T6F6H4IJ 	J &e,G "'*?0g>>!%%gkk.&AB<!9:C??,W[[-EFii'-2.1!%%c*t<txx	2!@A3FF,.!$xHHr3   c                 j   t        fd| j                  d      xs g D        d      }|t        d      dfS |j                  dd      }t               j                  |      }|t        d	      d
fS |j                  d      }|st        i       S |j                  d      xs i }	|r/|dk(  r*|	j                  d      xs ddk(  rt        dddddd      S |dk(  rL|sJ|	j                  d      s9t	        |	      }	|	j                  d      xs g D 
cg c]  }
|
t
        vr|
 c}
|	d<   t               j                  |      }t        |      st        d      dfS 	  ||	|      }t        |      S c c}
w # t        $ r1 t        j                  j                  d|       t        d      dfcY S w xY w)u  Shared tile-provider dispatch for the /display path — the device-token
    kiosk and the DVI-1217 session preview run the exact same guards.
    owner_people=True (preview of one's OWN dashboard) skips the D3
    people-layer strip, matching the owner branch of /dashboards/tile-data.c              3   L   K   | ]  }|j                  d       k(  s|  ywr	  r  rw  s     r1   r/  z)_display_dispatch_tile.<locals>.<genexpr> r  r  r  Nry  r  r  rT  r7   rr  r`  r6  r  rc  r  r	  r+  r  TFr  z5Personal calendars are not shown on signage displays.r  r  r  r[  r  r  z$display/tile-data provider %s failedr  )r  r   r   rf  r   r  r  r  ry   rm   rn   r  )rY  rx  r   r{   r}   r  r  r  r  rJ  rw  r  r>  s    `           r1   r~   r~    s    PTXXg.4"PRVWD|-.33HHVR E "&&u-D|01366HHZ(MB
((8

"C EZ'SWWX->-F$4,Ot%4'0(_a b 	b L9P3i%(WWX%6%<" ?q%==  ?H)--.KK 56;;4S,/ ?  4

C]S-.334s   E3	E8 87F21F2uU <!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Togen Signage</title>
<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>
  html, body { height: 100%; margin: 0; }
  body { cursor: none; overflow: hidden; background-position: center;
         background-repeat: no-repeat; background-attachment: fixed; }
  body.preview { cursor: auto; }
  /* DVI-1217 per-display theming: tile/pane surfaces read --ksk-alpha so the
     configured Tile/Pane Opacity works over corporate/custom backgrounds. */
  :root { --ksk-alpha: 1; }
  .ksk-pane { background-color: rgba(255,255,255,var(--ksk-alpha)); }
  html.dark .ksk-pane { background-color: rgba(17,24,39,var(--ksk-alpha)); }
  #dash-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr));
               grid-auto-rows: minmax(9rem, 1fr); gap: 1rem; }
  /* DVI-1220 P1 orientation profile: portrait walls drop to 6 columns (spans
     remapped in JS) so tiles get vertical room instead of wrapping thin. */
  #dash-grid.ksk-portrait { grid-template-columns: repeat(6, minmax(0, 1fr)); }
  /* fit=scroll: the page scrolls instead of scaling (display has user input). */
  #dash-grid-wrap.ksk-scroll { overflow-y: auto; }
  /* Kiosks have no pointer — hide tile scrollbars; auto-cycle pages instead. */
  body:not(.preview) .dash-tile-body { scrollbar-width: none; }
  body:not(.preview) .dash-tile-body::-webkit-scrollbar { display: none; }
  /* Auto-cycle page-progress dots (DVI-1220 P1). */
  .ksk-dots { position: absolute; bottom: 6px; right: 10px; display: flex;
              gap: 4px; z-index: 5; }
  .ksk-dots span { width: 6px; height: 6px; border-radius: 9999px;
                   background: rgba(107,114,128,.45); }
  .ksk-dots span.on { background: #00B4D8; }
  /* DVI-1243 P2: the powerbi-client SDK injects an iframe — fill the tile. */
  .pbi-embed iframe { width: 100%; height: 100%; border: 0; }
  .dash-md h1,.dash-md h2,.dash-md h3 { font-weight: 600; margin: .3em 0; }
  .dash-md ul { list-style: disc; margin-left: 1.2em; }
  .dash-md a { color: #00B4D8; text-decoration: underline; }
  .dash-md table { border-collapse: collapse; margin: .4em 0; font-size: .95em; }
  .dash-md th,.dash-md td { border: 1px solid rgba(148,163,184,.5); padding: .25em .5em; }
  .dash-md th { font-weight: 600; }
</style>
</head>
<body class="bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100 font-sans">
  <!-- Splash / status overlay (no token, waiting for assignment, or auth error) -->
  <div id="dash-splash" class="fixed inset-0 flex flex-col items-center justify-center text-center px-8 z-10">
    <div id="dash-splash-title" class="text-togen-teal text-4xl font-light tracking-widest uppercase mb-4">Togen Signage</div>
    <div id="dash-splash-msg" class="text-gray-500 dark:text-gray-400 text-lg max-w-2xl">Connecting…</div>
    <div id="dash-splash-sub" class="text-gray-400 dark:text-gray-600 text-sm mt-3"></div>
  </div>
  <!-- Fullscreen-preview chrome (DVI-1217) — only shown in ?dash= session mode -->
  <div id="dash-preview-ctrls" class="hidden fixed top-3 right-3 z-30 flex gap-2">
    <button id="dash-preview-fs" class="px-3 py-1.5 text-sm rounded-lg bg-black/40 text-white/90 hover:bg-black/60 border border-white/20">Fullscreen</button>
    <button id="dash-preview-close" class="px-3 py-1.5 text-sm rounded-lg bg-black/40 text-white/90 hover:bg-black/60 border border-white/20">&#10005; Exit</button>
  </div>
  <!-- Dashboard surface -->
  <div id="dash-root" class="hidden fixed inset-0 flex flex-col p-6">
    <div class="flex items-baseline justify-between mb-3 shrink-0 gap-4">
      <div class="min-w-0 flex items-baseline gap-3">
        <h1 id="dash-title" class="text-2xl font-semibold text-gray-900 dark:text-white truncate"></h1>
        <span id="dash-present" class="hidden shrink-0 px-2.5 py-1 text-sm font-medium rounded-full bg-togen-teal/20 text-togen-teal border border-togen-teal/40"></span>
      </div>
      <div id="dash-clock" class="text-gray-500 dark:text-gray-400 text-lg tabular-nums shrink-0"></div>
    </div>
    <div id="dash-grid-wrap" class="flex-1 min-h-0 overflow-hidden">
      <div id="dash-grid"></div>
    </div>
  </div>
<script>
(function(){
  "use strict";
  var _params = new URLSearchParams(location.search);
  var TOKEN = _params.get('token') || '';
  // DVI-1217 fullscreen preview: ?dash=<id> + the user's session (no device
  // token) drives the exact same kiosk pipeline for a viewable dashboard.
  var DASH = _params.get('dash') || '';
  // DVI-1245 virtual display: ?agent=<id> + the user's session — the owner's
  // own fullscreen window, with stored interface/layout + present overrides. It
  // heartbeats /signage/agents/virtual-checkin (staleness) and sendBeacons an
  // offline mark on unload; otherwise it renders exactly like the kiosk.
  var VAGENT = _params.get('agent') || '';
  var PREVIEW = !TOKEN && !VAGENT && !!DASH;
  var AUTHQ = VAGENT ? 'agent=' + encodeURIComponent(VAGENT)
                     : (PREVIEW ? 'dash=' + encodeURIComponent(DASH)
                                : 'token=' + encodeURIComponent(TOKEN));
  var CONFIG_URL = '/display/config?' + AUTHQ;
  var DASH_SIZES = {
    small:{cols:3,rows:1}, medium:{cols:6,rows:1}, tall:{cols:3,rows:2},
    large:{cols:6,rows:2}, wide:{cols:12,rows:1}, list:{cols:3,rows:3},
    xlarge:{cols:12,rows:3}, custom:{cols:6,rows:1}
  };
  // DVI-1220 P1 portrait profile: column spans on the 6-col portrait grid.
  // Wide presets go full width, small presets pair two-across; row spans keep
  // their preset value (portrait has the vertical room).
  var KSK_PORTRAIT_COLS = {
    small:3, medium:6, tall:3, large:6, wide:6, list:3, xlarge:6
  };
  var _tileTypes = {}, _dash = null, _dashSig = '', _tileData = {};
  var _configTimer = null, _dataTimer = null, _configMs = 15000;
  // DVI-1220 P1 layout engine state: per-display fit + orientation from
  // /display/config; _orient is the resolved (auto → viewport) orientation.
  // DVI-1288 P1: layout.columns — 'auto' (12 landscape / 6 portrait) or an int.
  // DVI-1288 P2: flow (row/column wrap priority) + optional rows target.
  var _layout = { fit: 'scale', orientation: 'auto', zoom: 100, columns: 'auto',
                  flow: 'row', rows: 'auto' };
  var _orient = '', _fitState = '';
  var _lastCfg = null;   // last applied config, for a fullscreen-settings re-apply

  // DVI-1321 P1: Fullscreen settings override (same-browser, ?dash= preview
  // only). The SPA writes this dashboard's settings to localStorage before it
  // opens this tab (so the first render already honors them) and broadcasts on
  // the 'togen-fullscreen' channel so its "Update" button live-updates this tab.
  // Layout merges over cfg.layout; when use_interface is on, the user's own
  // resolved interface overrides the kiosk default (finally showing their bg +
  // theme). Ignored for device tokens and virtual displays (they carry their own).
  var _fsOverride = null;
  function _fsReadOverride(){
    if (!PREVIEW || !DASH){ _fsOverride = null; return; }
    try { var raw = localStorage.getItem('togen-fs:' + DASH);
          _fsOverride = raw ? JSON.parse(raw) : null; }
    catch(e){ _fsOverride = null; }
  }
  _fsReadOverride();
  try {
    if (PREVIEW && window.BroadcastChannel){
      var _fsChan = new BroadcastChannel('togen-fullscreen');
      _fsChan.onmessage = function(ev){
        var m = ev && ev.data;
        if (!m || m.dash !== DASH) return;
        _fsOverride = m.settings || null;
        _fitState = ''; _ifaceSig = '';   // invalidate caches so the re-apply takes
        if (_lastCfg) applyConfig(_lastCfg);
      };
    }
  } catch(e){}

  // DVI-1288 P1: the grid width to lay tiles out on. An explicit column count
  // (2–24) wins over the orientation default (portrait 6 / landscape 12) — this
  // is what lets a portrait wall show two 2x2 tiles side by side at columns=12
  // and an ultrawide fill its width at columns=18–24.
  function explicitCols(){
    var c = _layout.columns;
    c = (typeof c === 'number') ? c : parseInt(c, 10);
    return (c >= 2 && c <= 24) ? c : 0;
  }
  function gridCols(){
    return explicitCols() || (_orient === 'portrait' ? 6 : 12);
  }
  // DVI-1288 P2: an explicit row-count target (1–24), else 0 = auto.
  function explicitRows(){
    var r = _layout.rows;
    r = (typeof r === 'number') ? r : parseInt(r, 10);
    return (r >= 1 && r <= 24) ? r : 0;
  }

  function tileSpan(tile){
    var cols = gridCols(), fixed = explicitCols();
    var size;
    if (tile.size === 'custom'){
      // DVI-1220 P2 (F3): explicit per-tile span, clamped to the grid width.
      var sp = tile.span || {};
      size = { cols: Math.max(1, Math.min(cols, parseInt(sp.cols, 10) || 6)),
               rows: Math.max(1, Math.min(6, parseInt(sp.rows, 10) || 1)) };
      return size;
    }
    size = DASH_SIZES[tile.size] || DASH_SIZES.medium;
    // Auto portrait remaps 12-based presets onto the 6-col grid; an explicit
    // column count instead keeps the literal 12-based span (clamped to width)
    // so `large` (6) sits half-width on a 12-col portrait wall = 2x2 side by side.
    if (!fixed && _orient === 'portrait'){
      return { cols: KSK_PORTRAIT_COLS[tile.size] || Math.min(6, size.cols),
               rows: size.rows };
    }
    return { cols: Math.min(cols, size.cols), rows: size.rows };
  }

  function resolveOrient(){
    return _layout.orientation !== 'auto' ? _layout.orientation
         : (window.innerWidth >= window.innerHeight ? 'landscape' : 'portrait');
  }

  // Mirror CSS sparse row-flow auto-placement to count how many grid rows the
  // dashboard occupies (placement depends only on column spans, never track
  // heights, so this is exact). Feeds the fit=scale row-height computation.
  function kskGridRows(specs, cols){
    var occ = {};
    function fits(r, c, w, h){
      if (c + w > cols) return false;
      for (var dr = 0; dr < h; dr++)
        for (var dc = 0; dc < w; dc++)
          if (occ[(r + dr) + ',' + (c + dc)]) return false;
      return true;
    }
    var maxRow = 0, curR = 0, curC = 0;
    specs.forEach(function(s){
      var w = Math.max(1, Math.min(cols, s.cols)), h = Math.max(1, s.rows);
      var r = curR, c = curC;
      while (!fits(r, c, w, h)){ c++; if (c + w > cols){ c = 0; r++; } }
      for (var dr = 0; dr < h; dr++)
        for (var dc = 0; dc < w; dc++)
          occ[(r + dr) + ',' + (c + dc)] = true;
      curR = r; curC = c + w;
      if (curC >= cols){ curC = 0; curR = r + 1; }
      if (r + h > maxRow) maxRow = r + h;
    });
    return maxRow;
  }

  // DVI-1220 P2 (B3): row span for an aspect-locked Oversight tile — derive
  // the rows that make the tile hug the plan image (stored dims arrive with
  // the tile data) at the tile's column width and the given row height.
  // Returns 0 when the lock doesn't apply (wrong type, no data yet, no image).
  function ovAspectRows(tile, spanCols, cols, rowH, wrapW){
    var cfg = tile.config || {};
    if (tile.type !== 'oversight' || !cfg.lock_aspect) return 0;
    var d = _tileData[tile.id];
    if (!d || !d.plan || !d.plan.images) return 0;
    var sid = ovkResolveStyle(d);
    var dims = sid ? (d.plan.images[sid] || {}) : {};
    if (!dims.w || !dims.h) return 0;
    var gap = 16;
    var colW = (wrapW - gap * (cols - 1)) / cols;
    var tileW = colW * spanCols + gap * (spanCols - 1);
    var HDR = 44;   // kiosk tile header row (text-lg + padding), approx
    var want = tileW * dims.h / dims.w + HDR;
    return Math.max(1, Math.min(6, Math.round((want + gap) / (rowH + gap))));
  }

  // fit=scale (plan B2, default D2a): size the grid rows so the whole
  // dashboard exactly fills the viewport — nothing is ever clipped. Other fit
  // modes keep the stock minmax(9rem,1fr) rows. Aspect-locked Oversight tiles
  // (P2 B3) get their row span here in the same pass: span depends on row
  // height and row height depends on total rows, so iterate to a fixed point
  // (bounded — the alternation is capped at 4 rounds).
  function applyFit(){
    var grid = document.getElementById('dash-grid');
    var wrap = document.getElementById('dash-grid-wrap');
    // DVI-1275 P1: display-level zoom via CSS `zoom` on the dashboard root.
    // Chromium/WebView2 treat `zoom` as an effective magnification — a
    // fixed-inset root lays its subtree out in the zoom-reduced coordinate
    // space, so the fit engine below (which measures wrap.client{Width,Height})
    // composes with zoom automatically: at 150% the wrap reports a smaller
    // space → tiles/text get bigger and the grid still fills the screen. Set it
    // BEFORE reading the wrap size so the measurement reflects the new zoom.
    var root = document.getElementById('dash-root');
    var zoom = Math.max(50, Math.min(200, (_layout.zoom | 0) || 100));
    root.style.zoom = (zoom === 100) ? '' : (zoom / 100);
    wrap.classList.toggle('ksk-scroll', _layout.fit === 'scroll');
    // DVI-1288 P1: an explicit column count overrides the CSS grid template
    // (12/portrait-6); 'auto' clears the inline style so the CSS default (incl.
    // the .ksk-portrait 6-col rule) applies.
    var cols = gridCols(), fixed = explicitCols();
    grid.style.gridTemplateColumns = fixed
      ? 'repeat(' + fixed + ', minmax(0, 1fr))' : '';
    if (!_dash){ grid.style.gridAutoRows = ''; grid.style.gridAutoFlow = '';
                 grid.style.gridTemplateRows = ''; _fitState = ''; return; }
    var tiles = (_dash.tiles || []).slice()
      .sort(function(a,b){ return (a.order||0)-(b.order||0); });
    var gap = 16;   // #dash-grid gap: 1rem
    var aspectSig = tiles.map(function(t){
      var c = t.config || {};
      if (t.type !== 'oversight' || !c.lock_aspect) return '';
      var d = _tileData[t.id];
      var sid = d && d.plan && d.plan.images ? ovkResolveStyle(d) : null;
      var dims = sid ? (d.plan.images[sid] || {}) : {};
      return (dims.w || 0) + 'x' + (dims.h || 0);
    }).join(',');
    var state = [_orient, _dashSig, _layout.fit, zoom, cols, _layout.flow,
                 _layout.rows, wrap.clientWidth,
                 wrap.clientHeight, aspectSig].join('|');
    if (state === _fitState) return;
    _fitState = state;
    var h = 144, spans;   // 144px = the stock 9rem row
    for (var it = 0; it < 4; it++){
      spans = tiles.map(function(t){
        var s = tileSpan(t);
        var ar = ovAspectRows(t, s.cols, cols, h, wrap.clientWidth);
        return ar ? { cols: s.cols, rows: ar } : s;
      });
      if (_layout.fit !== 'scale') break;
      var rows = Math.max(1, kskGridRows(spans, cols));
      var nh = Math.max(24, Math.floor((wrap.clientHeight - gap * (rows - 1)) / rows));
      if (nh === h) break;
      h = nh;
    }
    tiles.forEach(function(t, i){
      var el = document.querySelector('[data-tile-id="' + t.id + '"]');
      // DVI-1288 P1: set the column span here too (not just in buildTile) so a
      // columns-only config change re-lays-out tiles without a full grid rebuild.
      if (el){
        el.style.gridColumn = 'span ' + spans[i].cols;
        el.style.gridRow = 'span ' + spans[i].rows;
      }
    });
    grid.style.gridAutoRows = (_layout.fit === 'scale') ? h + 'px' : '';
    // DVI-1288 P2: wrap flow + row target for non-fit (scroll/clip) displays.
    // fit=scale computes its own row heights to fill the screen, so it ignores
    // these (a column flow would break the fill math — plan D3-A). column flow
    // needs a fixed row count as its wrap point; fall back to the natural row
    // count so a column-flow display never collapses to a single tall column.
    var flow = (_layout.flow === 'column') ? 'column' : 'row';
    var rowsT = explicitRows();
    if (_layout.fit !== 'scale' && (flow === 'column' || rowsT)){
      var nrows = rowsT || Math.max(1, kskGridRows(spans, cols));
      grid.style.gridAutoFlow = flow;
      grid.style.gridTemplateRows = 'repeat(' + nrows + ', minmax(9rem, 1fr))';
    } else {
      grid.style.gridAutoFlow = '';
      grid.style.gridTemplateRows = '';
    }
  }

  // Re-resolve orientation (admin override or viewport rotate) — a change
  // remaps spans, so the grid rebuilds; always refit after.
  function refreshLayout(){
    var orient = resolveOrient();
    if (orient !== _orient){
      _orient = orient;
      document.getElementById('dash-grid').classList
        .toggle('ksk-portrait', orient === 'portrait');
      if (_dash) renderGrid();
    }
    applyFit();
  }

  function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){
    return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }

  // Minimal, HTML-safe Markdown subset for Text tiles (escape first, then a
  // fixed set of inline/block rules). Not the full renderNewsMarkdown, but
  // covers the headings/bold/italic/links/lists a signage note uses.
  // DVI-1305 P1: kiosk mirror — GitHub tables + optional heading accent color.
  var KSK_TEXT_ACCENT = { teal:'#14b8a6', blue:'#3b82f6', green:'#22c55e', amber:'#f59e0b', red:'#ef4444', purple:'#a855f7', gray:'#64748b' };
  function kskIsTableSep(t){ return t.indexOf('-') !== -1 && /^\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?$/.test(t); }
  function kskSplitRow(t){ return t.replace(/^\|/, '').replace(/\|$/, '').split('|').map(function(c){ return c.trim(); }); }
  function kskColAligns(sep){ return kskSplitRow(sep).map(function(c){ var l=c.charAt(0)===':', r=c.charAt(c.length-1)===':'; if(l&&r)return 'center'; if(r)return 'right'; if(l)return 'left'; return ''; }); }
  function kskTableHtml(header, aligns, rows){
    function cell(tag, txt, idx){ var a=aligns[idx], st=a?' style="text-align:'+a+'"':''; return '<'+tag+st+'>'+esc(txt)+'</'+tag+'>'; }
    var th = '<tr>' + header.map(function(c,idx){ return cell('th', c, idx); }).join('') + '</tr>';
    var tb = rows.map(function(r){ return '<tr>' + header.map(function(_,idx){ return cell('td', r[idx]||'', idx); }).join('') + '</tr>'; }).join('');
    return '<table><thead>'+th+'</thead><tbody>'+tb+'</tbody></table>';
  }
  function renderMd(src, opts){
    opts = opts || {};
    var accent = KSK_TEXT_ACCENT[opts.accent] || '';
    var accentStyle = accent ? ' style="color:'+accent+'"' : '';
    // Extract table blocks from the raw source first (stashed behind a sentinel
    // so the block regexes below never mangle them), then restore at the end.
    var stash = [], SENT = '';
    var lines = String(src).replace(/\r\n?/g, '\n').split('\n'), kept = [];
    for (var i = 0; i < lines.length; i++){
      var t = lines[i].trim();
      if (t.indexOf('|') !== -1 && i + 1 < lines.length && kskIsTableSep(lines[i+1].trim())){
        var aligns = kskColAligns(lines[i+1].trim()), header = kskSplitRow(t), rows = [], j = i + 2;
        for (; j < lines.length; j++){ var rt = lines[j].trim(); if (!rt || rt.indexOf('|') === -1) break; rows.push(kskSplitRow(rt)); }
        stash.push(kskTableHtml(header, aligns, rows));
        kept.push(SENT + (stash.length - 1) + SENT);
        i = j - 1;
        continue;
      }
      kept.push(lines[i]);
    }
    var out = esc(kept.join('\n'));
    out = out.replace(/^###\s?(.*)$/gm, '<h3'+accentStyle+'>$1</h3>')
             .replace(/^##\s?(.*)$/gm, '<h2'+accentStyle+'>$1</h2>')
             .replace(/^#\s?(.*)$/gm, '<h1'+accentStyle+'>$1</h1>');
    out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
             .replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
    out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
                      '<a href="$2">$1</a>');
    out = out.replace(/^\s*[-*]\s+(.*)$/gm, '<li>$1</li>');
    out = out.replace(/(<li>[\s\S]*?<\/li>)/g, '<ul>$1</ul>');
    out = out.replace(/\n{2,}/g, '<br><br>').replace(/\n/g, '<br>');
    out = out.replace(new RegExp(SENT + '(\\d+)' + SENT, 'g'), function(_, k){ return stash[+k]; });
    return out;
  }

  function showSplash(msg, sub){
    document.getElementById('dash-root').classList.add('hidden');
    var sp = document.getElementById('dash-splash');
    sp.classList.remove('hidden');
    document.getElementById('dash-splash-msg').textContent = msg || '';
    document.getElementById('dash-splash-sub').textContent = sub || '';
  }
  function showDash(){
    document.getElementById('dash-splash').classList.add('hidden');
    document.getElementById('dash-root').classList.remove('hidden');
  }

  function tick(){
    var now = new Date();
    document.getElementById('dash-clock').textContent =
      now.toLocaleDateString([], {weekday:'long', month:'short', day:'numeric'}) + '  ' +
      now.toLocaleTimeString([], {hour:'numeric', minute:'2-digit'});
    // Drive any Time tiles off the same 1 s cadence (renderTime is defined below).
    if (_dash){
      (_dash.tiles || []).forEach(function(tile){
        if (tile.type !== 'time') return;
        var el = document.querySelector('[data-tile-id="' + tile.id + '"]');
        if (!el) return;
        var body = el.querySelector('.dash-tile-body');
        if (body) renderTime(body, tile.config || {});
      });
    }
  }

  var _WOR_STATUS = { new:'New', in_progress:'In progress', completed:'Completed', cancelled:'Cancelled' };
  var _WOR_TYPE = { maintenance:'Maintenance', mobilemachine:'Mobile Machine', safety:'Safety', preventive:'Preventive' };
  var _SCADA_EMAIL_STATUS = { sent:'Sent', sent_pending_approval:'Sent (internal) + pending approval',
    pending_approval:'Pending approval', skipped_notify_off:'Skipped (notify off)',
    skipped_no_recipients:'Skipped (no recipients)', manual_no_email:'Manual run (no email)',
    skipped_oversized:'Skipped (oversized)' };

  function renderTileBody(body, tile, data){
    var cfg = tile.config || {};
    if (tile.type === 'text'){ renderText(body, cfg); return; }
    if (tile.type === 'time'){ renderTime(body, cfg); return; }
    if (tile.type === 'stat'){
      if (statHasBinding(cfg) && !data){ body.innerHTML = '<span class="text-gray-500 text-sm italic">Loading…</span>'; return; }
      renderStat(body, cfg, data || null); return;
    }
    if (tile.type === 'logo'){ renderLogo(body, cfg); return; }
    if (tile.type === 'image'){ renderImage(body, cfg, tile); return; }
    if (tile.type === 'group'){ renderGroup(body, tile, data || {}); return; }   // DVI-1305 P4
    if (!data){ body.innerHTML = '<span class="text-gray-500 text-sm italic">Loading…</span>'; return; }
    if (data.access === false){
      body.innerHTML = '<span class="text-amber-400 text-sm">No access: ' + esc(data.reason || 'Permission denied') + '</span>';
      return;
    }
    if (tile.type === 'work_order_view'){ renderWo(body, data); return; }
    if (tile.type === 'parts'){ renderParts(body, data); return; }
    if (tile.type === 'scada_view'){ renderScada(body, data); return; }
    if (tile.type === 'batch_production'){ renderBatchProduction(body, data); return; }
    if (tile.type === 'calendar'){ renderCal(body, cfg, data); return; }
    if (tile.type === 'idencia'){ renderIdencia(body, cfg, data); return; }
    if (tile.type === 'weather'){ renderWeather(body, cfg, data); return; }
    if (tile.type === 'powerbi_data'){ renderPowerbiData(body, cfg, data); return; }
    if (tile.type === 'powerbi_report'){ renderPowerbiReport(body, cfg, data, tile); return; }
    if (tile.type === 'oversight'){ renderOversight(body, cfg, data); return; }
    if (tile.type === 'chart'){ renderChart(body, cfg, data); return; }
    if (tile.type === 'report_table'){ renderReportTable(body, cfg, data); return; }
    body.innerHTML = '<span class="text-gray-500">Unknown tile type.</span>';
  }

  // Group / Panel container (DVI-1305 P4) — kiosk mirror of dashTileRenderGroup.
  // Lays child tiles in an inner grid; each child body is rendered through the
  // shared renderTileBody with its own payload from data.children[child.id].
  var KSK_GROUP_ROWSPAN = { small:1, medium:1, tall:2, large:2, wide:1, list:3, xlarge:3, custom:1 };
  function kskGroupCols(cfg, n){
    var c = cfg.columns;
    if (c === '1' || c === '2' || c === '3' || c === '4') return parseInt(c, 10);
    return n <= 1 ? 1 : 2;
  }
  function renderGroup(body, tile, data){
    var cfg = tile.config || {};
    var children = cfg.children || [];
    var kids = (data && data.children) || {};
    body.innerHTML = '';
    if (!children.length){ body.innerHTML = '<span class="text-gray-500 italic">Empty panel.</span>'; return; }
    var cols = kskGroupCols(cfg, children.length);
    var grid = document.createElement('div');
    grid.className = 'grid gap-2 h-full content-start';
    grid.style.gridTemplateColumns = 'repeat(' + cols + ',minmax(0,1fr))';
    body.appendChild(grid);
    children.forEach(function(child){
      var panel = document.createElement('div');
      panel.className = 'ksk-pane rounded-lg border border-gray-200 dark:border-gray-700 flex flex-col min-w-0 overflow-hidden';
      var full = (child.size === 'wide' || child.size === 'xlarge');
      panel.style.gridColumn = 'span ' + (full ? cols : 1);
      panel.style.gridRow = 'span ' + (KSK_GROUP_ROWSPAN[child.size] || 1);
      var ctitle = (child.config && child.config.title) ? child.config.title : '';
      if (ctitle){
        var ch = document.createElement('div');
        ch.className = 'px-2 py-1 text-sm font-semibold text-gray-700 dark:text-gray-200 border-b border-gray-200 dark:border-gray-700 truncate';
        ch.textContent = ctitle;
        panel.appendChild(ch);
      }
      var cbody = document.createElement('div');
      var ovf = (child.overflow === 'clip') ? 'overflow-hidden' : 'overflow-auto';
      cbody.className = 'flex-1 min-h-0 ' + ovf + ' p-2';
      var cz = Math.max(50, Math.min(200, (child.zoom | 0) || 100));
      if (cz !== 100) cbody.style.zoom = cz / 100;
      panel.appendChild(cbody);
      grid.appendChild(panel);
      try { renderTileBody(cbody, child, kids[child.id]); }
      catch(e){ cbody.innerHTML = '<span class="text-red-400 text-sm">Tile failed to render.</span>'; }
    });
  }

  var KSK_TEXT_FONT = { small:'0.8rem', medium:'1rem', large:'1.25rem', xlarge:'1.6rem' };
  function kskTextStyle(cfg){
    var s = '';
    var fs = KSK_TEXT_FONT[cfg.font_size]; if (fs) s += 'font-size:' + fs + ';';
    var al = cfg.align; if (al === 'center' || al === 'right') s += 'text-align:' + al + ';';
    return s;
  }
  function renderText(body, cfg){
    var txt = cfg.text || '';
    if (!txt.trim()){ body.innerHTML = '<span class="text-gray-500 italic">Empty text tile.</span>'; return; }
    var style = kskTextStyle(cfg);
    if (cfg.markdown){ body.innerHTML = '<div class="dash-md"' + (style ? ' style="' + style + '"' : '') + '>' + renderMd(txt, { accent: cfg.accent }) + '</div>'; }
    else { var p = document.createElement('div'); p.className = 'whitespace-pre-wrap break-words';
           if (style) p.setAttribute('style', style);
           p.textContent = txt; body.innerHTML = ''; body.appendChild(p); }
  }

  // Stat / Metrics tile (DVI-1307) — kiosk mirror of dashTileRenderStat.
  var KSK_STAT_VALFS = { small:'1.4rem', medium:'2.1rem', large:'3rem' };
  function kskStatGrid(cfg, n){
    var cols = cfg.columns;
    if (cols === '1' || cols === '2' || cols === '3' || cols === '4') return 'grid-template-columns:repeat(' + cols + ',minmax(0,1fr));';
    var c = (n <= 1) ? 1 : (n <= 4 ? 2 : 3);
    return 'grid-template-columns:repeat(' + c + ',minmax(0,1fr));';
  }
  // DVI-1312 P1 — color rules + live-metric resolution (kiosk mirror of the
  // SPA _dashStatColorFor / _dashStatResolveRow / dashTileRenderStat).
  function statHasBinding(cfg){ return !!(cfg && (cfg.rows || []).some(function(r){ return r && r.metric; })); }
  function kskStatColorFor(rules, number, valueStr){
    if (!rules || !rules.length) return '';
    var hasNum = (number !== null && number !== undefined && !isNaN(number));
    var s = (valueStr == null ? '' : String(valueStr)).trim().toLowerCase();
    for (var i = 0; i < rules.length; i++){
      var rl = rules[i]; if (!rl || !rl.op) continue;
      var col = KSK_TEXT_ACCENT[rl.color] || '';
      if (!col) continue;
      if (rl.op === 'match'){ if (s === String(rl.value == null ? '' : rl.value).trim().toLowerCase()) return col; continue; }
      var v = parseFloat(rl.value); if (!hasNum || isNaN(v)) continue;
      var n = number, ok = false;
      if (rl.op === 'lt') ok = n < v; else if (rl.op === 'le') ok = n <= v;
      else if (rl.op === 'gt') ok = n > v; else if (rl.op === 'ge') ok = n >= v;
      else if (rl.op === 'eq') ok = n === v; else if (rl.op === 'ne') ok = n !== v;
      else if (rl.op === 'between'){ var v2 = parseFloat(rl.value2); if (!isNaN(v2)){ var lo = Math.min(v, v2), hi = Math.max(v, v2); ok = (n >= lo && n <= hi); } }
      if (ok) return col;
    }
    return '';
  }
  function kskStatResolveRow(r, metric){
    var out = { value: r.value || '', number: null, redacted: false, error: '' };
    if (r.metric){
      if (!metric) { out.pending = true; return out; }
      if (metric.access === false) { out.redacted = true; return out; }
      if (metric.ok === false) { out.error = metric.message || 'Unavailable'; return out; }
      out.value = (metric.value_str != null) ? String(metric.value_str) : '';
      out.number = (metric.number != null) ? metric.number : parseFloat(out.value);
      out.cached = !!metric.cached;
    } else if (out.value !== ''){ var n = parseFloat(out.value); if (!isNaN(n)) out.number = n; }
    return out;
  }
  function renderStat(body, cfg, data){
    cfg = cfg || {};
    var metrics = (data && data.metrics) || {};
    var shown = [];
    (cfg.rows || []).forEach(function(r, i){ if (r && (r.label || r.value || r.sub || r.metric)) shown.push({ r: r, i: i }); });
    if (!shown.length){ body.innerHTML = '<span class="text-gray-500 italic">No metrics yet.</span>'; return; }
    var vfs = KSK_STAT_VALFS[cfg.value_size] || KSK_STAT_VALFS.medium;
    var anyCached = false;
    var html = '<div class="grid gap-2 content-start h-full overflow-auto" style="' + kskStatGrid(cfg, shown.length) + '">';
    shown.forEach(function(item){
      var r = item.r, res = kskStatResolveRow(r, metrics[String(item.i)]);
      if (res.cached) anyCached = true;
      var disp, ruleCol = '';
      if (res.redacted) disp = '<span class="text-amber-400" style="font-size:0.8em">No access</span>';
      else if (res.error) disp = '<span class="text-amber-400" style="font-size:0.8em">&mdash;</span>';
      else { ruleCol = kskStatColorFor(r.color_rules, res.number, res.value);
        disp = res.value ? esc(res.value) : (res.pending ? '<span class="text-gray-500" style="font-size:0.7em">live</span>' : '&mdash;'); }
      var col = ruleCol || (KSK_TEXT_ACCENT[r.accent] || '');
      var vstyle = 'font-size:' + vfs + ';line-height:1.05;' + (col ? 'color:' + col + ';' : '');
      var valCls = col ? '' : 'text-gray-900 dark:text-white';
      html += '<div class="rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2 flex flex-col items-center justify-center text-center min-w-0">' +
        '<div class="font-bold tabular-nums break-words ' + valCls + '" style="' + vstyle + '">' + disp + '</div>' +
        (r.label ? '<div class="text-xs text-gray-500 dark:text-gray-400 mt-1 break-words">' + esc(r.label) + '</div>' : '') +
        (r.sub ? '<div class="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5 break-words">' + esc(r.sub) + '</div>' : '') +
        '</div>';
    });
    html += '</div>';
    if (anyCached) html += '<div class="text-[10px] text-amber-400 mt-1">Cached data</div>';
    body.innerHTML = html;
  }

  // Logo tile (DVI-1280) — kiosk mirror of dashTileRenderLogo. Namespaced
  // gradient id; iCast/branding via the no-auth serve route, Togen inlined.
  var KSK_TOGEN_LOGO_INNER =
    '<defs><linearGradient id="kskLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">' +
    '<stop offset="0%" style="stop-color:#00B4D8"/><stop offset="100%" style="stop-color:#0077A8"/></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(#kskLogoGrad)" stroke-width="8" fill="none" stroke-linecap="round"/>' +
    '<rect x="28" y="114" width="90" height="8" rx="2" fill="url(#kskLogoGrad)"/>' +
    '<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" fill="#111827" letter-spacing="-1">togen</text>' +
    '<circle cx="154" cy="70" r="5" fill="#00B4D8"/>';
  function kskLogoFilter(color){
    if (color === 'white') return 'brightness(0) invert(1)';
    if (color === 'dark') return 'brightness(0)';
    if (color === 'auto') return document.documentElement.classList.contains('dark') ? 'brightness(0) invert(1)' : '';
    return '';
  }
  function renderLogo(body, cfg){
    cfg = cfg || {};
    var which = cfg.logo || 'icast';
    var filt = kskLogoFilter(cfg.color || 'as_is');
    var dim = (cfg.size === 'fill')
      ? 'width:100%;height:100%;object-fit:contain'
      : 'height:' + ({small:'40px',medium:'72px',large:'120px'}[cfg.size] || '72px') + ';width:auto;max-width:100%;max-height:100%;object-fit:contain';
    if (filt) dim += ';filter:' + filt;
    var inner;
    if (which === 'togen'){
      inner = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="20 25 360 135" preserveAspectRatio="xMidYMid meet" style="display:block;' + dim + '">' + KSK_TOGEN_LOGO_INNER + '</svg>';
    } else {
      // iCast and "branding" both resolve to the corporate mark on the wall
      // (the kiosk has no branding settings; the route serves corporate else repo).
      inner = '<img alt="Logo" src="/dashboards/logo/icast.svg" style="display:block;' + dim + '" />';
    }
    var align = cfg.align || 'center';
    var jc = align === 'left' ? 'flex-start' : (align === 'right' ? 'flex-end' : 'center');
    body.innerHTML = '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:' + jc + ';overflow:hidden">' + inner + '</div>';
  }

  // Image tile (DVI-1280) — kiosk mirror. Images fetched via the dual-auth
  // serve route with the device token (AUTHQ). Slideshow timers keyed by tile id.
  var kskImgTimers = {};
  function kskStopImgTimers(){
    for (var k in kskImgTimers){ if (kskImgTimers[k]) clearInterval(kskImgTimers[k]); }
    kskImgTimers = {};
  }
  function kskImgUrl(id){ return '/dashboards/image/' + encodeURIComponent(id) + '?' + AUTHQ; }
  function renderImage(body, cfg, tile){
    cfg = cfg || {};
    var imgs = (cfg.images || []).filter(function(im){ return im && im.id; });
    var tid = tile && tile.id;
    if (tid && kskImgTimers[tid]){ clearInterval(kskImgTimers[tid]); kskImgTimers[tid] = null; }
    if (!imgs.length){ body.innerHTML = '<span class="text-gray-500 text-sm italic">No images.</span>'; return; }
    var fit = (cfg.fit === 'cover') ? 'cover' : 'contain';
    var mode = cfg.mode || 'single';
    if (mode === 'gallery'){
      body.innerHTML = '<div style="width:100%;height:100%;overflow:hidden"><div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:6px">' +
        imgs.map(function(im){ return '<div style="aspect-ratio:1;overflow:hidden;border-radius:6px"><img alt="" src="' + kskImgUrl(im.id) + '" style="width:100%;height:100%;object-fit:cover;display:block" /></div>'; }).join('') +
        '</div></div>';
      return;
    }
    if (mode === 'slideshow' && imgs.length > 1){
      var order = imgs.map(function(_, i){ return i; });
      if (cfg.shuffle){ for (var i = order.length - 1; i > 0; i--){ var j = Math.floor(Math.random() * (i + 1)); var t = order[i]; order[i] = order[j]; order[j] = t; } }
      var fade = !!cfg.fade;
      body.innerHTML = '<div style="position:relative;width:100%;height:100%;overflow:hidden">' +
        order.map(function(oi, idx){
          return '<img alt="" src="' + kskImgUrl(imgs[oi].id) + '" style="position:absolute;inset:0;width:100%;height:100%;object-fit:' + fit +
            ';opacity:' + (idx === 0 ? '1' : '0') + (fade ? ';transition:opacity .8s ease-in-out' : '') + '" />';
        }).join('') + '</div>';
      var frames = body.querySelectorAll('img');
      if (frames.length > 1 && tid){
        var cur = 0, ms = Math.max(5, Math.min(120, cfg.interval || 8)) * 1000;
        kskImgTimers[tid] = setInterval(function(){
          frames[cur].style.opacity = '0';
          cur = (cur + 1) % frames.length;
          frames[cur].style.opacity = '1';
        }, ms);
      }
      return;
    }
    body.innerHTML = '<img alt="" src="' + kskImgUrl(imgs[0].id) + '" style="width:100%;height:100%;object-fit:' + fit + ';display:block" />';
  }

  // Time tile (DVI-1214) — client-rendered clock; the header tick() drives its
  // per-second refresh (see tick()). Timezone conversion via Intl; bad zone → local.
  function timeParts(cfg){
    var now = new Date(), tz = cfg.timezone || '';
    var tOpts = { hour:'numeric', minute:'2-digit', hour12:(cfg.time_format !== '24h') };
    if (cfg.show_seconds) tOpts.second = '2-digit';
    if (tz) tOpts.timeZone = tz;
    var timeStr;
    try { timeStr = now.toLocaleTimeString([], tOpts); }
    catch(e){ delete tOpts.timeZone; tz = ''; timeStr = now.toLocaleTimeString([], tOpts); }
    var dateStr = '';
    if (cfg.show_date){
      var df = cfg.date_format || 'full';
      try {
        if (df === 'iso'){ dateStr = new Intl.DateTimeFormat('en-CA', { timeZone: tz || undefined, year:'numeric', month:'2-digit', day:'2-digit' }).format(now); }
        else {
          var dOpts;
          if (df === 'long') dOpts = { year:'numeric', month:'long', day:'numeric' };
          else if (df === 'medium') dOpts = { weekday:'short', month:'short', day:'numeric' };
          else if (df === 'short') dOpts = { year:'numeric', month:'numeric', day:'numeric' };
          else dOpts = { weekday:'long', year:'numeric', month:'long', day:'numeric' };
          if (tz) dOpts.timeZone = tz;
          dateStr = now.toLocaleDateString([], dOpts);
        }
      } catch(e2){ dateStr = ''; }
    }
    var zoneStr = (cfg.show_zone && tz) ? tz.split('/').pop().replace(/_/g,' ') : '';
    return { time: timeStr, date: dateStr, zone: zoneStr };
  }
  function timeFit(body, wrap, timeEl, dateEl, zoneEl, mode){
    function set(fs){ timeEl.style.fontSize = fs + 'px';
      if (dateEl) dateEl.style.fontSize = Math.max(14, Math.round(fs*0.34)) + 'px';
      if (zoneEl) zoneEl.style.fontSize = Math.max(12, Math.round(fs*0.26)) + 'px'; }
    var fixed = { small:28, medium:48, large:80, xlarge:120 };
    if (mode !== 'auto' && fixed[mode]){ set(fixed[mode]); return; }
    var aw = body.clientWidth, ah = body.clientHeight;
    if (aw <= 0 || ah <= 0){ set(48); return; }
    var probe = 120; set(probe);
    var w = wrap.scrollWidth, h = wrap.scrollHeight;
    if (w <= 0 || h <= 0){ set(48); return; }
    var scale = Math.min(aw/w, ah/h) * 0.9;
    set(Math.max(16, Math.floor(probe * scale)));
  }
  function renderTime(body, cfg){
    var p = timeParts(cfg);
    body.innerHTML = '';
    var wrap = document.createElement('div');
    wrap.className = 'w-full h-full flex flex-col items-center justify-center text-center overflow-hidden';
    var t = document.createElement('div');
    t.className = 'font-semibold tabular-nums leading-none whitespace-nowrap text-white';
    t.textContent = p.time; wrap.appendChild(t);
    var d = null, z = null;
    if (p.date){ d = document.createElement('div'); d.className = 'mt-1 leading-tight whitespace-nowrap text-gray-400'; d.textContent = p.date; wrap.appendChild(d); }
    if (p.zone){ z = document.createElement('div'); z.className = 'mt-1 leading-tight whitespace-nowrap uppercase tracking-wide text-gray-500'; z.textContent = p.zone; wrap.appendChild(z); }
    body.appendChild(wrap);
    timeFit(body, wrap, t, d, z, cfg.font_size || 'auto');
  }

  // Weather tile (DVI-1277) — kiosk mirror of dashTileRenderWeather. Same inline
  // SVG icons (fixed colors), so the wall matches the SPA with no device change.
  function weatherIcon(icon, isDay){
    var day = (isDay !== false);
    function cloud(){ return '<g fill="#94a3b8"><circle cx="24" cy="40" r="10"/><circle cx="38" cy="38" r="13"/><circle cx="46" cy="43" r="8"/><rect x="20" y="40" width="30" height="11" rx="5"/></g>'; }
    function rays(pts, w){ var r=''; pts.forEach(function(p){ r+='<line x1="'+p[0]+'" y1="'+p[1]+'" x2="'+p[2]+'" y2="'+p[3]+'"/>'; }); return '<g stroke="#f59e0b" stroke-width="'+w+'" stroke-linecap="round">'+r+'</g>'; }
    function sunBig(){ return '<circle cx="32" cy="30" r="12" fill="#f59e0b"/>'+rays([[32,6,32,13],[32,47,32,54],[6,30,13,30],[51,30,58,30],[14,12,19,17],[45,43,50,48],[50,12,45,17],[19,43,14,48]],3); }
    function moonBig(){ return '<path d="M42 44 A16 16 0 1 1 26 12 A13 13 0 0 0 42 44 Z" fill="#cbd5e1"/>'; }
    function sunSmall(){ return '<g transform="translate(2,2)"><circle cx="20" cy="20" r="8" fill="#f59e0b"/>'+rays([[20,2,20,7],[20,33,20,38],[2,20,7,20],[33,20,38,20],[7.5,7.5,11,11],[29,29,32.5,32.5],[32.5,7.5,29,11],[11,29,7.5,32.5]],2.5)+'</g>'; }
    function moonSmall(){ return '<path d="M30 30 A11 11 0 1 1 19 8 A9 9 0 0 0 30 30 Z" fill="#cbd5e1"/>'; }
    function drops(color){ return '<g stroke="'+color+'" stroke-width="3" stroke-linecap="round"><line x1="26" y1="54" x2="24" y2="60"/><line x1="34" y1="54" x2="32" y2="60"/><line x1="42" y1="54" x2="40" y2="60"/></g>'; }
    function flakes(){ return '<g fill="#e2e8f0"><circle cx="26" cy="57" r="2.5"/><circle cx="35" cy="59" r="2.5"/><circle cx="44" cy="57" r="2.5"/></g>'; }
    function bolt(){ return '<polygon points="34,52 27,62 33,62 30,70 41,57 34,57" fill="#f59e0b"/>'; }
    function fog(){ return '<g stroke="#cbd5e1" stroke-width="3" stroke-linecap="round"><line x1="20" y1="55" x2="46" y2="55"/><line x1="24" y1="61" x2="42" y2="61"/></g>'; }
    var svg = '<svg viewBox="0 0 64 72" width="100%" height="100%" preserveAspectRatio="xMidYMid meet" style="display:block">';
    if (icon === 'clear' || icon === 'mostly-clear'){ svg += day ? sunBig() : moonBig(); }
    else if (icon === 'partly-cloudy'){ svg += (day ? sunSmall() : moonSmall()) + cloud(); }
    else if (icon === 'fog'){ svg += cloud() + fog(); }
    else if (icon === 'drizzle'){ svg += cloud() + drops('#60a5fa'); }
    else if (icon === 'rain'){ svg += cloud() + drops('#3b82f6'); }
    else if (icon === 'snow'){ svg += cloud() + flakes(); }
    else if (icon === 'thunder'){ svg += cloud() + bolt(); }
    else { svg += cloud(); }
    return svg + '</svg>';
  }
  function weatherTemp(v){ if (v === null || v === undefined || isNaN(v)) return '—'; return Math.round(v) + '°'; }
  function weatherDayName(dateStr){
    var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(dateStr || '');
    if (!m) return '';
    var d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
    if (isNaN(d.getTime())) return '';
    return d.toLocaleDateString([], { weekday: 'short' });
  }
  function renderWeather(body, cfg, data){
    if (data.available === false){
      body.innerHTML = '<div class="flex items-start gap-1.5 text-amber-400 text-sm py-2"><span>&#9925;</span><span>' + esc(data.message || 'Weather not available.') + '</span></div>';
      return;
    }
    var cur = data.current || {}, tu = data.temp_unit || '°F', su = data.speed_unit || 'mph';
    var mode = data.mode || cfg.mode || 'current';
    var loc = data.location || '';
    var html = '<div class="h-full flex flex-col">' + ideCachedBadge(data);
    if (mode === 'compact'){
      html += '<div class="flex-1 min-h-0 flex flex-col items-center justify-center text-center">' +
        '<div style="width:72px;height:76px">' + weatherIcon(cur.icon, cur.is_day) + '</div>' +
        '<div class="text-5xl font-bold tabular-nums text-white leading-none mt-1">' + weatherTemp(cur.temp) + '<span class="text-2xl align-top">' + esc(tu) + '</span></div>' +
        (loc ? '<div class="text-sm text-gray-400 truncate max-w-full mt-1">' + esc(loc) + '</div>' : '') +
        '</div>';
      body.innerHTML = html + '</div>';
      return;
    }
    var today = data.today || {};
    html += '<div class="flex items-center gap-3">' +
      '<div class="shrink-0" style="width:80px;height:84px">' + weatherIcon(cur.icon, cur.is_day) + '</div>' +
      '<div class="min-w-0">' +
        '<div class="text-5xl font-bold tabular-nums text-white leading-none">' + weatherTemp(cur.temp) + '<span class="text-2xl align-top">' + esc(tu) + '</span></div>' +
        '<div class="text-lg text-gray-300 truncate">' + esc(cur.label || '') + '</div>' +
        (loc ? '<div class="text-sm text-gray-400 truncate">' + esc(loc) + '</div>' : '') +
      '</div></div>';
    if (mode === 'current'){
      var det = [];
      if (cur.feels_like != null) det.push('Feels ' + weatherTemp(cur.feels_like) + tu);
      if (today && today.high != null) det.push('H ' + weatherTemp(today.high) + ' / L ' + weatherTemp(today.low));
      if (cur.humidity != null) det.push('Humidity ' + Math.round(cur.humidity) + '%');
      if (cur.wind != null) det.push('Wind ' + Math.round(cur.wind) + ' ' + su);
      html += '<div class="mt-3 grid grid-cols-2 gap-1 text-base text-gray-300">' +
        det.map(function(d){ return '<div class="truncate">' + esc(d) + '</div>'; }).join('') + '</div>';
      body.innerHTML = html + '</div>';
      return;
    }
    var days = (data.days || []).slice(0, data.forecast_days || cfg.forecast_days || 5);
    html += '<div class="flex-1 min-h-0 overflow-hidden mt-3"><div class="flex gap-2">';
    days.forEach(function(d, i){
      html += '<div class="flex-1 min-w-0 flex flex-col items-center text-center rounded-lg border border-gray-700 py-2 px-1">' +
        '<div class="text-sm font-medium text-gray-300 truncate w-full">' + (i === 0 ? 'Today' : esc(weatherDayName(d.date))) + '</div>' +
        '<div style="width:44px;height:48px">' + weatherIcon(d.icon, true) + '</div>' +
        '<div class="text-base tabular-nums text-gray-100">' + weatherTemp(d.high) + '</div>' +
        '<div class="text-sm tabular-nums text-gray-500">' + weatherTemp(d.low) + '</div>' +
      '</div>';
    });
    html += '</div></div>';
    body.innerHTML = html + '</div>';
  }

  function wovEquipHtml(eq){
    if (!eq) return '';
    var chip = function(n, label, cls){
      return '<div class="flex-1 rounded-lg ' + cls + ' px-3 py-2 text-center">' +
        '<div class="text-2xl font-semibold leading-none">' + (n || 0) + '</div>' +
        '<div class="text-xs uppercase tracking-wide mt-1">' + label + '</div></div>';
    };
    return '<div class="flex gap-2 mb-2">' +
      chip(eq.down, 'Equipment down', 'bg-red-900/40 text-red-300') +
      chip(eq.offsite, 'Offsite', 'bg-amber-900/40 text-amber-300') +
      '</div>';
  }

  function wovItemHtml(wo){
    var sl = _WOR_STATUS[wo.status] || wo.status, tl = _WOR_TYPE[wo.wor_type] || wo.wor_type;
    var oc = wo.is_overdue ? ' border-l-4 border-red-500' : ' border-l-4 border-gray-700';
    var ot = wo.is_overdue ? '<span class="ml-1 text-sm font-medium text-red-400">Overdue</span>' : '';
    return '<div class="pl-2 py-1' + oc + '">' +
      '<div class="text-base font-medium text-gray-100 leading-snug">' + esc(wo.title) + ot + '</div>' +
      '<div class="text-sm text-gray-400">' + esc(tl) + ' &middot; ' + esc(sl) +
        (wo.due_date ? ' &middot; Due ' + esc(wo.due_date) : '') + '</div></div>';
  }

  function renderWo(body, data){
    var eqHtml = wovEquipHtml(data.equipment);
    var emptyMsg = data.pm_today ? 'No preventive maintenance due today.' : 'No work orders match the current filters.';
    if (data.grouped){
      var sections = data.sections || [];
      if (!sections.length){ body.innerHTML = eqHtml + '<p class="text-gray-500 text-sm italic text-center py-4">' + emptyMsg + '</p>'; return; }
      var ghtml = '';
      sections.forEach(function(sec){
        ghtml += '<div class="mb-3">' +
          '<div class="text-xs font-semibold uppercase tracking-wide text-gray-400 mb-1">' +
            esc(sec.label) + ' <span class="text-gray-500 font-normal">(' + (sec.total != null ? sec.total : sec.count) + ')</span></div>' +
          '<div class="space-y-1.5">';
        (sec.items || []).forEach(function(wo){ ghtml += wovItemHtml(wo); });
        ghtml += '</div>';
        if (sec.total && sec.total > (sec.items || []).length) ghtml += '<p class="text-sm text-gray-500 mt-0.5">+' + (sec.total - (sec.items || []).length) + ' more</p>';
        ghtml += '</div>';
      });
      body.innerHTML = eqHtml + ghtml;
      return;
    }
    var items = data.items || [];
    if (!items.length){ body.innerHTML = eqHtml + '<p class="text-gray-500 text-sm italic text-center py-4">' + emptyMsg + '</p>'; return; }
    var html = '<div class="space-y-1.5">';
    items.forEach(function(wo){ html += wovItemHtml(wo); });
    html += '</div>';
    if (data.total && data.total > items.length) html += '<p class="text-sm text-gray-500 mt-1 text-center">+' + (data.total - items.length) + ' more</p>';
    else if (items.length >= 50) html += '<p class="text-sm text-gray-500 mt-1 text-center">Showing first 50 results</p>';
    body.innerHTML = eqHtml + html;
  }

  function renderParts(body, data){
    var items = data.items || [];
    if (!items.length){ body.innerHTML = '<p class="text-gray-500 text-sm italic text-center py-4">No work orders match the current filters.</p>'; return; }
    var html = '<div class="space-y-1.5">';
    items.forEach(function(wo){
      var sl = _WOR_STATUS[wo.status] || wo.status, tl = _WOR_TYPE[wo.wor_type] || wo.wor_type;
      var pct = wo.part_count > 0 ? Math.round(wo.stocked_count / wo.part_count * 100) : 0;
      var bc = pct >= 100 ? 'bg-green-500' : (pct > 0 ? 'bg-amber-400' : 'bg-gray-600');
      html += '<div class="border-l-4 border-gray-700 pl-2 py-1">' +
        '<div class="text-base font-medium text-gray-100 leading-snug">' + esc(wo.title) + '</div>' +
        '<div class="text-sm text-gray-400">' + esc(tl) + ' &middot; ' + esc(sl) + ' &middot; ' +
          wo.stocked_count + '/' + wo.part_count + ' stocked' +
          (wo.unapproved_count > 0 ? ' &middot; ' + wo.unapproved_count + ' unapproved' : '') + '</div>' +
        '<div class="mt-1 h-1.5 rounded-full bg-gray-700"><div class="h-1.5 rounded-full ' + bc + '" style="width:' + pct + '%"></div></div>' +
        '</div>';
    });
    html += '</div>';
    if (data.total && data.total > items.length) html += '<p class="text-sm text-gray-500 mt-1 text-center">+' + (data.total - items.length) + ' more</p>';
    body.innerHTML = html;
  }

  function renderScada(body, data){
    var html = '', readings = data.readings || [];
    if (!readings.length){ html += '<p class="text-gray-500 text-sm italic text-center py-2">No sensor readings available.</p>'; }
    else {
      html += '<div class="space-y-1">';
      readings.forEach(function(r){
        var label = r.alias || r.label, oor = r.oor;
        var oc = oor ? ' text-red-400 font-semibold' : ' text-gray-100';
        var ot = oor ? '<span class="ml-1 text-sm text-red-400">⚠</span>' : '';
        var unit = r.unit ? ' ' + r.unit : '';
        html += '<div class="flex items-baseline justify-between border-b border-gray-800 py-0.5">' +
          '<span class="text-sm text-gray-300 truncate max-w-[55%]">' + esc(label) + '</span>' +
          '<span class="text-base' + oc + '">' + esc(String(r.value != null ? r.value : '—')) + esc(unit) + ot + '</span></div>';
      });
      html += '</div>';
      if (data.total && data.total > readings.length)
        html += '<p class="text-sm text-gray-500 mt-1 text-center">+' + (data.total - readings.length) + ' more</p>';
      if (data.oor_in_window && data.oor_in_window.length)
        html += '<p class="text-sm text-red-400 mt-1">Out of range in window (' + esc(data.oor_window) + '): ' + esc(data.oor_in_window.join(', ')) + '</p>';
    }
    if (data.snap_time) html += '<p class="text-sm text-gray-500 mt-1">As of ' + esc(data.snap_time) + '</p>';
    if (data.scheduler_status){
      var s = data.scheduler_status, el = _SCADA_EMAIL_STATUS[s.email_status] || s.email_status || '';
      html += '<div class="mt-2 pt-1 border-t border-gray-800 text-sm text-gray-400">Last report: ' +
        esc(s.run_date || '—') + (el ? ' (' + esc(el) + ')' : '') + '</div>';
    }
    body.innerHTML = html;
  }

  // DVI-1524 P5 — QC Batch Production daily summary (kiosk mirror).
  function renderBatchProduction(body, data){
    if (data.available === false){
      body.innerHTML = '<span class="text-amber-400 text-sm">' + esc(data.message || 'QC data unavailable.') + '</span>';
      return;
    }
    var html = '';
    html += '<div class="flex items-baseline justify-between mb-1">' +
      '<span class="text-3xl font-semibold text-gray-100">' + (data.batch_count || 0) + '</span>' +
      '<span class="text-sm text-gray-400">batch' + ((data.batch_count === 1) ? '' : 'es') + ' today</span></div>';
    var yd = data.total_yards || 0, oor = data.oor || 0;
    html += '<div class="flex items-baseline justify-between text-sm text-gray-300">' +
      '<span>' + esc(String(yd)) + ' yd³</span>' +
      '<span class="' + (oor ? 'text-red-400 font-semibold' : '') + '">' + oor + ' out of range' + (oor ? ' ⚠' : '') + '</span></div>';
    var sections = data.sections || [];
    if (sections.length && !data.segment){
      html += '<div class="mt-2 pt-1 border-t border-gray-800 space-y-0.5">';
      sections.forEach(function(s){
        html += '<div class="flex items-baseline justify-between text-sm">' +
          '<span class="text-gray-300 truncate max-w-[55%]">' + esc(s.label) + '</span>' +
          '<span class="text-gray-200">' + (s.batch_count || 0) + ' · ' + esc(String(s.total_yards || 0)) + ' yd³' +
          (s.oor ? ' · <span class="text-red-400">' + s.oor + ' OOR</span>' : '') + '</span></div>';
      });
      html += '</div>';
    }
    html += '<p class="text-sm text-gray-500 mt-1">' + esc(data.date || '') + '</p>';
    body.innerHTML = html;
  }

  function calDate(s){ var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s || ''); if (!m) return s || '';
    var d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
    return isNaN(d.getTime()) ? (s || '') : d.toLocaleDateString([], {weekday:'short', month:'short', day:'numeric'}); }
  function calWhen(ev){ var s = ev.start || ''; if (ev.all_day) return calDate(s);
    var iso = (s.indexOf('Z') > -1 || /[+\-]\d\d:?\d\d$/.test(s)) ? s : s + 'Z';
    var d = new Date(iso); if (isNaN(d.getTime())) return s;
    return d.toLocaleDateString([], {month:'short', day:'numeric'}) + ' ' + d.toLocaleTimeString([], {hour:'numeric', minute:'2-digit'}); }
  function renderCal(body, cfg, data){
    if (data.available === false){
      body.innerHTML = '<div class="flex items-start gap-1.5 text-sm text-amber-400 py-2"><span>&#128197;</span><span>' +
        esc(data.message || 'Calendar not available for this mailbox.') + '</span></div>';
      return;
    }
    var events = data.events || [];
    if (!events.length){ body.innerHTML = '<p class="text-gray-500 text-sm italic text-center py-4">No events in this range.</p>'; return; }
    var html = '<div class="space-y-1.5">';
    events.forEach(function(ev){
      var loc = ev.location ? ' &middot; ' + esc(ev.location) : '';
      var tt = ev.wor_type_label ? ' &middot; ' + esc(ev.wor_type_label) : '';
      html += '<div class="pl-2 py-1 border-l-4 border-togen-teal/50">' +
        '<div class="text-base font-medium text-gray-100 leading-snug">' + esc(ev.subject || '(no title)') + '</div>' +
        '<div class="text-sm text-gray-400">' + esc(calWhen(ev)) + tt + loc + '</div></div>';
    });
    html += '</div>';
    if (data.total && data.total > events.length) html += '<p class="text-sm text-gray-500 mt-1 text-center">+' + (data.total - events.length) + ' more</p>';
    body.innerHTML = html;
  }

  // Idencia tile (DVI-1226 P2) — mirrors the SPA renderer with kiosk (dark,
  // glanceable) styling. Charts are dependency-free: inline-SVG donut + flex
  // divs, no chart library. The display renders under the org connection
  // (the synthetic display identity has no email), resolved server-side.
  var IDE_COLORS = ['#00B4D8','#6366F1','#F59E0B','#10B981','#EF4444','#8B5CF6','#EC4899','#84CC16','#F97316','#64748B'];
  function ideColor(i){ return IDE_COLORS[i % IDE_COLORS.length]; }
  function ideSliceLabel(s){ return s.label + (s.more ? ' (+' + s.more + ' more)' : ''); }
  function ideCachedBadge(data){
    if (!data.cached) return '';
    var t = String(data.as_of || ''), when = t.slice(11, 16);
    var d = new Date();
    var todayStr = d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2);
    if (t.slice(0, 10) && t.slice(0, 10) !== todayStr) when = t.slice(0, 10) + ' ' + when;
    return '<div class="shrink-0"><span class="inline-flex items-center gap-1 rounded-full bg-amber-900/50 text-amber-300 text-xs font-medium px-2 py-0.5 mb-1">Cached &mdash; as of ' + esc(when || 'unknown') + '</span></div>';
  }
  function idePieSvg(slices, total){
    var svg = '<svg viewBox="0 0 42 42" class="h-full max-h-full" style="max-width:100%">';
    svg += '<circle cx="21" cy="21" r="15.915" fill="none" stroke="rgba(148,163,184,0.25)" stroke-width="6"></circle>';
    var off = 25;   // 12 o'clock start
    slices.forEach(function(s, i){
      var pct = total > 0 ? (s.value / total) * 100 : 0;
      if (pct <= 0) return;
      svg += '<circle cx="21" cy="21" r="15.915" fill="none" stroke="' + ideColor(i) + '" stroke-width="6" stroke-dasharray="' + pct + ' ' + (100 - pct) + '" stroke-dashoffset="' + off + '"></circle>';
      off -= pct;
    });
    svg += '<text x="21" y="21.5" text-anchor="middle" dominant-baseline="middle" fill="#f3f4f6" font-size="8" font-weight="600">' + total + '</text>';
    return svg + '</svg>';
  }
  function ideBarsHtml(slices){
    var max = 0;
    slices.forEach(function(s){ if (s.value > max) max = s.value; });
    var html = '<div class="flex-1 min-h-0 flex items-end justify-around gap-1 pt-3" style="min-height:70px">';
    slices.forEach(function(s, i){
      var pct = max > 0 ? Math.max(2, Math.round(s.value / max * 100)) : 2;
      html += '<div class="flex flex-col items-center justify-end flex-1 min-w-0 h-full">' +
        '<div class="text-sm font-medium text-gray-300">' + s.value + '</div>' +
        '<div class="w-full max-w-[56px] rounded-t" style="height:' + pct + '%;background:' + ideColor(i) + '"></div>' +
        '<div class="text-xs text-gray-400 truncate w-full text-center mt-0.5" title="' + esc(s.label) + '">' + esc(ideSliceLabel(s)) + '</div>' +
        '</div>';
    });
    return html + '</div>';
  }
  function ideLegendHtml(slices){
    return '<div class="shrink-0 mt-1.5 space-y-0.5 overflow-auto max-h-28">' + slices.map(function(s, i){
      return '<div class="flex items-center gap-1.5 text-sm">' +
        '<span class="inline-block w-2.5 h-2.5 rounded-sm shrink-0" style="background:' + ideColor(i) + '"></span>' +
        '<span class="truncate text-gray-200">' + esc(ideSliceLabel(s)) + '</span>' +
        '<span class="ml-auto text-gray-400 tabular-nums">' + s.value + '</span></div>';
    }).join('') + '</div>';
  }
  function ideStatusBarsHtml(data){
    var html = '<div class="flex-1 min-h-0 overflow-auto space-y-1.5 pt-1">';
    if (data.progress && data.progress.length){
      data.progress.forEach(function(p){
        var req = p.required || 0, prod = p.produced || 0;
        var pct = req > 0 ? Math.min(100, Math.round(prod / req * 100)) : (prod > 0 ? 100 : 0);
        var barCls = (req > 0 && prod >= req) ? 'bg-green-500' : 'bg-togen-teal';
        html += '<div>' +
          '<div class="flex items-baseline justify-between gap-2 text-sm">' +
            '<span class="truncate font-medium text-gray-100">' + esc(p.name) + '</span>' +
            '<span class="text-gray-400 tabular-nums shrink-0">' + prod + (req ? ' / ' + req : '') + '</span></div>' +
          '<div class="mt-0.5 h-2 rounded-full bg-gray-700"><div class="h-2 rounded-full ' + barCls + '" style="width:' + pct + '%"></div></div>' +
          '</div>';
      });
      if (data.truncated) html += '<p class="text-sm text-gray-500 text-center">+' + data.truncated + ' more</p>';
    } else {
      var slices = data.slices || [], max = 0;
      slices.forEach(function(s){ if (s.value > max) max = s.value; });
      slices.forEach(function(s, i){
        var pct = max > 0 ? Math.max(2, Math.round(s.value / max * 100)) : 0;
        html += '<div>' +
          '<div class="flex items-baseline justify-between gap-2 text-sm">' +
            '<span class="truncate font-medium text-gray-100">' + esc(ideSliceLabel(s)) + '</span>' +
            '<span class="text-gray-400 tabular-nums shrink-0">' + s.value + '</span></div>' +
          '<div class="mt-0.5 h-2 rounded-full bg-gray-700"><div class="h-2 rounded-full" style="width:' + pct + '%;background:' + ideColor(i) + '"></div></div>' +
          '</div>';
      });
    }
    return html + '</div>';
  }
  function ideKpiHtml(data){
    var chips = [];
    if (data.totals){
      chips = [
        {label: 'Projects', value: (data.progress || []).length},
        {label: 'Required', value: data.totals.required},
        {label: 'Produced', value: data.totals.produced},
        {label: 'Remaining', value: data.totals.remaining}
      ];
    } else {
      chips = [{label: 'Total', value: data.total || 0}];
      (data.slices || []).slice(0, 5).forEach(function(s){
        chips.push({label: ideSliceLabel(s), value: s.value});
      });
    }
    var html = '<div class="flex-1 min-h-0 overflow-auto grid grid-cols-2 gap-2 content-start pt-1">';
    chips.forEach(function(c){
      html += '<div class="rounded-lg border border-gray-700 px-3 py-2 text-center">' +
        '<div class="text-3xl font-bold text-white tabular-nums leading-tight">' + esc(String(c.value)) + '</div>' +
        '<div class="text-xs text-gray-400 truncate" title="' + esc(c.label) + '">' + esc(c.label) + '</div></div>';
    });
    return html + '</div>';
  }
  function renderIdencia(body, cfg, data){
    if (data.available === false){
      body.innerHTML = '<div class="flex items-start gap-1.5 text-sm text-amber-400 py-2">' +
        '<span>&#128202;</span><span>' + esc(data.message || 'Idencia data not available.') + '</span></div>';
      return;
    }
    var display = data.display || cfg.display || 'status_bars';
    var slices = data.slices || [];
    var hasRows = slices.length || (data.progress || []).length;
    var html = '<div class="h-full flex flex-col">' + ideCachedBadge(data);
    if (!hasRows){
      html += '<p class="text-gray-500 text-sm italic text-center py-4">No Idencia records match the current filters.</p></div>';
      body.innerHTML = html;
      return;
    }
    if (display === 'pie'){
      html += '<div class="flex-1 min-h-0 flex items-center justify-center text-gray-100" style="min-height:70px">' +
        idePieSvg(slices, data.total || 0) + '</div>';
      if (cfg.show_legend !== false) html += ideLegendHtml(slices);
    } else if (display === 'bar'){
      html += ideBarsHtml(slices);
    } else if (display === 'kpi'){
      html += ideKpiHtml(data);
    } else {
      html += ideStatusBarsHtml(data);
    }
    body.innerHTML = html + '</div>';
  }

  // Power BI tiles (DVI-1243 P2). Data tile reuses the ide* chart helpers;
  // report tile is an app-owns-data embed via the powerbi-client SDK (lazy CDN
  // load). The server rotates the embed token through the 60 s tile poll —
  // same signature just swaps the token in place, so walls render unattended.
  function pbiKpiHtml(data){
    var chips = [];
    if ((data.kpis || []).length){ chips = data.kpis.slice(0, 8); }
    else {
      chips = [{label: 'Rows', value: data.total || 0}];
      (data.slices || []).slice(0, 5).forEach(function(s){
        chips.push({label: ideSliceLabel(s), value: s.value});
      });
    }
    var html = '<div class="flex-1 min-h-0 overflow-auto grid grid-cols-2 gap-2 content-start pt-1">';
    chips.forEach(function(c){
      var v = (c.value == null) ? '—' : String(c.value);
      html += '<div class="rounded-lg border border-gray-700 px-3 py-2 text-center">' +
        '<div class="text-3xl font-bold text-white tabular-nums leading-tight">' + esc(v) + '</div>' +
        '<div class="text-xs text-gray-400 truncate" title="' + esc(c.label) + '">' + esc(c.label) + '</div></div>';
    });
    return html + '</div>';
  }
  function pbiTableHtml(data){
    var cols = data.columns || [], rows = data.rows || [];
    var html = '<div class="flex-1 min-h-0 overflow-auto"><table class="w-full text-sm">' +
      '<thead><tr>' + cols.map(function(c){
        return '<th class="text-left font-medium text-gray-400 px-1.5 py-1">' + esc(c) + '</th>';
      }).join('') + '</tr></thead><tbody>';
    rows.forEach(function(r){
      html += '<tr class="border-t border-gray-700/60">' + r.map(function(v){
        var num = (typeof v === 'number');
        return '<td class="px-1.5 py-1 text-gray-100' + (num ? ' tabular-nums text-right' : '') + '">' +
          esc(v == null ? '' : String(v)) + '</td>';
      }).join('') + '</tr>';
    });
    html += '</tbody></table>';
    if (data.total && data.total > rows.length){
      html += '<p class="text-sm text-gray-500 mt-1 text-center">+' + (data.total - rows.length) + ' more</p>';
    }
    return html + '</div>';
  }
  function renderPowerbiData(body, cfg, data){
    if (data.available === false){
      body.innerHTML = '<div class="flex items-start gap-1.5 text-sm text-amber-400 py-2">' +
        '<span>&#128200;</span><span>' + esc(data.message || 'Power BI data not available.') + '</span></div>';
      return;
    }
    var display = data.display || cfg.display || 'table';
    var slices = data.slices || [];
    var html = '<div class="h-full flex flex-col">' + ideCachedBadge(data);
    if (!(data.rows || []).length && !slices.length && !(data.kpis || []).length){
      html += '<p class="text-gray-500 text-sm italic text-center py-4">The query returned no rows.</p></div>';
      body.innerHTML = html;
      return;
    }
    if (display === 'pie' || display === 'bar'){
      if (!slices.length){
        html += '<p class="text-gray-500 text-sm italic text-center py-4">No numeric column to chart.</p>';
      } else if (display === 'pie'){
        var tot = 0; slices.forEach(function(s){ tot += (s.value || 0); });
        html += '<div class="flex-1 min-h-0 flex items-center justify-center text-gray-100" style="min-height:70px">' +
          idePieSvg(slices, Math.round(tot * 100) / 100) + '</div>';
        if (cfg.show_legend !== false) html += ideLegendHtml(slices);
      } else {
        html += ideBarsHtml(slices);
      }
    } else if (display === 'kpi'){
      html += pbiKpiHtml(data);
    } else {
      html += pbiTableHtml(data);
    }
    body.innerHTML = html + '</div>';
  }

  var _pbi = {};              // tileId -> {sig, token, embed}
  var _pbiSdkPromise = null;
  function pbiEnsureSdk(){
    if (window.powerbi) return Promise.resolve(window.powerbi);
    if (_pbiSdkPromise) return _pbiSdkPromise;
    _pbiSdkPromise = new Promise(function(resolve, reject){
      var s = document.createElement('script');
      s.src = 'https://cdn.jsdelivr.net/npm/powerbi-client@2.23.1/dist/powerbi.min.js';
      s.onload = function(){
        if (window.powerbi) resolve(window.powerbi);
        else { _pbiSdkPromise = null; reject(new Error('powerbi-client failed to load')); }
      };
      s.onerror = function(){ _pbiSdkPromise = null; reject(new Error('powerbi-client failed to load')); };
      document.head.appendChild(s);
    });
    return _pbiSdkPromise;
  }
  function renderPowerbiReport(body, cfg, data, tile){
    var tid = tile.id;
    if (data.available === false){
      delete _pbi[tid];
      body.innerHTML = '<div class="flex items-start gap-1.5 text-sm text-amber-400 py-2">' +
        '<span>&#128202;</span><span>' + esc(data.message || 'Power BI report not available.') + '</span></div>';
      return;
    }
    var rep = data.report || {};
    var sig = rep.id + '|' + (data.page || '') + '|' + !!data.show_filter_pane +
      '|' + !!data.show_page_nav + '|' + (data.interactive !== false);
    var st = _pbi[tid];
    var host = body.querySelector('.pbi-embed');
    if (st && st.sig === sig && host){
      if (st.token !== data.token && st.embed){
        st.token = data.token;
        try { st.embed.setAccessToken(data.token); }
        catch(e){ delete _pbi[tid]; renderPowerbiReport(body, cfg, data, tile); }
      }
      return;
    }
    body.style.padding = '0'; body.style.position = 'relative';
    body.innerHTML = '<div class="pbi-embed" style="position:absolute;inset:0;' +
      (data.interactive === false ? 'pointer-events:none;' : '') + '"></div>';
    host = body.querySelector('.pbi-embed');
    _pbi[tid] = st = { sig: sig, token: data.token, embed: null };
    pbiEnsureSdk().then(function(pbi){
      if (_pbi[tid] !== st || !document.body.contains(host)) return;   // superseded / tile gone
      var conf = {
        type: 'report',
        id: rep.id,
        embedUrl: rep.embed_url || ('https://app.powerbi.com/reportEmbed?reportId=' +
          encodeURIComponent(rep.id) + '&groupId=' + encodeURIComponent(rep.workspace_id || '')),
        accessToken: data.token,
        tokenType: 1,        // models.TokenType.Embed (app-owns-data)
        permissions: 0,      // View
        settings: { filterPaneEnabled: !!data.show_filter_pane,
                    navContentPaneEnabled: !!data.show_page_nav }
      };
      if (data.page) conf.pageName = data.page;
      try { pbi.reset(host); } catch(e){}
      try { st.embed = pbi.embed(host, conf); }
      catch(e2){
        host.innerHTML = '<div class="p-3 text-sm text-amber-400">Could not embed the report: ' +
          esc(String((e2 && e2.message) || e2)) + '</div>';
      }
    }).catch(function(){
      if (document.body.contains(host)){
        host.innerHTML = '<div class="p-3 text-sm text-amber-400">Could not load the Power BI viewer (script blocked or offline).</div>';
      }
    });
  }

  // Chart + Report Table tiles (DVI-1254 / DVI-1252 P1) — mirrors the SPA
  // dependency-free inline-SVG toolkit with kiosk (dark) styling. Static SVG,
  // no chart library, so the same output renders on a signage wall with no
  // build step and in Print-to-PDF.
  function chartColor(i){ return IDE_COLORS[i % IDE_COLORS.length]; }
  function chartFmt(v){
    if (v == null || isNaN(v)) return '';
    var a = Math.abs(v);
    if (a >= 1000000) return (v / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
    if (a >= 1000) return (v / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
    return (Math.round(v * 100) / 100).toString();
  }
  function chartNiceMax(m){
    if (!(m > 0)) return 1;
    var exp = Math.floor(Math.log(m) / Math.LN10), base = Math.pow(10, exp), f = m / base, nf;
    if (f <= 1) nf = 1; else if (f <= 2) nf = 2; else if (f <= 2.5) nf = 2.5;
    else if (f <= 5) nf = 5; else nf = 10;
    return nf * base;
  }
  function chartSegments(pts){
    var segs = [], cur = [];
    pts.forEach(function(p){ if (p) { cur.push(p); } else if (cur.length) { segs.push(cur); cur = []; } });
    if (cur.length) segs.push(cur);
    return segs;
  }
  function chartAxisSvg(model){
    var W = 320, H = 180, padL = 34, padR = 10, padT = 10, padB = 28;
    var cats = model.categories || [], series = model.series || [], type = model.chart_type;
    var stacked = (type === 'bar_stacked');
    var plotW = W - padL - padR, plotH = H - padT - padB, maxV = 0;
    if (stacked){
      cats.forEach(function(_, ci){ var s = 0; series.forEach(function(se){ var v = se.values[ci]; if (typeof v === 'number' && v > 0) s += v; }); if (s > maxV) maxV = s; });
    } else {
      series.forEach(function(se){ (se.values || []).forEach(function(v){ if (typeof v === 'number' && v > maxV) maxV = v; }); });
    }
    var niceMax = chartNiceMax(maxV), yFor = function(v){ return padT + plotH * (1 - (v / niceMax)); };
    var svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" class="w-full h-full" preserveAspectRatio="xMidYMid meet">';
    for (var t = 0; t <= 4; t++){
      var val = niceMax * t / 4, y = yFor(val);
      svg += '<line x1="' + padL + '" y1="' + y + '" x2="' + (W - padR) + '" y2="' + y + '" stroke="currentColor" stroke-opacity="0.14" stroke-width="0.5"></line>';
      svg += '<text x="' + (padL - 3) + '" y="' + (y + 2.2) + '" text-anchor="end" font-size="6" fill="currentColor" fill-opacity="0.6">' + esc(chartFmt(val)) + '</text>';
    }
    var y0 = yFor(0);
    svg += '<line x1="' + padL + '" y1="' + y0 + '" x2="' + (W - padR) + '" y2="' + y0 + '" stroke="currentColor" stroke-opacity="0.4" stroke-width="0.7"></line>';
    var band = cats.length ? plotW / cats.length : plotW;
    var every = cats.length > 12 ? Math.ceil(cats.length / 12) : 1;
    cats.forEach(function(c, ci){
      if (ci % every !== 0) return;
      var cx = padL + band * (ci + 0.5), lbl = String(c);
      if (lbl.length > 10) lbl = lbl.slice(0, 9) + '…';
      svg += '<text x="' + cx + '" y="' + (H - padB + 9) + '" text-anchor="middle" font-size="6" fill="currentColor" fill-opacity="0.65">' + esc(lbl) + '</text>';
    });
    if (type === 'bar' || type === 'bar_stacked'){
      var innerPad = band * 0.18, groupW = band - innerPad * 2;
      cats.forEach(function(c, ci){
        var gx = padL + band * ci + innerPad;
        if (stacked){
          var acc = 0;
          series.forEach(function(se, si){
            var v = se.values[ci]; if (typeof v !== 'number' || v <= 0) return;
            var h = plotH * (v / niceMax), yTop = yFor(acc + v); acc += v;
            svg += '<rect x="' + gx + '" y="' + yTop + '" width="' + groupW + '" height="' + Math.max(0.5, h) + '" fill="' + chartColor(si) + '"></rect>';
          });
        } else {
          var bw = groupW / Math.max(1, series.length);
          series.forEach(function(se, si){
            var v = se.values[ci]; if (typeof v !== 'number') return;
            var h = plotH * (Math.max(0, v) / niceMax), yTop = yFor(Math.max(0, v));
            svg += '<rect x="' + (gx + bw * si) + '" y="' + yTop + '" width="' + Math.max(0.5, bw * 0.9) + '" height="' + Math.max(0.5, h) + '" fill="' + chartColor(si) + '"></rect>';
          });
        }
      });
    } else {
      series.forEach(function(se, si){
        var pts = (se.values || []).map(function(v, ci){ return (typeof v === 'number') ? [padL + band * (ci + 0.5), yFor(v)] : null; });
        var col = chartColor(si), segs = chartSegments(pts);
        if (type === 'area'){
          segs.forEach(function(seg){
            if (seg.length < 2) return;
            var d = 'M' + seg[0][0] + ',' + y0 + ' L' + seg.map(function(p){ return p[0] + ',' + p[1]; }).join(' L') + ' L' + seg[seg.length - 1][0] + ',' + y0 + ' Z';
            svg += '<path d="' + d + '" fill="' + col + '" fill-opacity="0.2"></path>';
          });
        }
        segs.forEach(function(seg){
          if (seg.length < 2) return;
          svg += '<polyline points="' + seg.map(function(p){ return p[0] + ',' + p[1]; }).join(' ') + '" fill="none" stroke="' + col + '" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"></polyline>';
        });
        pts.forEach(function(p){ if (p) svg += '<circle cx="' + p[0] + '" cy="' + p[1] + '" r="1.6" fill="' + col + '"></circle>'; });
      });
    }
    return svg + '</svg>';
  }
  function chartToSlices(model){
    var s0 = (model.series && model.series[0]) || {values: []}, slices = [];
    (model.categories || []).forEach(function(c, ci){ var v = s0.values[ci]; if (typeof v === 'number' && v > 0) slices.push({label: String(c), value: v}); });
    return slices;
  }
  function chartPieSvg(slices, donut){
    var total = 0; slices.forEach(function(s){ total += s.value; });
    var sw = donut ? 6 : 16, r = 15.915;
    var svg = '<svg viewBox="0 0 42 42" class="h-full max-h-full" style="max-width:100%">';
    svg += '<circle cx="21" cy="21" r="' + r + '" fill="none" stroke="rgba(148,163,184,0.2)" stroke-width="' + sw + '"></circle>';
    var off = 25;
    slices.forEach(function(s, i){
      var pct = total > 0 ? (s.value / total) * 100 : 0; if (pct <= 0) return;
      svg += '<circle cx="21" cy="21" r="' + r + '" fill="none" stroke="' + chartColor(i) + '" stroke-width="' + sw + '" stroke-dasharray="' + pct + ' ' + (100 - pct) + '" stroke-dashoffset="' + off + '"></circle>';
      off -= pct;
    });
    if (donut) svg += '<text x="21" y="21.5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" font-size="7" font-weight="600">' + esc(chartFmt(total)) + '</text>';
    return svg + '</svg>';
  }
  function chartLegendItems(items){
    return '<div class="shrink-0 mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 overflow-auto max-h-24">' + items.map(function(it, i){
      return '<div class="flex items-center gap-1.5 text-sm">' +
        '<span class="inline-block w-2.5 h-2.5 rounded-sm shrink-0" style="background:' + chartColor(i) + '"></span>' +
        '<span class="truncate text-gray-200" title="' + esc(it.label) + '">' + esc(it.label) + '</span>' +
        (it.value != null ? '<span class="text-gray-400 tabular-nums">' + esc(chartFmt(it.value)) + '</span>' : '') + '</div>';
    }).join('') + '</div>';
  }
  function chartGaugeSvg(g){
    var val = g.value || 0, tgt = (g.target > 0 ? g.target : (val || 1));
    var ratio = tgt > 0 ? Math.max(0, Math.min(1, val / tgt)) : 0;
    var W = 120, H = 72, cx = 60, cy = 62, r = 48;
    var pt = function(a){ return [cx + r * Math.cos(Math.PI * (1 - a)), cy - r * Math.sin(Math.PI * (1 - a))]; };
    var st = pt(0), bg = pt(1), end = pt(ratio);
    var col = ratio >= 1 ? '#10B981' : (ratio >= 0.5 ? '#00B4D8' : '#F59E0B');
    var svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" class="w-full" style="max-height:100%">';
    svg += '<path d="M' + st[0] + ',' + st[1] + ' A' + r + ',' + r + ' 0 0 1 ' + bg[0] + ',' + bg[1] + '" fill="none" stroke="currentColor" stroke-opacity="0.18" stroke-width="9" stroke-linecap="round"></path>';
    svg += '<path d="M' + st[0] + ',' + st[1] + ' A' + r + ',' + r + ' 0 0 1 ' + end[0] + ',' + end[1] + '" fill="none" stroke="' + col + '" stroke-width="9" stroke-linecap="round"></path>';
    svg += '<text x="' + cx + '" y="' + (cy - 8) + '" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">' + esc(chartFmt(val)) + '</text>';
    if (g.has_target) svg += '<text x="' + cx + '" y="' + (cy + 3) + '" text-anchor="middle" font-size="7" fill="currentColor" fill-opacity="0.65">of ' + esc(chartFmt(tgt)) + ' (' + Math.round(ratio * 100) + '%)</text>';
    return svg + '</svg>';
  }
  function chartProgressHtml(g){
    var val = g.value || 0, tgt = (g.target > 0 ? g.target : 0);
    var pct = tgt > 0 ? Math.min(100, Math.round(val / tgt * 100)) : (val > 0 ? 100 : 0);
    var barCls = (tgt > 0 && val >= tgt) ? 'bg-green-500' : 'bg-togen-teal';
    return '<div class="flex-1 min-h-0 flex flex-col justify-center gap-2 px-1">' +
      '<div class="flex items-baseline justify-between gap-2">' +
        '<span class="text-base font-medium text-gray-200 truncate">' + esc(g.label || '') + '</span>' +
        '<span class="text-xl font-bold text-white tabular-nums">' + esc(chartFmt(val)) +
          (tgt > 0 ? '<span class="text-sm font-normal text-gray-400"> / ' + esc(chartFmt(tgt)) + '</span>' : '') + '</span></div>' +
      '<div class="h-3 rounded-full bg-gray-700 overflow-hidden"><div class="h-3 rounded-full ' + barCls + '" style="width:' + pct + '%"></div></div>' +
      (tgt > 0 ? '<div class="text-sm text-gray-400 text-right">' + pct + '%</div>' : '') + '</div>';
  }
  function chartKpiHtml(model){
    var chips = [];
    (model.series || []).forEach(function(se){
      var sum = 0, any = false;
      (se.values || []).forEach(function(v){ if (typeof v === 'number'){ sum += v; any = true; } });
      chips.push({label: se.name, value: any ? sum : null});
    });
    if (!chips.length) chips = [{label: model.x_label || 'Value', value: null}];
    var html = '<div class="flex-1 min-h-0 overflow-auto grid grid-cols-2 gap-2 content-start pt-1">';
    chips.slice(0, 8).forEach(function(c){
      var v = (c.value == null) ? '—' : chartFmt(c.value);
      html += '<div class="rounded-lg border border-gray-700 px-3 py-2 text-center">' +
        '<div class="text-3xl font-bold text-white tabular-nums leading-tight">' + esc(v) + '</div>' +
        '<div class="text-xs text-gray-400 truncate" title="' + esc(c.label) + '">' + esc(c.label) + '</div></div>';
    });
    return html + '</div>';
  }
  function renderChart(body, cfg, data){
    if (data.available === false){
      body.innerHTML = '<div class="flex items-start gap-1.5 text-sm text-amber-400 py-2"><span>&#128200;</span><span>' + esc(data.message || 'Chart data not available.') + '</span></div>';
      return;
    }
    var type = data.chart_type || cfg.chart_type || 'bar';
    var series = data.series || [], cats = data.categories || [];
    var hasData = (type === 'gauge' || type === 'progress') ? !!data.gauge : (cats.length && series.length);
    var html = '<div class="h-full flex flex-col text-gray-100">' + ideCachedBadge(data);
    if (!hasData){ body.innerHTML = html + '<p class="text-gray-500 text-sm italic text-center py-4">No data to chart.</p></div>'; return; }
    if (type === 'pie' || type === 'donut'){
      var slices = chartToSlices(data);
      if (!slices.length){ html += '<p class="text-gray-500 text-sm italic text-center py-4">No numeric values to chart.</p>'; }
      else {
        html += '<div class="flex-1 min-h-0 flex items-center justify-center" style="min-height:70px">' + chartPieSvg(slices, type === 'donut') + '</div>';
        if (cfg.show_legend !== false) html += chartLegendItems(slices);
      }
    } else if (type === 'kpi'){ html += chartKpiHtml(data); }
    else if (type === 'progress'){ html += chartProgressHtml(data.gauge || {}); }
    else if (type === 'gauge'){
      html += '<div class="flex-1 min-h-0 flex items-center justify-center">' + chartGaugeSvg(data.gauge || {}) + '</div>';
      if (data.gauge && data.gauge.label) html += '<div class="shrink-0 text-center text-sm text-gray-400 truncate">' + esc(data.gauge.label) + '</div>';
    } else {
      html += '<div class="flex-1 min-h-0 flex items-stretch" style="min-height:80px">' + chartAxisSvg(data) + '</div>';
      if (cfg.show_legend !== false && series.length > 1) html += chartLegendItems(series.map(function(s){ return {label: s.name}; }));
    }
    if (data.truncated) html += '<p class="text-sm text-gray-500 text-center shrink-0">+' + data.truncated + ' more categories</p>';
    body.innerHTML = html + '</div>';
  }
  function renderReportTable(body, cfg, data){
    if (data.available === false){
      body.innerHTML = '<div class="flex items-start gap-1.5 text-sm text-amber-400 py-2"><span>&#128203;</span><span>' + esc(data.message || 'Report not available.') + '</span></div>';
      return;
    }
    var html = '<div class="h-full flex flex-col">' + ideCachedBadge(data);
    if (!(data.rows || []).length){ body.innerHTML = html + '<p class="text-gray-500 text-sm italic text-center py-4">This report returned no rows.</p></div>'; return; }
    body.innerHTML = html + pbiTableHtml(data) + '</div>';
  }

  // Oversight tile (DVI-1213 P3) — a static, non-interactive floor-plan view
  // for the wall: the plan image auto-fits the tile and markers overlay it.
  // No pan/zoom (signage is glanceable). The server has already stripped the
  // people layers from a display render (D3), so nothing here can leak them.
  function ovkResolveStyle(data){
    var imgs = (data.plan && data.plan.images) || {};
    var keys = Object.keys(imgs); if (!keys.length) return null;
    if (data.style && imgs[data.style]) return data.style;
    var want = data.style_default_dark || data.style_default_light;   // kiosk is dark
    if (want && imgs[want]) return want;
    var ordered = (data.styles || []).filter(function(sid){ return imgs[sid]; });
    return ordered.length ? ordered[0] : keys[0];
  }
  function ovkSortLocFirst(pls){
    return (pls || []).slice().sort(function(a,b){
      return (a.layer === 'location' ? 0 : 1) - (b.layer === 'location' ? 0 : 1); });
  }
  function ovkColor(pl){
    var d = pl.data;
    if (!pl.known || !d) return 'bg-gray-500 text-white';
    if (pl.layer === 'equipment'){
      if (d.state === 'red') return 'bg-red-600 text-white';
      if (d.state === 'yellow') return 'bg-amber-500 text-white';
      return 'bg-emerald-600 text-white';
    }
    if (d.stale) return 'bg-amber-500 text-white';
    var v = parseFloat(d.value);
    if (!isNaN(v)){
      if (d.low != null && v < d.low) return 'bg-blue-600 text-white';
      if (d.high != null && v > d.high) return 'bg-red-600 text-white';
      return 'bg-emerald-600 text-white';
    }
    return 'bg-slate-600 text-white';
  }
  function ovkChip(pl){
    var d = pl.data, chip = document.createElement('div');
    if (pl.layer === 'location'){
      chip.className = 'inline-block px-1.5 py-0.5 text-sm font-medium text-gray-300 whitespace-nowrap';
      chip.textContent = pl.label || pl.ref || '';
      return chip;
    }
    chip.className = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-sm font-semibold shadow-md border border-white/40 whitespace-nowrap ' + ovkColor(pl);
    chip.textContent = pl.label || (d && d.label) || pl.ref || '';
    return chip;
  }
  function renderOversight(body, cfg, data){
    if (data.available === false){
      body.innerHTML = '<div class="text-amber-400 text-sm p-2">' + esc(data.message || 'Oversight view not available.') + '</div>';
      return;
    }
    var plan = data.plan || {}, imgs = plan.images || {};
    var sid = ovkResolveStyle(data);
    if (!sid){ body.innerHTML = '<div class="text-gray-500 text-sm p-2">This plan has no image.</div>'; return; }
    var dims = imgs[sid] || {}, iw = dims.w || 0, ih = dims.h || 0;
    var sig = plan.id + '|' + sid;
    body.style.padding = '0'; body.style.position = 'relative';
    var wrap = body.querySelector('.ovk-wrap');
    if (!wrap || wrap.getAttribute('data-ovsig') !== sig){
      body.innerHTML = '<div class="ovk-wrap" data-ovsig="' + esc(sig) + '" style="position:absolute;inset:0;overflow:hidden;">' +
        '<div class="ovk-plane" style="position:absolute;left:0;top:0;transform-origin:0 0;">' +
          '<img class="ovk-img" alt="" style="display:block;max-width:none;" src="/display/plan-image/' + encodeURIComponent(plan.id) + '?' + AUTHQ + '&style=' + encodeURIComponent(sid) + '&v=' + iw + 'x' + ih + '" />' +
          '<div class="ovk-mk" style="position:absolute;left:0;top:0;right:0;bottom:0;"></div>' +
        '</div></div>';
      wrap = body.querySelector('.ovk-wrap');
    }
    var plane = wrap.querySelector('.ovk-plane');
    var img = wrap.querySelector('.ovk-img');
    var mk = wrap.querySelector('.ovk-mk');
    function layout(){
      var niw = img.naturalWidth || iw, nih = img.naturalHeight || ih;
      var ww = wrap.clientWidth, wh = wrap.clientHeight;
      if (!niw || !nih || !ww || !wh) return;
      // P2 (B3): cover fills the tile (crops plan edges); contain letterboxes.
      var s = (cfg.fit === 'cover') ? Math.max(ww / niw, wh / nih)
                                    : Math.min(ww / niw, wh / nih);
      plane.style.transform = 'translate(' + ((ww - niw * s) / 2) + 'px,' + ((wh - nih * s) / 2) + 'px) scale(' + s + ')';
      var inv = 1 / s, ms = mk.querySelectorAll('.ovk-marker');
      for (var i = 0; i < ms.length; i++) ms[i].style.transform = 'translate(-50%,-50%) scale(' + inv + ')';
    }
    mk.innerHTML = '';
    ovkSortLocFirst(data.placements).forEach(function(pl){
      var el = document.createElement('div');
      el.className = 'ovk-marker';
      el.style.position = 'absolute';
      el.style.left = (pl.x * 100) + '%'; el.style.top = (pl.y * 100) + '%';
      el.style.transformOrigin = 'center center';
      el.appendChild(ovkChip(pl));
      mk.appendChild(el);
    });
    img.onload = layout;
    if (img.complete) layout();
    setTimeout(layout, 60);
  }

  function buildTile(tile){
    var size = tileSpan(tile);   // DVI-1220: orientation-profile span remap
    var el = document.createElement('div');
    el.className = 'ksk-pane relative rounded-2xl shadow border border-gray-200 dark:border-gray-800 flex flex-col overflow-hidden';
    el.style.gridColumn = 'span ' + size.cols;
    el.style.gridRow = 'span ' + size.rows;
    el.setAttribute('data-tile-id', tile.id);
    var cfg = tile.config || {};
    var titleText = cfg.title || '';
    if (titleText){
      // DVI-1286: show a header only when the optional title is set.
      var vh = document.createElement('div');
      vh.className = 'px-4 pt-3 pb-1 text-lg font-semibold text-gray-900 dark:text-white truncate';
      vh.textContent = titleText;
      el.appendChild(vh);
    }
    var body = document.createElement('div');
    // P2 (B1): a clip tile hides its overflow outright (and cycleTick skips it).
    var ovfCls = (tile.overflow === 'clip') ? 'overflow-hidden' : 'overflow-auto';
    body.className = 'dash-tile-body flex-1 min-h-0 ' + ovfCls + ' p-4 text-gray-700 dark:text-gray-200';
    // DVI-1275 P2: per-tile zoom composes with the display zoom (which is applied
    // on #dash-root); this CSS `zoom` scales just this tile's body.
    var tzoom = Math.max(50, Math.min(200, (tile.zoom | 0) || 100));
    if (tzoom !== 100) body.style.zoom = tzoom / 100;
    renderTileBody(body, tile, _tileData[tile.id]);
    el.appendChild(body);
    return el;
  }

  function renderGrid(){
    var grid = document.getElementById('dash-grid');
    kskStopImgTimers();   // clear slideshow timers before the grid rebuilds
    grid.innerHTML = '';
    var tiles = (_dash.tiles || []).slice().sort(function(a,b){ return (a.order||0)-(b.order||0); });
    tiles.forEach(function(t){ grid.appendChild(buildTile(t)); });
  }

  function fetchTile(tile){
    fetch('/display/tile-data?' + AUTHQ + '&tile_id=' + encodeURIComponent(tile.id))
      .then(function(r){ return r.json(); })
      .then(function(d){
        if (d.error) return;
        _tileData[tile.id] = d.data || {};
        var el = document.querySelector('[data-tile-id="' + tile.id + '"]');
        if (!el) return;
        var body = el.querySelector('.dash-tile-body');
        if (body) renderTileBody(body, tile, _tileData[tile.id]);
        // An aspect-locked Oversight tile only knows its plan dims once data
        // arrives — refit so its row span (and fit=scale row height) settle.
        if (tile.type === 'oversight') applyFit();
      }).catch(function(){});
  }

  function fetchAllTiles(){
    if (!_dash) return;
    (_dash.tiles || []).forEach(function(tile){
      var desc = _tileTypes[tile.type];
      if (!desc || !desc.renderer) return;
      // DVI-1312 P1: a Stat tile is polled only when a row binds a live metric;
      // a fully-manual Stat tile (like Text/Time) is client-rendered.
      if (tile.type === 'stat'){ if (statHasBinding(tile.config || {})) fetchTile(tile); return; }
      if (desc.client_only) return;   // Text/Time are client-rendered
      fetchTile(tile);
    });
  }

  // DVI-1217 per-display interface options: theme class, background mode,
  // pane opacity and side padding, resolved server-side by /display/config.
  // Signature-gated so the 15 s poll only touches the DOM on a real change.
  var _ifaceSig = '';
  function applyInterface(iface){
    iface = iface || {};
    var sig = JSON.stringify(iface);
    if (sig === _ifaceSig) return;
    _ifaceSig = sig;
    var dark = (iface.theme || 'dark') !== 'light';
    document.documentElement.classList.toggle('dark', dark);
    var mode = iface.bg_mode || 'togen_default';
    var body = document.body;
    if (mode === 'corporate'){
      body.style.backgroundImage = "url('/togen-admin/branding/bg-" + (dark ? 'dark' : 'light') + ".svg')";
      body.style.backgroundSize = 'contain';
    } else if (mode === 'custom' && iface.custom_bg){
      // DVI-1321 P1: a fullscreen preview supplies the user's own custom-bg URL
      // directly (session-authed, same browser); a device/virtual display uses
      // the per-display background route keyed on its auth token.
      var curl = iface.custom_bg_url || ("/display/background?which=" +
        encodeURIComponent(iface.custom_bg_target || 'interface') + "&" + AUTHQ +
        "&v=" + encodeURIComponent(iface.custom_bg_v || 0));
      body.style.backgroundImage = "url('" + curl + "')";
      body.style.backgroundSize = 'cover';
    } else {
      body.style.backgroundImage = '';
    }
    var alpha = 1;
    if ((mode === 'corporate' || mode === 'custom') &&
        iface.bg_opacity != null && iface.bg_opacity < 100){
      alpha = Math.min(100, Math.max(40, iface.bg_opacity)) / 100;
    }
    document.documentElement.style.setProperty('--ksk-alpha', String(alpha));
    var wrap = document.getElementById('dash-grid-wrap');
    var pad = Math.max(0, Math.min(40, parseInt(iface.side_padding, 10) || 0));
    wrap.style.paddingLeft = pad ? pad + '%' : '';
    wrap.style.paddingRight = pad ? pad + '%' : '';
  }

  // DVI-1321 P1: resolve the interface the kiosk should apply. In a ?dash=
  // fullscreen preview with "use my interface" on, the SPA-provided resolved
  // interface (user's theme/bg/opacity/padding) overrides the kiosk default.
  function _fsInterface(base){
    if (_fsOverride && _fsOverride.use_interface && _fsOverride.iface) return _fsOverride.iface;
    return base;
  }
  // Merge the fullscreen layout override (if any) over the config layout.
  function _fsLayout(base){
    base = base || { fit:'scale', orientation:'auto', zoom:100, columns:'auto', flow:'row', rows:'auto' };
    if (_fsOverride && _fsOverride.layout){
      var o = _fsOverride.layout;
      return { fit: o.fit || base.fit, orientation: o.orientation || base.orientation,
               zoom: o.zoom || base.zoom, columns: (o.columns != null ? o.columns : base.columns),
               flow: o.flow || base.flow, rows: (o.rows != null ? o.rows : base.rows) };
    }
    return base;
  }

  function applyConfig(cfg){
    _lastCfg = cfg;
    _configMs = (cfg.poll_seconds || 15) * 1000;
    _tileTypes = cfg.tile_types || {};
    if (!cfg.assigned || !cfg.dashboard){
      _dash = null; _dashSig = '';
      // Splash state: apply the splash's own interface (falls back server-side
      // to the display interface) + the configured headline text. A fullscreen
      // preview's "use my interface" override wins here too.
      applyInterface(_fsInterface((cfg.splash && cfg.splash.interface) || cfg.interface));
      var splashText = (cfg.splash && cfg.splash.text) || '';
      document.getElementById('dash-splash-title').textContent = splashText || 'Togen Signage';
      if (splashText && !cfg.reason){
        showSplash('', '');   // configured splash, benign state — no admin hint on the wall
      } else {
        showSplash(cfg.reason || 'No dashboard assigned yet.',
                   'Display: ' + (cfg.agent_name || cfg.agent_id || '') +
                   '. Assign one in Togen Admin → Agents → Display.');
      }
      return;
    }
    applyInterface(_fsInterface(cfg.interface));
    // DVI-1220 P1: adopt the per-display layout, resolve orientation BEFORE
    // any grid build so spans use the right profile. An orientation flip
    // forces a rebuild (spans changed) by invalidating the dashboard sig.
    // DVI-1321 P1: a fullscreen preview's settings merge over the config layout.
    _layout = _fsLayout(cfg.layout);
    var orient = resolveOrient();
    if (orient !== _orient){
      _orient = orient;
      document.getElementById('dash-grid').classList
        .toggle('ksk-portrait', orient === 'portrait');
      _dashSig = '';
    }
    var d = cfg.dashboard;
    var sig = JSON.stringify({ id: d.id, updated_at: d.updated_at,
      tiles: (d.tiles || []).map(function(t){
        return [t.id, t.type, t.size, t.order,
                t.span ? (t.span.cols + 'x' + t.span.rows) : '', t.overflow || ''];
      }) });
    _dash = d;
    document.getElementById('dash-title').textContent = d.name || 'Dashboard';
    // A live Present override shows a pill so the room knows this is a temporary
    // push (and, when timed, roughly when it reverts).
    var pill = document.getElementById('dash-present');
    if (cfg.present){
      var who = cfg.presenter_name ? 'Presented by ' + cfg.presenter_name : 'Presenting';
      var until = '';
      if (cfg.present_expires_at){
        var mins = Math.max(0, Math.round((cfg.present_expires_at * 1000 - Date.now()) / 60000));
        until = ' · reverts in ' + mins + ' min';
      }
      pill.textContent = who + until;
      pill.classList.remove('hidden');
    } else {
      pill.classList.add('hidden');
    }
    showDash();
    if (sig !== _dashSig){   // layout changed → rebuild grid + refetch data now.
      _dashSig = sig;
      _tileData = {};
      renderGrid();
      fetchAllTiles();
    }
    applyFit();   // DVI-1220: fit mode / viewport / row count may have changed
  }

  // DVI-1220 P1 auto-cycle: on an input-less display a tile whose content
  // overflows its body pages through it instead of hiding it below the fold,
  // with progress dots. Skipped in fit=scroll (that mode means the display HAS
  // user input). P2 adds the per-tile scroll/clip/auto-cycle override.
  var _CYCLE_MS = 8000;
  function cycleTick(){
    if (!_dash) return;
    var byId = {};
    (_dash.tiles || []).forEach(function(t){ byId[t.id] = t; });
    var bodies = document.querySelectorAll('.dash-tile-body');
    for (var i = 0; i < bodies.length; i++){
      var b = bodies[i], tile = b.parentNode;
      var dots = tile.querySelector('.ksk-dots');
      // P2 (B1) per-tile override: '' = surface default (cycle unless the
      // display's fit is scroll), 'cycle' forces paging even on a scrollable
      // display, 'scroll'/'clip' opt the tile out of paging.
      var rec = byId[tile.getAttribute('data-tile-id')] || {};
      var mode = rec.overflow || (_layout.fit === 'scroll' ? 'scroll' : 'cycle');
      if (mode !== 'cycle'){
        if (dots) dots.remove();
        continue;
      }
      var pageH = Math.max(1, b.clientHeight);
      var pages = Math.ceil(b.scrollHeight / pageH);
      if (pages < 2 || b.scrollHeight - b.clientHeight < 8){
        if (dots) dots.remove();
        continue;
      }
      var cur = Math.round(b.scrollTop / pageH);
      var next = cur + 1 >= pages ? 0 : cur + 1;
      b.scrollTo({ top: next * pageH, behavior: next ? 'smooth' : 'auto' });
      if (!dots){
        dots = document.createElement('div');
        dots.className = 'ksk-dots';
        tile.appendChild(dots);
      }
      if (dots.children.length !== pages){
        dots.innerHTML = '';
        for (var p = 0; p < pages; p++) dots.appendChild(document.createElement('span'));
      }
      for (var q = 0; q < pages; q++)
        dots.children[q].classList.toggle('on', q === next);
    }
  }

  function poll(){
    if (!TOKEN && !PREVIEW && !VAGENT){ showSplash('No display token in the URL.',
      'Open this page as /display?token=YOUR_DEVICE_TOKEN (the enrollment step provides it).'); return; }
    fetch(CONFIG_URL)
      .then(function(r){
        if (r.status === 401){
          if (PREVIEW || VAGENT){ showSplash('This display is not available.',
            'You are not signed in, or this virtual display was removed. Sign in to Togen in this browser tab, then reopen it from User Settings → Agents.'); return null; }
          showSplash('This display is not authorized.',
          'The device token is invalid or has been revoked. Re-enroll from Togen Admin → Agents → Display.'); return null; }
        return r.json();
      })
      .then(function(cfg){ if (cfg) applyConfig(cfg); })
      .catch(function(){ /* transient network error — keep last render, retry next tick */ });
  }

  // DVI-1217 fullscreen-preview chrome: keep the cursor, offer browser
  // fullscreen + a way back (window.close only works for script-opened tabs,
  // so fall back to the SPA).
  // A virtual display (?agent=) is the owner's own browser window, so it gets
  // the same session-window chrome as the fullscreen preview (cursor + browser
  // fullscreen + a way back).
  if (PREVIEW || VAGENT){
    document.body.classList.add('preview');
    document.getElementById('dash-preview-ctrls').classList.remove('hidden');
    document.getElementById('dash-preview-fs').onclick = function(){
      if (document.fullscreenElement){ document.exitFullscreen(); }
      else if (document.documentElement.requestFullscreen){ document.documentElement.requestFullscreen(); }
    };
    document.getElementById('dash-preview-close').onclick = function(){
      window.close();
      setTimeout(function(){ location.href = '/'; }, 150);
    };
  }

  // DVI-1245 virtual-display heartbeat: the open window is the liveness signal.
  // Beat on load + every 30 s (staleness), and sendBeacon an offline mark on
  // unload so the display flips to offline the moment the tab closes.
  function vHeartbeat(offline){
    if (!VAGENT) return;
    var body = JSON.stringify({agent_id: VAGENT, offline: !!offline});
    if (offline && navigator.sendBeacon){
      try { navigator.sendBeacon('/signage/agents/virtual-checkin',
              new Blob([body], {type:'application/json'})); return; } catch(e){}
    }
    fetch('/signage/agents/virtual-checkin', {method:'POST',
      headers:{'Content-Type':'application/json'}, body: body,
      credentials:'same-origin', keepalive: !!offline}).catch(function(){});
  }
  if (VAGENT){
    vHeartbeat(false);
    setInterval(function(){ vHeartbeat(false); }, 30000);
    window.addEventListener('pagehide', function(){ vHeartbeat(true); });
  }

  // Boot: clock every second, config poll every ~15 s, tile-data refresh every
  // 60 s, overflow auto-cycle every 8 s (DVI-1220), refit on rotate/resize.
  tick(); setInterval(tick, 1000);
  poll();
  _configTimer = setInterval(poll, 15000);
  _dataTimer = setInterval(fetchAllTiles, 60000);
  setInterval(cycleTick, _CYCLE_MS);
  window.addEventListener('resize', refreshLayout);
})();
</script>
</body>
</html>r  c                  B    t        t              } d| j                  d<   | S )uv  Token-authed full-screen kiosk page (plan D4). The device token is passed
    as ?token=…; all rendering + polling is client-side (plan D3 — the agent is
    thin, all display logic lives here) so reassignment/edits take effect live
    with zero agent-side logic. Served as a self-contained page (no SPA boot, no
    MSAL) so an unattended Chromium kiosk can render it.ztext/html; charset=utf-8r  )r   _DISPLAY_KIOSK_HTMLrx   r  s    r1   display_kioskr    s"     ,-D#=DLL Kr3   z/oversight/datac                     t         d   } t        j                  j                  d      xs dj	                         t        j                  j                  d      xs dj                  d      D cg c]  }|s|	 }}t               }t        fd|d   D        d      }|t        d	
      dfS t               }t               i }}|D ]?  }|j                  |      }	|	r |	d   |       s"|j                  |        |	d          ||<   A g }
|j                  dg       D ]  }|j                  d      }|t        k(  rg|
j                  |j                  d      ||j                  d      |j                  d      |j                  d      |j                  dd      ddd       ||vr|j                  |i       }|j                  d      }|
j                  |j                  d      |||j                  d      |j                  d      |j                  dd      ||v |j                  |      d        |j                  d      xs i j                         D ci c])  \  }}||j                  d      |j                  d      d+ }}}t        |d   |j                  dd      |j                  d      t        |      |d|
t!        |      D cg c]  }|||   d   d c}      S c c}w c c}}w c c}w ) u   Live marker data for one plan, filtered server-side by layer access.
    Placements on layers the user can't access are omitted entirely — the
    client layer checkboxes are a display convenience, never the gate.r'  rO  r7   r[  r  c              3   4   K   | ]  }|d    k(  s|  ywr	  rX   r  s     r1   r/  z!oversight_data.<locals>.<genexpr>      ?qAdGw,>?r  r_  Nr  r  r  r  rd   r  r  r  r  r  r|
  r   Tr  rm  r  r}
  r  r9   r  )r  r9   r  r  rm  r  )rO  r  r[  )r   r   rh  r   r   r  rx  r  r   r  r  r  r  ri   r   r   r  )r'  rw  r  rJ  rO  r  r  r  r  r  r  r  r{	  r  r   r  rm  r  s                    @r1   oversight_datar    s    6?D||'-2446G%ll..x8>BEEcJPa!PJP
 
"C?CL?FD|-.33 "H"ubJ -||C >4>$/s*DN,S- Jhh|R( ffWo** ffTlvve}VVC[VVC[,	 	 j c2&ffUm&&,VVGR(BYFF3K	
 		+@ #hhx06B==?A#r 266#;77 AF AJ(<((:.<6; !*-/ Xc]7%;< /0 0_ QZA/s   0K,8K,.K1K7c                 `    t               }t         fd|d   D        d      }|sy|j                  d      xs i }|sy|xs dj                         }|j                  |      xs |j                  |d         }|s;t	        |d         D ci c]  \  }}|d	   | c}}|t        |fd
      d      }t        |d   z  }	|	j                         sy|	j                  j                  d      j                         }
t        j                  |
d      }t        t        |	      |      S c c}}w )a  Resolve and serve a plan's image for one style. Falls back to the light
    default, then any imaged style, so a stale/unknown style id still renders
    something rather than a broken viewer. Shared by the login-gated tool route
    and the device-token display route (DVI-1213 P3).c              3   4   K   | ]  }|d    k(  s|  ywr	  rX   r  s     r1   r/  z1_oversight_plan_image_response.<locals>.<genexpr>T r   r  r_  NrJ  rm  r7   ra  r`  r  c                 (    j                  | d      S )Ni  r  )r  r  s    r1   r	  z0_oversight_plan_image_response.<locals>.<lambda>^ s    1c1B r3   r  r   r  r  r  r   )rx  r  r   r   r  r  r  r   r!  rV  rE  OVERSIGHT_IMG_EXT_MIMESr   r*   )r  r\  rJ  rO  rm  r   r  r  r  r  r  r:  r  s   `           @r1   _oversight_plan_image_responser   N s   
 !
"C?CL?FDXXh%2F;B


C	C	BFJJs+@'ABB(1#h-(@A14!AF6'BCAFG	r&z	)B::<
))

3

%
%
'C"&&s,FGDSWt,, Bs   D*z/oversight/plan-image/<plan_id>c                 \    t        | t        j                  j                  d      xs d      S )zEServe a plan's image for one style (?style=), login-gated (the tool).r\  r7   )r   r   rh  r   )r  s    r1   oversight_plan_imager   g s&     *'7<<3C3CG3L3RPRSSr3   z/display/plan-image/<plan_id>c                     t               }t        |t                     )t        j                  d      xs i j	                  d      syt        | t        j                  j	                  d      xs d      S )u  Device-token-authed plan image for the kiosk Oversight tile (P3). The
    kiosk has no user session, so it can't use the login-gated route above; a
    valid (non-revoked) display token is required. A logged-in session also
    passes (DVI-1217 fullscreen preview) — the same exposure as the
    login-gated tool route above. Plan images are not sensitive (people layers
    are stripped from the display data path — D3).r'  r  r  r\  r7   )r  r  r  r   r   r   r   rh  )r  r	  s     r1   display_plan_imager   n s^     !"Eu&;&=>FF#)r..w7()'7<<3C3CG3L3RPRSSr3   z/oversight/style-savec                     t        j                  d      xs t         j                  } | j                  d      xs dj	                         d d }| j                  d      xs dj	                         |st        d      d	fS t               }r.t        fd
|d   D        d       }|st        d      dfS ||d<   n/t        d      |d   j                  |t        |d         d       t        |       t        d      S )NTr  r9   r7   r   r  zStyle name is requiredr  r`  c              3   4   K   | ]  }|d    k(  s|  ywr	  rX   r'  s     r1   r/  z'oversight_style_save.<locals>.<genexpr> s     AA!D'S.aAr  r`  Style not foundr  styrl  rU  r  r   r  rg  r   r   r   rx  r  r  ri   rC  rz  )r   r9   rJ  r\  r   s       @r1   oversight_style_saver   } s     4(8GLLDHHV"))+CR0D88D>R
&
&
(C56;;
 
"C
AXA4H!23S88f&Hs3x=/AB	D3ds##r3   z/oversight/style-deletec                  ~   t        j                  d      xs t         j                  } | j                  d      xs dj	                         t               }t        fd|d   D              st        d      d	fS t        |d         d
k  rt        d      dfS |d   D cg c]  }|d   k7  s| c}|d<   |d   D ]<  }|j                  d      xs i v st        |       |d   j                  d        > |d   d   d   }|d   k(  r||d<   |d   k(  r||d<   t        |       t        d      S c c}w )NTr  r  r7   c              3   .   K   | ]  }|d    k(    ywr	  rX   r'  s     r1   r/  z)oversight_style_delete.<locals>.<genexpr>      5!qw#~5r  r`  r   r  r  r^   zAt least one style must remainr`  r_  rm  )r  r   ra  rb  rY  )r   r  rg  r   r   rx  r2  r   rC  r  r  rz  )r   rJ  r  r  r  r   s        @r1   oversight_style_deleter    sL    4(8GLLD88D>R
&
&
(C
 
"C5s8}55./44
3x=Q=>CC #H@14CQ@CM\ '155?(b)(S9hKOOC&' 8}Q%H
 !S(%-!"
 C'$, !3d As   "D:0D:z/oversight/style-defaultsc                     t        j                  d      xs t         j                  } t               }|d   D ch c]  }|d   	 }}| j	                  d      xs dj                         }| j	                  d      xs dj                         }||vs||vrt        d	      d
fS ||d<   ||d<   t        |       t        d      S c c}w )NTr  r`  r  r{  r7   rz  zUnknown style for defaultr  r`  ra  rb  rY  )r   r  rg  rx  r   r   r   rz  )r   rJ  r  r|  r{  rz  s         r1   oversight_style_defaultsr    s     4(8GLLD
 
"CM
*q1T7
*C
*XXg$"++-EHHV"))+DC4s?893>>!&C $C3d +s   Cz/oversight/facility-savec                     t        j                  d      xs t         j                  } | j                  d      xs dj	                         }| j                  d      xs dj	                         |st        d      dfS t               }r.t        fd	|d
   D        d       }|st        d      dfS ||d<   n/t        d      |d
   j                  |t        |d
         d       t        |       t        d      S )NTr  r9   r7   r  zFacility name is requiredr  r`  c              3   4   K   | ]  }|d    k(  s|  ywr	  rX   )r,  r  fids     r1   r/  z*oversight_facility_save.<locals>.<genexpr>Ů s     C!AdGsNACr  r^  Facility not foundr  facrl  r   r   )r   r9   rJ  r   r   s       @r1   oversight_facility_saver    s     4(8GLLDHHV"))+D88D>R
&
&
(C893>>
 
"C
Cs<0CTJ!56;;F&L  s3|3D/EF	H3ds##r3   z/oversight/facility-deletec                     t        j                  d      xs t         j                  } | j                  d      xs dj	                         }t               }|d   D cg c]  }|d   |k7  s| c}|d<   g }|d   D ]3  }|j                  d      |k(  rt        |       #|j                  |       5 ||d<   t        |       t        d      S c c}w )	NTr  r  r7   r^  r_  r  rY  )
r   r  rg  r   r   rx  r  ri   rz  r   )r   r   rJ  r  rI   r  s         r1   oversight_facility_deleter   Ѯ s     4(8GLLD88D>R
&
&
(C
 
"C$'$5Hq4CHCD\ 55#(+KKN	
 CL3d Is   C+Cz/oversight/plan-savec            
      ,   t        j                  d      xs t         j                  } | j                  d      xs dj	                         }| j                  d      xs dj	                         | j                  d      xs dj	                         xs d }| j                  d      xs dj	                         |st        d	      d
fS t               }r%t        fd|d   D              st        d	      d
fS |r|k(  rt        d	      d
fS r8t        fd|d   D        d       }|st        d	      dfS ||d<   |d<   ||d<   n3t        d      |d   j                  ||t        |d         i g d       t        |       t        d      S )NTr  r9   r7   r  r  r  zPlan name is requiredr  r`  c              3   .   K   | ]  }|d    k(    ywr	  rX   )r,  r  r  s     r1   r/  z&oversight_plan_save.<locals>.<genexpr> s     KAAdGx/Kr  r^  r   zA plan cannot be its own parentc              3   4   K   | ]  }|d    k(  s|  ywr	  rX   r  s     r1   r/  z&oversight_plan_save.<locals>.<genexpr> s     ?1$3Q?r  r_  r  r  rO  )r  r  r9   r  r  rm  r  r   )r   r  rg  r   r   r   rx  r2  r  r  ri   rC  rz  )r   r9   r  rJ  rO  r  r  s        @@r1   oversight_plan_saver    s    4(8GLLDHHV"))+D$*113Hhhx &B--/74F88D>R
&
&
(C45s::
 
"CK\9JKK12C77&C->?DD
?G?F!12C77V#ZX'G8TVW&"BH 	I 3ds##r3   z/oversight/plan-deletec                     t        j                  d      xs t         j                  } | j                  d      xs dj	                         t               }t        fd|d   D        d       }|rt        |       |d   D cg c]  }|d   k7  s| c}|d<   |d   D ]  }|j                  d      k(  sd |d<    t        |       t        d      S c c}w )	NTr  r  r7   c              3   4   K   | ]  }|d    k(  s|  ywr	  rX   r  s     r1   r/  z(oversight_plan_delete.<locals>.<genexpr>      ;qAdGsN;r  r_  r  rY  )
r   r  rg  r   r   rx  r  r  rz  r   )r   rJ  rO  r  r  s       @r1   oversight_plan_deleter    s     4(8GLLD88D>R
&
&
(C
 
"C;CL;TBD$T*"7|>!qw#~A>CL\ 55?c!AhK 3d ?s   CCz/oversight/plan-imagec                     t         j                  j                  d      xs dj                         t	               } t        fd| d   D        d       }|st        d      dfS t         j                  j                  d      xs dj                         xs | d	   t        fd
| d   D              st        d      dfS t         j                  j                  d      }||j                  st        d      dfS d|j                  v r-|j                  j                  dd      d   j                         nd}|t        vrt        d      dfS |j                         }t        |      t        kD  rt        d      dfS |dk(  rt!        |      }t#        ||      \  }}t$        j'                  dd        d d| }|j)                  di       }|j                        xs i j                  d      }	|	r|	|k7  r	 t$        |	z  j+                          t$        |z  j/                  |       d }
|rc|ra|j1                         D ]N  }|j                  d      r|j                  d      s&	 |d   |d   z  }|rt3        |||z  z
        d|z  kD  rd}
 nP |||d|<   t7        |        t        d|||
       S # t,        $ r Y w xY w# t4        $ r Y w xY w)!NrO  r7   c              3   4   K   | ]  }|d    k(  s|  ywr	  rX   r  s     r1   r/  z.oversight_plan_image_upload.<locals>.<genexpr> r   r  r_  r  r  r  r\  ra  c              3   .   K   | ]  }|d    k(    ywr	  rX   r'  s     r1   r/  z.oversight_plan_image_upload.<locals>.<genexpr> r   r  r`  r   r~
  zNo image providedr`  r  r^   r  z'Unsupported type. Use PNG, JPG, or SVG.zImage too large (max 10 MB).rv  TrM   __rm  r  r  r}
  g{Gz?znAspect ratio differs from this plan's other images; shared marker positions were kept but may need adjustment.rp  )rU  rn  ro  r  r\  )r   rg  r   r   rx  r  r   r2  r  r  rj  rE  r   r  rC  OVERSIGHT_IMG_MAX_BYTESr  r  r  r  r}  r/   r   r-   r  r+  ZeroDivisionErrorrz  )rJ  rO  r  r  r   r  r}
  r  rm  r  r  r  other_arr  r   s                @@r1   oversight_plan_image_uploadr    s    <<F#)r
0
0
2C
 
"C;CL;TBD-.33<<G$*
1
1
3
Qs;P7QC5s8}55./44'"Ay

0136636!**3D!**

C
#B
'
-
-
/"C
))FGLL668D
4y**;<cAA
e|&t, d+DAqdT:e2cU!C5!E__Xr*F::c? b
%
%f
-C
se|	 3&..0 5 --d3 DQ --/ 	BFF3KBFF3Kc7RW,Hq1u$4 5x G3D 	 !qq1F3K3d!14sCC1  		( % s$   ?J9 +&K9	KK	KKz/oversight/placementsc                     t        j                  d      xs i } | j                  d      xs dj                         | j                  d      }t	        |t
              st        d      dfS t               }t        fd	|d
   D        d      }|st        d      dfS t        t               j                               t        hz  }g }|D ]D  }t	        |t              s	 t        dt        dt!        |j                  d                        }t        dt        dt!        |j                  d                        }t'        |j                  d      xs d      j                         }	t'        |j                  d      xs d      j                         }
|	|vr|
s|	t        k(  rt)        d      }
n|j+                  |j                  d      xs t)        d      |	|
||t'        |j                  d      xs d      j                         d       G ||d<   t-        |       t        dt/        |            S # t"        t$        f$ r Y w xY w)zLocations-editor save. Editor-gated (oversight_editor_required re-checks
    the grant on every POST, so a client that flips into edit mode without
    authority is rejected here). Editors = Togen Admins + granted users
    (DVI-1103).Tr  rO  r7   r  zplacements must be a listr  r`  c              3   4   K   | ]  }|d    k(  s|  ywr	  rX   r  s     r1   r/  z,oversight_placements_save.<locals>.<genexpr>Z r   r  r_  Nr  r  r  r  r  r|
  r  r  r  r  r  r   )r  r  r  r  r|
  r   )rU  r  )r   r  r   r   r   r  r   rx  r  r  r  r,  r  r   rL  rC  r  r   r   r*   r  ri   rz  rC  )r   r  rJ  rO  known_layersrF  r  r  r|
  r  r  r  s              @r1   oversight_placements_saver   M s    4(.BD88F!r
(
(
*C,'Jj$'893>>
 
"C;CL;TBD-.33(*//126N5OOLE "d#	CS%s"456ACS%s"456A BFF7O)r*002"&&-%2&,,.$ 00'.&&,9"3D"93QQ.B/557
 	)2 D3d#e*--+ :& 		s   #AH;;IIz/oversight/editorsc                  *    t        t                     S )zReturn the granted Oversight-editor email list (DVI-1103). Managing this
    list is Togen-admin-only; the grant itself lets a non-admin use the
    Locations/plan editor.)r  )r   r}  rX   r3   r1   oversight_editors_getr   } s     2455r3   c            
         t        j                  d      xs i } | j                  dd      }| j                  d      xs dj                         }|dvrt	        d      d	fS |j                         }|d
k(  r(d|vsd|j                  d      d   vrt	        d      d	fS t               }|d
k(  r||vr'|j                  |       n|D cg c]
  }||k7  s	| }}	 t        |      }t        dd| t        j                  d      xs i j                  d      d| d| d|||d       t	        d|      S c c}w # t        $ r}t	        d|       dfcY d}~S d}~ww xY w)zFAdd or remove a granted Oversight editor (DVI-1103, Togen-admin-only).Tr  r  r7   r  r  r  r  r`  r  r  r  r  r  r;  r  Nr  oversight_editor_r'  r9   zOversight editor r\   r  r  r	  )rU  r  )r   r  r   r   r   rE  r  r}  ri   r  r   r	  r   )r  r  r  rH  r  rB  rl  s          r1   oversight_editors_postr    su    4(.BDXXh#FXXg$"++-E&&./44;;=DCtOs$**S/":M/MABCGG%'GwNN4 %3d133<)'2 *6(3{{6"(b--f5#D66("50 dG,, 4  </u56;;<r  z/scada/configc                  *    t        t                     S )z8Return the shared SCADA config (ranges + enabled flags).r  )r   r4  rX   r3   r1   scada_config_getr    r9  r3   c                     t        j                  d      xs i } t               }| j                  d      rD| j                  d      }t	        |t
              sg }t	        | j                  d      t              r| j                  d      ni }t	        | j                  d      t              r| j                  d      ni }t        | j                  d            }|j                  d      xs i }|j                  d	      xs i }|D ]S  }t	        |t              rt        |      sd
|v r|n|}	|	j                  dd      |	j                  dd      d||<   |||<   U ||d<   ||d	<   t        |       t        d      S d| v rt	        | d   t              r| d   |d<   d| v rt	        | d   t              r| d   |d<   d| v rt	        | d   t              r| d   |d<   d| v rt	        | d   t              r| d   |d<   d	| v rt	        | d	   t              r| d	   |d	<   d| v rt	        | d   t              rh d}
i }| d   j                         D ]  \  }}t	        |t              rt	        |t              s'i }t        |j                  d      xs d      j                         j                         }||
v r||d<   dD ]9  }t        |j                  |      xs d      j                         }|s2|dd ||<   ; |s|||<    ||d<   d| v rnt	        | d   t              r[|j                  d      xs t        t              }t        D ]+  }|| d   v s	 t!        | d   |         }t'        d|      ||<   - ||d<   d| v r$t	        | d   t              rt)        | d         |d<   d| v r$t	        | d   t              rt+        | d         |d<   d| v rXt	        | d   t              rEt,        d   j                  d      r-| d   j                  d       }d t	        |t              r|ndi|d<   t        |       t        d      S # t"        t$        f$ r Y w xY w)!z2Save shared SCADA config (ranges + enabled flags).Tr  bulk_overrider  temphumidityr
  rP  r  TemprK  r7   r  )rK  r  rY  r~  rN  r  r  >   r  relaydigitalr   r  rT  )r  r	  Nr   r  r^   rQ  rS  r  r'  r   rO  )r   r  r4  r   r   r  r   r   r*   _scada_is_sensor_labelrX  r   r   r   rE  rT  r   r   r   rC  r,  r*  r   )r  rc  r  r   r   r
  rP  r  r   r  allowed_typesr  r  r   mtyper)  rQ  r  rO  s                      r1   scada_config_postr    sU    4(.BD!F xx (#&$'F#-dhhv.>#Etxx2+5dhhz6JD+Q488J'WYdhh'789H%+H%+ 	+EeS)1G1N E/$xC$'GGE2$6PR@STF5M*F5M	+ "x!x6"$4JtH~t<>xDZY> OyDZY> Oydz${*;TB";/{4JtH~t<>x~*T&\48 QV**, 	'HE1eS)At1DEf+,224::<E% %f+ *!%%**+113!$SbE#J* !&	' !vdz${*;TBJJ{+Mt4L/M	+ 	-Cd;''d;/45C "%Q	#	- ({D Z5F0G%N %:$?P:Q$R !*T.-A4"H "7tN7K!L~dz${*;TB 6?}-$((0D#):dC3H4b"QF;vd' ":. s   
P44QQSCADA_INGEST_TOKENz/scada/ingestc            	      :   t         st        d      dfS t        j                  j	                  dd      } | j                  d      r| dd nt        j                  j	                  d	d      }|rt        j                  |t               st        d
      dfS t        j                  d      xs i }|j	                  d      }t        |t              r|st        d      dfS t        |      t        kD  rt        dt         d      dfS t        |j	                  d      xs d      j                         dd xs d}t        j                          j#                  d      }g d}}|D ]  }t        |t$              s|dz  }t        |j	                  d      xs d      j                         }	|j	                  d      }
|	r|
t        |
t$        t        f      r|dz  }v|j'                  |	dd t        |j	                  d      xs |      j                         dd t        |
      j                         dd d        	 t)        j*                  ||      }t        d||#      S # t,        $ r0 t.        j0                  j3                  d        t        d!      d"fcY S w xY w)$ub  Accept a batch of readings from a SCADA Agent.

    No session auth — the caller is a headless agent on the plant network; it
    authenticates with a bearer token instead (constant-time compared).
    Body: {"source": "agent", "readings": [{"sensor", "value", "ts"?}, ...]};
    ts defaults to the server clock. Responds {ok, inserted, rejected}.
    z9Ingest is disabled: SCADA_INGEST_TOKEN is not configured.r  rZ  r  r7   r  r   NzX-Scada-TokenInvalid ingest token.r  Tr  r  z"readings must be a non-empty list.r`  Batch too large (max z readings).r	  r  rS  rr  r   r^   r  r  r  r_   rX  r  r  z SCADA ingest: store write failedzFailed to store readings.r  rU  insertedr|  )r   r   r   rx   r   rw   secretscompare_digestr  r   r  rC  SCADA_INGEST_MAX_BATCHr*   r   r  r  r  r   ri   r  r  ry   rm   rn   r  )r  suppliedr  r  r	  	now_labelrn  r|  r  r  r  r   s               r1   scada_ingestr    s^    XY[^^^??3D??95tABxOR0 711(<NO45s::4(.BDxx
#Hh%XABCGG
8}--45K4LKXY[^^^(#.w/557<GF""$--.ABI(D 8!T"MHQUU8_*+113g*UT4L*IMHvds|quuT{7i8>>@"E!%j..0"57 	88?..tFC dXAA  ?

?@893>>?s   <I! !6JJTHINGPARK_INGEST_TOKENr  zthingpark_config.json)r  r  r  rM  c                     i dddddt        t              d} t        j                         r
	 t	        j
                  t        j                               }t        |j                  d      t               r|d   | d<   t        |j                  d      t               r;| d   D ]3  }t        |d   j                  |      t              s&|d   |   | d   |<   5 t        |j                  d      t               r@|d   j                  d      }t        |t        t        f      r|dkD  rt        |      | d   d<   | S | S # t        j                  t        f$ r Y | S w xY w)	z0Return ThingPark config with defaults filled in.r7   )r(   	token_url	client_idclient_secret)r&  dx_corer  r&  r   r  rM  r   )r   THINGPARK_SETTINGS_DEFAULTSTHINGPARK_CONFIG_FILEr   r   r   r   r   r   r*   r   r  r   r   )rJ  r  r  sams       r1   r0  r0  U sK   "246&M78:C $$&	JJ4>>@AE%))I.5!&y!1I%))I.5Y @A!%	"2"6"6q"93?,1),<Q,?Iq)@ %))J/6J'++,ABcC<0S1W=@XC
O$9: J3J $$g. 	J	s   BD: A/D: :EEc                 X    t         j                  t        j                  | d             y r   )r   r   r   r   rN  s    r1   _save_thingpark_configr   l s    $$TZZA%>?r3   c                 b   | j                  di       j                  |      xs i }|j                  d      t        v r|j                  d      nd}|j                  d      xs dj                         ||j                  d      xs dj                         t        |j                  d            dS )zJNormalized registry entry for one device (empty defaults if unregistered).r&  r!	  r7   r9   r  r  r9   r!	  r  r  )r   THINGPARK_DEVICE_KINDSr   r   )rJ  r'  r   r!	  s       r1   _thingpark_device_metar   p s    
'')R
 
$
$W
-
3C!ggfo1GG3776?RD&B--/WWZ(.B557 $SWW-C%D E	 r3   c                     | sy	 t        j                  t        |       d      }t        j
                         |z
  j                         |dz  kD  S # t        t        f$ r Y yw xY w)zTrue when last_seen ("YYYY-MM-DD HH:MM:SS") is older than the window.

    Unparseable/absent stamps are treated as stale so a broken clock never
    masquerades as a live device.Trr  r   )r  r  r*   r   r   r  r  )r  rM  r  s      r1   _thingpark_is_staler   | sj    
 &&s9~7JK  4'668;NQS;SSS 	" s   A A! A!c                    t        | t              syt        | j                  d      t              r| j                  d      n| fd} |dddd      }|syt        j                  d      t              rj                  d      ni }d}t        |j                  d	      t              r%|d	   r t        |d	   d
   t              r|d	   d
   nd}t	        |      j                          |dddd       |dddd       |ddd       |dddd       |ddd      xs |xs i j                  d       |ddd      xs |xs i j                  d       |d d!d"       |d#d$d%d&       |d'd(d)d*d+      d,S )-a  Map one ThingPark uplink object to a thingpark_store row dict, or None.

    Accepts either the Actility `{"DevEUI_uplink": {...}}` envelope or the inner
    object directly. Pulls the common fields tolerantly (case-insensitive keys
    vary by connector format) and keeps the whole object under ``decoded`` so no
    telemetry is lost before per-model columns are pinned in Phase 2.
    NDevEUI_uplinkc                  :    | D ]  }|v s|   dvs|   c S  y )Nr  rX   )r,  r  r  s     r1   r  z'_thingpark_extract_uplink.<locals>.pick s2     	ADyT!WJ6Aw	 r3   DevEUIdevEUIr'  devEuiLrrsLrrr   rK
  rU   rv
  r_   FCntUpfCntUpfcntFrameCounterFPortfPortfportpayload_hex
payloadHexr  r   LrrRSSIr(  RSSILrrSNRr)  SNRBatteryLevelr  batteryLevelLrrLATr*  rN  DevLATLrrLONr+  r  rP  DevLON)r'  r_   fcnt_upr!  r!  r(  r)  r  r*  r+  decoded)r   r   r   r  r*   r   )r  r  r'  lrrslrrr  s        @r1   _thingpark_extract_uplinkr!   sp    c4 '1 $((377?#-0 	 8Xy(;G)$((6*:DA488FrD
C$((5/4(T%[ *4;q>4 @d5k!ndw<%%'66;5(FNCgw0M<FKY/MCI2??93MHeU+H	rx/H	>BHeZ:HeUKB r3   z/thingpark/ingestc                  |   t         st        d      dfS t        j                  j	                  dd      } | j                  d      r| dd n@t        j                  j	                  d	      xs  t        j                  j	                  d
d      }|rt        j                  |t               st        d      dfS t        j                  d      }t        |t              r%t        |j	                  d      t              r|d   }n5t        |t              r|}n"t        |t              r|g}nt        d      dfS |st        d      dfS t        |      t        kD  rt        dt         d      dfS g d}}|D ]&  }t        |      }||dz  }|j!                  |       ( 	 t#        j$                  |d      }t#        j&                  dt        |      |       t        d||      S # t(        $ rN}	t*        j,                  j/                  d       t#        j&                  d|	       t        d      dfcY d}	~	S d}	~	ww xY w)uo  Accept LoRaWAN uplink(s) pushed by ThingPark's Generic HTTPS connector.

    No session auth — the caller is Actility's Application Server; it presents a
    bearer/header/query token (constant-time compared). Body is either a single
    uplink object or a list; ThingPark posts one uplink per request in the
    normal case. Responds {ok, inserted, rejected}.
    z=Ingest is disabled: THINGPARK_INGEST_TOKEN is not configured.r  rZ  r  r7   r  r   NzX-Thingpark-TokenrD  r   r  Tr  uplinksz0Body must be an uplink object or a list of them.r`  zNo uplinks in request.r   r  r   r^   	thingparkr  )ro  r   z$ThingPark ingest: store write failedFzFailed to store uplinks.r  r   )r   r   r   rx   r   rw   rh  r   r   r  r   r   r  rC  THINGPARK_INGEST_MAX_BATCHr!  ri   r.  insert_uplinksrecord_ingestry   rm   rn   r  )
r  r   r  r   rn  r|  r|	  r  r   rl  s
             r1   thingpark_ingestr!   s    "\]_bbb??3D??95tABx/0 	)<<GR(  711(<RS45s::4(D$*TXXi-@$"GY	D$		D$	OPRUUU56;;
5z..45O4PPRSTVYYY(D '+;MHC>"11${K%%dSZ(S
 dXAA	  >

CD%%e3778#==>s   8G$ $	H;-AH60H;6H;z/thingpark/healthc                  |    t               } t        j                         }t        d|t	        t
              | d         S )zHIngest health + whether the ingest token is configured (enables writes).Tr  )rU  r-  token_configuredr  )r0  r.  r2  r   r   r   )rJ  r-  s     r1   thingpark_healthr!   s=     !
"C**,F45Z	" "r3   z/thingpark/devicesc                     t               } | d   d   }t        j                         }i }|d   D ]
  }|||d   <    g }t        t	        |      t	        | j                  di             z        D ]  }|j                  |i       }t        | |      }|j                  d      }	|j                  ||d   |d   |d   |d	   || j                  di       v ||v |	||v rt        |	|      nd
|j                  d      |j                  d      |j                  d      |j                  d      |j                  d      |j                  d      d        t        d||d   t        j                         |      S )zLatest reading per device joined with the registry (name/kind/location).

    Includes registered-but-never-seen devices (so an admin can pre-name a
    device before it reports) and auto-discovered ones absent from the registry.
    r  rM  r&  r'  r  r9   r!	  r  r  Nr(  r)  r  r!  r*  r+  )r'  r9   r!	  r  r  r  r  r  r  r(  r)  r  r!  r*  r+  TrU   )rU  r&  r  r-  rM  )r0  r.  r/  r  r  r   r   ri   r   r   r2  )
rJ  	stale_minr:  r  rn  r&  euir  r  r  s
             r1   thingpark_devicesr"!   s    !
"CJ 56I**,DD)_ Qy\Gc$i#cggi&<"==> xxR %c3/HH[)	LLZ($()?$@B!774K"BE+(I>SWHHV$88E?xx	*xx	*88E?88E?
 		* F|,,.%' 'r3   z/thingpark/device/<dev_eui>c           	         t               }| xs dj                         dd } t        j                  j	                  dt
              xs d}t        dt        |d            }t        d	| t        ||       t        j                  | |
            S )zBRecent uplinks for one device (newest first) + its registry entry.r7   N    r   r  rK   r^   rs	  Tr@  )rU  r'  r  r!  )r0  r   r   rh  r   r   rC  rL  r   r   r.  device_uplinks)r'  rJ  r   s      r1   thingpark_device_detailr&!  % s     !
"C}"##%cr*GLLW3/63E3ud#$E#C1..weD	F Fr3   z/thingpark/configc            
          t               } t        | d         }t        |j                  dd            }t	        d| d   ||| d   t        t                    S )zCConfig for the admin pane. client_secret is redacted to has_secret.r   r   r7   Tr&  r  )rU  r&  r   
has_secretr  device_kinds)r0  r   r   r  r   r  r   )rJ  dxr(!  s      r1   thingpark_config_getr+!  4 s\     !
"C	c)n	Bbff_b12JIZ013 3r3   c            	      Z   t        j                  d      xs i } t               }t        | j	                  d      t
              ri }| d   j                         D ]  \  }}t        |t
              st        |      j                         dd }|s6|j	                  d      }t        |j	                  d      xs d      j                         dd	 |t        v r|ndt        |j	                  d
      xs d      j                         dd t        |j	                  d            d||<    ||d<   t        | j	                  d      t
              r| d   }dD ];  }t        |j	                  |      t              s#||   j                         |d   |<   = t        |j	                  d      t              r,|d   j                         r|d   j                         |d   d<   t        | j	                  d      t
              r@| d   j	                  d      }	t        |	t        t        f      r|	dkD  rt        |	      |d   d<   	 t        |       t        |d         }t        |j!                  dd            }t        d|d   |||d         S # t        $ r}
t        d|
       dfcY d}
~
S d}
~
ww xY w)a  Save device registry / DX-Core creds / settings.

    Body keys are all optional (partial merge): {devices, dx_core, settings}.
    A blank/omitted dx_core.client_secret keeps the stored one (so the redacted
    GET can round-trip without wiping the secret).Tr  r&  Nr$!  r!	  r9   r7   r  r  rK   r  r   r   )r(   r   r   r   r  rM  r   Failed to save config: r  r  )rU  r&  r   r(!  r  )r   r  r0  r   r   r   r   r*   r   r   r   r   r  r   r   r   r  )r  rJ  r  r!!  r   r)  r!	  r*!  r  r   rl  r(!  s               r1   thingpark_config_postr.!  D sw    4(.BD
 
"C$((9%t,Y--/ 	HCc4(c(.."3B'C776?DCGGFO1r288:4C@ $(> >B
 3 9r:@@B4CH(,SWW5K-L(M	GCL	 !I$((9%t,)_7 	2A"&&)S)$&qEKKMIq!	2 bff_-s3?8K8Q8Q8S.0.A.G.G.IC	N?+$((:&-:""#89cC<(S1W58XC
O12Cs# 
c)n	Bbff_b12JdC	NB(3z?D D	  C6se<=sBBCs   <J 	J*J%J*%J*z/thingpark/dx-testc                  .   t               } | d   }|j                  dd      j                         }|st        d      dfS |j                  d      r|j                  d      st        d	      dfS 	 t	        j
                  |d
|d   |d   dd      }|j                  dz  dk7  r*t        dd|j                   |j                  dd       dfS 	 |j                         }|j                  d      st        ddt        |      dd       dfS t        d|j                  d      |j                  d            S # t        j                  $ r}t        dd|       dfcY d}~S d}~ww xY w# t        $ r  t        dd|j                  dd       dfcY S w xY w)a  Test DX-Core OAuth2 client_credentials against the configured token URL.

    Proves the stored creds can mint a token (the prerequisite for the future
    device/tag inventory pull). Does not persist anything. Returns
    {ok, token_type, expires_in} on success, or a descriptive error.r   r   r7   z Set the DX-Core token URL first.r  r`  r   r   z+Set the DX-Core client id and secret first.client_credentials
grant_typer   r   r  r   r7  FzRequest failed: r  rU  Nrd  r   zToken endpoint returned HTTP )rU  r  r	  z"Token endpoint did not return JSONr  zNo access_token in responseT
token_typer  )rU  r4!  r  )r0  r   r   r   r  r  r?  rg   r&  r   r   r*   )rJ  r*!  r   r  rl  r   s         r1   thingpark_dx_testr5!  w s    !
"C	YB{B'--/I?@#EE66+bff_&=JKSPPF"" 4!+#%o#68  3!#%<T=M=M<NO"iio/034 	44yy{
 88N#%'D!$io/034 	4dtxx'="hh|46 6 ** F%)9#'?@#EEF  4%A"iio/034 	44s0   1"D9 E+ 9E(E#E(#E(+&FFzidencia_config.jsonz/api/v1/loginz/graphql	login_urlgraphql_urlu   No Idencia server is configured. Set the API endpoints (a full https:// URL or your Idencia domain, e.g. icast.idencia.net) in Togen Admin → Services → Idencia, or your own under User Settings → Services → Idencia.)sync_interval_minutessync_enabledr   c                     dddddt         t        ddt        t              d} t        j                         r	 t        j                  t        j                               }|j                  d      dv r|d   | d<   t        |j                  d      t              r;| d   D ]3  }t        |d   j                  |      t              s&|d   |   | d   |<   5 t        |j                  d	      t              rV| d	   D ]N  }|d	   j                  |      }t        |t              s(|j                         s9|j                         | d	   |<   P t        |j                  d
      t              r^|d
   j                  d      }t        |t        t        f      r|dkD  rt        |      | d
   d<   d|d
   v rt        |d
   d         | d
   d<   | S | S # t        j                   t"        f$ r Y | S w xY w)z.Return Idencia config with defaults filled in.r7   rF  rs  org_idapi)r7!  r8!  r  )r^  rO  	endpointsr  rO  r>!  r   r^  r?!  r  r9!  r   r:!  )IDENCIA_LOGIN_URL_DEFAULTIDENCIA_GRAPHQL_URL_DEFAULTr   IDENCIA_SETTINGS_DEFAULTSIDENCIA_CONFIG_FILEr   r   r   r   r   r   r*   r   r   r  r   r   r   )rJ  r  r  rQ  ivs        r1   _load_idencia_configrF!  ڱ s   !rR@&?)D%') 568C ""$	JJ2<<>?Eyy N2#FmF%))E*D1U 8A!%,"2"21"5s;(-eQE
18 %))K0$7[) :A,003C!#s+		.1iikK(+: %))J/6:&**+BCb3,/BF?B2wC
O$;<!U:%666:j).97;C
ON3 J3J $$g. 	J	s&   BG AG #G 4BG G*)G*c                     t         j                  t        j                  | d             	 t         j	                  t
        j                  t
        j                  z         y # t        $ r Y y w xY wr   )	rD!  r   r   r   r  r  r  r  r   rN  s    r1   _save_idencia_configrH!   M    ""4::c!#<=!!$,,"=> r  c                    | xs dj                         }|syd|v r|j                  d      sd| dfS |dd dfS |j                  d      rt        fd	d
D              rd| dfS dv r
dz   dd dfS dz   t        |   z   dd dfS )u  Normalize an endpoint setting to a full https URL. Accepts a full
    https:// URL, a bare tenant domain (icast.idencia.net — derives the
    standard API path for ``kind``), or a domain-with-path. Returns
    (url, error); blank input returns ("", None) meaning "use the fallback".r7   r}  r>  r@  Nz must be an https:// URL.r  r6   c              3   &   K   | ]  }|v  
 y wrT   rX   r,  r  r$  s     r1   r/  z._idencia_normalize_endpoint.<locals>.<genexpr>      5QqDy5r   "'<>\zC must be an https:// URL or a bare domain (e.g. icast.idencia.net).)r   rw   r2  _IDENCIA_ENDPOINT_PATHS)r  r!	  rQ  r$  s      @r1   _idencia_normalize_endpointrP!   s    
 ;B


C|~~j)D6!:;;;4Cy$99S>D35*55 2 2 3 	3
d{T!4C($.. 7 ==tDdJJr3   c                    | xs dj                         }|syd|v r(|j                  d      sy|j                  d      dd dfS |j                  d      rt        fd	d
D              rydj	                  d      d   z   dd dfS )zNormalize the web sign-in base URL (mode "web"). Accepts a full
    https:// URL or a bare tenant domain (icast.idencia.net); no API path is
    ever appended. Returns (url, error); blank returns ("", None).r7   r}  r>  r@  )Nz0The Idencia web address must be an https:// URL.r6   Nr  c              3   &   K   | ]  }|v  
 y wrT   rX   rL!  s     r1   r/  z-_idencia_normalize_web_url.<locals>.<genexpr>% rM!  r  rN!  )NzZThe Idencia web address must be an https:// URL or a bare domain (e.g. icast.idencia.net).r   )r   rw   r8   r2  r  )r  rQ  r$  s     @r1   _idencia_normalize_web_urlrS!   s     ;B


C|~~j)Kzz#t$d**99S>D35*55?C++Tc2D88r3   c                    ddl m}  ||xs d      j                  xs |xs d}t        |       }|j	                         }d|v sd|v sd|v sd|v rd	| d
S t        | t        j                        sd|v r)t        | t        j                        sd|v r| dS d| dS t        | t        j                        rd| d|dd  dS |dd S )u   Condense a requests exception into an actionable one-liner — the raw
    text buries the cause (DNS, timeout) inside a connection-pool stack, which
    is what the board saw on the DVI-1226 prod-verify reject.r   r  r7   zthe Idencia servernameresolutiongetaddrinfoname or service not knownnodename nor servnamecould not resolve host 'uG   ' — check the Idencia API endpoints in Services → Idencia settings.	timed outzread timed outux    took too long to respond — the site is reachable but slow; cached data will serve once a fetch or sync has succeeded.timed out contacting r  could not connect to r:  N   r  r  )
r   r
   rI  r*   rE  r   r  TimeoutReadTimeoutConnectionErrorrl  rJ  r
   r$  r&  lowereds         r1   _idencia_request_errorrc!  + s     &CI2%%F#*E1EDs8DjjlGG#}'?*g5&'1*4& 1> > 	?#~--.+2H sN667#w.f 3 3 4 'tfA..#~556&tfBtDSzl"==:r3   c                     | xs
 t               } | d   }|j                  d      rR|j                  d      rA|d   |d   |j                  dd      | j                  dd      | d   j                  d	d      d
S y)z<Org service-account creds dict, or None when not configured.r^  rF  rs  r=!  r7   rO  r>!  r?!  r  )rF  rs  r=!  rO  r  N)rF!  r   rJ  r^  s     r1   _idencia_org_credsrf!  G s    

'%'C
e*C
wwzswwz2
OZ''(B/.{+//	2>@ 	@ r3   c                 *   | syt        |       }t        |j                  d      t              r|j                  d      ni }t        |j                  d      t              r|j                  d      ni }|j                  d      r|j                  d      r|d   |d   |j                  dd      d}d	D ]L  }t        |j                  |      t              s#||   j                         s7||   j                         ||<   N t               }|j                  d
      dv r|j                  d
      nd}|xs |j                  d
d      |d
<   t	        |j                  d      xs d      j                         xs |d   j                  dd      |d<   |S y)zCThe user's personal Idencia connection from user_settings, or None.Nrr  r  rF  rs  r=!  r7   r<!  r6!  rO  r@!  r>!  r  r?!  )r  r   r   r   r*   r   rF!  )r  r  rv  iderO  r  rJ  rO  s           r1   _idencia_user_connectionri!  S s^   "5)H&0j1I4&P(,,z
"VXC *3779+=t D#'')
"C
wwzswwz2
OZ''(B/1
 . 	)A#''!*c*s1v||~a&,,.Q	) #$"%''&/^"Cswwv5swwvu5Vswwy17R8>>@ B!+.229bA 	Yr3   c                     | xs i j                  dd      }t        |      }|rt        j                  |      |ddfS t	               }|rt        j
                  |ddfS y)u  Resolve which Idencia connection a request runs under (D1-C).

    Returns (scope_key, creds, source, error): the viewer's personal
    connection when configured, else the org service account. source is
    "user"/"org" so callers can label the data's visibility. A configured
    personal connection is ALWAYS used — if its login fails downstream the
    error surfaces; it never silently falls back to org scope, so a user is
    never shown org-visibility data believing it is their own.r  r7   r'  Nr^  )NNr7   u   Idencia is not configured. Connect your account in User Settings → Services, or ask an admin to configure the organization account.)r   ri!  idencia_store
user_scoperf!  	ORG_SCOPE)r'  r  personalr^  s       r1   _idencia_resolve_connectionro!  p sg     ZRWb)E'.H''.&$FF

C
&&UD88Cr3   c                 :   t        | t              r|dkD  ry|D ]D  }| j                         D ]/  \  }}|j                         |j                         k(  s(|s+|c c S  F | j	                         D ])  }t        |t              st        |||dz         }|s'|c S  y)u   Depth-first search of a nested dict for the first of ``names`` with a
    truthy value — absorbs envelope drift (e.g. AuthenticationResult) in the
    login response without pinning its exact shape.r   Nr^   )r   r   r   rE  r  _idencia_find_key)r  r.  depthr9   r)  rQ  r
  s          r1   rq!  rq!   s     c4 EAI 		 	HCyy{djjl*s
	 zz| c4 %c5%!)<E	
 r3   c                 r   |xs dj                         xs t               d   d   }|sdt        fS 	 t        j                  || |d|      }|j                  dz  d	k7  r|j                  dd
 }|j                  dv rdd|j                   dfS |j                  dk(  rJd|j                  v s;d|j                  j                         v sd|j                  j                         dd v rydd|j                   d| fS 	 |j                         }t        |d      }	t        |d      }
t        |d      }|	s|
syt        |d      }	 t        |      }|	xs d|
xs d|xs d|ddfS # t        j
                  $ r}ddt        ||      z   fcY d}~S d}~ww xY w# t        $ r Y yw xY w# t        t        f$ r d}Y hw xY w)ztExchange username/password for tokens. Returns (tokens, error) where
    tokens = {access, id, refresh, expires_in}.r7   r?!  r7!  N)rF  rs  r  zIdencia login request failed: rd  r   r  r  z'Idencia rejected the credentials (HTTP r  r  RequestVerificationTokenzanti-forgeryz<!doctype html)Nu  The Login URL appears to point to the Idencia web UI instead of the API backend. The web UI requires browser CSRF cookies and cannot be used directly. Enter the full API Login URL — find it in your Idencia API documentation, or by opening the Idencia web app in a browser, opening DevTools (F12) → Network, logging in, and copying the URL of the login POST request. It is typically an https://….execute-api.…amazonaws.com/… or similar cloud endpoint, not icast.idencia.net.zIdencia login returned HTTP r>  )Nz"Idencia login did not return JSON.)AccessTokenr  )IdTokenid_token)RefreshTokenrefresh_token)Nz/Idencia login response carried no access token.)	ExpiresInr    )r  r  rX  r  )r   rF!  _IDENCIA_NOT_CONFIGURED_MSGr  r  r?  rc!  rg   r&  rE  r   r   rq!  r   r   )rF  rs  r7!  r7  rJ  r  rl  r	  r   r  rw!  rX  r  s                r1   _idencia_loginr}!   s    ?
!
!
#
W';'=k'J;'WC000:""8B 3!#4Cz)B4CSCSBTTVWWW s"*dii7!TYY__%66#tyy'8#'>>4 3D4D4D3ERxPPP:yy{ t%DEF '>?H&GHG(F"4)DEJ_
 l(.b}"J@AEF FM ** :6.sC89 : 	::4  :9: z" 
sA   E" <F F" "F5FFF	FF"F65F6c                    t        j                          }t        5  t        j                  |       }|r|s|d   |kD  r|d   dfcddd       S ddd       t	        |d   |d   |j                  d            \  }}|r)t        5  t        j                  | d       ddd       d|fS |d   xs |d	   }t        5  ||t        d
|d   d
z
        z   dt        | <   ddd       |dfS # 1 sw Y   xY w# 1 sw Y   d|fS xY w# 1 sw Y   |dfS xY w)u  Cached bearer token for a scope, logging in when missing/expired.
    Returns (token, error). Prefers the IdToken — AppSync Cognito user-pool
    auth validates the id token's audience (confirm against the board's API
    references; the access token is kept as fallback).r.  rD  NrF  rs  r7!  r7!  r  r  r   r  rD  r.  )rU   _idencia_token_lock_IDENCIA_TOKENSr   r}!  r  rC  )	scope_keycredsr  r  r  r  r  rD  s           r1   _idencia_get_tokenr!  ղ s-   
 ))+C	 &!!),u\!2S!8w<%& & & !z!2E*4E+099[+ACKFC
  	1	40	1Sy4L,F8,E	 DB|(<r(A BB&D	"D $;& &	1SyD $;s#   (C$C08 C?$C-0C<?Dc                 (   |j                  d      xs dj                         xs t               d   d   }|sdt        fS dD ]
  }t	        | |t        |            \  }}|rd|fc S 	 t        j                  |d|i||xs i d|	      }	|	j                  dv r|dk(  r]|	j                  dz  dk7  r#dd|	j                   d|	j                  dd  fc S 	 |	j                         }|j                  d      }|r>dj                  d |dd D              }t        d |D              }|r|dk(  rdd| fc S |j                  d      xs i dfc S  y# t        j                  $ r}
dd
t        |
|      z   fcY d}
~
c S d}
~
ww xY w# t        $ r Y  yw xY w)zRun one GraphQL query under a scope's connection. Returns (data, error).
    One re-login retry on an auth failure (expired/revoked token).r8!  r7   r?!  Nr   r^   r  r  )r#  	variablesr  zIdencia request failed: r  r   rd  r   zIdencia GraphQL returned HTTP r>  r  )Nz$Idencia GraphQL did not return JSON.r?  rk  c              3   `   K   | ]&  }t        |xs i j                  d |            dd  ( yw)r  NrK   )r*   r   r,  rB  s     r1   r/  z#_idencia_graphql.<locals>.<genexpr> s2      L;<QW"MM)Q/0#6Ls   ,.rP   c              3      K   | ]<  }d t        |      j                         v xs dt        |      j                         v  > yw)r  rD  Nr%  r!  s     r1   r/  z#_idencia_graphql.<locals>.<genexpr> s>      ,  "SV\\^3Pw#a&,,.7PP ,s   AAzIdencia GraphQL error: r   )Nz,Idencia authorization failed after re-login.)r   r   rF!  r|!  r!  r   r  r  r?  rc!  rg   r&  r   r   r  r2  )r!  r!  r#  r!  r7  gql_urlattemptrD  r  r  rl  r  r?  r  auth_errs                  r1   _idencia_graphqlr!   s    		-(.B557 E&(5mD 000 !,'	5WN
s9
	B!&& )%0$9?C!D z)gls"a':4;K;K:LB!YYt_-/ 0 0	@99;D (#)) L@Fr
L LC ,$*, ,HGqL23%888xx%2t++C!,D @- .. 	B423@A B B	B  	@?	@s0   )"EFF$E<4F<F	FFu   No Idencia web address is configured. Set it (e.g. icast.idencia.net) in Togen Admin → Services → Idencia, or your own under User Settings → Services → Idencia.rs	  i  z5name="__RequestVerificationToken"[^>]*value="([^"]+)"z*validation-summary-errors.*?<li>(.*?)</li>z<[^>]+>z$/(?:Details|Edit)/([0-9a-fA-F-]{36})c                 f    t         5  t        j                  | d        d d d        y # 1 sw Y   y xY wrT   )_idencia_web_lock_IDENCIA_WEB_SESSIONSr  )r!  s    r1   _idencia_web_clear_sessionr!  < s*    	 3!!)T23 3 3s   '0c                     t         j                  dt        | | nd            }dj                  t	        j
                  |      j                               S )u   Plain text from a grid cell value — the site returns HTML-laced cells
    (serials wrapped in Details links, action buttons). Tags stripped,
    entities unescaped, whitespace collapsed.r\   r7   )_IDENCIA_WEB_TAG_REr\  r*   r  r  unescaper  )rQ  r&  s     r1   _idencia_web_textr!  A sB     ""33?C(KLD88DMM$'--/00r3   c                     t         j                  t        | xs d            }|r|j                  d      j	                         S dS )z<The record GUID buried in a cell's Details/Edit link, or "".r7   r^   )_IDENCIA_WEB_GUID_REr  r*   rb  rE  )rQ  r  s     r1   _idencia_web_guidr!  I s8    ##C	rN3A!"1771:**r3   c                     t        | xs d      j                         } dD ])  }	 t        j                  | |      j	                  d      c S  y# t
        $ r Y 8w xY w)u   ISO timestamp from the site's US-format dates ("11/03/2025 2:12 PM"),
    or "" when unparseable — downstream range filters keep undated rows.r7   )z%m/%d/%Y %I:%M %pr  r  )r*   r   r  r  r  r   )r&  r;  s     r1   _idencia_web_isor!  O sj     tzr?  "D0 	!**45>>#% %   		s   $A	AAc           	      "   t        | j                  d            \  }}|rdd|fS |s	ddt        fS t        j                         }|dz   }	 |j                  ||      }t
        j                  |j                  xs d      }|sdd| dfS |j                  |dz   |j                  d      | j                  d	d      | j                  d
d      dd|      }|j                  dz  dk7  rddd|j                   dfS |j                  xs d}	d|	v rLd|	v rHt        j                  |	      }
|
rt        |
j                  d            nd}ddd|rd|dd  z   fS dz   fS ||dfS # t        j                  $ r}dddt        ||      z   fcY d}~S d}~ww xY w)a  Sign into the Idencia website. Returns (session, base_url, error).
    ASP.NET anti-forgery: GET the login page for the token field/cookie pair,
    then POST the form; a failed login re-renders the form (detected by the
    Password field still being present).r  Nz/Account/Loginr7  r7   ze does not look like an Idencia web login page (no sign-in form found). Check the Idencia web address.z?ReturnUrl=%2Fr^   rF  rs  r  )__RequestVerificationTokenr	  Password
RememberMer3!  zIdencia web sign-in failed: rd  r   z"Idencia web sign-in returned HTTP r  zname="Password"r!  z Idencia rejected the web sign-inr>  rK   z  (check the email and password).)rS!  r   _IDENCIA_WEB_NOT_CONFIGURED_MSGr  Session_IDENCIA_WEB_TOKEN_REr  r&  r  rb  r?  rc!  rg   _IDENCIA_WEB_ERROR_REr!  )r!  r7  r  uerrsess
login_pager  r  rl  r&  emr	  s               r1   _idencia_web_loginr!  \ s   
 ,EIIi,@AJD$T4T:::!!#D((JAxx
Gx4!((b9& J JK K yy))01
 99Z4#ii
B7 ')    3!#T@#//03 4 	499?DD %AT%I"))$/35"288A;/2T>6<F4CL>2C D 	DAC D 	D t ** AT:4S$?@ A 	AAs%   
?E 
AE F2F	F	Fc                 .   t        |       \  }}}|rd|fS ddd}	 |j                  |dz   dddddd	d
dddd
dd      }|j                         }t        |j                  d      t              |d<   |dfS # t
        t        j                  f$ r Y |dfS w xY w)a  Test-button path for web mode: sign in, then one 1-row grid probe so
    "connected" means data access, not just an accepted password. Returns
    (info, error); a failed probe after a good login is reported in info
    (the site may have moved its grids) rather than as a hard error.NTF)	signed_ingrid_okz/ItemTypes/PagedIndexDatarE   rF   Namer7   r  r  )sEchoiColumnsiDisplayStartiDisplayLengthmDataProp_0sSearchbRegex
iSortCol_0
sSortDir_0iSortingColsrv  r  r  r7  aaDatar!  )r!  r   r   r   r  r   r  r?  )r!  r!  r  r  rl   r  r  s          r1   _idencia_web_checkr!   s    
 )/OD$
Sy%0Dxx.. cC&)&!WC"'%	'
    yy{$TXXh%7>Y : 778 :s   AA5 5BBc                 t   t        j                          }t        5  t        j                  |       }|r|s|d   |kD  r|d   |d   dfcddd       S ddd       t	        |      \  }}}|rt        |        dd|fS t        5  |||t        z   dt        | <   ddd       ||dfS # 1 sw Y   RxY w# 1 sw Y   xY w)zICached signed-in web session for a scope. Returns (session, base, error).r.  r   r  N)r   r  r.  )rU   r!  r!  r   r!  r!  _IDENCIA_WEB_SESSION_TTL_S)r!  r!  r  r  hitr!  r  r  s           r1   _idencia_web_sessionr!   s    
))+C	 5#''	2u\!2S!8y>3v;45 5 5 )/OD$
"9-T3	 <T ::,<i(< t5 5< <s   ,B" B."B+.B7c                 L   dD ]  }t        | |t        |            \  }}}	|	rd|	fc S g dd}}}
t        t              D ]i  }dt	        t        |            t	        |      t	        t              ddd	d
dd	}t        |      D ]  \  }}||d| <    |j                  |xs i        | d| d}	 |j                  |||      }|j                  dv rd} n|j                  dz  dk7  rdd|j                   d| dfc c S 	 |j                         }|j                  d      xs g D cg c]  }t!        |t"              r| }}|
j%                  |       	 t'        |j                  d      xs |j                  d      xs d      }|r|rt        |
      |k\  r n|t        |      z  }l |s|
dfc S t+        |         y# t        j                  $ r}ddt        ||      z   fcY d}~c c S d}~ww xY w# t        $ r d}Y  Zw xY wc c}w # t(        t        f$ r d}Y w xY w)u#  All rows of one grid via its PagedIndexData endpoint (paginated,
    same 20-page cap as the API path). Returns (raw_rows, error); cell values
    are left raw (normalizers pull GUIDs from the HTML before stripping).
    A non-JSON body means forms-auth expired server-side — one re-login.r!  r  Nr   FrE   r7   r  rF   r  )	r!  r!  r!  r!  r!  r!  r!  r!  r!  
mDataProp_r6   z/PagedIndexDatar!  zIdencia web request failed: r  Trd  r   zIdencia web returned HTTP  for r  r!  iTotalDisplayRecordsiTotalRecords)Nz0Idencia web session expired and re-login failed.)r!  r   r  _IDENCIA_SYNC_MAX_PAGESr*   rC  _IDENCIA_WEB_PAGE_LIMITr  r  r   r  r?  rc!  rg   r   r   r   r   r  r   r   r!  )r!  r!  
controllerr   rT  r7  r!  r!  r  r  rn  rq   	auth_lost_pager  r  proprJ  r  rl  r  r  	page_rowsr@  s                           r1   _idencia_web_gridr!   s     -..y%59']DdC9!#QYe23 $	$E"CJ'*5z(+,C(D!#wc$)3	@F
 %U+ 04+/A3'(0MM%+2&F!J<7CBxxFGxD :- 	3&!+ :4;K;K:L M%%/L3 4 4yy{ &*XXh%7%=2 1q&q$/  1I 1KK	"DHH%;< >#xx8><=? 3t9+=S^#EI$	$J :"9-[-.\ D9 "22 B< 6sC @A B B BB   	1 z* sH   0GG8)H
1HG5G0&G50G58HHH#"H#c                     |dk(  rt         dg d      \  }}|rd|fS g }|D ]  }|j                  d      }|j                  t        |      t	        |      t	        |j                  d            t	        |j                  d            t        t	        |j                  d	                  t	        |j                  d
            d        |dfS |dk(  rt         dg dddi      \  }}|rd|fS t         d fd      }	i }
|	j                  d      xs g D ][  }t        |xs i j                  d      xs d      j                         j                         }|sD|
j                  |d      dz   |
|<   ] g }|D ]  }|j                  d      }t	        |      }t	        |j                  d            }|r|
j                  |j                               nd}|j                  t        |      |xs |xs d|t	        |j                  d            |t        t	        |j                  d	                  t        t	        |j                  d                  d        |dfS |dk(  rt         dg d d!d"i      \  }}|rd|fS g }|D ]  }|j                  d      }t        t	        |j                  d#                  }|j                  t        |      t	        |j                  d            xs t	        |      xs dt	        |      |t        t	        |j                  d$                  |rd%nd&d'        |dfS |d(k(  rt         d)g d*d+di      \  }}|rd|fS |D cg c]S  }t        |j                  d            t	        |j                  d            xs t	        |j                  d            d,U c}dfS dd-| d.fS c c}w )/u  Web-mode equivalent of the GraphQL dataset fetch: rows normalized to
    the SAME tolerant field names the providers/reports already walk, so the
    tile, Auditor reports, sync, and cache work unchanged. Because the web
    grids carry display names (not ids), the name fields go straight into the
    *Id slots — the id→name map lookups miss and fall back to the raw string,
    which already IS the name.r   r  )AttachmentsSerialNumberItemType	JobNumberCreatedOnUtc	CreatedByr!  Nr!  r!  r!  r!  r!  )r  r9   
itemTypeId	projectId	createdAt	createdByr  Jobs)Numberr!  r  r!  CompletedOnUtc
activeJobsr  )rT  r7  c                  "    t         d      S )Nr   r!  )_idencia_web_fetch_dataset)r!  r!  r7  s   r1   r	  z,_idencia_web_fetch_dataset.<locals>.<lambda> s    .y%7>@ r3   r   r!  r7   r   r^   r!  r!  Unnamedr  r!  )r  r9   re  r	  quantityProducedr!  completedAtr  r  )rw  r!  r!  ScheduledDateShippedDate	IsShippedShowAllShipmentsrd  r!  r!  Shipped	Scheduled)r  r9   r  shipDatescheduledDaterd   
item_types	ItemTypes)r!  r!  DefaultProcessUrlrv  r  zUnknown Idencia dataset 'z'.)
r!  r   ri   r!  r!  r!  _idencia_fetchr*   r   rE  )r!  r!  r  r7  r   r  rn  r  serial_cell	items_resby_jobr|	  r)  number_cellr  r9   producedshippeds   `` `              r1   r!  r!   s    '$ug*3:<S 9 	A%%/KKK'4)+6/j0AB.quu[/AB-.?EE.)/+ ,.quu[/AB	 		 Tz*$ufL)7<S 9
 #ug@A	 ==(.B 	5BrxRnn[17R8>>@FFHC$jja014s	5  	A%%/K&{3F$QUU6]3D5;vzz&,,.1HKK'43$3)-aeeJ.?@$,-.?EE.)/+ ,/0AEE*+1-  .
 
	  Tz+$uk"4f!=	S
 9 	A%%/K&'8}9M'NOGKK'4*155=9 H-k:H>G+K8#!12CEE/*3, "-'.)K	 		 Tz,$uk7W%w8S 9   )x9+AEE&M: @.quuX?B   $$ 	$
 ,WIR888s   .AOc                     t         5  t        j                  | |f      }|r(|d   t        j                         kD  r|d   cd d d        S t        j	                  | |fd        d d d        y # 1 sw Y   y xY wNr   r^   )_idencia_memo_lock_IDENCIA_MEMOr   rU   r  r!  	query_keyr!  s      r1   _idencia_memo_getr!  Z p    	 8I 673q6DIIK'q68 8 	9i0$7	8
 8
    8A*	A**A3c                     t         5  t        j                         t        z   |ft        | |f<   d d d        y # 1 sw Y   y xY wrT   )r!  rU   IDENCIA_MEMO_TTL_Sr!  r!  r!  r  s      r1   _idencia_memo_putr "  c <    	 7IIK,,g17y),-7 7 7	   '7A c                 P   t        | |      }||ddddS  |       \  }}|,t        | ||       	 t        j                  | ||       |ddddS t        j                  | |      }|r|d   d|d   |dS ddd|dS # t        $ r" t
        j                  j                  d       Y ]w xY w)a'  Live-first data fetch for tiles/reports (D2). fetch_fn() returns
    (payload, error). Returns {data, cached, as_of, error}:

    - memo hit (<60 s old live result)  -> live data, no badge
    - live success                      -> memoized + written through to the
                                           scope's cache; no badge
    - live failure w/ cached last-good  -> cached payload, cached=True +
                                           as_of (renderers show the badge)
    - live failure, nothing cached      -> data=None + the error
    NFr   r  r  r  z"Idencia cache write-through failedr  T
fetched_at)	r!  r "  rk!  	cache_putry   rm   rn   r  	cache_getr!  r!  r!  fetch_fnr  r  r  r!  s           r1   r!  r!  i s     Y	2DtLL:LGS
{)Y8	G##Iy'B  54$OO

!
!)Y
7C
I$\*S: 	:ED3GG  	GJJ  !EF	G   A: :(B%$B%zquery ListProjects($limit: Int, $nextToken: String) { listProjects(limit: $limit, nextToken: $nextToken) { items { id name status quantityRequired quantityProduced quantityRemaining } nextToken } }zquery ListItemTypes($limit: Int, $nextToken: String) { listItemTypes(limit: $limit, nextToken: $nextToken) { items { id name } nextToken } }zquery ListItems($limit: Int, $nextToken: String) { listItems(limit: $limit, nextToken: $nextToken) { items { id name status itemTypeId projectId createdAt updatedAt } nextToken } }zquery ListShipments($limit: Int, $nextToken: String) { listShipments(limit: $limit, nextToken: $nextToken) { items { id name status shipDate projectId } nextToken } }r  r!  r   r  c                 :   t        | t              r| j                         D ]J  }t        |t              st        |j                  d      t              s4|d   |j                  d      fc S  | j                         D ]  }t        |t              s|dfc S  g dfS )u   Pull (items, nextToken) out of a GraphQL data payload regardless of the
    operation name — the first dict value carrying an ``items`` list wins.r   	nextTokenN)r   r   r  r   r  )r   rQ  s     r1   _idencia_result_itemsr"   s     $;;= 	:C#t$CGGG4Dd)K7|SWW[%999	: ;;= 	!C#t$Dy 	! t8Or3   c                 <   |xs i j                  d      dk(  rt        | |||      S t        |   }g d}}t        t              D ]S  }t
        |d}t        | ||||      \  }	}
|
rd|
fc S t        |	      \  }}|j                  d |D               |rP |dfS  |dfS )u  Fetch one dataset fully (paginated). Returns (rows, error). This is
    the single mode fork: connections with mode "web" read the Idencia
    website's grid endpoints instead of the GraphQL API — everything above
    (memo/cache, tile provider, reports, sync, vocab) is mode-blind.rO  r   r!  N)r   r"  )r!  r7  c              3   B   K   | ]  }t        |t              s|  y wrT   )r   r   )r,  r  s     r1   r/  z)_idencia_fetch_dataset.<locals>.<genexpr> s     <!
1d(;A<s   )	r   r!  _IDENCIA_QUERIESr  r!  _IDENCIA_SYNC_PAGE_LIMITr!  r"  r  )r!  r!  r  r7  r#  rn  
next_tokenr!  r!  r   r  r   s               r1   _idencia_fetch_datasetr"   s     	 E)))UG29; 	;W%E4*D./ 	6ZP	$Yu/8'K	c91$7z<u<<:	 :r3   c                 h    |D ]-  }t        | t              s| j                  |      dvs(| |   c S  y)z:First present-and-truthy of several candidate field names.r  Nr
  )r  r.  r  s      r1   _idencia_pickr"  ƴ s8     c4 SWWQZz%Aq6M r3   c           	          i }| D ]G  }t        t        |ddd      xs d      j                         xs d}|j                  |d      dz   ||<   I |S )zD{status: count} over an item list (tolerant of status field naming).rd   
itemStatusr  r  r   r^   )r*   r"  r   r   )r   r$  r|	  rd   s       r1   _idencia_status_countsr"  δ sg    F 3]2xxH "!##(578.7 	FA.2v3 Mr3   c           	          g }| D ]  }t        t        |ddd      xs d      j                         }t        |ddd      }t        |dd	d
      }t        |ddd      }	 t        |      }	 t        |      }|||t        d||z
        }n	 t        |      }|j                  t        |dd      ||||d        |S # t        t
        f$ r d}Y gw xY w# t        t
        f$ r d}Y rw xY w# t        t
        f$ r d}Y gw xY w)z3Slim per-project progress rows for snapshots/tiles.r9   projectNamere  r!  quantityRequiredqtyRequiredr  r!  qtyProducedr!  quantityRemainingqtyRemainingr  Nr   r  r!  )r  r9   r  r!  r  )r*   r"  r   r   r   r   rC  ri   )r  r   r  r9   r  r!  r  s          r1   _idencia_project_progressr!"  ش sA   
C C=FM7C  !!& 	 $6zR $6zR!!%8.+V		8}H	8}H !5(:NAx(23I!	N	 	

-4= h (yB 	C+C0 J! :& 	H	 :& 	H	 z* ! 	!s6   B;(C
C);CCC&%C&)C=<C=c                    t               }|sd}t        j                  d| |       d|fS t        j                  }i i }}dD ]]  }t	        |||      \  }}|rt        j                  d| ||       d|fc S |||<   t        |      ||<   t        j                  |||       _ t        |d         }t        |d   D 	ch c]+  }	t        t        |	d	d
d      xs d      j                         - c}	dhz
        t        |d   D 
ch c]+  }
t        t        |
d	dd      xs d      j                         - c}
dhz
        t        |      d}t        j                  |d|       t        j                         j                  d      }t        j                  d||       t        j                  dt!        |d         |       t        j                  ddt        |d         i|       t        j                  d| |       |dfS c c}	w c c}
w )zOne org-scope sync pass: pull every dataset, refresh the cache +
    picker vocabulary, and append snapshot rows for Reporting. Returns
    (counts, error); the outcome is always recorded via
    idencia_store.record_sync so the Admin pane shows it.z/Organization Idencia account is not configured.r  r		  r  Nr"  )r		  r  r$  r   r  r9   r"  re  r7   r!  typeName)r  r!  r  vocabrr  r  run_tsproject_progressr  r@  rU  r		  r$  )rf!  rk!  record_syncrm!  r"  rC  r"  r"  r  r*   r"  r   r  r  r  add_snapshotr!"  )r		  r!  r  r  datasetsr$  r9   rn  r  r  r   r%"  r'"  s                r1   _idencia_run_syncr-"   s   
  E?!!'cBSy##E2fH@ 3*5%>	c%%gU#-3594yttT23 +8G+<=M%-j%9; !  a P VTVW]]_ ;>@TB C'/'=?"# "-6:w"O"USUV\\^ ?BDF G=)E E7E2 !**+>?FfM5hz6JK gs8K#89:6K d%?4<%;?s   ;0H 0H
)r8  rq  r		  c                     	 t         j                         5  t        |       \  }}|r!t         j                  j	                  d|       n t         j                  j                  d|       ddd       t        5  dt        d	<   ddd       y# 1 sw Y   "xY w# t        $ r: t         j                  j                  d       t        j                  d| d       Y gw xY w# 1 sw Y   yxY w# t        5  dt        d	<   ddd       w # 1 sw Y   w xY wxY w)
zEThread body around _idencia_run_sync: always clears the running flag.zIdencia sync failed: %szIdencia sync ok: %sNzIdencia sync worker crashedr  worker crashed (see server log)r#"  Fr8  )rm   app_contextr-"  rn   ro   rl   ry   r  rk!  r*"  _idencia_sync_lock_idencia_sync_stater		  r$  r  s      r1   _idencia_sync_workerr4"  (     3__ 	?-e4MFE

""#<eD

 5v>	?   	3-2	*	3 	3	? 	?  K

:;!!'(I	KK
	3 	3 	3-2	*	3 	3 	3`   B AB	(B 6
C	BB A CC' CC' C$'D.
D8	DD
Dc                 h   | d   d   rt        |       syt        dt        | d   d               dz  }t        j                  d      xs i }|j                  d      }|sy		 t        j                  |d
      }t        j                         |z
  j                         |k\  S # t        t        f$ r Y y	w xY wzTrue when a scheduled sync should fire now (enabled + org configured +
    interval elapsed since the last attempt, success or failure).r  r:!  Fr   r9!  r   	last_syncr|  Trr  )rf!  rC  r   rk!  r3  r   r  r  r   r   r  r  rJ  
interval_sr  r|  r
  s        r1   _idencia_sync_duer<"  :      z?>*2DS2IQC
O,CDEFKJ!!+.4"D	$B&&r+>?  4'668JFF 	"    !B B10B1c                     ddl } 	  | j                  d       	 t        t                     s(t        5  t
        d   r
	 ddd       Bt
        j                  ddt        j                         j                  d             ddd       t        d       # 1 sw Y   xY w# t        $ r" t        j                  j                  d	       Y 7w xY w)
zBackground thread (togen-worker only): org-scope sync on the
    configured cadence. Checks each minute; the interval gate lives in
    _idencia_sync_due so config changes apply without a restart.r   NTr   r8  	schedulerrr  r8  r		  rq  zIdencia sync loop error)rU   r  r<"  rF!  r1"  r2"  r  r  r  r  r4"  ry   rm   rn   r  rU   s    r1   _idencia_sync_looprC"  K      


2	<$%9%;<# S&y1S S $** -113<<=PQ + SS !- 
S S  	<JJ  !:;	<8   B  B  BB  9B B  BB   (C
Cc                      t         5  t        t              } ddd       t        j                         } d   | d   | d   |d   |d   dS # 1 sw Y   4xY wzCSync state block for the Admin pane (running flag + last outcomes).Nr8  rq  r		  r  last_ok)r8  rq  r		  r  rH"  )r1"  r   r2"  rk!  sync_healthro  r-  s     r1   _idencia_sync_publicrK"  ` a    	 *()*&&(FY'u\7J7^VF^i(* ** *   A

Az(project|type|status)\s*:z%\s*"([^"]*)"|\s*\'([^\']*)\'|([^,;]+)c                 J   i }t        | xs d      } t        t        j                  |             }t	        |      D ]  \  }}|dz   t        |      k  r||dz      j                         n
t        |       }| |j                         | }g }t        j                  |      D ]@  \  }}	}
|xs |	xs |
j                         }|s"|j                  |j                                B |s|j                  |j                  d      j                         g       j                  |        |S )zParse the tile filter DSL into {key: [lowercased values]}. Tolerant:
    unknown text between keys is ignored, empty values dropped, repeated keys
    merge. Matching downstream is case-insensitive (hence the lowercasing).r7   r^   )r*   r  _IDENCIA_FILTER_KEY_REr>  r  rC  rq   r  _IDENCIA_FILTER_VAL_REfindallr   ri   rE  r}  rb  r  )r&  r   r  r  r  r  r  r  q1q2plainr  s               r1   _idencia_parse_filtersrU"  s s    Ctzr?D)22489G'" 	@1()AG(<ga!en""$#d)155733;;C@ 	'MBE"r"U))+AAGGI&	' NN1771:++-r299$?	@ Jr3   c                 j   | xs i j                  dd      }|syt        |      }t        |j                  d      t              r|j                  d      ni }t        |j                  d      t              r|j                  d      ni }t	        |j                  d      xs d      j                         S )u   The viewer's saved default filter string (User Settings → Services →
    Idencia). Empty for the synthetic signage identity (no email) — a wall
    display never inherits anyone's personal defaults.r  r7   rr  r  default_filters)r   r  r   r   r*   r   )r'  r  r  rv  rh!  s        r1   _idencia_user_default_filtersrX"   s     ZRWb)E"5)H&0j1I4&P(,,z
"VXC *3779+=t D#'')
"Csww()/R06688r3   c           	      z     xs i j                  d      dk(  rt        nt        t          fd      S )u  Live-first rows for one dataset (D2) at the tile timeout. Same cache
    key as the org sync, so the cached fallback serves the sync's last-good
    data when Idencia is unreachable. Web mode gets the slower budget — the
    website's grid pages routinely exceed the 5 s API allowance.rO  r   c                  "    t               S )Nr!  )r"  )r!  r  live_timeoutr!  s   r1   r	  z%_idencia_fetch_rows.<locals>.<lambda> s    &y%/;= r3   )r   IDENCIA_WEB_LIVE_TIMEOUT_SIDENCIA_LIVE_TIMEOUT_Sr!  )r!  r!  r  r["  s   ```@r1   _idencia_fetch_rowsr^"   sD     ((0E9 //  5'	=> >r3   c                     i }| xs g D ]J  }t        |ddd      }t        |dddd      }|#|s&t        |      j                         |t        |      <   L |S )z>{str(id): name} over a dataset's rows (projects / item types).r  r!  r!  r9   r"  r$"  re  )r"  r*   r   )rn  r   r  rK  r9   s        r1   _idencia_name_mapr`"   sb    
CZR .At[,?Qz7K?tIOO-CCM	.
 Jr3   c           	         |D ]  }t        j                  dt        | j                  |      xs d            }|s8t	        |j                  d            t	        |j                  d            t	        |j                  d            fc S  y)u   (yyyy, mm, dd) date tuple from the first parseable of several timestamp
    fields, or None. Tolerant of ISO / epoch-less formats — only the leading
    date part is compared.z(\d{4})-(\d{2})-(\d{2})r7   r^   r   rP   N)rF  r  r*   r   r   rb  )r  r.  r  r  s       r1   _idencia_row_daterb"   sq      GII0#cggaj6FB2GH
OS_c!''!*oFFG r3   )r  r  r  c                     t         j                  |      }|syt        | g| }|yt        j                         t        |      z
  }||j                  |j                  |j                  fk\  S )u   Range filter predicate. Rows with no parseable date are KEPT (tolerant
    — the schema is unconfirmed, so an unrecognized timestamp format must not
    silently empty the tile).Tr  )	_IDENCIA_RANGE_DAYSr   rb"  r  r  r  r  r  r  )r  r  date_fieldsr  rn  r   s         r1   _idencia_range_keeprf"   sg     ""3'D#,,Ay!O$>>FfllFJJ777r3   c                    t        | j                         d       }|D cg c]
  \  }}||d }}}|rHt        |      |kD  r:|d| ||d }}|j                  dt	        d |D              t        |      d       |}|S c c}}w )u   Ordered chart slices from a {label: count} map, largest first. With a
    cap, the tail folds into one "Other" slice carrying `more` (the folded
    slice count) so renderers can show "+N more" — data is never silently
    dropped.c                 4    | d    | d   j                         fS Nr^   r   rs  r  s    r1   r	  z!_idencia_slices.<locals>.<lambda>ε s    beVRU[[]4K r3   r  r  Nr@  c              3   &   K   | ]	  }|d      ywr  NrX   r&  s     r1   r/  z"_idencia_slices.<locals>.<genexpr>ҵ s     3M1AgJ3M   r   r  more)r  r   rC  ri   r  )r$  r  rG  r  r  slicesr.  r&  s           r1   _idencia_slicesrp"  ɵ s    
 V\\^)KLG3:;41aA&;F;c&kJ.KZ(&*=dg3M3M0M Y( 	)M <s   B c                    t        |      \  }}}}|rdd|dS | j                  d      xs ddvrd| j                  d      xs d}| j                  d	      xs d
| j                  d      xs d}t        ||      }|d   dd|d   xs ddS |d   D 	cg c]  }	t        |	t              s|	 }
}	| j                  d      xs dj                         xs t        |      }t        |      i i cdk7  rEdv sdk(  r|dk(  s
dk(  r2|dk(  r-t        t        ||d      xs i j                  d      xs g       dk(  r6dv s|dk(  r-t        t        ||d      xs i j                  d      xs g       fdfdd fd}|
D 	cg c]  }	 ||	      s|	 }
}	dd||| j                  d      xs dt        |d         |d    |d   r|d   ndt        |
      d!
}dk(  rt        |
      }|j                  d" #       d}|r!t        |      |kD  rt        |      |z
  }|d| }||d$<   ||d%<   t        |D ci c]  }|d&   |d'   xs d c}d      |d(<   t        d) |D              t        d* |D              t        d+ |D              d,|d-<   |S }dk(  r|dk(  r}n|dk(  r}i }|
D ]"  }	 ||	      }|j                  |d      d.z   ||<   $ t        ||      |d(<   |S c c}	w c c}	w c c}w )/u  Idencia dashboard tile (DVI-1226 P2). Viewer-scope via D1-C resolution
    (personal connection → org; a wall display's synthetic identity has no
    email so it always lands on the org account). Live-first with cached
    fallback (D2) — the payload carries cached/as_of so renderers can badge
    stale data. No role gate: the tile is available to every logged-in user,
    it degrades soft (available=False + message) when Idencia isn't set up.TFr  r  r   )r   r  r  r|  rd   r  r  r  r   r   Nr  Idencia data is unavailable.r  r7   r  r  r  rT  r  r!  c                 v    t        | dd      }j                  t        |      |t        |      nd      xs dS )Nr!  
project_idr7   r  r"  r   r*   )r  rK  proj_maps     r1   row_projectz+_dash_provider_idencia.<locals>.row_project s5    A{L9||CH#/c#hrJWiWr3   c                 x    t        | ddd      }j                  t        |      |t        |      nd      xs dS )Nr!  item_type_idtypeIdr7   r  ru"  )r  rK  type_maps     r1   row_typez(_dash_provider_idencia.<locals>.row_type s7    A|^XF||CH#/c#hrJWiWr3   c                 \    t        t        | ddd      xs d      j                         xs dS )Nrd   r"  r  r  )r*   r"  r   rx	  s    r1   
row_statusz*_dash_provider_idencia.<locals>.row_status s3    =HlHE  !!&6,5	6r3   c                 l   dk(  r~t        t        | ddd      xs d      j                         }j                  d      r|j	                         d   vryj                  d      r |       j	                         d   vryy	j                  d      r |       j	                         d   vryj                  d      r |       j	                         d   vryd
k(  r-j                  d      r |       j	                         d   vryd
k(  rt        | d      S dk(  rt        | d      S y	)Nr  r9   r"  re  r7   r  Frd   Tr   rT  r!  	updatedAtr  r!  r!  )r*   r"  r   r   rE  rf"  )r  r9   r(  r  r  rw"  r~"  r|"  s     r1   rI   z$_dash_provider_idencia.<locals>.keep
 s*   }QwGM2NTTVD{{9%$**,gi>P*P{{8$A)<)<)>ghFW)W;;y!k!n&:&:&<GIDV&V;;x Z]%8%8%:'(BS%S=W[[0QK%%'wv>=&q#/IJJ&q#/HIIr3   r{  r  r  r  )
r  r  r	  r  r|  r{  r  r  r  r@  c                 <    | d   xs d | d   j                         fS )Nr!  r   r9   rs  r  s    r1   r	  z(_dash_provider_idencia.<locals>.<lambda>+ s"    q}'9%:AfIOO<M$N r3   r  r  r  r9   r!  ro"  c              3   .   K   | ]  }|d    xs d  ywr  r   NrX   rp  s     r1   r/  z)_dash_provider_idencia.<locals>.<genexpr>5      A1AjM.Q.A   c              3   .   K   | ]  }|d    xs d  ywr!  r   NrX   rp  s     r1   r/  z)_dash_provider_idencia.<locals>.<genexpr>6 r"  r"  c              3   .   K   | ]  }|d    xs d  ywr  r   NrX   rp  s     r1   r/  z)_dash_provider_idencia.<locals>.<genexpr>7 s     CQQ{^0q0Cr"  )r  r!  r  r  r^   )ro!  r   r^"  r   r   r   rX"  rU"  r`"  r   rC  r!"  r  rp"  r  )rJ  r'  r  r!  r	  r  r*  r  rK  r  rn  	filt_textrI   r  r  r  r  group_fnr$  r)  r(  r  rv"  r  rw"  r~"  r|"  r{"  s                       @@@@@@@@r1   r  r  ص s    !<D AE5&#
UsCC			&wB	33			(B
'''

#eC&+!J!%3GvU"7+M/MO 	Ov>!*Q*=A>D>#)r002Y6STX6YI$Y/G RHh	ZY'1bGmi!#{!2rY$&9%
&S ',)+SS[&?<>@	W}&G+r[/@$&9%&U ',)+SS[&?<>@XX6 * '!tAwA'D' TV2#'')2D2Uwx()GG4D%,X%6!DTG 
Z,T2NO	#h-*4H
2I,H&
(+6>?QvY:+!+?D AAAAAAC(CC

 H	W}{*	yF -qkjja(1,s- (
;GHNo ?b (( @s   !K87K8K=+K=Lr  z&https://api.open-meteo.com/v1/forecastz.https://geocoding-api.open-meteo.com/v1/search)z	Clear skyrR  )zMainly clearzmostly-clear)zPartly cloudyzpartly-cloudy)Overcastcloudyrv  )Fogfogr  )zRime fogr"  3   )zLight drizzledrizzle5   )Drizzler"  7   )zDense drizzler"  8   )zFreezing drizzler"  9   =   )zSlight rainrain?   )Rainr"  A   )z
Heavy rainr"  B   )zFreezing rainr"  C   G   )zSlight snowsnow)Snowr"  )z
Heavy snowr"  )zSnow grainsr"  )zRain showersr"  )zViolent rain showersr"  )zSnow showersr"  )zHeavy snow showersr"  )Thunderstormthunder)zThunderstorm w/ hailr"  )I   K   M   r   Q   r;  U   V   _   `   c   c                 p    	 t        |       }t        j	                  |d      S # t        t        f$ r Y yw xY w)uK   (label, icon-category) for a WMO weather code; unknown → a neutral cloud.)r  r"  )r   r   r   _WMO_WEATHERr   )r3  r  s     r1   _weather_code_metar"  z s?    %I A455 z" %$%s   # 55c                    t        | xs d      xs i j                  d      }t        |t              rX|j                  d      G|j                  d      6t	        |j                  d      xs d      j                         |d   |d   dS y)u  The resolved regional location for a viewer (system baseline → org
    default → the user's own override) as a weather location dict, or None.
    Backs the ``my_location`` / ``org_default`` tile location sources (DVI-1364
    P3). An empty email resolves the org location only (never a person's) — the
    signage identity has no email, so a wall always lands on the org location.r7   r  rN  NrP  r   r|  )r  r   r   r   r*   r   )r  r  s     r1   _weather_regional_locationr"   s     U[b)/R
4
4Z
@C#t!4!@$0SWWW-34::<
O#k:JL 	Lr3   c                      t         } t        | t              rX| j                  d      G| j                  d      6t	        | j                  d      xs d      j                         | d   | d   dS t        d      S )a)  The org-default location dict, or None. Only used when a manual tile has
    no location of its own. Prefers the explicit ``_WEATHER_ORG_DEFAULT``
    constant if set, else falls back to the org regional location (DVI-1364 P3),
    so the plant's regional location is the natural facility default.rN  rP  r   r7   r|  )_WEATHER_ORG_DEFAULTr   r   r   r*   r   r"  r  s    r1   _weather_default_locationr"   sr    
 	A!TquuZ0<k".QUU7^1r288:jM+H 	H%b))r3   c                 6   ddl m}  ||xs d      j                  xs d}t        |       }|j	                         }d|v sd|v sd|v sd|v rd	|z  S t        | t        j                        sd
|v rd|z  S t        | t        j                        rd|dd z  S |dd S )u   Condense a requests exception into an actionable one-liner (mirrors the
    Idencia/Power BI condensers — the raw text buries the cause in a pool stack).r   r  r7   zthe weather servicerU!  rV!  rW!  rX!  z0could not resolve the weather service host '%s'.rZ!  z2the weather service (%s) took too long to respond.z.could not connect to the weather service (%s).Nr  rK   	r   r
   rI  r*   rE  r   r  r^!  r`!  ra!  s         r1   _weather_request_errorr"   s     &CI2%%>)>Ds8DjjlGG#}'?*g5&'1ADHH#~--.+2HCdJJ#~556?$t*LL:r3   c                     t         5  t        j                  |       }|r(|d   t        j                         kD  r|d   cd d d        S t        j	                  | d        d d d        y # 1 sw Y   y xY wr!  )_weather_memo_lock_WEATHER_MEMOr   rU   r  )r)  r!  s     r1   _weather_memo_getr"   sf    	 %$3q6DIIK'q6% % 	#t$	%
 %
 s   6A&A&&A/c                     t         5  t        j                         t        z   |ft        | <   t	               |ft
        | <   d d d        y # 1 sw Y   y xY wrT   )r"  rU   WEATHER_MEMO_TTL_Sr"  r  _WEATHER_LAST_GOOD)r)  r  s     r1   _weather_memo_putr"   sI    	 9"iik,>>Hc $-;"83	9 9 9s   8AAc                 d    t         5  t        j                  |       cd d d        S # 1 sw Y   y xY wrT   )r"  r"  r   r  s    r1   _weather_last_goodr"   s(    	 +!%%c*+ + +s   &/c                 8   | j                  d      xs i }|j                  d      }t        |      \  }}| j                  d      xs i }|j                  d      xs g }|j                  d      xs g }|j                  d      xs g }	|j                  d      xs g }
g }t        |      D ]w  \  }}t        |t        |      k  r||   nd      \  }}|j	                  ||t        |      k  r||   nd|||t        |	      k  r|	|   nd|t        |
      k  r|
|   ndd       y |j                  d	      |j                  d
      |j                  d      |j                  d      t        |j                  dd            |||d|r|d   nd||rdnd|rddS ddS )u   Open-Meteo response → a render-ready shape the SPA/kiosk renderers walk.
    Always carries up to 7 forecast days; the tile slices to its configured
    forecast_days at render time (so the memo is location-complete).rC  weather_coder  rU   temperature_2m_maxtemperature_2m_minN)r   r3  r   rW  r  rK  temperature_2mapparent_temperaturerelative_humidity_2mwind_speed_10mis_dayr^   )r   
feels_liker   windr"  r3  r   rW  r   u   °Fu   °Cmphzkm/h)rC  r	  r  	temp_unit
speed_unit)r   r"  r  rC  ri   r   )r   rT  r  r3  r   rW  r  timescodeshighslowsr  r  rn  r   dis                   r1   _weather_normalizer"  ƶ s    '')

"C77>"D$T*KE4GGG"EIIf#EIIn%+EII*+1rE99)*0bDD%  1#CJE!HDIB !CJE!HD !CJE!HD#d)m47
 	 GG,-''"89 67GG,-3778Q/05$
 !ad Ue!e  (. r3   c           	      t   |dk7  }| |dd|rdnd|rdnddd	d
}	 t        j                  t        |t              }|j                  dk7  rdd|j                  z  fS 	 |j                         }t        ||      dfS # t         j                  $ r}dt        |t              fcY d}~S d}~ww xY w# t        $ r Y yw xY w)zGFetch current + 7-day forecast from Open-Meteo. Returns (payload, err).rU  z[temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10mz2weather_code,temperature_2m_max,temperature_2m_min
fahrenheitcelsiusr"  kmhr   r   )rN  rP  rC  r  temperature_unitwind_speed_unitr   rY  r!  NrK   zOpen-Meteo returned HTTP %s.)Nz+Open-Meteo returned an unreadable response.)
r  r   _OPEN_METEO_FORECAST_URLWEATHER_FETCH_TIMEOUT_Sr?  r"  rg   r   r   r"  )r*  r  rR  rT  r  r  rl  r   s           r1   _weather_fetch_openmeteor"   s    
8
Bc9E,.LI$&5E	FK!!":6*AC 33d6F6FFFFCiik c2&,, ** K+C1IJJJK  CBCs/    A9 B+ 9B(B#B(#B(+	B76B7c                 N   | xs dj                         }|sg dfS |dd t        dt        dt        |xs d                  ddd	}	 t	        j
                  t        |t        
      }|j                  dk7  rg d|j                  z  fS 	 |j                         }g }|j                  d      xs g D ]  }t        |t              s|j                  d      |j                  d      }
}	|	|
;|j                  d      |j                  d      |j                  d      xs |j                  d      g}dj                  d |D              }|j!                  ||	|
d        |dfS # t        j                  $ r}g t        |t              fcY d}~S d}~ww xY w# t        $ r g dfcY S w xY w)u   Resolve a place name → candidate locations (Open-Meteo geocoding, no key).
    Returns (results, err); each result is {label, latitude, longitude}.r7   Nr  r^   r   r   enr   )r9   r  languager	  r!  rK   zPlace search returned HTTP %s.z-Place search returned an unreadable response.r  rN  rP  r9   admin1country_codecountryr  c              3   8   K   | ]  }|st        |        y wrT   )r*   rp  s     r1   r/  z#_weather_geocode.<locals>.<genexpr>" s     5Q1#a&5s   r|  )r   rC  rL  r   r  r   _OPEN_METEO_GEOCODE_URLr"  r?  r"  rg   r   r   r   r   r  ri   )r#  r  r  r  r  rl  r   r   r  r*  r  r0
  r   s                r1   r  r   s    
"A4xgAs2s5:A/G(H&2FH!!"9&*AC 33d6F6FFFFCiik Cggi &B H!T"55$aeeK&8S;#+vh~1F1Z!%%PYJZ[		5%55

U#FGH 9% ** H)#/FGGGH  CBBBCs0    E" F "F5FFFF$#F$c                    | j                  d      xs d}|dvrd}| j                  d      xs dj                         }dx}}|dk(  r5t        |xs i j                  dd      xs d      }|rw|d	   |d
   |d   }}}ng|dk(  rt        d      }|rU|d	   |d
   |d   }}}nE| j                  d	      }| j                  d
      }||t               }|r|d	   |d
   }}|xs |d   }||ddddS 	 t	        t        |      d      }t	        t        |      d      }| j                  d      dk(  rdnd}	| j                  d      }
|
dvrd}
	 t        dt        dt        | j                  d      xs d                  }|||	f}d\  }}}t        |      }|t        |      }nVt        |||	      \  }}|t        ||       t        |      }n,t        |      }|rt        |d         d|d   }}}n	ddd|z   dS |j!                  dd|xs d ||fz  |
|	||||r|ndd!	       |S # t        t        f$ r	 ddddcY S w xY w# t        t        f$ r d}Y w xY w)"uR  Weather tile provider (DVI-1277). Resolves the tile's stored lat/lon (or
    the org default), fetches current + forecast from Open-Meteo through the
    memo/last-good layer, and returns a render-ready payload. No role gate.

    Location source (DVI-1364 P3):
      - ``manual`` (default): the tile's stored lat/lon; if none, the org default.
      - ``my_location``: the viewer's resolved regional location (system → org →
        their own override). On signage (no email) this lands on the org location.
      - ``org_default``: the org regional location (system → org, no per-user).r  r  )r  r  r  rM  r7   Nr  r  rN  rP  r   r  TFzLNo location set for this weather tile. Edit the tile and search for a place.r  r   z(This weather tile's location is invalid.rR  rU  rT  rO  )rC  rW  rX  rC  r^   r   rY  r   )FNNr   zWeather is unavailable: z
%.3f, %.3f)	r  r  r  rO  rR  rY  r  r  r  )r   r   r"  r"  rj   r  r   r   rC  rL  r   r"  r   r"  r"  r"  r  )rJ  r'  r	  r   r*  r  rn  latflonfrR  rO  r1  r)  r  r  r  r  r   r  r!  s                       r1   rZ  rZ  ' s    WW&'38F==WW%&,"335EC#&
'7'7'D'JK
mQ{^QwZeC	=	 &r*
mQ{^QwZeCggj!ggk";#+)+AZ=!K.S+7
{ckU;= 	=GU3Z#U3Z# (H4H$E776?D55As1c#''/":"?a@AB u
C*FE3S!DDz/dEB;c7+=D$S)C&*3q6lD#a&ef"&U#=#CE E 	KKT:ldD\9uu5D  KK z" GUEG 	GG z" s$   /*H 	2H: H76H7:IIz/idencia/configc                     t               } t        | d         }t        |j                  dd            }t	        d||| j                  dd      | d   | d   t               t        j                  t        j                        	      S )
zConfig for the Admin -> Services -> Idencia pane. The org password is
    redacted to has_password (write-only, ThingPark convention).r^  rs  r7   TrO  r>!  r?!  r  )rU  r^  rt  rO  r?!  r  synccache)
rF!  r   r   r  r   r   rK"  rk!  
cache_keysrm!  )rJ  r^  rt  s      r1   idencia_config_getr"  w sz    
 
 C
s5z
C
B/0Ld,. -J,.&11-2I2IJ	L Lr3   c            	      @   t        j                  d      xs i } t               }t        | j	                  d      t
              r| d   }dD ]>  }t        |j	                  |      t              s#||   j                         dd |d   |<   @ t        |j	                  d      t              r,|d   j                         r|d   j                         |d   d<   | j	                  d      d	v r| d   |d<   t        | j	                  d
      t
              rdD ]P  }| d
   j	                  |      }t        |t              s(t        ||      \  }}|rt        |      dfc S ||d
   |<   R t        | d
   j	                  d      t              r,t        | d
   d         \  }}|rt        |      dfS ||d
   d<   t        | j	                  d      t
              rr| d   j	                  d      }t        |t        t        f      r*|dkD  r%t        dt        dt        |                  |d   d<   d| d   v rt        | d   d         |d   d<   	 t!        |       t$        5  t&        j)                  t*        j,                  d       ddd       t/        t*        j,                         t        |d         }t        |j)                  dd            }	t        d||	|j	                  dd      |d
   |d         S # t"        $ r}t        d|       dfcY d}~S d}~ww xY w# 1 sw Y   xY w)zSave org account / endpoints / sync settings (partial merge). A
    blank/omitted org.password keeps the stored one so the redacted GET can
    round-trip without wiping the secret.Tr  r^  )rF  r=!  NrK   rs  rO  r@!  r?!  r6!  r  r`  r  r  r9!  r     r   r:!  r-!  r  r7   r>!  )rU  r^  rt  rO  r?!  r  )r   r  rF!  r   r   r   r*   r   rP!  r   rS!  r   r  rL  rC  r   rH!  r   r!  r!  r  rk!  rm!  r!  )
r  rJ  r^  r  rQ  rJ  r!  rE!  rl  rt  s
             r1   idencia_config_postr"   s    4(.BD

 C$((5/4(5k' 	5A#''!*c* #At 4E
1	5 cggj)3/C
O4I4I4K%(_%:%:%<CJz"xx>)6lF$((;'.- 	*A{#''*C#s#7Q?	T".33 '*K #	* d;'++I6<243DY3OPICT*C//*-CY'$((:&-*!!"9:b3,'BF7:4QB7QC
O34T*--.24
3CN3S.TC
ON+CS! 
 ;M33T:;}667
s5z
C
B/0Ld,. -JI I  C6se<=sBBC; ;s*   K- %L-	L6LLLLz/idencia/testc            
         t        j                  d      xs i } t               }| j                  d      xs dj	                         xs |d   d   }| j                  d      xs dj	                         xs |d   d   }|r|st        d      d	fS | j                  d
      dv r| j                  d
      n|j                  d
d      }|dk(  ryt        | j                  d      xs d      \  }}|rt        |      d	fS t        |||xs |d   j                  dd      d      \  }}|rt        d|      dfS t        dddd|S t        | j                  d      xs dd      \  }	}|rt        |      d	fS t        |||	xs |d   d         \  }
}|rt        d|      dfS t        ddt        |
d         t        |
d         t        |
d               S )zTest the org connection: login, then one minimal GraphQL round trip.
    Body may carry {username, password, login_url} to test values before
    saving; blanks fall back to the stored org config. Persists nothing.Tr  rF  r7   r^  rs  z1Set the organization username and password first.r  r`  rO  r@!  r>!  r   r  r?!  rF  rs  r  Fr  rU  rU  rO  r7!  r!  r  r  rX  )rU  rO  has_access_tokenhas_id_tokenhas_refresh_tokenrX   )r   r  rF!  r   r   r   rS!  r!  rP!  r}!  r   )r  rJ  rF  rs  rO  r  r!  rl   r  r7!  r  s              r1   idencia_testr"   s    4(.BD

 C$*113Ms5z*7MH$*113Ms5z*7MH8PQSVVV#xx/>A488FWWVU# 	u}2488I3F3L"M&++&!xF3{#3#7#7	2#FHI	c e3/443$U3d331#[2OItT"C'' (<s;/<>KFC %s+S00d$()9$: $VD\ 2%)&*;%<> >r3   z/idencia/syncc                     t         d   j                  dd      } t               st        d      dfS t        5  t
        d   rt        d      d	fcd
d
d
       S t
        j                  d| t        j                         j                  d             d
d
d
       t        j                  t        | fd      j                          t        d      S # 1 sw Y   @xY w)z8Kick off an org-scope sync in the background (Sync now).r'  r  r7   z1Configure the organization Idencia account first.r  r`  r8  A sync is already running.r9  NTrr  rA"  r(  rg  )r   r   rf!  r   r1"  r2"  r  r  r  r  r  r  r4"  rq   rg  s    r1   idencia_sync_startr"   s     FO,EPQSVVV	 Ky)!=>CK K 	""%))+445HI 	# 	KK 0x ""'%'4  K K   C9CCz/idencia/sync-statusc                  p    t        dt               t        j                  t        j                              S z1Poll target for the Admin pane's Sync now button.T)rU  r"  r"  )r   rK"  rk!  r"  rm!  rX   r3   r1   idencia_sync_statusr#   /     d!5!7&11-2I2IJL Lr3   z/idencia/user-connectionc                     t         d   j                  dd      } t        |       }t        |j                  d      t              r|j                  d      ni }t        |j                  d      t              r|j                  d      ni }t               }t        t        |            }t        dt        |j                  d      xr |j                  d            |j                  dd      t        |j                  d            |j                  d	d      |j                  d
d      |j                  dd      |j                  d      dv r|j                  d      nd|j                  dd      |j                  dd      |      S )zBThe current user's personal Idencia connection, password redacted.r'  r  r7   rr  r  TrF  rs  rW"  r7!  r8!  rO  r@!  r  r>!  )rU  r  rF  rt  rW"  r7!  r8!  rO  r  org_modeorg_available)	r   r   r  r   r   rF!  r   rf!  r   )r  r  rv  rh!  rJ  r#  s         r1   idencia_user_connection_getr#   s5    FO,E"5)H&0j1I4&P(,,z
"VXC *3779+=t D#'')
"C

 C+C01Md"377:#6#N377:;NOGGJ3 $SWWZ%8 9#&77+<b#A WW["5"ww}b9,/GGFO~,M 779b1GGFE2!.0 0r3   c                     t         d   j                  dd      } t        j                  d      xs i }t	        |       }|j                  d      }t        |t              si }|j                  d      }t        |t              si }|j                  d      r|j                  dd	       nt        |j                  d
      t              r|d
   j                         d	d |d
<   t        |j                  d      t              r)|d   j                         r|d   j                         |d<   t        |j                  d      t              r|d   j                         d	d |d<   dD ]`  }t        |j                  |      t              s#t        ||   |      \  }}|rt        |      dfc S |r|||<   O|j                  |d	       b d|v r.|j                  d      dv r	|d   |d<   n|j                  dd	       t        |j                  d      t              r;t        |d         \  }}|rt        |      dfS |r||d<   n|j                  dd	       |j                  d
      s|j                  dd	       ||d<   ||d<   	 t        | |       t        5  t         j                  t#        j$                  |       d	       d	d	d	       t'        t#        j$                  |              t        dt)        |j                  d
      xr% |j                  d      xr |j                  d                   S # t        $ r}t        d|       dfcY d	}~S d	}~ww xY w# 1 sw Y   xY w)zSave/clear the current user's personal Idencia connection. Password is
    write-only: blank keeps the stored one. {"disconnect": true} removes the
    connection (the user reverts to the org account, when configured).r'  r  r7   Tr  rr  r  
disconnectNrF  rK   rs  rW"  r  r6!  r  r`  rO  r@!  r  r;  )rU  r  )r   r   r   r  r  r   r   r  r*   r   rP!  r   rS!  r  r   r!  r!  rk!  rl!  r!  r   )	r  r  r  rr  rh!  r  rJ  r!  rl  s	            r1   idencia_user_connection_postr	#   s    FO,E4(.BD"5)H||J'Hh%
,,y
!Cc4 xxY%dhhz*C0":.446t<C
Odhhz*C0T*5E5K5K5M":.446C
Odhh0137%)*;%<%B%B%DTc%JC!"- 	%A$((1+s+7QC	T".33 CFGGAt$	% T>xx>1"6lF%dhhy)3/24	?CICT*C//!$I	4(wwz"GGJ%!#HZ<E8, 
 CM44U;TBC}77>?d"377:#6 $B377:;N $B+/88L+A'ACD D  </u56;;<C Cs*   L/ *M/	M8MMMMz/idencia/user-testc            	      v   t         d   j                  dd      } t        j                  d      xs i }t	        |       xs i }|j                  d      xs dj                         xs |j                  dd      }|j                  d      xs dj                         xs |j                  dd      }|r|st        d	      d
fS |j                  d      dv r|j                  d      n,|j                  d      xs t               j                  dd      }|dk(  rt        |j                  d      xs d      \  }}|rt        |	      d
fS t        |||xs0 |j                  d      xs t               d   j                  dd      d      \  }}	|	rt        d|	      dfS t        dddd|S t        |j                  d      xs dd      \  }
}|rt        |	      d
fS t        |||
xs |j                  d            \  }}	|	rt        d|	      dfS t        ddt        |d         t        |d               S )zTest the current user's personal Idencia login. Body may carry
    {username, password, login_url} to test before saving; blanks fall back
    to the stored personal connection (then the org endpoint settings).
    Persists nothing.r'  r  r7   Tr  rF  rs  z/Enter your Idencia username and password first.r  r`  rO  r@!  r>!  r   r  r?!  r"  Fr  rU  r"  r7!  r!  r  r  )rU  rO  r"  r"  rX   )r   r   r   r  ri!  r   r   rF!  rS!  r!  rP!  r}!  r   )r  r  r4  rF  rs  rO  r  r!  rl   r  r7!  r  s               r1   idencia_user_testr#  ] s7    FO,E4(.BD%e,2F$*113Qvzz*b7QH$*113Qvzz*b7QH8NOQTTT#xx/>A488Fjj M$8$:$>$>vu$M 	u}2488I3F3L"M&++&!x  SFJJy$9 S02;?CCIrRUV	c e3/443$U3d331#[2OItT"C'' (6vzz+68KFC %s+S00d$()9$: $VD\ 24 4r3   z/idencia/vocabc                  D   t        j                  t         j                  d      } | r$t        | j	                  d      t
              r| d   ni }t        d|j	                  d      xs g |j	                  d      xs g |j	                  d      xs g | r
| d   	      S d	      S )
u	  Picker vocabulary for the Idencia tile editor (D3 guided pickers):
    project / item-type / status names from the org sync's cached vocab.
    Empty lists until the first successful sync — the pickers degrade to free
    text, so this is never a hard dependency.r%"  r  Tr  r!  r  r"  N)rU  r  r!  r  r  )rk!  r"  rm!  r   r   r   r   r!  r%"  s     r1   idencia_vocabr#   s     
!
!-"9"97
CC!j1CT&JC	NPREd!IIj17R#ii5;!IIj17R.1\*	= = 8<	= =r3   )r  zAll timer  r  r  r  r  r  ))r  zPer day)r  zPer week)r  z	Per monthc                 V    t        | j                  d      xs d      }|t        v r|S dS )Nr  r  )r*   r   rd"  )r  r  s     r1   _idencia_report_ranger#   s-    
fjj!*U
+C,,37%7r3   c                 F    | t        |d         |d   |d   r|d   dS ddS )zEReport meta block from a live-first fetch result (cached badge data).r  r  r  Nr	  r  r  r  )r   )r	  rK  s     r1   _idencia_report_metar#   sC    WX->(?W%)0):WW%F F@DF Fr3   c                     t        |       \  }}}}|rdddd|fS t        |||      }|d   dddd|d   xs dfS |d   D cg c]  }t        |t              s| }}|||t	        ||      dfS c c}w )u   Viewer-scope live-first rows for one dataset. Returns
    (rows, scope, creds, meta, error) — error is a user-facing message when
    Idencia is unconfigured or both live and cache are unavailable.Nr   r  rr"  )ro!  r^"  r   r   r#  )	r'  r  r  r!  r	  r  rK  r  rn  s	            r1   _idencia_report_fetchr#   s     !<D AE5&#
T4s**!%8GvT4(8 )J+IK 	Kv>!*Q*=A>D>3FGDdJJ ?s   A.A.c                    t        |d      \  }}}}}|rd|fS t        | j                  d      xs d      }|j                  d      rK|D cg c]@  }t        t	        |ddd      xs d      j                         j                         |d   v r|B }}t        |      }	|	j                  d	 
       d }
|	D cg c]"  }|d   |d   |d   |d    |
|d   |d         g$ }}|rSt        d |	D              }t        d |	D              }t        d |	D              }|j                  d||| |
||      g       dddddddddddddddg}|||ddfS c c}w c c}w )zPer-project required / produced / remaining / % complete, plus a
    trailing totals row (also wanted in the CSV, so it lives in the data).r  Nr  r7   r  r9   r"  re  c                 (    | d   j                         S r  rs  r  s    r1   r	  z4_idencia_report_production_summary.<locals>.<lambda>Ը s    &	 1 r3   r  c                 4    |sy t        d| xs dz  |z  d      S )N      Y@r   r^   rj   )r!  r  s     r1   pctz/_idencia_report_production_summary.<locals>.pctָ s#    Uhm!,x7;;r3   r  r!  r  c              3   .   K   | ]  }|d    xs d  ywr"  rX   rp  s     r1   r/  z5_idencia_report_production_summary.<locals>.<genexpr>޸ s     91AjM&Q&9r"  c              3   .   K   | ]  }|d    xs d  ywr"  rX   rp  s     r1   r/  z5_idencia_report_production_summary.<locals>.<genexpr>߸ s     :AQz]'a':r"  c              3   .   K   | ]  }|d    xs d  ywr"  rX   rp  s     r1   r/  z5_idencia_report_production_summary.<locals>.<genexpr> s     :AAkN'a':r"  zAll projects (total)r  r  RequiredProduced	Remainingpct_completez
% Completer   rn  r  )r#  rU"  r   r*   r"  r   rE  r!"  r  r  ri   )r  r'  rn  r  r!  r  r  r  r  r  r#  r  r   t_reqt_prodt_remr   s                    r1   "_idencia_report_production_summaryr+#  ȸ s    %:$
$K!D%c
Sy$VZZ	%:%@bAG{{9 Ea}QwG #egeeg1CD  E E ).HMM1M2< 9AB34 fIq}a
mQ{^*q}-/ BC B
999::::::

*E65&( 	) 95!J7!J7"[9%=	?G
 T:D@@1EBs   AE8'Ec           
         t        |d      \  }}}}}|rd|fS t        | j                  d      xs d      t        |       i i cj                  d      r-t	        t        ||d      xs i j                  d      xs g       j                  d      r-t	        t        ||d	      xs i j                  d      xs g       fd
}t        |D cg c]  } ||      s| c}      }	t        |	j                               }
t        |	j                         d       D cg c]  \  }}|||
rt        d|z  |
z  d      ndg  }}}|r|j                  d|
|
rdndg       dddddddddg}|||ddfS c c}w c c}}w )zItem counts by status, filterable by project / item type / date range.
    Name maps are fetched only when a filter needs them and fail soft to raw
    ids (same tolerance as the dashboard tile provider).r   Nr  r7   r  r  r   rT  r!  c                 H   j                  d      rOt        | dd      }j                  t        |      |t        |      nd      xs d}|j                         d   vryj                  d      rPt        | dd	d
      }j                  t        |      |t        |      nd      xs d}|j                         d   vryj                  d      rCt        t        | ddd      xs d      j	                         xs d}|j                         d   vryt        | d      S )Nr  r!  rt"  r7   r  FrT  r!  ry"  rz"  rd   r"  r  r"  )r   r"  r*   rE  r   rf"  )r  rK  r9   rd   r  rv"  r  r{"  s       r1   rI   z)_idencia_report_item_status.<locals>.keep s   ;;y!;=C<<Cco#c(2N[R[Dzz|79#55;;v<JC<<Cco#c(2N[R[Dzz|76?2;;x q(L(K &%'',uw<2; ||~WX%66"1c+EFFr3   c                 4    | d    | d   j                         fS ri"  rs  r  s    r1   r	  z-_idencia_report_item_status.<locals>.<lambda> s    r!ufbekkm-D r3   r  r#  r^   zAll statuses (total)rd   r  r  r  r  r#  
% of Totalr'#  )r#  rU"  r   r#  r`"  r^"  r"  r  r  r  r   rj   ri   )r  r'  rn  r  r!  r  r  rI   r  r$  r@  r  r  r   r   r  rv"  r  r{"  s                  @@@@r1   _idencia_report_item_statusr0#   s    %:$$H!D%c
Sy$VZZ	%:%@bAG

'CRHh{{9$ z:@bEEfMSQSU{{6$ |<BGGOUSUWG$ $$@1QQ$@AF Eflln"DFGq! q%	E)1-DA GC G 

*EE5tLM(31|46G T:D@@ %AGs   E?&E?)#Fc                 (   t        |d      \  }}}}}|rd|fS t        |       }t        | j                  d      xs d      dvrd|D cg c]  }t	        ||d      r| }}fd}	i }
|D ]Y  } |	|      }|
j                  |ddd	      }|dxx   d
z  cc<   t        |dddd      }	 t        |      }|K|d   xs d|z   |d<   [ t        d |
D              }d|
v r|j                  d       |D cg c]  }||
|   d   |
|   d   g }}|rG|D cg c]  }|
|   d   |
|   d    }}|j                  dt        |      |rt        |      ndg       dddddddddg}|||ddfS c c}w # t        t        f$ r d}Y w xY wc c}w c c}w )a  Shipments grouped per period (day / ISO week / month) from each row's
    ship date, with summed item counts where the schema provides one. Rows
    with no parseable date land in an explicit "Unknown" bucket rather than
    being dropped (schema-tolerant, matching _idencia_range_keep).r  Nperiodr  )r  r  r  r"  c                     t        | dd      }|ydk(  rd|z  S dk(  rd|d   |d	   fz  S t        | j                         }d
|d   |d	   fz  S )Nr!  r!  r  r  z%04d-%02d-%02dr  z	%04d-%02dr   r^   z
%04d-W%02d)rb"  r  isocalendar)r  rn  r  r2#  s      r1   bucketz)_idencia_report_shipments.<locals>.bucket* su    a[99U?#a''W!A$!--a ,,.s1vs1v...r3   r   )r  r   r^   	itemCountr  r   r	  c              3   ,   K   | ]  }|d k7  s	|  yw)r  NrX   r  s     r1   r/  z,_idencia_report_shipments.<locals>.<genexpr>A s     7Y!7s   
r  zAll periods (total)Periodr  r  r  r'#  )r#  r#  r*   r   rf"  r}  r"  r   r   r   r  ri   rC  r  )r  r'  rn  r  r!  r  r  r  r  r5#  rs  r)  r   r  r,  r  r   known_itemsr   r2#  s                      @r1   _idencia_report_shipmentsr:#   s   
 %:$$L!D%c
Sy

'CH%/0F-- G!"1c+DE  GD G	/ G 
3Qis!d$CD	+!alGZP	JE G*/U2AgJ
3 7W77DGIFJ
KAwqz+&
7(;<
KC
K
48 ;q!!*W-9 qz'* ; ;

)3t9(3C$? 	@(3"[913G T:D@@MG, :& 	E	 L;s$   E.+E35F
F3FF))r=  r  )r:  r  )source_sheetTeam)r;  zForm IDc           	         t        | j                  d      xs d      }|t        j                  vrd}t        j                  || j                  d      xs d      }|d   fd}|d   D cg c]  }|d	   |d
    ||d
         g }}|r|j                  drdndg       d	t        j                  |   dd
dddddg}||ddiddfS c c}w )zInventory breakdown: asset counts grouped by Type / Area / Team / Form
    ID with % of total, plus a trailing totals row (kept in the data so the
    CSV carries it). An optional text filter scopes it like the list search.r|  r=  r  r7   r)  r@  c                 0    rt        d| z  z  d      S d S Nr#  r^   r#  r  r@  s    r1   r#  z%_assets_report_breakdown.<locals>.pcta      27uUU]U*A.ATAr3   r   r  r  zAll assets (total)r#  Nr  Assetsr#  r/#  r	  r  r'#  )r*   r   r  GROUP_FIELDSgroup_countsri   )	r  r'  r  r   r#  r   rn  r   r@  s	           @r1   _assets_report_breakdownrE#  W s     

:&6,7EL---$$Ufjjo.CDDMEB @DH~N!QwZ7S7_5NDN)55%dKL)B)B5)IJ2|46G x6JKTQQ Os   /Cc           
         
 t        j                  | j                  d      xs d      }|d   
|d   }
fd}d}|D cg c]  \  }}|||    |||         g }}}dd	d
ddd
ddd
g}	|	|d
dddfS c c}}w )u   Data completeness / exceptions: how many assets are missing key fields
    (Type / Area / Serial # / photo). Flag, not fix — one row per gap.r  r7   r)  r@  missingc                 0    rt        d| z  z  d      S d S r?#  r#  r@#  s    r1   r#  z(_assets_report_completeness.<locals>.pctt rA#  r3   ))zMissing Asset Typer=  )zMissing Arear:  )zMissing Serial #	serial_no)zNo photo attachedr  r  zData gapr  r  rB#  r#  r/#  r  )r	  r@  r'#  N)r  completenessr   )r  r'  r   rG#  r#  checksr   r)  rn  r   r@  s             @r1   _assets_report_completenessrL#  m s     $$vzz#'<"=DME9oGB.F HNNUGCL#gcl"34NDN42|46G   (59;<@B B	 Os   A8c                    	 t        | j                  d      xs d      }t	        dt        |d            }t               }	 |j                  d|f      j                         }|j                          |D cg c]  }|d   |d   |d   xs d	g }}d
ddddddddg}||ddiddfS # t        t        f$ r d}Y w xY w# |j                          w xY wc c}w )zMost-viewed assets (D3-A usage angle) from the asset_viewed history
    events, most views first. Empty until usage logging has accrued.r\  rx  r^   rK   zSELECT ref, COUNT(*) AS n, MAX(created_at) AS last FROM tool_history WHERE action = 'asset_viewed' AND COALESCE(ref,'') != '' GROUP BY ref ORDER BY n DESC, last DESC LIMIT ?r  r  r  r7   r  zAsset Tag #r  viewsViewszLast viewed (UTC)r	  r  r'#  N)
r   r   r   r   rC  rL  r  rL  r  r[  )r  r'  r\  rO  hitsr}
  rn  r   s           r1   _assets_report_most_viewedrQ#   s    &**U#)r* aS#
C=D||2 586	; <D8:	 	 	

9=>AQuXqvqyB/>D>}51(;<>G x6JKTQQ! z"  	

>s#   B) !C  6C)B=<B= C)r#  r#  r#  )mtdzMonth to date)ytdzYear to dater#  c                 v   t        j                         j                         }|j                  d      }| dk(  r|t	        d      z
  }n_| dk(  r|t	        d      z
  }nJ| dk(  r|t	        d      z
  }n5| d	k(  r|j                  d
      }n| dk(  r|j                  d
d
      }nd|fS |j                  d      |fS )zl(start, end) YYYY-MM-DD for a QC report range key; start None = open
    (all captured batches up to today).r  r  r   r  r  r(
  r  Y   rR#  r^   rc  rS#  )r  r  N)r  r  r   r  r  r  )r  r	  r  rq   s       r1   _qc_report_windowrV#   s      %%'E
..
$C
iQ//	
	R00	
	R00	!$	A1-Sy>>*%s**r3   c           
         t        | j                  d      xs d      }|t        vrd}t        | j                  d      xs d      j                         xs d}|rt	        j
                  |      sd}t        |      \  }}t	        j                  |||      }t	        j                  |t	        j                               }dddd	d
dddddddddddddddddddg}d||d   |d   |d   |d   d}|t        |      |ddfS )a  QC material consumption totals over a date range, in each material's
    consumption (ordering) unit with its administered rounding decimals, split
    per plant when no plant is chosen. Reuses the same range_totals + row
    flattening as the /qc/report/range.csv export (one shape).r  r  rH  r7   Nplantr  r  r  r  capturedr  r  r  consumptionr  r  r  r  r  r  r  r  r  r  r  r  )r	  r  r  r  r  r  r'#  )r*   r   _QC_REPORT_RANGE_KEYSr   r  rz  rV#  r  r  r  r  )	r  r'  r  rH  rq   r  r  r   r  s	            r1   _qc_report_consumption_totalsr\#   s=   
 fjj!/Z
0C
''&**Y'-2.446>$Gx**73"3'JE3


sG
4C 
&
&sH,G,G,I
JC1!J7!,<=%?$/BC)4FG +=>%?AG SL8IJM8J}-/D (=c(B   r3   ))last_1dr^   )r  r   )r  r  )last_60dr   )r  r  in  c                    ddl m} ddlm} t	        | j                  d      xs d      j                         }	 |j                  d      d d D cg c]  }t        |       c}\  }}d|cxk  rdk  rn J d|cxk  rd	k  sJ  J 	 t	        | j                  d      xs d      j                         xs t        d      j                  d      xs d}	  ||      }	t        t              j                  t	        | j                  d      xs d      d      }
d } || j                  d            } || j                  d            }|s#t        j                  |	      j                         }|s|t!        |
dz
        z
  }||kD  r||}}||z
  j"                  dz   t$        kD  r|t!        t$        dz
        z
  }t	        | j                  d      xs d      j                         j'                         }|r>|j                  d      D ch c]#  }|j                         s|j                         % c}nd }t)        t+               d         D cg c]  }|j                  d      dk(  r| }}|9|D cg c].  }|d   j'                         |v s|d   j'                         |v r|0 }}|j-                  d        dd d!g}||D cg c]  }d"|d   z   |d   d! c}z  }g }t/               }	 t1        |       |}||k\  rt        |j2                  |j4                  |j6                  |||	#      }|j9                  |j:                        j=                  d$      }|j=                  d%      g}|D ]'  }t?        ||d   |      }|jA                  |dn|       ) |jA                  |       |t!        d      z
  }||k\  r|jC                          d&|d'||fz  |j=                  d%      |j=                  d%      tE        |      d(}|||d)d fS c c}w # t        $ r	 d
\  }}Y w xY w# t        $ r d} ||      }	Y \w xY wc c}w c c}w c c}w c c}w # |jC                          w xY w)*Nr   )r   r  time_of_day19:00rS  r   r   r   )   r   r  r7   r   r|  r  r  r  c                     	 t        j                  t        |       j                         d      j	                         S # t
        $ r Y y w xY wr"  )r  r  r*   r   r   ry   r  s    r1   _pdz)_dash_manual_snapshot_report.<locals>._pd s?    	!**3q6<<>:FKKMM 		s   := 	A	A	r2  rX  r^   r  r  r  rT  r  r  r9   c                 (    | d   j                         S r  rs  r  s    r1   r	  z._dash_manual_snapshot_report.<locals>.<lambda> s    aioo/ r3   r  r   r	  r  r  r  r  r  r  	%02d:%02d)r	  r   r`#  r  r  r  r'#  )#r   r   r  r  r*   r   r   r  r   ry   r  r   _DATASOURCE_SNAPSHOT_RANGESr  r  r   r  r  _DATASOURCE_SNAPSHOT_MAX_DAYSrE  r  r  r  r  r  r  r  r  r  r  r  r  ri   r[  rC  )r  r'  r8  r  todr  r  r  r  r  rng_daysrd#  rq   r  rG  r   sel_setr  r  r   rn  rO  r  local_dtinstantr  r  r  s                               r1   _dash_manual_snapshot_reportrn#   sJ   (!
fjj'27
3
9
9
;C"%))C.!"45Q#a&5BB||,,R",,,, &**T"(b)//1 L"2&**:6L:K f
 /044FJJw-:.4H 

6"#E
fjj
C  $))+o8a<88s{%seA ==o+H1+LMM
fjj#)r
*
0
0
2
8
8
:CBE#))C.>QAGGIqwwy>4G/!),. LA12v(1J  LF L# 3tW]]_/vY__&'1  3 3 KK/K0/0GvN!4(1V9=NNGDD$T*Ul%chh		377&("R9H))#''2;;<PQG<<
+,C 3#D!D'7;

223 KKQ//C Ul 	

#&"b1..4Z06{	$D
 d;TAAE 6 B  "f2 ?L3
 O" 	

s`   P P	"%P P# 	P=P=Q33QQ-CQ 	P P P #P:9P:Q#c                     dddt         D  cg c]  } t        |        c} d}ddddd	}d
ddt        |d      gt        dddd||gt        dddddddt
        D  cg c]  } t        |        c} dd|gt        dddddddt        D  cg c]  } t        |        c} ddddddd	gt        ddddddddd	gt        dd ddd!d"dd#d	gt        dd$d%d&dddt        D  cg c]  } t        |        c} d'dd(d)dd*d+ggt        j                  d,-      D cg c]  }|d.   |d/   g c}z   d*dgt        dd0d1d2dd3dd4d5gd6d7gd'd8gd9d:gd;d<ggd'dd=d>dd?d	d@dAddBd	dCdDddEd	dFdGddHdIdJdKdLddMd	gt        ddN}dOdPdQg t         d|dR<   t#        t%               dS         D ]G  }dT|dU   z   dPdQ|j'                  dV      xs g D cg c]  }|dW   |d/   ddX c}|fdYd|dZ|d.   z   <   I |S c c} w c c} w c c} w c c} w c c}w c c}w )[a  Auditor -> Reporting registry (same shape philosophy as
    _dashboard_tile_types() / the SCADA report-type registry). Adding a
    report = one entry here + its run() builder; a new source also adds a
    reporting.* leaf in _AUDITOR_RESOURCE_TREE for access granting.r  z
Date ranger9  r  r  Filtersr&  z(project:"Bridge A" type:Beam status:Cast)r)  r   rT  placeholderu)   Idencia — Production summary by projectr  r  zproject:"Bridge A", "Plant B")rq#  )r   r	  resourcer  r:  u!   Idencia — Item status breakdownu   Idencia — Shipments over timer2#  Groupr  )r)  r   rT  r  r.  u   Assets — Inventory breakdownr  r  r|  r  r=  r  zSearch filteru#   tag / description / manufacturer…u)   Assets — Data completeness / exceptionsu   Assets — Most viewedr\  zTop N20u0   Batch Production — Material consumption totalsr  r  r  rH  r  r7   r  Tr  r  r   u-   Data sources — Manual values at time of dayr  r  Lookbackr]#  r  r  r  r  r^#  zLast 60 daysr  r  r`#  z Time of day (HH:MM, plant-local)ra#  r2  zFrom (YYYY-MM-DD, optional)zoverrides lookbackrX  zTo (YYYY-MM-DD, optional)zdefaults to todayr  z Sources (names/ids, blank = all)zblank = every manual sourcemanual_sources)r)  r   rT  rq#  pickerr  z Timezone (blank = plant default)r|  )idencia_production_summaryidencia_item_statusidencia_shipmentsassets_inventory_breakdownassets_data_completenessassets_most_viewedqc_consumption_totalsmanual_source_snapshotu#   Power BI — Dataset refresh statuspowerbir  powerbi_refresh_statusqueriesu   Power BI — r9   r  r)  r  c                     t        || |      S rT   )_powerbi_report_query_run)r  r'  r  s      r1   r	  z'_auditor_report_types.<locals>.<lambda> s    -b&$? r3   
powerbi_q_)_IDENCIA_REPORT_RANGESr  r   r+#  r0#  _IDENCIA_REPORT_PERIODSr:#  _ASSETS_GROUP_FIELDSrE#  rL#  rQ#  _QC_REPORT_RANGESr  r  r\#  rn#  _powerbi_report_refresh_status_powerbi_sanitize_queries_load_powerbi_configr   )r"	  range_paramfilters_paramr  rV  r  r  s          r1   r  r  2 ss   
 "L(0FG1tAwGIK%	6$NPM A-@M(GI J5'
 9-@$k2.	 
 7-@''85L#MDG#M#)+,79 -
 6,>"Z.BCT!WC(* ov EG	H ,
'
 A,>"_f'LN O.%
 .,>$w'+- .-
 H,.?@T!W@&( "GX!<01'/'='=t'TV"#  !w'
3 VV 	 	! 1"
$ E#1H*h'9'7(.9(.9(.9	;
 '( &0R9)F0DF'B0CE!,N0M+- 'I0AC#D& 0-#
K]EF 7)<-	'E
"# '';'=i'HI 
$qy0-@ !h 527  !x!G*fM 7,- @)
lQtW$%
 Li H* $N D6 AV^7s#   G)
G.
G3G8G=  Hc                     t               }| j                  d      rt        |      S t        | j                  dd            }|j	                         D ch c]  \  }}|d   |v s| c}}S c c}}w )z>Report ids the user may list/run (admins hold every resource).r   r  r7   rr#  )r  r   r  r  r   )r'  rV  heldrK  r   s        r1   r2  r2   s^    !#Exx5ztxx45D#kkmEFCq}/DCEEEs   A-%A-c                     | d   D ci c]=  }|d   t         j                  j                  |d         xs dj                         dd ? c}S c c}w )zCQuery-arg values for a report's declared params (strings, trimmed).r  r)  r7   Nr  )r   rh  r   r   )r  r  s     r1   _auditor_report_paramsr#   sZ      )+ eHw||''%17R>>@#FF + + +s   AAz/auditor/reportsr  c            	          t        t        d         } t               j                         D cg c]  \  }}|| v r||d   |d   |d   d }}}|j	                  d        t        d|	      S c c}}w )
uG   Report types the caller may run (id/label/source/params — no run fn).r'  r   r	  r  r  c                 (    | d   j                         S r  rs  r*  s    r1   r	  z&auditor_reports_list.<locals>.<lambda>ƺ s    1W:++- r3   r  Tr  )r2  r   r  r   r  r   )r   rK  r   r   s       r1   auditor_reports_listr#   s     )9G /0668LsAC7N '
akk# LC L HH-H.dC((	Ls   A/z/auditor/reports/<rid>c                    t               j                  |       }|st        d      dfS | t        t        d         vr
t               S  |d   t        |      t        d         \  }}|rt        d|      S t        d| |d	   |
      S )zRun one report. Source-side failures return ok=False + message (soft,
    like tile providers) so the UI shows the reason instead of a broken pane.Unknown report.r  r  r'  r:  Fr  Tr   )rU  r  r   r6  )r  r   r   r2  r   r  r#  )rK  r  r6  r  s       r1   auditor_report_runr#  ʺ s    
 '(,,S1J./44
,WV_==  #*U#$::$F$+FO5KFC
%s++ds*W*=fMMr3   z!/auditor/reports/<rid>/export.csvc                    t               j                  |       }|st        d      dfS | t        t        d         vr
t               S  |d   t        |      t        d         \  }}|rt        |      dfS dt        |       dt        j                         j                  d	      d
}t        ||d   D cg c]  }|d   	 c}|d         S c c}w )z9CSV of one report run (same params as the on-screen run).r#  r  r  r'  r:  rU  togen_report_r  z%Y%m%d_%H%Mr  r   r   rn  )r  r   r   r2  r   r  r#  rt  r  r  r  rq  )rK  r  r6  r  r9   r  s         r1   auditor_report_export_csvr#  ۺ s     '(,,S1J./44
,WV_==  #*U#$::$F$+FO5KFC
S!3&&  	3++-66}EGD4fY6G!H!G*!H!&>+ +!Hs   7C
))last_24hr  )last_dayzLast day)r  r  )
last_monthz
Last month)rY  zCustom rangec                 f    | sy| j                  d      ry|t        | j                  dd            v S )zTrue if the user may use a reporting feature leaf (admins hold every
    resource; otherwise the specific reporting.* leaf must be granted).Fr   Tr  r7   r  )r'  r)  s     r1   _reporting_feature_allowedr#   s5     xx&txx'<===r3   c                     t        | d      S )Nr  r#  r-  s    r1   _reporting_can_buildr#   s    %d,?@@r3   c                 :   t        | t              si } | j                  d      }|t        vrd}d|i}|dk(  rdt	        | j                  d      xs d      j                         dd |d<   t	        | j                  d      xs d      j                         dd |d<   |S )	zkCoerce the report-level date-range param. Custom carries ISO date
    strings; presets carry only the mode.rO  r  rY  rq   r7   Nr$!  r  )r   r   r   _REPORT_RANGE_MODE_KEYSr*   r   )r   rO  r   s      r1   _report_sanitize_date_ranger#   s     c4 776?D**4.Cx 3777+1r288:3B?G-2.446s;E
Jr3   c                    t        | t              sg S t               }g }t        | dd       D ]Q  \  }}t        |t              s|j                  d      xs dj                         }||vr@|j                  d      xs dj                         }|t        vrd}|j                  d      rt        |j                  d            dd n#d	t        j                         j                  dd
 z   |||t        ||j                  d      xs i       t        |j                  d            d}|dk(  rt        |j                  d            |d<   t        |j                  d            }|dk7  r||d<   |j!                  |       T |S )zNormalize a template's tile list to the same shape the dashboard store
    uses (reuse of the P1 tile registry + sanitizers). Unknown tile types are
    dropped so a hand-edited definition can't smuggle an unrenderable tile.Nr   rT  r7   rd  r  r  rS  r9  r   rc  r:  r;  rY  r<  rP  rd  r=  )	r   r  r  r  r   r  rd  r  r8  s	            r1   _report_sanitize_tilesr#  , sc    c4 	!#EE#cr(# 1!T"v$"++-f)002,,D,-EE$K3quuT{#CR($**,"2"23B"77+E155?3HbI/j0AB
 8.quuV}=DL!!%%-0#:DLT/0 Lr3   >   r  r  monthlyc                 6   t        | t              si } | j                  d      }|t        vrd}d\  }}t	        | j                  d      xs d      j                         }d|v rT|j                  dd      \  }}	 t        dt        d	t        |                  }t        dt        d
t        |                  }	 t        dt        dt        | j                  d                        }	 t        dt        dt        | j                  d                        }t        | j                  d            |d||fz  ||t        | j                  d            dS # t        t        f$ r d\  }}Y w xY w# t        t        f$ r d}Y w xY w# t        t        f$ r d}Y w xY w)u   Coerce a template's delivery schedule (DVI-1258, P5). Never trusts a
    client-supplied `identity` — that is stamped server-side from the live
    session in the create/update route (see _report_apply_schedule_identity).r  r  r  r   rU   r7   rS  r^   r   r  r  r   r  r'
  day_of_monthr~  rf#  r  )r~  r  rU   r  r#  r  )r   r   r   _REPORT_SCHEDULE_FREQSr*   r   r  rC  rL  r   r   r   r   )	r   r  r  r  r   r  r  r  doms	            r1   _report_sanitize_scheduler#  R s    c4 77;D))FBCGGFO!r"((*A
axwwsA1	QBA(BQBA(BaQCGGI$6 789 !SS!89:; 	*+r2h&cggg&'  :& 	FB	 z"  z" s6   9>E 8.E- '.F E*)E*-F FFFc           	         | xs i j                  d      xs i }|j                  d      s|j                  dd       | S |j                  d      xs dj                         |j                  d      xs dt        |j                  d            t        |j                  d	            t        |j                  d
            d|d<   | S )u  Stamp the authorizing identity onto an enabled schedule from the live
    session (never from the client). A scheduled run in the togen-worker has no
    web session, so it renders the report AS this captured identity — the
    builder who saved the schedule — keeping every tile's per-source D8 gate.
    Azure role flags aren't resolvable offline, hence the snapshot; the
    builder re-authorizes on each definition save. Cleared when scheduling is
    off. Mutates + returns `definition`.r  r~  identityNr  r7   r9   r   r   r   r  r9   r   r   r   )r   r  rE  r   )
definitionr'  r  s      r1   _report_apply_schedule_identityr#  x s     2"":.4"E99Y		*d#((7#)r002 &BDHH]34$((;/0DHH]34E* r3   c                 p    t        | xs d      j                         dd }t        j                  dd|      S )u   Scrub a linked-dashboard id (DVI-1388). Dashboard ids are
    `dash_<hex>` / `dash_system_oversight`, so limit to that charset and cap
    length. Blank/garbage → '' (an unlinked report).r7   NrX  rl  )r*   r   rF  r\  )r   r  s     r1   $_report_sanitize_source_dashboard_idr#   s5     	CI2s#A66#R++r3   c                 :   t        | t              si } t        | j                  d            t	        | j                  d            t        | j                  d            t        | j                  d            d}t        | j                  d            }|r||d<   |S )zAFull defence-in-depth sanitize of a template's authoring payload.r  r`   r  r  )r  r`   r  r  source_dashboard_id)r   r   r#  r   r#  rS  r#  r#  )r   r   r$  s      r1   _report_sanitize_definitionr#   s    c4 '(891#'',2GH -SWW\-BC-cggj.ABC /sww7L/M
NC
%(!"Jr3   c           	      `    | d   | d   | d   | d   | d   | d   | j                  d      xs i dS )	z4API view of a template record (definition included).r  r9   rL  r  r  r  r#  )r  r9   rL  r  r  r  r#  r  r
  s    r1   _report_template_publicr#   sH     $iVs7|y>\1B,'ggl+1r	 r3   z/reporting/templatesc                     t         d   } t        |       }|xs t        | d      xs t        | d      }|s
t               S t	        j
                         }t        d||t        |       t        D cg c]  }t        |       c}      S c c}w )zList report templates (metadata) + whether the caller may author them +
    the range-mode vocabulary the Builder UI needs. Visible to anyone who can
    view OR build reporting; empty list is fine for a viewer with no templates
    yet.r'  r  r  T)rU  r  	can_buildcan_use_viewerrange_modes)
r   r#  r#  r  r[   list_templatesr   ro   _REPORT_RANGE_MODESr  )r'  r#  can_viewr  r  s        r1   reporting_templates_listr#   s     6?D$T*I @6t=MN @%d,>?   ,,.Idi9";D"A1DEAQEG GEs   -Bz/reporting/templates/<tid>c                     t         d   }t        |      s"t        |d      st        |d      s
t               S t	        j
                  |       }|t        d      dfS t        dt        |            S )	z&Reopen one template (full definition).r'  r  r  r!  r  r  TrU  r  )r   r#  r#  r  r[   rp   r   r#  r   r'  r~  s      r1   reporting_template_getr#  ɻ sm     6?D &)$0@A)$0BC  

$
$S
)C
{23S88d%<S%ABBr3   c            	         t         d   } t        |       s
t               S t        j                  d      xs t        j
                  }|j                  d      xs dj                         xs ddd }t        |j                  d	      xs i       }t        ||        t        j                  |t               |      }t        d
dd|d   z  |d   d|d   i       t        dt        |            S )uD   Create a new report template (Report Builder — reporting.builder).r'  Tr  r9   r7   zUntitled reportNr  r#  r  report_template_createdzCreated report template '%s'r  r  rn   r#  )r   r#  r  r   r  rg  r   r   r#  r#  r[   create_templater  r	  r   r#  )r'  r   r9   r#  r~  s        r1   reporting_template_creater#  ػ s     6?D%  4(8GLLDXXf#**,A0A4CHD,TXXl-C-IrJJ#J5

'
'.>.@*
MC=";9CKG4y-T)CE d%<S%ABBr3   c           	      J   t         d   }t        |      s
t               S t        j                  d      xs t        j
                  }d}d|v r8|j                  d      xs dj                         }|st        d      d	fS |dd
 }d}d|v r*t        |j                  d      xs i       }t        ||       t        j                  | ||t                     }|t        d      dfS t        ddd|d   d|d   d|d   |d   |d   d       t        dt        |            S )z{Partial update (rename and/or replace the definition). A definition
    change bumps the version (templates are versioned).r'  Tr  Nr9   r7   zName cannot be blank.r  r`  r  r#  )r9   r#  r		  r!  r  r  report_template_updatedzUpdated report template '' (vr  r7  r  )r  r  rn   r#  )r   r#  r  r   r  rg  r   r   r   r#  r#  r[   update_templater  r	  r#  )r   r'  r   r9   r#  r~  s         r1   reporting_template_updater#   s,   
 6?D%  4(8GLLDD~ &B--/!893>>DSzJt0,1G1M2N
'
D9

'
'$:.>.@BC
{23S88=";&k3y>38;D	*-d)IOQ d%<S%ABBr3   c                    t         d   }t        |      s
t               S t        j                  |       }t        j
                  |       st        d      dfS t        ddd|r|j                  d      n| z  | d	| i
       t        d      S )Nr'  r!  r  r  r  report_template_deletedzDeleted report template '%s'r9   r  rn   TrY  )	r   r#  r  r[   rp   delete_templater   r	  r   r#  s      r1   reporting_template_deleter#   s     6?D%  

$
$S
)C((-23S88=";9),cggfo#7<?(#.0 dr3   c           	          g }| j                  d      xs g D ]L  }t        |t              s|j                  |j	                         D ci c]  \  }}|dk7  s|| c}}       N |S c c}}w )u  Dashboard tiles prepared for copy into a report template (DVI-1388): drop
    the dashboard-side tile id so _report_sanitize_tiles mints a fresh one — the
    report is an independent snapshot, never a live reference back into the
    dashboard's tile ids.r  r  )r   r   r   ri   r   )rY  r   r   r  r  s        r1   _report_tiles_from_dashboardr#  # sj    
 Chhw%2 BaJJ@Aa4i1@AB J As   
A)A)z#/reporting/templates/from-dashboardc                     t         d   } t        |       s
t               S t        j                  d      xs t        j
                  }|j                  d      xs dj                         }|st        d      dfS t        |      \  }}|t        d
      dfS |j                  d      xs dj                         xs d|j                  d      xs dz  d	d }t        t        |      i g i |d      }t        j                  |t               |      }t        ddd|d   d|j                  d      xs |d|d   |d   |t!        |d         d       t        dt#        |            S )zSnapshot a viewable dashboard's tiles into a new, editable, schedulable
    report template (reporting.builder). The report is independent thereafter;
    a stored source_dashboard_id enables the on-demand re-sync below.r'  Tr  rm  r7   zChoose a dashboard.r  r`  Nz+Dashboard not found or not shared with you.r  r9   z	%s report	Dashboardr  )r  r`   r  r  r#  r  r#  zCreated report template '' from dashboard 'rd
  r  r  )r  r#  r7  rn   r#  )r   r#  r  r   r  rg  r   r   r   rN  r#  r#  r[   r#  r  r	  rC  r#  )r'  r   rr  rM  rY  r9   r#  r~  s           r1   !reporting_template_from_dashboardr#  / su    6?D%  4(8GLLDxx'-2446G23S88 'w/LFD|JKSPPXXf#**, Atxx/>;?$3HD
 --d3&. J 
'
'.>.@*
MC=";&k488F#3#>w#>@4y*-d)29),Z-@)AC	D d%<S%ABBr3   z!/reporting/templates/<tid>/resyncc                    t         d   }t        |      s
t               S t        j                  |       }|t        d      dfS t        |j                  d      xs i       }|j                  d      xs dj                         }|st        d      d	fS t        |      \  }}|t        d
      dfS t        |      |d<   ||d<   t        |      }t        ||       t        j                  | |t                     }|t        d      dfS t        ddd|d   d|j                  d      xs |d|d   d| | ||d   t!        |d         d       t        dt#        |            S )u   Re-pull the linked dashboard's CURRENT tiles into this template
    (reporting.builder). Replaces only the tiles — name/date-range/recipients/
    schedule are preserved. Bumps the template version.r'  r!  r  r  r#  r#  r7   z)This report is not linked to a dashboard.r`  z8Source dashboard not found or no longer shared with you.r  )r#  r		  r  report_template_resyncedzRe-synced report template 'r9   r#  r#  r  r7  )r  r#  r  r7  rn   Tr#  )r   r#  r  r[   rp   r   r   r   r   rN  r#  r#  r#  r#  r  r	  rC  r#  )r   r'  r~  r#  rr  rM  rY  r  s           r1   reporting_template_resyncr#  Y s    6?D%  

$
$S
)C
{23S88cggl+1r2J~~34:AACGHI3NN&w/LFD|LNORS 	S6t<Jw(/J$%,Z8J#J5++CJ2B2DFG23S88="<fotxx'7'B7'Bi(* *-g&-i&8),Z-@)ACD d%<W%EFFr3   c                 R    t        | d      xs t        | d      xs t        | d      S )zTrue if the caller may run/view reports: any reporting.* leaf (view,
    viewer, or builder) or admin. Per-tile source gating (D8) is the real data
    boundary; this just guards the Viewer surface itself.r  r  r  r#  r-  s    r1   ro   ro    s7     't-?@ B)$0CDB)$0@ACr3   c                    | xs i } | j                  d      t        v r| j                  d      nd}t        t              j                  ||      }t	        j
                  t        j                        j                         }ddddd}|dk(  rt        | j                  d      xs d	      j                         d
d }t        | j                  d      xs d	      j                         d
d }d
}	 |rb|r`t	        j                  |d      j                         }t	        j                  |d      j                         }	t        |	|z
  j                        }d|s|r	d|d|dnd	z   }|||||dS |j                  |d      }
|t        |
      z
  j!                         }||||j!                         |
dS # t        $ r d
}Y lw xY w)zResolve a sanitized date_range ({mode, start?, end?}) to a display window
    {mode, label, start (ISO), end (ISO), days}. Presets are anchored on today
    (UTC); custom carries the author/viewer-entered ISO dates.rO  r  r^   r   r  )r#  r#  r  r#  rY  rq   r7   Nr   r  r  Customr:  rl  r7  )rO  r   rq   r  r  r  )r   r#  r   r#  r  r  r  r  r   r*   r   r  r+  r  r   r  r  )drrO  r   r	  preset_daysrq   r  r  d0d1r  s              r1   _report_resolve_windowr#   s    
rBVVF^/FF266&>KD$%))$5E|//0557E aarRKxBFF7O)r*0023B7"&&-%2&,,.s3	#,,UJ?DDF#,,S*=BBDBG>>* uc:SUVuuS 	a A_!,,779E55??$a1 1  	D	s   .A$F1 1F?>F?c                     t        |       }|d   }|dv ry|dk(  ry|dk(  r'|j                  d      }|y|d	k  ry|d
k  ry|dk  ryyy)u  Map the report-level date range onto the {all,last_7d,last_30d,last_90d}
    vocabulary the date-filterable Auditor report sources (Idencia time
    reports) understand. Lossy by necessity — there is no per-day source option,
    so last_day/last_week both map to last_7d; a custom window maps by span.
    Sources with no `range` param ignore this (the value is dropped by
    _dash_report_source_run, which only passes a report's declared params).rO  )r#  r#  r  r  r#  r  rY  r  r  r      r  r  )r#  r   )r#  r  rO  r  s       r1   _report_range_to_source_ranger#   sp     $B'F&>D44|xzz&!<192:2:r3   c                    | j                  d      xs dj                         }t               }|j                  |      }|dddS t        | j                  d      xs i       }|dk(  rt	        |||      S |dv r|rt        |j                  d	            }t               j                  |      }|r^|j                  d
      dk7  rJt        d |j                  dg       D              r(t        |j                  d      xs i       }	||	d<   |	|d<   |dk(  r?|j                  d      s.|j                  d      xs g D 
cg c]  }
|
t        vr|
 c}
|d<   |j                  d      }|si S t               j                  |      }t        |      sdddS 	  |||      S c c}
w # t        $ r( t        j                  j                  d|       dddcY S w xY w)ux  Dispatch one report tile through its registered provider (mirrors
    dashboards_tile_data) under `user`, injecting the report-level date filter
    into date-filterable report sources. Returns the provider payload verbatim
    — including its {access:false}/{available:false} soft states, so the D8
    per-source gate is preserved. Never raises (a bad tile soft-degrades).rT  r7   FzUnknown tile type.r  rc  rb  r   r  r6  r	  r  c              3   D   K   | ]  }|j                  d       dk(    yw)r)  r  Nr  rp  s     r1   r/  z#_report_run_tile.<locals>.<genexpr> s     QAe/Qs    r  r  r  r  r[  r6  zProvider not available.z&reporting run: tile provider %s failedzThis tile failed to run.)r   r   rf  r   rB  r  r  r2  r  r  r  ry   rm   rn   r  )r  r'  rA  r  r  r  rJ  rK  rdescr  rw  r  r  s                r1   r@  r@  ͼ s    XXf#**,E$&H<<D|"/CDD
txx!'R
(C "3l;; ))lcggh/0%'++C0 eii)]:Q8R9PQQ#''(+1r2F*F7O"CM CGGN$;%(WWX%6%<" ?q%==  ?HHHZ(M	)--.KK "/HIIK3%%?  K

E}U"/IJJKs   <FF   .GGc                 0   | j                  d      xs i }t        ||n|j                  d            }t        |      }t        |      }t	        |j                  d      xs g d       }g }|D ]  }	|	j                  d      |	j                  d      |	j                  d      xs d	|	j                  d
      xs d|	j                  d      xs i t        |	||      d}
|	j                  d      dk(  r%|	j                  d      r|	j                  d      |
d<   |	j                  d      r(|	j                  d      dk7  r|	j                  d      |
d<   |j                  |
        d| j                  d      | j                  d      | j                  d      ||dS )zRun a template into a resolved view-model: the ordered tiles (layout +
    config) each with its provider payload, plus the resolved date window and
    template identity. This is exactly what the Viewer renders and what a run
    snapshots to history.r#  r`   r  c                 &    | j                  dd      S r  r  r*  s    r1   r	  z*_report_build_view_model.<locals>.<lambda> s    w!2 r3   r  r  rT  rd  r  r:  r7   rc  )r  rT  rd  r:  rc  r  rY  r<  rP  rd  r^   r9   r  )r  r  r  template_versionr`   r  )r   r#  r#  r#  r  r@  ri   )r  r'  r`   r#  r#  r  rA  r  r  r   r   s              r1   rq   rq    ss   
 l+1rJ	$ ,
*..2N
PB#B'F04LJNN7+1r24EI  %%+EE&MEE&M-Xj)/ReeHo+'4>
 55=H$vEE&ME&M55=QUU6]c1EE&ME&M  ||D)!f-$LL3 r3   c                     | j                  d      xs g }t               }|D ]=  }|j                  |      }||j                  d      }t        |      s4 ||      r= y y)u   True only if the viewer can access every layer the oversight tile
    references (re-runs the layer registry's access() role checks — cheap).r[  r  FT)r   r  r  )rJ  r'  r  r[  r  r  r  s          r1   _report_oversight_layers_okr#  ' sf     778"D F zz#<hhx C=T r3   c                    |j                  d      ry| j                  d      }| j                  d      xs i }|dv ry|dk(  ry|dv r6t        |j                  d            }|r|t               vry|t        |      v S |d	k(  r7t	        |j                  d
      xs t        |j                  dd                  S |dk(  rt        |      S |dk(  rt	        |j                  d            S |dk(  rt        |      S |dk(  rl|j                  d      xs d}|dk(  rt        |      S t	        |xr: |j                  dd      j                         t        |      j                         k(        S |dk(  rt        ||      S |dv ryy)aI  Re-gate one stored tile against the CURRENT viewer for historical-run
    reopen (D8). Correct-or-stricter: returns True only when the viewer clearly
    holds the tile's source gate; unknown/identity-scoped cases redact. Mirrors
    each live provider's own gate so a snapshot can never leak data the viewer
    lacks access to.r   TrT  rc  )r&  rU   r  r[  r~
  rb  r#  r6  rp  r   r  r7   r0
  r  r   r  r  r	  r+  r  )r  r  r  rK  F)r   r  r  r2  r   rv  r$  r  r  rE  r*   r#  )r   r'  
run_authorr  rJ  rK  r$  s          r1   _report_tile_access_okr#  6 s    xxIIfE
))H

#C99
  ))cggh/0c!6!880666!!DHH[) G/"0EFH 	HD!!DHH]+,,""T""
ggh'4$;&t,, J Q"-335Z9N9N9PPR 	R*355HH
 r3   c                     ddd}| j                  d      xs g D ]  }t        |||      st        |      |d<   |j                  d      dk(  s4|j                  d      xs i j                  d	      xs g }|j                  d      xs i }|j                  d	      }t        |t              s|D ]Y  }|j                  d
      }	|	|v st        |j                  d      |j                  d      xs i d||      rLt        |      ||	<   [  | S )z|Redact any tile in a stored view-model the current viewer can't access
    (D8 on reopen). Mutates + returns the view-model.Fr  rd  r  r  rT  rb  rc  r  r  r  )r   r#  r   r   )
rJ  r'  r#  r?  r   r  r  kidsr  r  s
             r1   _report_regate_view_modelr#  l s    GIF&&/'R 1%eT:>#F|E) 99V'8,277
CJHii	*0bG;;z*D$%% 1E))D/Cd{+A%*YYv%6',yy':'@bB *,. %)LS	11$ Ir3   z/reporting/templates/<tid>/runc           	         t         d   }t        |      s
t               S t        j                  |       }|t        d      dfS t        j                  d      xs i }|j                  d      }t        |||      }d}|j                  d	      r[t        j                  |d
   |d   t               ||j                  d            }|d
   }t        ddd|d   z  |d
   |d
   |d       t        d||      S )zRun a template live under the viewer's identity. Body: {date_range?,
    save?}. Returns the resolved view-model; when save is truthy the run is
    snapshotted to history (D4-A).r'  Nr!  r  r  Tr  r`   r  r  r  rk   r  rl   zSaved a run of report '%s'r9   )r  rf  rn   )rU  rI  rf  )r   ro   r  r[   rp   r   r   r  r   rq   rr   r  r	  )r   r'  r~  r   r#  rJ  rf  r:  s           r1   reporting_template_runr#   s     6?D$T*  

$
$S
)C
{23S884(.BD	,	B	!#tR	0BFxx&&s4y#i.'7'9224&&2FH T}&8;c&kIt9.1$i6J	L dr&99r3   z/reporting/templates/<tid>/runsc                     t         d   }t        |      s
t               S t        j                  |       t        d      dfS t        dt        j                  |             S )z6Run history (metadata) for one template, newest first.r'  r!  r  r  TrU  r  )r   ro   r  r[   rp   r   rB  r   r'  s     r1   reporting_template_runsr#   sX     6?D$T*  !!#&.23S88d!8!8!=>>r3   z/reporting/runs/<run_id>c                 :   t         d   }t        |      s
t               S t        j                  |       }|t        d      dfS |j                  d      xs i }t        |||j                  d             t        d|d	   |d
   |d   |d   |d   |d   |d      S )u   Reopen a historical run from its stored snapshot, re-gated against the
    current viewer (D8) — the snapshot data is served (immune to later data
    changes) but a tile the viewer can't access is redacted.r'  zRun not found.r  r  rI  ran_by)r#  Tr  r  r#  r=  rd   )r  r  r#  r=  r#  rd   rI  )rU  r:  )r   ro   r  r[   rR  r   r   r#  )rf  r'  r:  rJ  s       r1   reporting_run_getr $   s     6?D$T*  



'C
{-.33			$"Bb$37783DEd$iM(: 23s8}h-3x=	!  r3   z(/reporting/templates/<tid>/schedule-runsc                     t         d   }t        |      s
t               S t        j                  |       t        d      dfS t        dt        j                  |             S )z|Scheduled-run health log for one template (Report Builder). Shows when
    each fire ran, whether it emailed, and any error.r'  r!  r  r  Tr#  )r   r#  r  r[   rp   r   list_schedule_runsr#  s     r1   reporting_schedule_runsr$  ý sX    
 6?D%  !!#&.23S88d!A!A#!FGGr3   c           	      <   | xs i j                  d      xs i }|j                  d      xs dj                         |j                  d      xs dt        |j                  d            t        |j                  d            t        |j                  d            dS )	u  Reconstruct the identity a scheduled run renders as. A worker run has no
    web session, so the builder's identity is snapshotted at schedule-save time
    (Azure role flags aren't resolvable offline) — the report renders exactly
    what its author could see, with per-source D8 gating intact. Offline grants
    (reporting.* leaves, approver depts, tool visibility) are still re-read live
    from JSON by the tile providers keyed on this identity's email.r#  r  r7   r9   r   r   r   r#  )r   rE  r   )r  rs  s     r1   _report_scheduled_userr$  ݽ s     ^  ,2E))G$*113		&!'REIIm45%))K01EIIm45 r3   c                 N   | j                  d      sy| j                  d      xs d}	 d t        | j                  d      xs d      j                  dd	      D        \  }}|j
                  |j                  f||fk  ry|dk(  r1|j                         t        | j                  dd      xs d      k7  ry|dk(  r-|j                  t        | j                  dd	      xs d	      k7  ryt        j                  ||j                  d             S # t        t        f$ r d
\  }}Y w xY w)a3  True when an enabled schedule should fire at `now` and hasn't already
    fired this period. Cadence day gate + a time-of-day floor; per-period dedup
    via the run-log's run_date (one row per day; weekly/monthly only reach the
    day gate on their matching day, so the day-scoped guard covers all three).r~  Fr  r  c              3   2   K   | ]  }t        |        y wrT   )r   r  s     r1   r/  z'_report_schedule_due.<locals>.<genexpr> s     UQ#a&Ur  rU   08:00rS  r^   r#  r  r  r   r#  r#  r  )r   r*   r  r   r   r  r  r  r   r  r[   schedule_ran_onr  )r  r  r  r  r  r  s         r1   _report_schedule_duer
$   s   
 <<	"<<$/DU#hll6&:&Eg"F"L"LSRS"TUB 	#**R(xCKKMSi1K1Pq-QQySWWHLL,K,Pq(QQ,,[#,,z:RSSS z" Bs   8D D$#D$c                    ddl m} | j                  d      xs d}|j                  d      xs i }|j                  d      xs |j                  d      xs d}|j                  d	      xs g }t        j                         d
z    ||      z   }	d}
|dd D ]{  }|j                  d      xs i }|j                  d      xs3 |j                  d      xs dj                  dd      j                         }|
dt        j                  |      z   dz   z  }
} dt        j                  |      z   dz   |rdt        j                  |      z   dz   ndz   |
rd|
z   dz   ndz   dz   t        j                  |	      z   dz   }t        j                  ||d      }|r	d|d|d nd!|z  }	 t        t        j                  |||            S # t        $ r2 t        j                  j                  d"| j                  d#             Y y$w xY w)%zEmail a scheduled report to a simple recipient list with a deep-link to
    the saved run. HTML-only for now (server-rendered PDF attachment = F2).
    Returns True on a Graph accept, False otherwise.r   r  r9   r_   r`   r   rO  r7   r  z/?open_report_run=NrS  rc  re  rT  r  r\   z7<li style="margin:2px 0;font-size:13px;color:#374151;">r   zV<p style="margin:0 0 6px;font-size:14px;color:#374151;">Your scheduled report <strong>z</strong> is ready.</p>zE<p style="margin:0 0 10px;font-size:13px;color:#6b7280;">Date range: r  z.<ul style="margin:0 0 4px;padding-left:18px;">r  z)<div style="margin:18px 0 2px;"><a href="a!  " style="display:inline-block;background:#0077A8;color:#ffffff;text-decoration:none;font-size:14px;font-weight:600;padding:10px 20px;border-radius:8px;">View report online</a><p style="margin:8px 0 0;font-size:12px;color:#9ca3af;">Opens the saved run in Togen (sign-in required).</p></div>zScheduled reportr  zReport: r:  r7  z
Report: %sz$Scheduled report email failed for %sr  F)r   r   r   r=  r;   r  re  r  r  r   r  ry   rm   rn   r  )r  rJ  rf  r  r   r9   r#  r  r  r   rn  r   rJ  re  r  r  r  s                    r1   _report_scheduled_emailr$   s    #<<+8D			#BVVG_4v4"FFF7O!rE""$';;eFmKDD3BZ 8eeHo# SQUU6]%8b$A$A#s$K$Q$Q$SJ %%e,-/67 	88	)+8+=+=d+C	D
#	$ 5; S'(*01@B		D  <dBWL		 &**40	14G	G 	 ++D$ASTD6<D&1<RVCVGM11*gtLMM 

C%\\$/	1s    G   8G;:G;c                    t        j                         }|j                  d      }| j                  d      }| j                  d      xs i }|j                  d      xs i }t	        |      }|j                  d      xs g }	 t        | |      }t        j                  || j                  d      |j                  d      xs d||j                  d	      
      }	|	d   }
d}|j                  d      r|rt        | ||
|      }t        j                  |||
||t        |j                  d      xs g       d       t        j                  j                  d||
|       y# t        $ r}t        j                  j                  d|       	 t        j                  ||dt!        |             n4# t        $ r# t        j                  j                  d|       Y nw xY wY d}~yY d}~yd}~ww xY w)u   Run one due template: snapshot to history + (optionally) email a link to
    it. Records a run-log entry either way — the health surface + per-period
    dedup guard.r  r  r#  r  r  r  r  r@"  r`   rk   Fr  rU  )rf  rV  r  r7  rd   z.Report schedule fired: %s (run %s, emailed=%s)zReport schedule run failed: %sr  r  z'Report schedule error-record failed: %sN)r  r  r  r   r$  rq   r[   rr   r$  record_schedule_runrC  rm   rn   rl   ry   r  r*   )r  r  r  r   r#  r  r'  r  rJ  r:  rf  rV  rl  s                r1   _report_run_scheduledr$  , s    


C||J'H
,,t
Cl+1rJ~~j)/RH!(+D-3JQ%h5&&i($((7*;*J{266,/1 T<< Z-hFJOG))&'!c"&&/2GR.H	 	

HVW	. Q

=sC	Q--XgSX? 	QJJ  !JCP	QP?Qs=   CE 	G'% G""F)(G"))GG"GG""G'c                     t        j                         } t        j                         D ]a  }	 t        j                  |d         }||j                  d      xs i j                  d      xs i }t        ||d   |       rt        |       c y# t        $ r2 t        j                  j                  d|j                  d             Y w xY w)z5One pass over all templates, firing any that are due.r  Nr#  r  z"Report scheduler tick error for %s)r  r  r[   r#  rp   r   r
$  r$  ry   rm   rn   r  )r  r  tmplr  s       r1   _report_scheduler_tickr$  O s    



C,,. 
1		1 --d4j9D|.4"99*EKH#Hd4j#>%d+
1  	1JJ  !E!%$1	1s   BAB8C	C	c                      ddl } 	  | j                  d       	 t        j                         5  t	                ddd       ;# 1 sw Y   
xY w# t
        $ r" t        j                  j                  d       Y 7w xY w)zBackground thread (togen-worker only): fire due report schedules. Checks
    each minute; cadence + time gate live in _report_schedule_due so Builder
    changes apply without a worker restart (mirrors the sync loops).r   Nr   zReport scheduler loop error)rU   r  rm   r0"  r$  ry   rn   r  rB"  s    r1   _report_scheduler_loopr$  _ sj     


2	@" )&() ) ) 	@JJ  !>?	@'   A AA A
A (A87A8zpowerbi_config.jsonz"https://api.powerbi.com/v1.0/myorgz1https://analysis.windows.net/powerbi/api/.defaultz!https://login.microsoftonline.comu   Power BI is not configured. An admin must set the Azure tenant ID, client (app) ID, and client secret in Togen Admin → Services → Power BI.c                     ddddg t        t              g d} t        j                         r	 t	        j
                  t        j                               }t        |j                  d      t               r;| d   D ]3  }t        |d   j                  |      t              s&|d   |   | d   |<   5 t        |j                  d      t              rI|d   D cg c]4  }t        |      j                         rt        |      j                         6 c}dd | d<   t        |j                  d      t              r)|d   D cg c]  }t        |t               r| c}dd | d<   t        |j                  d	      t               r^|d	   j                  d
      }t        |t        t        f      r|dkD  rt        |      | d	   d
<   d|d	   v rt        |d	   d         | d	   d<   | S | S c c}w c c}w # t        j                  t         f$ r Y | S w xY w)z/Return Power BI config with defaults filled in.r7   	tenant_idr   r   )r^  
workspacesr  r#  r^  r$  NrK   r#  r  r9!  r   r:!  )r   POWERBI_SETTINGS_DEFAULTSPOWERBI_CONFIG_FILEr   r   r   r   r   r   r*   r  r   r   r  r   r   r   )rJ  r  r  r  r  rE!  s         r1   r#  r#   s    "bI 56 C ""$	JJ2<<>?E%))E*D1U 8A!%,"2"21"5s;(-eQE
18 %))L148=B<=P %8(+A &)V\\^ %88<%>L!%))I.5-29-= ":%/4%8 #$ "::>3"@I%))J/6:&**+BCb3,/BF?B2wC
O$;<!U:%666:j).97;C
ON3 J3J%8": $$g. 	J	s7   A0G+  6G+ 9G!/G+ >G&BG+ !
G+ +HHc                     t         j                  t        j                  | d             	 t         j	                  t
        j                  t
        j                  z         y # t        $ r Y y w xY wr   )	r$  r   r   r   r  r  r  r  r   rN  s    r1   _save_powerbi_configr$   rI!  r  c                    g t               }}| xs g dd D ]W  }t        |t              st        |j	                  d      xs d      j                         dd }t        j                  ddt        |j	                  d      xs d      j                               dd }t        j                  ddt        |j	                  d	      xs d      j                               dd }t        |j	                  d
      xs d      j                         dd }|r|r|r|st        j                  ddt        |j	                  d      xs d      j                               dd }|r||v r$dt        j                         j                  dd z   }|j                  |       g }	|j	                  d      xs g dd D ]  }
t        |
t              st        j                  ddt        |
j	                  d      xs d      j                               dd }|s[|	j                  |t        |
j	                  d      xs |      j                         dd xs |d        |j                  ||||||	d       Z |S )u  Validated admin DAX query library (P3, D3-A). Each entry becomes a
    runnable Auditor -> Reporting report, so this is strict: entries missing a
    name/workspace/dataset/DAX are dropped, ids are minted when blank and
    de-duplicated, and id/GUID fields are charset-scrubbed (they land in REST
    URL paths / report ids). params (≤5) declare {{key}} placeholders the
    report UI collects as text inputs.NrK   r9   r7   rd  r  r  rS  r  r  r  z[^0-9a-z_-]r  r  r   r  r   rR  r)  r   r  )r  r9   r  r  r  r  )r  r   r   r*   r   r   rF  r\  rE  rW  rX  rY  r  ri   )r   r   r  r  r9   rK	  r(  r  qidr  r  r)  s               r1   r#  r#   s>    CECiR# E!T"155=&B'--/5VV$b#aeeN.C.Ir*J*P*P*RSTWUWXVV$b#aeeL.A.GR*H*N*N*PQRUSUV!%%,$"%++-et4sff^RQUU4[->B)?)E)E)GH"McTk

(("--C%%/'R!, 	ZAa&&&Ce0B,C,I,I,KLSbQCc(+AEE'N,Ac(B(H(H(J4C(P(WTWY Z	Z 	

#tR"$SFD 	E+E. Jr3   c                     | xs
 t               } | d   }|j                  d      r1|j                  d      r |j                  d      r|d   |d   |d   dS y)z>Org service-principal creds dict, or None when not configured.r^  r$  r   r   r$  N)r#  r   re!  s     r1   _powerbi_org_credsr!$   s_    

'%'C
e*C
ww{ 49Q -C<L!$_!57 	7r3   c                 V    t               }|rt        j                  |ddfS dddt        fS )u*  Resolve which Power BI connection a request runs under. D1-A: the org
    service principal is the ONLY scope — per-user delegated access is the
    deferred D1-B follow-up, which slots in here (mirror
    _idencia_resolve_connection when it lands). Returns
    (scope_key, creds, source, error).r^  Nr7   )r!$  powerbi_storerm!  _POWERBI_NOT_CONFIGURED_MSGr'  r^  s     r1   _powerbi_resolve_connectionr&$   s4     
C
&&UD88r666r3   c                 J   ddl m}  ||xs d      j                  xs |xs d}t        |       }|j	                         }d|v sd|v sd|v sd|v rd	| d
S t        | t        j                        sd|v rd| dS t        | t        j                        rd| d|dd  dS |dd S )u   Condense a requests exception into an actionable one-liner (the raw
    text buries DNS/timeout causes inside a connection-pool stack — same
    lesson as the Idencia prod-verify reject).r   r  r7   zthe Power BI servicerU!  rV!  rW!  rX!  rY!  )   ' — check the server's internet access.rZ!  r[!  r  r\!  r:  Nr]!  r  r  r"  ra!  s         r1   rd  rd   s     &CI2%%H#*G1GDs8DjjlGG#}'?*g5&'1)$/XYY#~--.+2H&tfA..#~556&tfBtDSzl"==:r3   c                    | j                  d      xs dj                         }| j                  d      xs dj                         }| j                  d      xs d}|r|r|sdt        fS t         d| d}	 t	        j
                  |d||t        d	|
      }	 |j                         }|j                  dz  dk7  s|j                  d      st        |j                  d      xs |j                  d      xs |j                        dd }	|	j                  d      d   j                  d      d   dd }	|j                  d      dk(  rdd|	 fS |j                  dk(  rd|	j                         v rdd|	 fS dd|j                   d|	 fS 	 t!        |j                  d            }
|d   |
ddfS # t        j                  $ r}ddt        ||      z   fcY d}~S d}~ww xY w# t        $ r i }Y Ew xY w# t"        t        f$ r d}
Y `w xY w) zMint an app-only Power BI API token via the AAD v2 client-credentials
    grant. Returns (tokens, error) where tokens = {access, expires_in}.r$  r7   r   r   Nr6   z/oauth2/v2.0/tokenr0!  )r2!  r   r   r  r3!  zAzure AD token request failed: rd  r   r  r4  r  r  r  r   r0  rK   invalid_clientz;Azure AD rejected the client credentials (invalid_client): r`  r1  z!Azure AD rejected the tenant ID: z%Azure AD token request returned HTTP r>  r  r{!  r  r  )r   r   r$$  POWERBI_LOGIN_BASEr  r  POWERBI_TOKEN_SCOPEr?  rd  r   r   rg   r*   r&  r  rE  r   r   )r!  r7  r1  r   r  rJ  r  rl  r   r	  r  s              r1   _powerbi_mint_tokenr.$   sU    ii$*113F;'-2446IYY'-2F&000 &);
<C
:"" 4(#).0 yy{ 3!#488N+C TXX12 7)7-1YY88<>d#A&,,T215ds;88G 00 //5h8 9 9s"x6<<>'A<VHEEE=))*"VH6 7 	7,/0
 >**EtKK1 ** :7.sC89 : 	::
  " z" 
sB   7!F/ G  G2 /GGGG G/.G/2HHc                    t        j                          }t        5  t        j                  |       }|r|s|d   |kD  r|d   dfcddd       S ddd       t	        |      \  }}|r)t        5  t        j                  | d       ddd       d|fS t        5  |d   |t        d|d   dz
        z   dt        | <   ddd       |d   dfS # 1 sw Y   zxY w# 1 sw Y   d|fS xY w# 1 sw Y   +xY wzZCached bearer token for a scope, minting when missing/expired.
    Returns (token, error).r.  rD  Nr  r   r  r!  )rU   _powerbi_token_lock_POWERBI_TOKENSr   r.$  r  rC  r!  r!  r  r  r  r  r  s          r1   _powerbi_get_tokenr4$  A     ))+C	 &!!),u\!2S!8w<%& & & &e,KFC
  	1	40	1Sy	 DH%B|(<r(A BB&D	"D (T!!& &	1SyD D#   (C+C#C!CC!C*c                 "   |j                  d      r|nt        |z   }dD ]*  }t        | |t        |            \  }}	|	rd|	fc S 	 t	        j
                  ||dd|z   i||nd|      }
|
j                  d	v r|d
k(  r^|
j                  dk(  r y|
j                  dk(  r y|
j                  dk(  rdd|j                  d      d   dd  dfc S |
j                  dk(  r y|
j                  dz  dk7  r#dd|
j                   d|
j                  dd  fc S |
j                  xs dj                         si dfc S 	 |
j                         dfc S  y# t        j                  $ r}ddt        ||      z   fcY d}~c S d}~ww xY w# t        $ r Y  yw xY w)zOne Power BI REST call under a scope's connection. ``path`` is
    relative to /v1.0/myorg (or a full https URL). Returns (data, error);
    one re-mint retry on an auth failure (expired/revoked token).r@  r!  r  Nr  r  r  Power BI request failed: r  r   r  )NzTPower BI rejected the service principal (HTTP 401). Check the Azure app credentials.r  )NzPower BI refused access (HTTP 403). Enable 'Allow service principals to use Power BI APIs' in the Power BI tenant settings and add the service principal to the target workspace(s).r  zPower BI: not found (myorgr  r  r    )NuA   Power BI is throttling requests (HTTP 429) — try again shortly.rd  r   zPower BI returned HTTP r>  r  r7   )NzPower BI did not return JSON.)Nz,Power BI authorization failed after re-mint.)rw   POWERBI_API_BASEr4$  r   r  r   r?  rd  rg   r  r&  r   r   r   )r!  r!  rb   ra   r  r7  rJ  r!  rD  r  r  rl  s               r1   _powerbi_apir<$  U s   
 //*-$3Cd3JC #9'	5WN
s9	>!))()e*;< ' 3W	!D z)gls">s"C s"071CB1G1M0NbQQQs"\s"a'3D4D4D3ER!YYt_-/ 0 0		R&&(t8O	999;$$C#9H @5 .. 	>523<= > >	>0  	98	9s0   #E8FE>!E91E>9E>	FFc                     t         5  t        j                  | |f      }|r(|d   t        j                         kD  r|d   cd d d        S t        j	                  | |fd        d d d        y # 1 sw Y   y xY wr!  )_powerbi_memo_lock_POWERBI_MEMOr   rU   r  r!  s      r1   _powerbi_memo_getr@$   r!  r!  c                     t         5  t        j                         t        z   |ft        | |f<   d d d        y # 1 sw Y   y xY wrT   )r>$  rU   POWERBI_MEMO_TTL_Sr?$  r!  s      r1   _powerbi_memo_putrC$   r"  r"  c                 P   t        | |      }||ddddS  |       \  }}|,t        | ||       	 t        j                  | ||       |ddddS t        j                  | |      }|r|d   d|d   |dS ddd|dS # t        $ r" t
        j                  j                  d       Y ]w xY w)a'  Live-first data fetch for tiles/reports (D4). fetch_fn() returns
    (payload, error). Returns {data, cached, as_of, error}:

    - memo hit (<60 s old live result)  -> live data, no badge
    - live success                      -> memoized + written through to the
                                           scope's cache; no badge
    - live failure w/ cached last-good  -> cached payload, cached=True +
                                           as_of (renderers show the badge)
    - live failure, nothing cached      -> data=None + the error
    NFr"  z#Power BI cache write-through failedr  Tr"  )	r@$  rC$  r#$  r"  ry   rm   rn   r  r"  r"  s           r1   _powerbi_fetchrE$   s     Y	2DtLL:LGS
{)Y8	H##Iy'B  54$OO

!
!)Y
7C
I$\*S: 	:ED3GG  	HJJ  !FG	Hr
"  c                 P   | syt        |j                  d      xs d      j                         }t        |j                  d      xs d      j                         j                         }| D ]8  }t        |      j                         j                         }|s-||k(  s||k(  s8 y y)zTrue when a workspace passes the admin allowlist (GUID or exact name,
    case-insensitive). Empty allowlist = every visible workspace.Tr  r7   r9   F)r*   r   rE  r   )	allowlistrK	  r1  r9   r   rB  s         r1   _powerbi_workspace_allowedrH$   s     
bffTl b
!
'
'
)Crvvf~#$**,224D J$$&!s(a4i r3   c                 
   t               }|sd}t        j                  d| |       d|fS t               }t        j                  }t        ||dt              \  }}|rt        j                  d| |       d|fS |xs i j                  dg       D cg c]  }t        |t              s| }}|D cg c]P  }t        |d   |      r? |j                  d	       |j                  d
      t         |j                  d            dR }}g g d}}
}	|D ]g  }t        ||d|d	    dt              \  }}t        ||d|d	    dt              \  }}|s|r2|dz  }t        j                  j                  d|d
   |d	   |xs |       t|xs i j                  dg       D ]q  }t        |t              s|	j                  |j                  d	      |j                  d
      |j                  d      |j                  d      |d	   |d
   |d   d       s |xs i j                  dg       D ]Q  }t        |t              s|
j                  |j                  d	      |j                  d
      |d	   |d
   |d   d       S j ||	|
d}t        j                   |d|       t        j                   |d|	       t        j                   |d|
       t        j                   |d|       t#        j$                         j'                  d      }t        j(                  dt+        |      t+        |	      t+        |
      d|       t+        |      t+        |	      t+        |
      d}|r||d<   t        j                  d | |!       |dfS c c}w c c}w )"u  One org-scope sync pass: pull the workspace/report/dataset inventory
    into the cache + picker vocabulary (so P2 tile editors offer names, not
    GUIDs) and append an inventory snapshot. A workspace whose reports or
    datasets can't be listed is skipped and counted — one bad workspace never
    kills the whole sync. Returns (counts, error); the outcome is always
    recorded via powerbi_store.record_sync so the Admin pane shows it.z-Power BI service principal is not configured.r  r#"  N/groups?$top=5000r!  r  r$  r  r9   isOnDedicatedCapacity)r  r9   on_capacityr   /groups/z/reportsz	/datasetsr^   z-Power BI sync: skipping workspace %s (%s): %s	datasetIdembedUrlrL$  )r  r9   r  	embed_urlr  workspace_namerL$  )r  r9   r  rQ$  rL$  )r$  r  r,"  r  r,"  r%"  rr  	inventoryr&"  workspaces_skippedrU  r)"  )r!$  r#$  r*"  r#  rm!  r<$  POWERBI_SYNC_TIMEOUT_Sr   r   r   rH$  r   rm   rn   ro   ri   r"  r  r  r  r+"  rC  )r		  r!  r  rJ  r  r   r   r   r$  r  r,"  r  rK	  r  rx  r  derrr  rn  r%"  r'"  r$  s                         r1   _powerbi_run_syncrV$  Ŀ s     E=!!'cBSy

 C##EUE+>%;=ID#
!!'cBSy*"))'26NA*Q:MaNFN M 5c,6GK quuT{EAEE&MUQUU#:;<	>MJ M
 "$RwXG D "T(8/L)?A	T "T(9/M)?A	T4qLGJJN!&z2d8T\TC)"- 	CA!T"aeeDk155=./eeK.@-.UU:->02424V*/1-/@ B C	C )"- 	DA!T"quuT{AEE&M13D35f:02=0A!C D	D%D2 &'!#EE<<E9g6E:x8E7E2!**+>?FC
OG"%h-19?A  
OGh-)F'.#$d%?4<c OMs   M;'M;1AN c                     	 t         j                         5  t        |       \  }}|r!t         j                  j	                  d|       n t         j                  j                  d|       ddd       t        5  dt        d	<   ddd       y# 1 sw Y   "xY w# t        $ r: t         j                  j                  d       t        j                  d| d       Y gw xY w# 1 sw Y   yxY w# t        5  dt        d	<   ddd       w # 1 sw Y   w xY wxY w)
zEThread body around _powerbi_run_sync: always clears the running flag.zPower BI sync failed: %szPower BI sync ok: %sNzPower BI sync worker crashedr  r/"  r#"  Fr8  )rm   r0"  rV$  rn   ro   rl   ry   r  r#$  r*"  _powerbi_sync_lock_powerbi_sync_stater3"  s      r1   _powerbi_sync_workerrZ$   s    3__ 	@-e4MFE

""#=uE

 6?	@   	3-2	*	3 	3	@ 	@  K

;<!!'(I	KK
	3 	3 	3-2	*	3 	3 	3r6"  c                 h   | d   d   rt        |       syt        dt        | d   d               dz  }t        j                  d      xs i }|j                  d      }|sy		 t        j                  |d
      }t        j                         |z
  j                         |k\  S # t        t        f$ r Y y	w xY wr8"  )r!$  rC  r   r#$  r3  r   r  r  r   r   r  r  r:"  s        r1   _powerbi_sync_duer\$  ! r="  r>"  c                     ddl } 	  | j                  d       	 t        t                     s(t        5  t
        d   r
	 ddd       Bt
        j                  ddt        j                         j                  d             ddd       t        d       # 1 sw Y   xY w# t        $ r" t        j                  j                  d	       Y 7w xY w)
zBackground thread (togen-worker only): org-scope vocab sync on the
    configured cadence. Checks each minute; the interval gate lives in
    _powerbi_sync_due so config changes apply without a restart.r   NTr   r8  r@"  rr  rA"  zPower BI sync loop error)rU   r  r\$  r#  rX$  rY$  r  r  r  r  rZ$  ry   rm   rn   r  rB"  s    r1   _powerbi_sync_loopr^$  2 s     


2	=$%9%;<# S&y1S S $** -113<<=PQ + SS !- 
S S  	=JJ  !;<	=rE"  c                      t         5  t        t              } ddd       t        j                         } d   | d   | d   |d   |d   dS # 1 sw Y   4xY wrG"  )rX$  r   rY$  r#$  rI"  rJ"  s     r1   _powerbi_sync_publicr`$  G rL"  rM"  z/powerbi/configc                      t               } t        | d         }t        |j                  dd            }t	        d||| d   | d   | d   t               t        j                  t        j                              S )	zConfig for the Admin -> Services -> Power BI pane. The client secret
    is redacted to has_secret (write-only, ThingPark convention).r^  r   r7   Tr$  r  r#  )rU  r^  r(!  r$  r  r#  r"  r"  )	r#  r   r   r  r   r`$  r#$  r"  rm!  rJ  r^  r(!  s      r1   powerbi_config_getrc$  S ss    
 
 C
s5z
Ccggor23Jd
!,/#j/y>,.&11-2I2IJ	L Lr3   c            	      z   t        j                  d      xs i } t               }t        | j	                  d      t
              r| d   }dD ]>  }t        |j	                  |      t              s#||   j                         dd |d   |<   @ t        |j	                  d      t              r,|d   j                         r|d   j                         |d   d<   t        | j	                  d      t              rL| d   D cg c]7  }t        |      j                         rt        |      j                         dd 9 c}dd |d<   t        | j	                  d	      t              rt        | d	         |d	<   t        | j	                  d
      t
              rr| d
   j	                  d      }t        |t        t        f      r*|dkD  r%t        dt        dt        |                  |d
   d<   d| d
   v rt        | d
   d         |d
   d<   	 t        |       t$        5  t&        j)                  t*        j,                  d       ddd       t        |d         }t        |j)                  dd            }t#        d|||d   |d
   |d	         S c c}w # t         $ r}t#        d|       dfcY d}~S d}~ww xY w# 1 sw Y   xxY w)zSave service principal / workspace allowlist / sync settings (partial
    merge). A blank/omitted org.client_secret keeps the stored one so the
    redacted GET can round-trip without wiping the secret.Tr  r^  )r$  r   NrK   r   r$  r#  r  r9!  r   r"  r   r:!  r-!  r  r  r7   )rU  r^  r(!  r$  r  r#  )r   r  r#  r   r   r   r*   r   r  r#  r   r  rL  rC  r   r$  r   r   r1$  r2$  r  r#$  rm!  )r  rJ  r^  r  r  rE!  rl  r(!  s           r1   powerbi_config_postre$  b s    4(.BD

 C$((5/4(5k+ 	5A#''!*c* #At 4E
1	5 cggo.4_9M9S9S9U*-o*>*D*D*FCJ'$((<($/;?;M 0a #A !V\\^DS1 0046L $((9%t,24	?CI$((:&-*!!"9:b3,'BF7:4QB7QC
O34T*--.24
3CN3S.TC
ON+CS! 
 ;M33T:;
s5z
Ccggor23Jd
!,/#j/y>+ +/0   C6se<=sBBC; ;s0   =<JJ
 %J1
	J.J)#J.)J.1J:z/powerbi/testc                  x   t        j                  d      xs i } t               }| j                  d      xs dj	                         xs |d   d   | j                  d      xs dj	                         xs |d   d   | j                  d      xs dj	                         xs |d   d   d}|d   r
|d   r|d   st        d	
      dfS t        |      \  }}|rt        d|      dfS t        dz   }	 t        j                  |dd|d   z   it              }|j                  dz  dk7  r/d|j                   d}|j                  dv rd}t        d|      dfS 	 |j                         j                  dg       }	|	D 
cg c]  }
t        |d   |
      r|
 }}
t        dt!        |	      t!        |      |dd D 
cg c]!  }
t#         |
j                  d      xs d      # c}
       S # t        j                  $ r&}t        ddt        ||      z         dfcY d}~S d}~ww xY w# t        $ r g }	Y w xY wc c}
w c c}
w )!zTest the service principal: mint an AAD token, then list workspaces.
    Body may carry {tenant_id, client_id, client_secret} to test values
    before saving; blanks fall back to the stored org config. Persists
    nothing (bypasses the token cache).Tr  r$  r7   r^  r   r   r$  z6Set the tenant ID, client ID, and client secret first.r  r`  Fr  rU  rJ$  r  r  r  r;  r8$  Nrd  r   z/Token OK, but listing workspaces returned HTTP r  r  u   Token OK, but Power BI refused API access — enable 'Allow service principals to use Power BI APIs' in the Power BI tenant settings and add the service principal to the target workspace(s).r  r$  r   r9   )rU  workspaces_visibleworkspaces_allowedr  )r   r  r#  r   r   r   r.$  r;$  r  rT$  r?  rd  rg   r   r   rH$  rC  r*   )r  rJ  r!  r  r  rJ  r  rl  r  r   r   r   s               r1   powerbi_testri$   sr    4(.BD

 Chh{+1r88:Uc%j>Uhh{+1r88:Uc%j>U88O4:AAC 9 Z8	;E
 +5#5%:PUVX[[[%e,KFC
%s+S00
0
0C@!!/9vh7G+GH*, 3!#?@P@P?QQRSz)1C %s+S00"- ! DQ,S->B  DG Dds6{&)'l=DRa[I3uquuV}23IK K# ** @%'B/S9(: ;<?@ 	@@  D Js<   2$G%  H! <H27&H7%H8HHH!H/.H/z/powerbi/syncc                     t         d   j                  dd      } t               st        d      dfS t        5  t
        d   rt        d      d	fcd
d
d
       S t
        j                  d| t        j                         j                  d             d
d
d
       t        j                  t        | fd      j                          t        d      S # 1 sw Y   @xY w)z>Kick off an org-scope vocab sync in the background (Sync now).r'  r  r7   z/Configure the Power BI service principal first.r  r`  r8  r"  r9  NTrr  rA"  r(  r"  )r   r   r!$  r   rX$  rY$  r  r  r  r  r  r  rZ$  rq   rg  s    r1   powerbi_sync_startrk$   s     FO,ENOQTTT	 Ky)!=>CK K 	""%))+445HI 	# 	KK 0x ""'%'4  K Kr"  z/powerbi/sync-statusc                  p    t        dt               t        j                  t        j                              S r #  )r   r`$  r#$  r"  rm!  rX   r3   r1   powerbi_sync_statusrm$   r#  r3   z/powerbi/user-connectionc                  B    t        ddt        t                     d      S )u   Status for the User Settings -> Services -> Power BI pane. D1-A: org
    service principal only — the pane is read-only status; personal
    connections arrive with the D1-B follow-up.Tr^  F)rU  rO  r#  personal_supported)r   r   r!$  rX   r3   r1   powerbi_user_connection_getrp$   s$     d!%&8&:!;&+- -r3   z/powerbi/vocabc                  D   t        j                  t         j                  d      } | r$t        | j	                  d      t
              r| d   ni }t        d|j	                  d      xs g |j	                  d      xs g |j	                  d      xs g | r
| d   	      S d	      S )
u  Picker vocabulary for the P2 tile editors / P3 query library UI:
    workspace / report / dataset names from the org sync's cached vocab.
    Empty lists until the first successful sync — pickers degrade to typing
    GUIDs, so this is never a hard dependency.r%"  r  Tr$  r  r,"  r"  N)rU  r$  r  r,"  r  )r#$  r"  rm!  r   r   r   r   r#  s     r1   powerbi_vocabrr$   s     
!
!-"9"97
CC!j1CT&JC	NPREd#ii5; 99Y/52!IIj17R.1\*	= = 8<	= =r3   i  zThis report's workspace is not on a Microsoft Fabric capacity, which the embedded-report tile requires. Assign the workspace to a capacity (F2 is the entry SKU) in the Fabric admin portal, or use the Power BI Data tile instead (no capacity needed).c                 N   t        | xs d      j                         j                  d      }t        j                  dd|      }dD ]H  }	 t        j                  ||      }|j                  t        j                        j                         c S  y# t        t        f$ r Y ]w xY w)u   Epoch seconds from a Power BI expiration stamp, or None. GenerateToken
    returns ISO-8601 UTC, with or without fractional seconds (.NET stamps can
    carry 7 fractional digits — more than %f accepts, so truncate to 6).r7   r  z(\.\d{6})\d+$z\1)z%Y-%m-%dT%H:%M:%S.%fr  r  N)r*   r   r8   rF  r\  r  r  r  r  r  rv
  r   r   )r&  r   r;  r  s       r1   _powerbi_parse_expirationrt$   s     djb/


!
(
(
-C
&&!5#
.C< 	((c2B::\%5%5:6@@BB  I& 		s   
ABB$#B$c           	         t        j                          }| ||f}t        5  t        j                  |      }|r|d   t        z
  |kD  r|dfcddd       S ddd       t        | |d| d| ddddi	      \  }}|rd|fS |xs i j                  d
      }	|	syt        |j                  d            xs	 |t        z   }
|	|j                  d      |
d}t        5  |t        |<   ddd       |dfS # 1 sw Y   xY w# 1 sw Y   |dfS xY w)a"  Cached app-owns-data embed token for one report (POST GenerateToken,
    accessLevel View). Returns (info, error); info = {token, expiration,
    expires_epoch}. Cache hits serve until POWERBI_EMBED_MARGIN_S before
    expiry, so a wall of tiles mints roughly one token per report per hour.expires_epochNrM$  	/reports/z/GenerateTokenr  accessLevelViewra   r  rD  )Nz'Power BI did not return an embed token.
expiration)rD  r{$  rv$  )rU   _powerbi_embed_lock_POWERBI_EMBED_TOKENSr   POWERBI_EMBED_MARGIN_Sr<$  rt$  POWERBI_EMBED_FALLBACK_TTL_S)r!  r!  ws_idr  r  r)  r!  r   r  rD  rv$  rl   s               r1   _powerbi_embed_tokenr$   s1   
 ))+CeY
'C	 #'',3'*@@3F9   5
5'9+^<v68ID# SyZRW%E>.txx/EF ;:: $((<*@*,D	 *%)c"*:' "*:s   *C,
C8,C58Dc                    t        j                  t         j                  d      }|r$t        |j	                  d      t
              r|d   ni }|j	                  d      xs g D ]d  }t        |t
              st        |j	                  d            t        |      k(  s;t        |j	                  d            t        |       k(  sb|c S  y)u   The synced vocab row for one report, or None (no sync yet / not found).
    Carries name/embed_url/on_capacity — the P2 embed gate's capacity signal.r%"  r  r  r  r  N)r#$  r"  rm!  r   r   r   r*   )r$  r  r!  r%"  r  s        r1   _powerbi_vocab_reportr$  4 s     
!
!-"9"97
CC!j1CT&JC	NPREYYy!'R q$Cd$4I$Fn-.#e*<H r3   c                 H    t              }|r;|j                  d      xs d|j                  d      xs d|j                  d      dS  fd}t         d d |      }t        |j                  d	      t              r|d	   ni }|j                  dd
       |S )u  Report display metadata: the synced vocab row when available (fast, and
    it knows on_capacity), else a live-first fetch of the single report. Never
    fatal — the embed URL has a well-known fallback shape, so this returns {}
    on total failure and the tile still tries to embed.r9   r7   rP$  rL$  )r9   rP$  rL$  c                      t        d d       \  } }|rd |fS | xs i j                  d      xs d| xs i j                  d      xs ddd fS )NrM$  rw$  r9   r7   rO$  )r9   rP$  r<$  r   )r   r  r!  r  r!  r$  s     r1   fetchz#_powerbi_report_meta.<locals>.fetchJ sv     E#+E7)I;!GI	c9((06B"jb--j9?RABFG 	Gr3   r^   rS  r   N)r$  r   rE$  r   r   r}  )r!  r!  r$  r  r  r$  fetchedr  s   ````    r1   _powerbi_report_metar$  @ s    
  y
1C
-2CGGK<P<VTV"ww}57 	7G Y&ugQyk:ECG(V)<dC76?DOOM4(Kr3   c                 P   t        |      \  }}}}|rdd|dS | j                  d      xs d}| j                  d      xs d}|r|sddddS t        ||||      }t        ||||      \  }	}
|
r|j                  d      du rt        }
dd|
dS |j                  d	      xs d
|d|}dd||||j                  d      xs d|d|	d   |	d   | j                  d      xs dt        | j                  d            t        | j                  d            | j                  dd      dud
S )u  Embedded Power BI report tile (DVI-1243 P2, D2-A half of D2-C). D1-A:
    every viewer — including the emailless signage identity — renders under
    the org service principal, so personal content can never reach a wall.
    The embed token rides the tile-data payload; the existing 60 s tile polls
    (SPA + kiosk) keep handing renderers a fresh-enough token, so unattended
    walls survive the ~1 h token expiry with zero device change.TFr  r  r7   r  z1Pick a workspace and report in the tile settings.rL$  rP$  z-https://app.powerbi.com/reportEmbed?reportId=z	&groupId=r9   )r  r  r9   rP$  rD  r{$  rO  r  r  r  )
r  r  r	  r6  rD  token_expires_atrO  r  r  r  )r&$  r   r$  r$  _POWERBI_CAPACITY_MSGr   )rJ  r'  r  r!  r	  r  rK	  rK  r  r  ru  rP$  s               r1   r  r  Y sV    !<D AE5&#
UsCC		 	&BB
''+

$"CSUNP 	Pub#6D$UE2s;IC 88M"e+(DUtDD% RHKRP  TVb88F+1r	KW3|3D%2 );!<=cggo67ww}d35@	 	r3   c                     t        j                  dt        | xs d            }|r|j                  d      S t        | xs d      S )z~Bare column label from a DAX result key ('Sales[Total]' -> 'Total';
    measure keys like '[Total]' also shed their brackets).z^.*\[(.*)\]$r7   r^   )rF  rG  r*   rb  )r9   r  s     r1   _powerbi_strip_colr$   s9     	#djb/2A1771:/DJB/r3   c                 >    t        | t              rt        | d      S | S )zCJSON-friendly cell value; floats rounded to 2 decimals for display.r   )r   r  rj   r  s    r1   _powerbi_cellr$   s    !UQ{Hr3   c                    t        | |d| d| ddd|igddid	      \  }}|rd
|fS |xs i j                  d      xs g }|rt        |d   t              sy|d   j                  d      }|rd
dt	        |      d
d z   fS |d   j                  d      xs g }	|	r|	d   j                  d      ng xs g D 
cg c]  }
t        |
t              r|
 }}
g }|D ]-  }
|
j                         D ]  }||vs|j                  |        / |D 
cg c]*  }
|D cg c]  }t        |
j                  |             c}, }}
}|D cg c]  }t        |       c}|dd
fS c c}
w c c}w c c}}
w c c}w )zOne executeQueries call. Returns ({'columns': [...], 'rows': [[...]]},
    error). Column order = first-seen key order across rows (executeQueries
    emits rows as objects); names are stripped to bare column labels.rM$  
/datasets/z/executeQueriesr  r#  includeNullsT)r#  serializerSettingsrz$  Nr  r   )Nz*Power BI returned no result for the query.r  zDAX query error: r  tablesrn  rq  )	r<$  r   r   r   r*   r,  ri   r$  r$  )r!  r!  r$  r  r  r   r  r  res_errr$  r  r  r,  r  rn  s                  r1   _powerbi_run_daxr$   s    5
5'J<?%s^,(6'=?	@ID# Syzry)/RG*WQZ6AajnnW%G(3w<+====QZ^^H%+F6<VAY]]62"K (aa&  (H (D  	A}A	 >FFd3]1558$3FDF7;<!*1-<dKTQQ( 4F<s$   7E	E%!E 5E%E+ E%c                    g }t        t        |             D ]P  }|D cg c]  }|t        |      k  s||   ||    }}|s-t        d |D              s@|j                  |       R |syt        t        |             D cg c]	  }||vs| }}|r
|d   |d   fS t        |      dk\  r
|d   |d   fS yc c}w c c}w )z(label_index, value_index) for charting a DAX result, or (None, None)
    when there is nothing numeric to chart. Value = the first all-numeric
    column; label = the first non-numeric column (falling back to a second
    numeric column, stringified).c              3   n   K   | ]-  }t        |t        t        f      xr t        |t                / y wrT   )r   r   r  r   r  s     r1   r/  z&_powerbi_chart_cols.<locals>.<genexpr> s5      ' #1sEl3OJq$<O8OO 's   35r  r   r   r^   )r  rC  r  ri   )r   rn  numericr  r  r  non_numerics          r1   _powerbi_chart_colsr$   s    
 G3w<  "Fa#a&jQqT5E!FFC '!%' 'NN1	
 #CL1FQg5E1FKF1~wqz))
7|qqz71:%% G Gs   B8B8B8	B=B=c           	         t        | |      \  }}|g S g }|D ]  }|t        |      k  r||   nd}t        |t        t        f      rt        |t
              r?||t        |      k  r||   nd}|dvrt        |      nd}|j                  |t        |      d        |rQt        |      |kD  rC|d| ||d }
}	|	j                  dt        t        d |
D                    t        |
      d       |	}|S )u   Chart slices from a DAX result, preserving the QUERY's row order (a DAX
    author's ORDER BY is intent — unlike the Idencia count fold, we don't
    re-sort). The tail beyond max_slices folds into 'Other' carrying `more`.Nr  z(blank)r  r@  c              3   &   K   | ]	  }|d      ywrk"  rX   r&  s     r1   r/  z'_powerbi_data_slices.<locals>.<genexpr> s     /Iq'
/Irl"  rm"  )
r$  rC  r   r   r  r   r*   ri   r$  r  )r   rn  r  label_ivalue_iro"  r  rQ  r   r.  r&  s              r1   _powerbi_data_slicesr$   s    +7D9GW	F E#c!f,aj$#U|,
30E&2wQ7G'
d#:5E
9c0BCDE c&kJ.KZ(&*=dg+C/ID/I,IJ Y( 	) Mr3   c                    ddl }t        |      \  }}|rdd|dS | j                  d      xs d| j                  d      xs d| j                  d	      xs dj                         rrsddd
dS ddd|j	                  j                  d            j                         dd }t        |fd      }|d   }t        |t              sdd|d   xs ddS |j                  d      xs g }|j                  d      xs g }	t        | j                  d      xs d      xs d}
dd|| j                  d      xs dt        |d         |d   |d   r|d   nd||	d|
 t        |	      t        ||	t        | j                  d      xs d            d}t        |	      dk(  rQt        t        |            D cg c]-  }||   xs d|dz    |t        |	d         k  r|	d   |   ndd/ c}dd  |d!<   |S c c}w )"u  Power BI data tile (DVI-1243 P2, D2-B half of D2-C): the server runs
    the tile's DAX via executeQueries and returns render-ready shapes (table
    rows + chart slices + single-row KPIs) for Togen's dependency-free
    renderers — no capacity licensing needed. Live-first with cached fallback
    (D4); soft-degrades with a message, never a broken tile. D1-A: org scope
    for every viewer, signage-safe by construction.r   NTFr  r  r7   r  r  zHPick a workspace and dataset and enter a DAX query in the tile settings.dax:rS  r  r$
  c                  "    t               S rT   r$  )r!  r  dsidr  rK	  s   r1   r	  z-_dash_provider_powerbi_data.<locals>.<lambda> s    %5eUBc%R r3   r   r  Power BI data is unavailable.r   rn  r  r	  r{  r  r  r  r  )r  r  r	  r{  r  r  r  r   rn  r@  ro"  r^   zColumn r  r  kpis)r  r&$  r   r   r2  r  r  rE$  r   r   r   r   rC  r$  r  )rJ  r'  r  r	  r  r!  r$  r>  r   rn  row_capr  r  r!  r  r$  r  rK	  s                @@@@@r1   r  r   sF     ;D AE5&#
UsCC		 	&BB77< &BD775>R
&
&
(CTU9: 	: "%
 	D',,szz'23==?DFIUE9RTGV_Ffd#U"7+N/NP 	Pjj#)rG::f#D#''*%*+1rGTV779%0wx()GG4D%,X%6!DXgT&w'*377<+@+EA'FH
G 4yA~
 3w<(*  aj5ga!eW$5$%DG$4d1gaj$@* +-1. N	*s   	2Hc                 l    t        | xs d      }t        |      dk\  r|dd dk(  r|dd dz   |dd z   S |S )z@Readable 'YYYY-MM-DD HH:MM' from a Power BI ISO timestamp (UTC).r7   r$
  r   r  rP  Nr\   )r*   rC  )r&  r   s     r1   _powerbi_fmt_tsr$   sG    
djb/C
3x2~#b*+3Bx#~Br
**Jr3   c                     | d   }| j                  d      xs g D ]N  }t        |j                  |d         xs d      j                  dd      dd }|j                  d	|d   z  |      }P |S )
a  The library query's DAX with {{key}} placeholders substituted from the
    report run's params. Values are length-capped and have double quotes
    flipped to single so a value can never break out of a DAX string literal;
    a missing param substitutes as empty.r  r  r)  r7   r  rd
  NrK   z{{%s}})r   r*   r  )r#  r  r  r  rQ  s        r1   _powerbi_query_daxr$   sz    
 ,CYYx &B 4&**QuX&,"-55c3?Ekk(QuX-s34 Jr3   c           
      @    ddl }t        |      \  }}|rd|fS t         |      d d   d d   d|j                  j	                  d            j                         dd }t        | fd	      }|d
   }t        |t              sd|d   xs dfS t        |j                  d      xs g       D 	
cg c]  \  }	}
d|	z  |
xs d|	dz   z  d }}	}
|t        |d         |d   |d   r|d   ndd}||j                  d      xs g |ddfS c c}
}	w )zRun one admin-defined library query as an Auditor report. Same fetch
    path (and cache keys) as the powerbi_data tile, so a report and a tile on
    the same query share the live-first memo + cached fallback.r   Nr$  r  rS  r  r  r$
  c                  .    t         d   d         S )Nr  r  r$  )r!  r  r#  r  s   r1   r	  z+_powerbi_report_query_run.<locals>.<lambda>0 s"     u^/D!&|!4c; r3   r   r  r$  r   rY  rX  r^   r  r  r  r#  rn  r'#  )r  r&$  r$  r2  r  r  rE$  r   r   r  r   r   )r#  r  r'  r  r	  r  r!  r$  r>  r  r  r   r  r!  r  r  s   `            @@@r1   r#  r#  " s`     ;D AE5&#
Sy
UF
+Cnu\2SZZ()335cr:<I ui	;<G V_Ffd#WW%H)HHH$VZZ	%:%@bAC1a qy1+GA1FH CG CWX->(?W%)0):WW%FD 

6(:(@b   Cs    Dc                    t        |      \  }}|rd|fS t        j                  t        j                  d      }|xs i j	                  d      xs g D cg c]6  }t        |t              r$|j	                  d      r|j	                  d      r|8 }}|syt        dt        |      t        z
        }|dt         }g dd}
}	}|D ]Z  }|d   |d   }}||ffd		}t        d
| d| |      }|j	                  d      xs ||j	                  d      xs |g}|d   /|j                  |dddt        |d   xs d      dd z   dgz          |d   rd}	|
|d   xs d|
k  r|d   }
|d   }|s|j                  |g dz          t        |d   t              r|d   ni }|j                  |t        |j	                  d            t        |j	                  d            t        |j	                  d      xs d      t        |j	                  d      xs d      gz          ] |r |j                  d|t        fz  dddddg       dddd d!dd"d#dd$d%ddd&dd'd(dg}||	|
dd)}|||d*dfS c c}w )+u|  Built-in ops report: last refresh per dataset (the plan's D3 'genuinely
    useful' baseline). Dataset inventory comes from the synced vocab — without
    a completed sync there is nothing to probe, so the report says to run one.
    Each dataset costs one /refreshes call (live-first + cached fallback);
    a failing dataset degrades to an inline note, never kills the report.Nr,"  r  r  r  )Nuv   No dataset inventory is cached yet — run a Power BI sync first (Togen Admin → Services → Power BI → Sync now).r   Fc           	      t    t        d|  d| d      \  }}|rd |fS |xs i j                  d      xs g d fS )NrM$  r$  z/refreshes?$top=1r  r$  )r$  ds_idr   r]  r!  r  s       r1   r$  z-_powerbi_report_refresh_status.<locals>.fetchT sX    %u5'E72CDFJD$ Tz!JB##G,2D88r3   zrefresh:rS  rQ$  r9   r   zunavailable: r  r7   r   r  Tr  )NNznever refreshedN	startTimeendTimerd   r  refreshTypeu0   (+%d more datasets not probed — report cap %d)	workspace	Workspacer  r  Datasetrq   zLast refresh startr  zLast refresh endr  rT  r  r#  r'#  )r&$  r#$  r"  rm!  r   r   r   rC  rC  POWERBI_REFRESH_REPORT_CAPrE$  ri   r*   r$  )r  r'  r	  r  r!  rn  r,"  r  rn  
any_cachedcached_as_ofr$  r$  r$  r$  r  	refreshesr\  r   r  r!  r  s                       @@r1   r#  r#  > s    !<D AE5&#
Sy

!
!-"9"9:
FC YBOOI6<" Raa&155;155;P  RH RZAs8}'AABI334H%'l*D  @(!D'uU 	9 !%%/I5Q&'05!%%-2H5I6?" KKd /#gg6F6L"2Mcr2R R $ & & ' 8J#(8(>B,'N&w/FO	KKDDE(1t<il"DOCGGK,@A+CGGI,>? 1 >Y? 6 <"=? ? 	@; @B G!#=>?4tT3 	4 #[9 95)=>'9:(3/1G 
\Dd;TAAmRs   ;I.znetsuite_config.jsonu   NetSuite is not configured. An admin must set the account ID, client (consumer) ID, certificate ID, and private key in Togen Admin → Services → NetSuite.r"  )PS256RS256c                  @   dddddddt        t              d} t        j                         rO	 t	        j
                  t        j                               }t        |j                  d      t               rQ| d   D ]3  }t        |d   j                  |      t              s&|d   |   | d   |<   5 | d   d   t        vrd| d   d<   t        |j                  d      t              r|d   j                         dd	 | d<   t        |j                  d
      t               r^|d
   j                  d      }t        |t        t        f      r|dkD  rt        |      | d
   d<   d|d
   v rt        |d
   d         | d
   d<   | S | S # t        j                  t         f$ r Y | S w xY w)z/Return NetSuite config with defaults filled in.r7   r$  
account_idr   certificate_id	algorithmprivate_key)r^  item_filterr  r^  r$  r$  Nr  r  r9!  r   r:!  )r   NETSUITE_SETTINGS_DEFAULTSNETSUITE_CONFIG_FILEr   r   r   r   r   r   r*   NETSUITE_JWT_ALGSr   r   r  r   r   r   )rJ  r  r  rE!  s       r1   _load_netsuite_configr$   s   !#" '< 679C ##%	JJ3==?@E%))E*D1U 8A!%,"2"21"5s;(-eQE
18 u:k*2CC.5CJ{+%))M2C8%*=%9%?%?%A%4%HM"%))J/6:&**+BCb3,/BF?B2wC
O$;<!U:%666:j).97;C
ON3 J3J $$g. 	J	s   A0F  !CF   FFc                     t         j                  t        j                  | d             	 t         j	                  t
        j                  t
        j                  z         y # t        $ r Y y w xY wr   )	r$  r   r   r   r  r  r  r  r   rN  s    r1   _save_netsuite_configr$   sM    ##DJJs1$=>""4<<$,,#>? r  c                     | xs
 t               } | d   }|j                  d      rZ|j                  d      rI|j                  d      r8|j                  d      r'|d   |d   |d   |j                  d      xs d|d   dS y	)
zOrg service-account creds dict, or None when not fully configured (all
    of account_id, client_id, certificate_id, private_key are required).r^  r$  r   r$  r$  r$  r$  r$  N)r$  r   re!  s     r1   _netsuite_org_credsr$   s     
(&(C
e*C#''+"6()cggm.D!,/c+>N"%&6"7 WW[1<W"=13 	3 r3   c                 V    t               }|rt        j                  |ddfS dddt        fS )u   Resolve which NetSuite connection a request runs under. D1-A: the org
    service account is the ONLY scope — no per-user creds. Returns
    (scope_key, creds, source, error).r^  Nr7   )r$  netsuite_storerm!  _NETSUITE_NOT_CONFIGURED_MSGr%$  s     r1   _netsuite_resolve_connectionr$   s4     
C
''eT99r777r3   c                     t        | xs d      j                         j                         j                  dd      }| dS )u   Account-specific SuiteTalk host: 1234567 → 1234567.suitetalk.api.netsuite.com,
    1234567_SB1 → 1234567-sb1.suitetalk.api.netsuite.com (lowercase, '_'→'-').r7   r  rc
  z.suitetalk.api.netsuite.com)r*   r   rE  r  )r$  accts     r1   _netsuite_domainr$   s@     zR &&(..088cBDV.//r3   c                 &    dt        | d          dS )Nr@  r$  z#/services/rest/auth/oauth2/v1/tokenr$  r!  s    r1   _netsuite_token_urlr$   s"    'l(;<=12 3r3   c                 &    dt        | d          dS )Nr@  r$  z/services/rest/query/v1/suiteqlr$  r$  s    r1   _netsuite_suiteql_urlr$   s"    'l(;<=-. /r3   c                 J   ddl m}  ||xs d      j                  xs |xs d}t        |       }|j	                         }d|v sd|v sd|v sd|v rd	| d
S t        | t        j                        sd|v rd| dS t        | t        j                        rd| d|dd  dS |dd S )u   Condense a requests exception into an actionable one-liner (matches the
    Idencia/Power BI lesson — the raw text buries DNS/timeout causes).r   r  r7   zthe NetSuite servicerU!  rV!  rW!  rX!  rY!  u<   ' — check the account ID and the server's internet access.rZ!  r[!  r  r\!  r:  Nr]!  r  r  r"  ra!  s         r1   _netsuite_request_errorr$   s     &CI2%%H#*G1GDs8DjjlGG#}'?*g5&'1*4& 10 0 	1#~--.+2H&tfA..#~556&tfBtDSzl"==:r3   c                    t        | t              r| j                  d      }t        |t              rb|r`t        |d   t              r|d   ni }t	        |j                  d      xs |j                  d      xs d      j                         }|r|dd S dD ]8  }| j                  |      st	        | |         j                  d	      d   dd c S  t	        t        |d
d      xs d      dd S )zPull a readable message out of a NetSuite REST error body. NetSuite
    uses RFC-7807 problem+json: {type,title,status,'o:errorDetails':[{detail}]}.zo:errorDetailsr   r	  zo:errorCoder7   Nr  )r4  r  re  r  r0  r&  r   r   r   r  r*   r   r  re   )r   r  detailsr#  r  r  s         r1   _netsuite_error_detailr$   s     $((+,gt$)'!*d;BbffX&E"&&*?E2FLLNC4Cy C 	9Axx{47|))$/24C88	9 wtVR(.B/55r3   c                    	 ddl }| j                  d      xs d}|t        vrd}t	        t        j
                               }t        |       }| d   dg|||dz   d	}d
| d   d}	 |j                  || d   ||      }t        |t              r|j                  d      }|dfS # t        $ r Y yw xY w# t        $ r }dd| dt        |      dd  fcY d}~S d}~ww xY w)a  Build + sign the JWT client assertion NetSuite's client-credentials M2M
    grant requires. Returns (assertion, error). The private key is signed with
    the configured algorithm (PS256 recommended, RS256 supported); the
    certificate id is the JWT `kid`.r   N)Nu\   PyJWT is not installed on the server — the NetSuite JWT client assertion cannot be signed.r$  r$  r   rest_webservicesi  )issr  audiatr/  JWTr$  )typkidr$  )r$  rx   uQ   Could not sign the JWT client assertion — check the private key and algorithm (r  rK   rC  )jwtr#  r   r$  r   rU   r$  r  ry   r*   r   r  r  )	r!  _pyjwtalgr  r   rC  rx   	assertionrl  s	            r1   _netsuite_build_assertionr$   s&   
@ ))K
 
+GC
##
diik
C#E*I;'*+4Z!F E*:$;<GNMM&%*>,/ " B	
 )U#$$W-	d?/  @@@$  N 4475CHTcN;KM N 	NNs/   B" %B1 "	B.-B.1	C:CCCc                 ,   | j                  d      r3| j                  d      r"| j                  d      r| j                  d      sdt        fS t        |       \  }}|rd|fS t        |       }	 t	        j
                  |dd|dd	d
i|      }	 |j                         }|j                  dz  dk7  s|j                  d      sXt        ||      }|j                  d      dk(  rdd| dfS |j                  d      dk(  rdd| dfS dd|j                   d| fS 	 t        |j                  d            }	|d   |	ddfS # t        j                  $ r}ddt        ||      z   fcY d}~S d}~ww xY w# t        $ r i }Y w xY w# t        t        f$ r d}	Y _w xY w)zMint an app-only SuiteTalk REST token via the OAuth 2.0
    client-credentials grant with a JWT client assertion. Returns
    (tokens, error) where tokens = {access, expires_in}.r$  r   r$  r$  Nr0!  z6urn:ietf:params:oauth:client-assertion-type:jwt-bearer)r2!  client_assertion_typeclient_assertionr  !application/x-www-form-urlencodedr   rx   r7  zNetSuite token request failed: rd  r   r  r  r*$  z;NetSuite rejected the client credentials (invalid_client): zx. Check the client ID, certificate ID, and that the certificate mapping is active for a role with SuiteQL + item access.invalid_grantz5NetSuite rejected the JWT assertion (invalid_grant): z:. Check the certificate ID/key pairing and the account ID.z%NetSuite token request returned HTTP r>  r  r  r+$  )r   r$  r$  r$  r  r  r?  r$  r   r   rg   r$  r   r   )
r!  r7  r$  r  rJ  r  rl  r   r	  r  s
             r1   _netsuite_mint_tokenr$  5 s    IIl#		+(>		*+		-0H111.u5NIs
Sy
e
$C;"" 4N&/1 $%HIyy{ 3!#488N+C'd388G 00 //5h 7JJ K K 88G/Q#H %,, - - =))*"VH6 7 	7,/0
 >**EtKK1 ** ;7/S9: ; 	;;
  " z" 
sB   -D= E. E? =E+E& E+&E+.E<;E<?FFc                    t        j                          }t        5  t        j                  |       }|r|s|d   |kD  r|d   dfcddd       S ddd       t	        |      \  }}|r)t        5  t        j                  | d       ddd       d|fS t        5  |d   |t        d|d   dz
        z   dt        | <   ddd       |d   dfS # 1 sw Y   zxY w# 1 sw Y   d|fS xY w# 1 sw Y   +xY wr0$  )rU   _netsuite_token_lock_NETSUITE_TOKENSr   r$  r  rC  r3$  s          r1   _netsuite_get_tokenr$  l s    ))+C	 &""9-u\!2S!8w<%& & & 'u-KFC
! 	2  D1	2Sy	 DH%B|(<r(A BB'D#D (T!!& &	2SyD Dr6$  c           	         t        dt        t        |xs t              t                    }t        dt        |xs d            }t	        |       d| d| }dD ]  }t        | |t        |            \  }}	|	rd|	fc S 	 t        j                  |d|z   d	d
dd|i|      }
|
j                  dv r|dk(  r\	 |
j                         }|
j                  dk(  r y|
j                  dk(  r y|
j                  dk(  r y|
j                  dz  dk7  r!dd|
j                   dt        ||
      z   fc S t        |t               r|ni dfc S  y# t        j                  $ r}ddt        ||      z   fcY d}~c S d}~ww xY w# t        $ r i }Y w xY w)zRun one SuiteQL query page. Returns (data, error); one re-mint retry on
    an auth failure (expired/revoked token). data is the parsed SuiteQL
    response ({items, hasMore, totalResults, ...}).r^   r   z?limit=z&offset=r!  r  Nr  r  	transient)r  r  r  r  r  zNetSuite request failed: r  r  )NzbNetSuite rejected the service account (HTTP 401). Check the integration credentials + certificate.r  )NzNetSuite refused access (HTTP 403). The role mapped to this integration needs SuiteQL permission and item-read access, and the REST Web Services feature must be enabled.r:$  )NuA   NetSuite is throttling requests (HTTP 429) — try again shortly.rd  r   zNetSuite returned HTTP r>  )Nz,NetSuite authorization failed after re-mint.)rC  rL  r   NETSUITE_PAGE_LIMITr$  r$  r   r  r  r?  r$  rg   r   r   r$  r   r   )r!  r!  r  r   r  r7  rJ  r!  rD  r  r  rl  r   s                r1   _netsuite_suiteqlr$   s   
 3s57$78:MNOEC!$%F"5)*'%
IC %<(EgO
s9	?!&&*3e*;); $/	0
 3Z!D z)gl	99;D s"Ns". s",s"a'3D4D4D3ERH24>? @ @!$-t2t;;K%<L @1 .. 	?53C=> ? ?	?  	D	s0    "E7E:E7E2*E72E7:FFc                 d    d}t        | xs d      j                         }|r	|d| dz  }|dz  }|S )aX  Default SuiteQL item query. A conservative, widely-available column set
    on the `item` table (id, itemid/name, display name, type, description,
    weight); an optional admin WHERE fragment scopes the set further. On-hand
    qty / dimensions are read tolerantly from the row when the account exposes
    them (see _netsuite_normalize_item).z^SELECT id, itemid, displayname, itemtype, description, weight FROM item WHERE isinactive = 'F'r7   z AND (r7  z ORDER BY idr  )r$  r  filts      r1   _netsuite_item_sqlr$   sF    .C{ b!'')DvQ>CJr3   c           	         t        | t              sy| j                         D ci c]  \  }}t        |      j	                         |! }}}d}dD ]  }|j                  |      dvs||   } n i }dD ]  }|j                  |      dvs||   ||<     t        |j                  d      xs d      t        |j                  d      xs d      t        |j                  d      xs |j                  d      xs d      t        |j                  d	      xs d      t        |j                  d
      xs d      ||dS c c}}w )a	  Normalize a SuiteQL item row (lowercased keys) to a stable catalog
    shape. Tolerant: on-hand qty / dimensions flow through only when the
    account's query exposes them, so a customized item_filter/query that adds
    columns lights them up with no code change.N)quantityonhandtotalquantityonhandquantityavailabler  )r  cubicvolumelengthrE  rF  r  r7   itemiddisplaynameitemtyper  )r  r%  r9   rT  r  r  dims)r   r   r   r*   rE  r   )r  r  r  r  r  r	%  s         r1   _netsuite_normalize_itemr
%   s0   
 c4 '*yy{3tq!Q	3A3GK 558:%dG DC 558:%dDG aeeDk'R(!%%//R0m,EhE2Fj)/R0quu]39r:  	4s   $D=c                     t         5  t        j                  | |f      }|r(|d   t        j                         kD  r|d   cd d d        S t        j	                  | |fd        d d d        y # 1 sw Y   y xY wr!  )_netsuite_memo_lock_NETSUITE_MEMOr   rU   r  r!  s      r1   _netsuite_memo_getr%   sp    	 9  )Y!783q6DIIK'q69 9 	Iy148	9
 9
 r!  c                     t         5  t        j                         t        z   |ft        | |f<   d d d        y # 1 sw Y   y xY wrT   )r%  rU   NETSUITE_MEMO_TTL_Sr%  r!  s      r1   _netsuite_memo_putr%   s<    	 8IIK--w28	9-.8 8 8r"  c                 P   t        | |      }||ddddS  |       \  }}|,t        | ||       	 t        j                  | ||       |ddddS t        j                  | |      }|r|d   d|d   |dS ddd|dS # t        $ r" t
        j                  j                  d       Y ]w xY w)u   Live-first fetch: memo hit → live success (memoized + cache
    write-through) → cached last-good ('Cached — as of') → error. Same shape as
    _powerbi_fetch/_idencia_fetch.NFr"  z#NetSuite cache write-through failedr  Tr"  )	r%  r%  r$  r"  ry   rm   rn   r  r"  r"  s           r1   _netsuite_fetchr%   s     i3DtLL:LGS
{9i9	H$$Y	7C  54$OO

"
"9i
8C
I$\*S: 	:ED3GG  	HJJ  !FG	Hr
"  c                 .   t               }|sd}t        j                  d| |       d|fS t               }t        j                  }t        |d         }g ddddf\  }}}}	}
	 t        |||t        |t        	      \  }}|rt        j                  d| |       d|fS d
 |j                  d      xs g D        D cg c]  }|r| }}|j                  |       |dz  }t        |j                  d      t              r|d   }
|j                  d      r|snD|t        k\  r1d}	t        j                  j!                  dt        t#        |             n
|t        z  }t        j$                  |d|       t        j$                  |dt#        |      |	|
d       t'        j(                         j+                  d      }t        j,                  dt#        |      |	d|       t#        |      |d}|	rd|d<   |
|
|d<   t        j                  d| |       |dfS c c}w )a  One org-scope sync pass: page the SuiteQL item query into the local
    product catalog (cache) + an inventory snapshot, so the LTL picker never
    blocks on a live query. Returns (counts, error); the outcome is always
    recorded via netsuite_store.record_sync for the Admin pane.z+NetSuite service account is not configured.r  r#"  Nr$  r   FTr   r  r7  c              3   2   K   | ]  }t        |        y wrT   )r
%  r  s     r1   r/  z%_netsuite_run_sync.<locals>.<genexpr> s      @ ! 5Q7 @r  r   r^   totalResultshasMoreu\   NetSuite sync hit the %d-page cap (%d items) — catalog truncated; tighten the item filter.r  catalog_meta)r  r  r@  rr  )r   r  r&"  )r   r  r  r@  rU  r)"  )r$  r$  r*"  r$  rm!  r$  r$  r$  NETSUITE_SYNC_TIMEOUT_Sr   r  r   r   NETSUITE_PAGE_CAPrm   rn   ro   rC  r"  r  r  r  r+"  )r		  r!  r  rJ  r  r  r   r  r  r  r@  r   r  rO  r'"  r$  s                   r1   _netsuite_run_syncr%   s3   
  !E;""7%sCSy

!C$$E
S/
0C-/Aud-B*E65)U
%eUC?R-3=TV	c&&we3G9@&*hhw&7&=2@ FaCD  F FT
dhh~.4(Exx	"$%%IJJ67HE
 %%+ . UIu5UN'*5z	',./ !**+>?F	SZ9B,D'-/ 5zE2F"{wt5@4<?Fs   9Hc                     	 t         j                         5  t        |       \  }}|r!t         j                  j	                  d|       n t         j                  j                  d|       ddd       t        5  dt        d	<   ddd       y# 1 sw Y   "xY w# t        $ r: t         j                  j                  d       t        j                  d| d       Y gw xY w# 1 sw Y   yxY w# t        5  dt        d	<   ddd       w # 1 sw Y   w xY wxY w)
zFThread body around _netsuite_run_sync: always clears the running flag.zNetSuite sync failed: %szNetSuite sync ok: %sNzNetSuite sync worker crashedr  r/"  r#"  Fr8  )rm   r0"  r%  rn   ro   rl   ry   r  r$  r*"  _netsuite_sync_lock_netsuite_sync_stater3"  s      r1   _netsuite_sync_workerr %  A s    4__ 	@.u5MFE

""#=uE

 6?	@ ! 	4.3 +	4 	4	@ 	@  L

;<""7%)J	LL
	4 	4  	4.3 +	4 	4 	4r6"  c                 h   | d   d   rt        |       syt        dt        | d   d               dz  }t        j                  d      xs i }|j                  d      }|sy		 t        j                  |d
      }t        j                         |z
  j                         |k\  S # t        t        f$ r Y y	w xY wr8"  )r$  rC  r   r$  r3  r   r  r  r   r   r  r  r:"  s        r1   _netsuite_sync_duer"%  S s     z?>*2Ec2JQC
O,CDEFKJ"";/52D	$B&&r+>?  4'668JFF 	" r>"  c                     ddl } 	  | j                  d       	 t        t                     s(t        5  t
        d   r
	 ddd       Bt
        j                  ddt        j                         j                  d             ddd       t        d       # 1 sw Y   xY w# t        $ r" t        j                  j                  d	       Y 7w xY w)
zBackground thread (togen-worker only): org-scope catalog sync on the
    configured cadence. Checks each minute; the interval gate lives in
    _netsuite_sync_due so config changes apply without a restart.r   NTr   r8  r@"  rr  rA"  zNetSuite sync loop error)rU   r  r"%  r$  r%  r%  r  r  r  r  r %  ry   rm   rn   r  rB"  s    r1   _netsuite_sync_loopr$%  d s     


2	=%&;&=>$ S'	2S S %++ -113<<=PQ , SS "+. 
S S  	=JJ  !;<	=rE"  c                      t         5  t        t              } ddd       t        j                         } d   | d   | d   |d   |d   dS # 1 sw Y   4xY wrG"  )r%  r   r%  r$  rI"  rJ"  s     r1   _netsuite_sync_publicr&%  y sa    	 +)*+'')FY'u\7J7^VF^i(* *+ +rM"  z/netsuite/configc                      t               } t        | d         }t        |j                  dd            }t	        d||| d   | d   t               t        j                  t        j                              S )u   Config for the Admin → Services → NetSuite pane. The private key is
    redacted to has_key (write-only, ThingPark/Power BI convention).r^  r$  r7   Tr$  r  )rU  r^  r  r$  r  r"  r"  )	r$  r   r   r  r   r&%  r$  r"  rm!  )rJ  r^  r  s      r1   netsuite_config_getr(%   sl    
  
!C
s5z
C377="-.GdW"=1C
O-/'22>3K3KLN Nr3   c            	      F   t        j                  d      xs i } t               }t        | j	                  d      t
              r| d   }dD ]>  }t        |j	                  |      t              s#||   j                         dd |d   |<   @ t        |j	                  d      t              r3|d   j                         j                         }|t        v r|nd|d   d<   t        |j	                  d	      t              r,|d	   j                         r|d	   j                         |d   d	<   t        | j	                  d
      t              r| d
   j                         dd |d
<   t        | j	                  d      t
              rr| d   j	                  d      }t        |t        t        f      r*|dkD  r%t        dt        dt        |                  |d   d<   d| d   v rt        | d   d         |d   d<   	 t        |       t$        5  t&        j)                  t*        j,                  d       ddd       t        |d         }t        |j)                  d	d            }t#        d|||d
   |d         S # t         $ r}t#        d|       dfcY d}~S d}~ww xY w# 1 sw Y   oxY w)zSave service account / item filter / sync settings (partial merge). A
    blank/omitted org.private_key keeps the stored one so the redacted GET can
    round-trip without wiping the key.Tr  r^  )r$  r   r$  NrK   r$  r$  r$  r$  r  r  r9!  r   `'  r   r:!  r-!  r  r  r7   )rU  r^  r  r$  r  )r   r  r$  r   r   r   r*   r   rB  r$  r   r  rL  rC  r   r$  r   r   r$  r$  r  r$  rm!  )r  rJ  r^  r  r$  rE!  rl  r  s           r1   netsuite_config_postr+%   sl    4(.BD

!C$((5/4(5k> 	5A#''!*c* #At 4E
1	5 cggk*C0k"((*002C-04E-Ec7CJ{#cggm,c2s=7I7O7O7Q(+M(:(@(@(BCJ}%$((=)3/!-0668$?M$((:&-*!!"9:b3,'BF7:5#aR/7RC
O34T*--.24
3CN3S.TC
ON+Cc" 
 =^55t<=
s5z
C377="-.GdW"=1C
OM M  C6se<=sBBC= =s*   3I0 %J0	J9J	JJJ z/netsuite/testc                  6   t        j                  d      xs i } t               }t        | j	                  d      t
              r| j	                  d      ni }|j	                  d      xs dj                         xs |d   d   |j	                  d      xs dj                         xs |d   d   |j	                  d      xs dj                         xs |d   d   |j	                  d      xs dj                         j                         xs |d   j	                  d      xs d	|j	                  d
      xs dj                         xs |d   d
   d}|d   t        vrd	|d<   |d   r|d   r
|d   r|d
   st        d      dfS t        |      \  }}|rt        d|      dfS d|d   z   }t        5  |d   t        j                         dz   dt        |<   ddd       	 t        |d         }t        |||ddt               \  }}	t        5  t        j#                  |d       ddd       |	rt        ddd|	z         dfS |j	                  d      xs g D 
cg c]  }
t%        |
       }}
|D cg c]  }|s|d   s|d    c}dd }|j	                  d      }t        ddt        |t&              r||       S d|       S # 1 sw Y   xY w# 1 sw Y   xY w# t        5  t        j#                  |d       ddd       w # 1 sw Y   w xY wxY wc c}
w c c}w )!zTest the service account: mint a token, then run a tiny SuiteQL item
    probe. Body may carry unsaved creds to test values before saving; blanks
    fall back to the stored org config. Persists nothing (bypasses caches).Tr  r^  r$  r7   r   r$  r$  r$  r$  r$  zDSet the account ID, client ID, certificate ID and private key first.r  r`  Fr  rU  test:r  r   r!  Nr$  r   r   r%  z&Signed in, but the item query failed: rU  token_okr  r   r9   r%  )rU  r/%  items_totalr  )r   r  r$  r   r   r   r   rB  r$  r   r$  r$  rU   r$  r$  r$  NETSUITE_LIVE_TIMEOUT_Sr  r
%  r   )r  rJ  r^  r!  r  r  r  r  r   qerrr  r   r  r  r@  s                  r1   netsuite_testr3%   s.    4(.BD

!C'>$((5/BCww|,299;Ws5z,?Wggk*0b779TSZ=T77#34:AACcs5zRbGcww{+1r88:@@B @U4@8?/52<<> 5u:m47E [!22$k,E+$6&'E-,@ 0 1256 	6&u-KFC
%s+S00 eL))E	 C,28,<15r1A#CC. ]!34&ueS!/FH
d " 	.  -	.%$4t;>?BC 	C3788G3D3JLQ%a(LEL!&:A!&	ai:2A>FHH^$EdT(25#(>u " "DH " "!C C	. 	.! 	.  -	. 	. 	.
 M:sT   3%K!&K KL0L8L>LKKL"L9	LLLz/netsuite/syncc                     t         d   j                  dd      } t               st        d      dfS t        5  t
        d   rt        d      d	fcd
d
d
       S t
        j                  d| t        j                         j                  d             d
d
d
       t        j                  t        | fd      j                          t        d      S # 1 sw Y   @xY w)z=Kick off an org-scope product-catalog sync in the background.r'  r  r7   z-Configure the NetSuite service account first.r  r`  r8  r"  r9  NTrr  rA"  r(  r"  )r   r   r$  r   r%  r%  r  r  r  r  r  r  r %  rq   rg  s    r1   netsuite_sync_startr5%   s     FO,E LMsRR	 K	*!=>CK K 	##%))+445HI 	$ 	KK 1 ""'%'4  K Kr"  z/netsuite/sync-statusc                  p    t        dt               t        j                  t        j                              S r #  )r   r&%  r$  r"  rm!  rX   r3   r1   netsuite_sync_statusr7%   s/     d!6!8'22>3K3KLN Nr3   z/netsuite/user-connectionc                      t        j                  t         j                  d      } d}| r3t        | j	                  d      t
              r| d   j	                  d      }t        ddt        t                     d|      S )	u   Status for the User Settings → Services → NetSuite pane. D1-A: org
    service account only — read-only status; no personal connections.r%  Nr  r  Tr^  F)rU  rO  r#  ro$  catalog_count)	r$  r"  rm!  r   r   r   r   r   r$  )r  r9%  s     r1   netsuite_user_connection_getr:%   sm    
 ##N$<$<nMDM
488I.5Y++G4d!%&9&;!<&+!.0 0r3   z/netsuite/catalogc                      t        j                  t         j                  d      } | r$t        | j	                  d      t
              r| d   ng }t        dt        |      | xs i j	                  d      |dd       S )u   The synced product catalog for pickers/status. Reads the local cache
    only (never a live query) so callers never block — the whole point of the
    D3-A synced catalog. Empty until the first successful sync.r  r  Tr"  Nr  )rU  r  r  r   )r$  r"  rm!  r   r   r  r   rC  )r!  r   s     r1   netsuite_catalogr<%   si     
"
">#;#;Y
GC!j1CT&JC	NPREd#e*SYBOOL4Qu' 'r3   zpaychex_config.jsonu   Paychex is not configured. An admin must set the API client ID, client secret, and company ID in Togen Admin → Services → PayChex.zhttps://api.paychex.comz/auth/oauth/v2/tokenz/companies/{cid}/workersz /companies/{cid}/timeoffrequestsz/companies/{cid}/checkinsi  c                     ddddi t        t              d} t        j                         r	 t	        j
                  t        j                               }t        |j                  d      t               r;| d   D ]3  }t        |d   j                  |      t              s&|d   |   | d   |<   5 t        |j                  d      t               r`|d   j                         D ci c]>  \  }}t        |t              r)t        |t              r|r|rt        |      t        |      @ c}}| d<   t        |j                  d      t               r^|d   j                  d      }t        |t        t        f      r|dkD  rt        |      | d   d<   d	|d   v rt        |d   d	         | d   d	<   | S | S c c}}w # t        j                  t        f$ r Y | S w xY w)
u   Return Paychex config with defaults filled in. driver_overrides maps a
    Paychex workerId → a Togen Personnel `per_*` id (D5-A manual linkage).r7   r   r   
company_id)r^  driver_overridesr  r^  r@%  r  r9!  r   r:!  )r   PAYCHEX_SETTINGS_DEFAULTSPAYCHEX_CONFIG_FILEr   r   r   r   r   r   r*   r   r   r  r   r   r   )rJ  r  r  r  r  rE!  s         r1   _load_paychex_configrC%  J s    !#RrJ!568C ""$	JJ2<<>?E%))E*D1U 8A!%,"2"21"5s;(-eQE
18 %))$67>/45G/H/N/N/P+N'+q!!!S)jC.@Q1 FCFN+N&' %))J/6:&**+BCb3,/BF?B2wC
O$;<!U:%666:j).97;C
ON3 J3J+N $$g. 	J	s-   A0F5 AF5 $AF/'BF5 /F5 5GGc                     t         j                  t        j                  | d             	 t         j	                  t
        j                  t
        j                  z         y # t        $ r Y y w xY wr   )	rB%  r   r   r   r  r  r  r  r   rN  s    r1   _save_paychex_configrE%  g rI!  r  c                     | xs
 t               } | d   }|j                  d      r1|j                  d      r |j                  d      r|d   |d   |d   dS y)z}Org service-app creds dict, or None when not fully configured (all of
    client_id, client_secret, company_id are required).r^  r   r   r?%  r>%  N)rC%  r   re!  s     r1   _paychex_org_credsrG%  o sd     
'%'C
e*C
ww{ 8SWW\=R -!$_!5!,/1 	1 r3   c                 V    t               }|rt        j                  |ddfS dddt        fS )u   Resolve which Paychex connection a request runs under. D1-A: the org
    service account is the ONLY scope — no per-user creds. Returns
    (scope_key, creds, source, error).r^  Nr7   )rG%  paychex_storerm!  _PAYCHEX_NOT_CONFIGURED_MSGr%$  s     r1   _paychex_resolve_connectionrK%  { s4     
C
&&UD88r666r3   c                 J   ddl m}  ||xs d      j                  xs |xs d}t        |       }|j	                         }d|v sd|v sd|v sd|v rd	| d
S t        | t        j                        sd|v rd| dS t        | t        j                        rd| d|dd  dS |dd S )u   Condense a requests exception into an actionable one-liner (matches the
    NetSuite/Idencia/Power BI lesson — the raw text buries DNS/timeout causes).r   r  r7   zthe Paychex servicerU!  rV!  rW!  rX!  rY!  r($  rZ!  r[!  r  r\!  r:  Nr]!  r  r  r"  ra!  s         r1   _paychex_request_errorrM%   s     &CI2%%G#*F1FDs8DjjlGG#}'?*g5&'1*4& 1# # 	$#~--.+2H&tfA..#~556&tfBtDSzl"==:r3   c                    t        | t              r| j                  d      }t        |t              rb|r`t        |d   t              r|d   ni }t	        |j                  d      xs |j                  d      xs d      j                         }|r|dd S dD ]8  }| j                  |      st	        | |         j                  d	      d   dd c S  t	        t        |d
d      xs d      dd S )zPull a readable message out of a Paychex REST error body. Paychex returns
    {errors:[{code,message,field}]} on failures and OAuth {error,
    error_description} on token failures.r?  r   r  r3  r7   Nr  )r4  r  r  re  r0  r&  r$  )r   r  errse0r  r  s         r1   _paychex_error_detailrQ%   s     $xx!dD!d&tAw5a2BbffY'?266&>?R@FFHC4Cy C 	9Axx{47|))$/24C88	9 wtVR(.B/55r3   c                    | j                  d      r| j                  d      sdt        fS 	 t        j                  t        d| d   | d   dddi|      }	 |j                         }|j                  d
z  dk7  s|j                  d      sMt        ||      }|j                  d      dv rdd|j                  d       d| dfS dd|j                   d| fS 	 t        |j                  d            }|d   |ddfS # t        j
                  $ r}dd	t        |t              z   fcY d}~S d}~ww xY w# t        $ r i }Y w xY w# t        t        f$ r d}Y cw xY w)zMint an app-only Paychex API token via the OAuth 2.0 client-credentials
    grant. Returns (tokens, error) where tokens = {access, expires_in}.r   r   Nr0!  r1!  r  r$  r$  zPaychex token request failed: rd  r   r  r  )r*$  unauthorized_clientz)Paychex rejected the client credentials (r  zR. Check the API client ID and secret, and that the app is approved for the tenant.z$Paychex token request returned HTTP r>  r  r  r+$  )r   rJ%  r  r  PAYCHEX_TOKEN_URLr?  rM%  r   r   rg   rQ%  r   r   )r!  r7  r  rl  r   r	  r  s          r1   _paychex_mint_tokenrU%   s    IIk"uyy'A000
H"" 4$[1#(#9; $%HIyy{ 3!#488N+C&tT288G II "hhw/0F8 <,, - - <))*"VH6 7 	7,/0
 >**EtKK) ** H6.s4EFG H 	HH
   z" 
sA   )C; D0 E ;D-D("D-(D-0D>=D>EEc                    t        j                          }t        5  t        j                  |       }|r|s|d   |kD  r|d   dfcddd       S ddd       t	        |      \  }}|r)t        5  t        j                  | d       ddd       d|fS t        5  |d   |t        d|d   dz
        z   dt        | <   ddd       |d   dfS # 1 sw Y   zxY w# 1 sw Y   d|fS xY w# 1 sw Y   +xY wr0$  )rU   _paychex_token_lock_PAYCHEX_TOKENSr   rU%  r  rC  r3$  s          r1   _paychex_get_tokenrY%   r5$  r6$  c           	         t        |      j                  d      r|nt        |z   }dD ]  }t        | |t	        |            \  }}|rdd|fc S 	 t        j                  |d|z   dd|xs d|      }	|	j                  d
v r|dk(  r_	 |	j                         }|	j                  dk(  r y|	j                  dk(  r y|	j                  dk(  r y|	j                  dk(  r y|	j                  dz  dk7  r,d|	j                  d|	j                   dt        ||	      z   fc S t        |t              r|ni |	j                  dfc S  y# t
        j                  $ r}
ddd	t        |
|      z   fcY d}
~
c S d}
~
ww xY w# t        $ r i }Y w xY w)zGET one Paychex API resource. path may be a full URL (a pagination
    `next` link) or an app-relative path. Returns (data, status, error); one
    re-mint retry on an auth failure (expired/revoked token).rA  r!  r  Nr  r  r  r  zPaychex request failed: r  r   r  )Nr  zNPaychex rejected the service app (HTTP 401). Check the API client credentials.r  )Nr  zPaychex refused access (HTTP 403). The API app needs scope for this data, and clock/time data requires the Time & Attendance product.r  )Nr  z&Paychex returned HTTP 404 (not found).r:$  )Nr:$  u@   Paychex is throttling requests (HTTP 429) — try again shortly.rd  r   zPaychex returned HTTP r>  )NNz+Paychex authorization failed after re-mint.)r*   rw   PAYCHEX_BASErY%  r   r  r   r?  rM%  rg   r   r   rQ%  r   r   )r!  r!  rb   r  r7  rJ  r!  rD  r  r  rl  r   s               r1   _paychex_api_getr\%   s   
 d)&&v.$\D5HC $P'	5WN
ss?"		D!%%*3e*;#57~!D z)gl	99;D s"Ds"J s"Fs"1s"a'))()9)9(:"='d345 5 #4.B9I9I4OOI$PJ E5 .. 	D :"8c"B!C D D	D  	D	s0   #D?E3?E0E+#E0+E03F Fc                     | xs i j                  d      }t        |t              rE|D ]@  }t        |t              s|j                  d      dk(  s)|j                  d      s;|d   c S  y)zsReturn the pagination `next` href from a Paychex response, or None. HAL
    style: {links:[{rel:'next',href:...}]}.r  r  r  rC  N)r   r   r  r   )r   r  rw  s      r1   _paychex_next_linkr^%   sc     ZRW%E% 	!A!T"quuU|v'=!%%-y 	! r3   c                 F   g dd}}}t        |xs i       }	|	j                  dt               |}
|	}|
rkt        | ||
||      \  }}}|r||||fS |j	                  d      }t        |t              r|j                  |       |dz  }||k\  rd}nt        |      }
d}|
rk|||dfS )	ziFollow Paychex pagination, accumulating the `content` array. Returns
    (rows, pages, truncated, error).r   Fr   r!  r  r^   TN)	r   r}  PAYCHEX_PAGE_LIMITr\%  r   r   r  r  r^%  )r!  r!  rb   r  r7  rx  rn  r  r  r  rN  
use_paramsr   _statusr  r  s                   r1   _paychex_get_allrc%  $ s      E%DV\rALL,-HJ
-i5?Rgs	3..((9%gt$KK 
C<I%d+
  	4''r3   c                    t        | t              sy| j                  d      }t        |t              r.|j	                         r|j	                         j                         S | j                  d      }t        |t              r|D ]  }t        |t              st        |j                  d      xs d      j                         dv sC|j                  d      xs$ |j                  d      xs |j                  d      }t        |t              s|j	                         s|j	                         j                         c S  y)zxPull a worker's email from the common Paychex shapes (direct `email`, or
    a communications list with an EMAIL entry).r7   r  communicationsrT  )EMAIL
WORK_EMAILPERSONAL_EMAILurir  )r   r   r   r*   r   rE  r  rB  )r  directcommsr  r  s        r1   _paychex_worker_emailrl%  > s     c4 WWWF&#6<<>||~##%%GG$%E% 	-Aa&155=&B'--/ 4F FEE'NDaeeElDaeeGna%!'')779??,,	- r3   c                    t        | t              syt        | j                  d      xs( | j                  d      xs | j                  d      xs d      }t        | j                  d      t              r| j                  d      ni }t        |j                  d      xs | j                  d      xs d      j	                         }t        |j                  d      xs | j                  d      xs d      j	                         }d	j                  d
 ||fD              xs? t        | j                  d      xs |j                  d      xs d      j	                         }|s|sy||xs |||t        |       t        | j                  d      xs d      j	                         t        t        | j                  d      t              r7| j                  d      xs9 | j                  d      xs i j                  d      n| j                  d      xs d      j	                         t        | j                  d      xs d      j	                         dS )zNormalize a Paychex worker row to a stable roster shape. Tolerant of the
    tenant's field variants (name object vs legalName, worker_id vs id).NworkerIdr  	workerUidr7   r9   	givenName
familyNamer\   c              3   &   K   | ]	  }|s|  y wrT   rX   r  s     r1   r/  z,_paychex_normalize_worker.<locals>.<genexpr>\ s     511Q5r'  	legalNamer  
workerTypeemploymentStatuscurrentStatus
statusTypejobTitle)	worker_idr9   r  r  r  worker_typerd   	job_title)r   r   r*   r   r   r  rl%  )r  r1  name_objgivenfamilyr9   s         r1   _paychex_normalize_workerr%  S s    c4 
cggj!PSWWT]Pcggk6JPb
QC",SWWV_d"CswwvH[)GSWW[-AGRHNNPEl+Jsww|/DJKQQSFHH555 "377;' 8<<+F %' 	 tKC!*3/sww|4:;AAC'0B(CTJ ''/2 S!gg&89?RDD\R"ww7=2? @EuwSWWZ06B7==?
A 
Ar3   c                     t         5  t        j                  | |f      }|r(|d   t        j                         kD  r|d   cd d d        S t        j	                  | |fd        d d d        y # 1 sw Y   y xY wr!  )_paychex_memo_lock_PAYCHEX_MEMOr   rU   r  r!  s      r1   _paychex_memo_getr%  t r!  r!  c                     t         5  t        j                         t        z   |ft        | |f<   d d d        y # 1 sw Y   y xY wrT   )r%  rU   PAYCHEX_MEMO_TTL_Sr%  r!  s      r1   _paychex_memo_putr%  } r"  r"  c                 P   t        | |      }||ddddS  |       \  }}|,t        | ||       	 t        j                  | ||       |ddddS t        j                  | |      }|r|d   d|d   |dS ddd|dS # t        $ r" t
        j                  j                  d       Y ]w xY w)u   Live-first fetch: memo hit → live success (memoized + cache
    write-through) → cached last-good ('Cached — as of') → error. Same shape as
    _netsuite_fetch/_powerbi_fetch/_idencia_fetch.NFr"  z"Paychex cache write-through failedr  Tr"  )	r%  r%  rI%  r"  ry   rm   rn   r  r"  r"  s           r1   _paychex_fetchr%   s     Y	2DtLL:LGS
{)Y8	G##Iy'B  54$OO

!
!)Y
7C
I$\*S: 	:ED3GG  	GJJ  !EF	Gr
"  c                 x   t               }|sd}t        j                  d| |       d|fS |d   }t        j                  }t	        ||t
        j                  |      t              \  }}}}|rt        j                  d| |       d|fS d |D        D cg c]  }|s|	 }	}|r.t        j                  j                  d	t        t        |	             t        j                  |d
|	       t        j                  |dt        |	      |d       t        |	      |d}
|rd|
d<   t	        ||t        j                  |      t              \  }}}}|&t        j                  |d|       t        |      |
d<   nd|
d<   t	        ||t        j                  |      t              \  }}}}|&t        j                  |d|       t        |      |
d<   nd|
d<   t!        j"                         j%                  d      }t        j&                  d
t        |	      |d|       t        j                  d| |
       |
dfS c c}w )u  One org-scope sync pass: page the workers roster into the local cache +
    a snapshot so the driver-availability overlay never blocks on a live query.
    Time-off + clock status are best-effort augmentation reads (they need the
    tenant's Time & Attendance product) — a 403/404 there is counted+skipped,
    never fatal. Returns (counts, error); the outcome is always recorded via
    paychex_store.record_sync for the Admin pane.z*Paychex service account is not configured.r  r#"  Nr?%  r  r!  c              3   2   K   | ]  }t        |        y wrT   )r%  r,  r  s     r1   r/  z$_paychex_run_sync.<locals>.<genexpr> s     FA4Q7Fr  uC   Paychex sync hit the %d-page cap (%d workers) — roster truncated.rt  roster_meta)r  r  )workersr  Tr  timeoffunavailablecheckinrr  )r%  r  r&"  rU  r)"  )rG%  rI%  r*"  rm!  rc%  PAYCHEX_WORKERS_PATHr	  PAYCHEX_SYNC_TIMEOUT_Srm   rn   ro   PAYCHEX_PAGE_CAPrC  r"  PAYCHEX_TIMEOFF_PATHPAYCHEX_CHECKIN_PATHr  r  r  r+"  )r		  r!  r  r  r  rn  r  r  r  r%  r$  to_rowsr  r  to_errci_rowsci_errr'"  s                     r1   _paychex_run_syncr%   s<     E:!!'cBSy

C##E #3u*11c1:&#(D%C !!'cBSyFFLQ!qLGL

Qc'l	, E8W5E=&)'lKMW6F"{ /u*11c1:&(GRV ~y':Ly)y /u*11c1:&(GRV ~y':Ly)y!**+>?FxS\7@*B&,. d%?4<K Ms   H7H7c                     	 t         j                         5  t        |       \  }}|r!t         j                  j	                  d|       n t         j                  j                  d|       ddd       t        5  dt        d	<   ddd       y# 1 sw Y   "xY w# t        $ r: t         j                  j                  d       t        j                  d| d       Y gw xY w# 1 sw Y   yxY w# t        5  dt        d	<   ddd       w # 1 sw Y   w xY wxY w)
zEThread body around _paychex_run_sync: always clears the running flag.zPaychex sync failed: %szPaychex sync ok: %sNzPaychex sync worker crashedr  r/"  r#"  Fr8  )rm   r0"  r%  rn   ro   rl   ry   r  rI%  r*"  _paychex_sync_lock_paychex_sync_stater3"  s      r1   _paychex_sync_workerr%   r5"  r6"  c                 h   | d   d   rt        |       syt        dt        | d   d               dz  }t        j                  d      xs i }|j                  d      }|sy		 t        j                  |d
      }t        j                         |z
  j                         |k\  S # t        t        f$ r Y y	w xY wr8"  )rG%  rC  r   rI%  r3  r   r  r  r   r   r  r  r:"  s        r1   _paychex_sync_duer%   r="  r>"  c                     ddl } 	  | j                  d       	 t        t                     s(t        5  t
        d   r
	 ddd       Bt
        j                  ddt        j                         j                  d             ddd       t        d       # 1 sw Y   xY w# t        $ r" t        j                  j                  d	       Y 7w xY w)
zBackground thread (togen-worker only): org-scope roster sync on the
    configured cadence. Checks each minute; the interval gate lives in
    _paychex_sync_due so config changes apply without a restart.r   NTr   r8  r@"  rr  rA"  zPaychex sync loop error)rU   r  r%  rC%  r%  r%  r  r  r  r  r%  ry   rm   rn   r  rB"  s    r1   _paychex_sync_loopr%    rD"  rE"  c                      t         5  t        t              } ddd       t        j                         } d   | d   | d   |d   |d   dS # 1 sw Y   4xY wrG"  )r%  r   r%  rI%  rI"  rJ"  s     r1   _paychex_sync_publicr%   rL"  rM"  c                    | xs
 t               } | j                  d      xs i }t        j                  t        j                  d      }|r$t        |j                  d      t              r|d   ng }t               }|D ci c]&  }|j                  d      s|j                  d      |( }}i }|D ]H  }|j                  d      xs dj                         j                         }|s7|j                  ||       J d }	g g }}
|D ]  }t        |t              s|j                  d      xs d}d	\  }}|j                  |      }|r||v r||   d
}}n"|j                  d      r|d   |v r
||d      d}}| |	|      |d}|
j                  |       ||j                  |        |
|t        |      t        |
      t        |      z
  |xs i j                  d      dS c c}w )uI  Match the synced Paychex roster to Togen Personnel/driver records (D5-A).
    Precedence per worker: a manual override (driver_overrides) → an exact email
    match against a Personnel record → unmatched. Never a fuzzy match, never
    silently dropped. Returns
    {links:[{worker, person, match}], unmatched:[worker], ...}.r@%  rt  r  r  r  r7   c           	          | sy | j                  d      t        |       | j                  d      xs dj                         j                         dt	        | j                  d            v dS )Nr  r  r7   r  rA  rn  )r   r  r   rE  r=  r  s    r1   _person_publicz-_paychex_driver_links.<locals>._person_public3 sb    eeDk?1+=%%..B557==?%)?EE.)*+ +, 	,r3   ry%  r  r  )workerrP  rG  r"  )r  r  worker_countr  r  )rC%  r   rI%  r"  rm!  r   r  r
  r   rE  r}  r   ri   rC  )rJ  r  r!  r%  rc  r  r  by_emailr!  r%  r  r  r  r1  rP  rG  r  r   s                     r1   _paychex_driver_linksr%  ! s    
'%'C*+1rI

!
!-"9"98
DC #
37793Et(Lc)nRTGF%+;quuT{QUU4[!^;E;H 'eeGn"))+113A&'
, 29E  !T"eeK &B"]]3"+!"IxEFUU7^'
h 6$QwZ0'EFv(>OU>Q  LSZ#i.5PiR__\24 4= <s   >GGz/paychex/configc            
          t               } t        | d         }t        |j                  dd            }t	        d||| d   t               t        j                  t        j                              S )u   Config for the Admin → Services → PayChex pane. The client secret is
    redacted to has_secret (write-only, ThingPark/Power BI/NetSuite convention).r^  r   r7   Tr  )rU  r^  r(!  r  r"  r"  )	rC%  r   r   r  r   r%  rI%  r"  rm!  rb$  s      r1   paychex_config_getr%  Q se    
 
 C
s5z
Ccggor23Jd

O2F2H&11-2I2IJL Lr3   c            	      *   t        j                  d      xs i } t               }t        | j	                  d      t
              r| d   }dD ]>  }t        |j	                  |      t              s#||   j                         dd |d   |<   @ t        |j	                  d      t              r,|d   j                         r|d   j                         |d   d<   t        | j	                  d      t
              rr| d   j	                  d	      }t        |t        t        f      r*|d
kD  r%t        dt        dt        |                  |d   d	<   d| d   v rt        | d   d         |d   d<   	 t        |       t         5  t"        j%                  t&        j(                  d       ddd       t        |d         }t        |j%                  dd            }t        d|||d         S # t        $ r}t        d|       dfcY d}~S d}~ww xY w# 1 sw Y   kxY w)zSave service app / sync settings (partial merge). A blank/omitted
    org.client_secret keeps the stored one so the redacted GET can round-trip
    without wiping the secret.Tr  r^  )r   r?%  NrK   r   r  r9!  r   r*%  r   r:!  r-!  r  r  r7   )rU  r^  r(!  r  )r   r  rC%  r   r   r   r*   r   r   r  rL  rC  r   rE%  r   r   rW%  rX%  r  rI%  rm!  )r  rJ  r^  r  rE!  rl  r(!  s          r1   paychex_config_postr%  ^ s    4(.BD

 C$((5/4(5k, 	5A#''!*c* #At 4E
1	5 cggo.4_9M9S9S9U*-o*>*D*D*FCJ'$((:&-*!!"9:b3,'BF7:5#aR/7RC
O34T*--.24
3CN3S.TC
ON+CS! 
 ;M33T:;
s5z
Ccggor23Jd

O- -  C6se<=sBBC; ;s*   )G" :%H	"	H+H;HH	Hz/paychex/testc                     t        j                  d      xs i } t               }t        | j	                  d      t
              r| j	                  d      ni }|j	                  d      xs dj                         xs |d   d   |j	                  d      xs dj                         xs |d   d   |j	                  d      xs dj                         xs |d   d   d}|d   r
|d   r|d   st        d	
      dfS t        |      \  }}|rt        d|      dfS d|d   z   }t        5  |d   t        j                         dz   dt        |<   ddd       	 t        ||t        j                  |d         ddit              \  }}}	t        5  t        j!                  |d       ddd       |	rt        ddd|	z         dfS |xs i j	                  d      xs g D 
cg c]  }
t#        |
       }}
|D cg c]  }|s|d   s|d    c}dd }t        dd|      S # 1 sw Y   xY w# 1 sw Y   xY w# t        5  t        j!                  |d       ddd       w # 1 sw Y   w xY wxY wc c}
w c c}w )zTest the service app: mint a token, then read a tiny workers probe. Body
    may carry unsaved creds to test values before saving; blanks fall back to
    the stored org config. Persists nothing (bypasses caches).Tr  r^  r   r7   r   r?%  r>%  z;Set the API client ID, client secret, and company ID first.r  r`  Fr  rU  r-%  r  r   r!  Nr%  r   r   r!  z)Signed in, but the workers query failed: r.%  r  r9   )rU  r/%  r  )r   r  rC%  r   r   r   r   r   rU%  rW%  rU   rX%  r\%  r%  r	  PAYCHEX_LIVE_TIMEOUT_Sr  r%  )r  rJ  r^  r!  r  r  r  r   rb%  r2%  r  rn  r  r  s                 r1   paychex_testr%   s    4(.BD

 C'>$((5/BCggk*0b779TSZ=T77?39r@@B 9 Z8ww|,299;Ws5z,?W	YE
 +5#9eL>Q # $%() 	)%e,KFC
%s+S00 eL))E	 B+1(+;04		b0@"BB-.5.55%:M5NQ<)?Agt ! 	-t,	-%$7$>ABEF 	F48JB3C3CI3N3TRTVQ%a(VDV!%9Aqyai9"1=FdT&99B B	- 	-  	-t,	- 	- 	-
 W9sT   (%H1H5 H)I+4I0<I0I0H&)H25I(<I	I(I%!I(z/paychex/syncc                     t         d   j                  dd      } t               st        d      dfS t        5  t
        d   rt        d      d	fcd
d
d
       S t
        j                  d| t        j                         j                  d             d
d
d
       t        j                  t        | fd      j                          t        d      S # 1 sw Y   @xY w)z4Kick off an org-scope roster sync in the background.r'  r  r7   z,Configure the Paychex service account first.r  r`  r8  r"  r9  NTrr  rA"  r(  r"  )r   r   rG%  r   r%  r%  r  r  r  r  r  r  r%  rq   rg  s    r1   paychex_sync_startr%   s     FO,EKLcQQ	 Ky)!=>CK K 	""%))+445HI 	# 	KK 0x ""'%'4  K Kr"  z/paychex/sync-statusc                  p    t        dt               t        j                  t        j                              S r #  )r   r%  rI%  r"  rm!  rX   r3   r1   paychex_sync_statusr%   r#  r3   z/paychex/driver-linksc                  X   t               } t               D cg c]{  }|j                  d      rh|j                  d      t        |      |j                  d      xs dj	                         j                         dt        |j                  d            v d} }}t        d	d|d| S c c}w )
u   Matched/unmatched Paychex worker → Personnel driver view (D5-A). Also
    returns the Personnel roster so the admin can pick a manual override.r  r  r7   r  rA  rn  T)rU  r  rX   )r%  r
  r   r  r   rE  r=  r   )r  r  rc  s      r1   paychex_driver_links_getr%   s    
 "#E
 '(	9 AEE$K	 UU4[/!*<w-2446<<>$(>55()* *+ 9F 9
 6df6669s   B B'z/paychex/driver-linkc                  \   t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         |st	        d      dfS r*t        fd	t               D              st	        d
      dfS t               }t        |j                  d      xs i       }r||<   n|j                  |d       ||d<   	 t        |       t	        dddit        |      S # t        $ r}t	        d|       dfcY d}~S d}~ww xY w)zSet or clear a manual per-worker driver override (D5-A). Body:
    {worker_id, person_id}. A blank/omitted person_id clears the override (falls
    back to the email auto-match).Tr  ry%  r7   ra  zworker_id is required.r  r`  c              3   F   K   | ]  }|j                  d       k(    ywr	  r  r  s     r1   r/  z+paychex_driver_link_post.<locals>.<genexpr> s     EaquuT{c)ErI  zUnknown person_id.r  r@%  Nr;  r  rU  rX   )r   r  r   r   r   r2  r
  rC%  r   r  rE%  r   r%  )r  r1  rJ  r  rl  r  s        @r1   paychex_driver_link_postr%   s"    4(.BD88K &B
-
-
/C88K &B
-
-
/C56;;
3E?3DEE12C77

 CSWW/06B7I
	#c4 'C<S! 9d93C899  </u56;;<s   &D 	D+D& D+&D+z/paychex/user-connectionc                      t        j                  t         j                  d      } d}| r3t        | j	                  d      t
              r| d   j	                  d      }t        ddt        t                     d|      S )	u   Status for the User Settings → Services → PayChex pane. D1-A: org service
    account only — read-only status; no personal connections.r%  Nr  r  Tr^  F)rU  rO  r#  ro$  r%  )	rI%  r"  rm!  r   r   r   r   r   rG%  )r  r%  s     r1   paychex_user_connection_getr%   sm    
 ""=#:#:MJDL
488I.5I**73d!%&8&:!;&+ ,. .r3   z/paychex/workersc                      t        j                  t         j                  d      } | r$t        | j	                  d      t
              r| d   ng }t        dt        |      | xs i j	                  d      |dd       S )u   The synced workers roster for pickers/status. Reads the local cache only
    (never a live query) so callers never block — the whole point of the synced
    roster. Empty until the first successful sync.rt  r  Tr"  Nr  )rU  r  r  r%  )rI%  r"  rm!  r   r   r  r   rC  )r!  r%  s     r1   paychex_workersr%   si     
!
!-"9"98
DC #
37793Et(Lc)nRTGd#g,sybool6S"5D>+ +r3   )r  r#  rY  c                  0   t         j                         r\	 t        j                  t         j	                               } t        | t              r!| D cg c]  }t        |t              s| c}S 	 g S g S c c}w # t        j                  t        f$ r Y g S w xY w)z?Return the list of saved report schedules (empty list if none).)
SCADA_REPORT_SCHEDULES_FILEr   r   r   r   r   r  r   r   r   )r  r  s     r1   _load_scada_report_schedulesr%   s    "**,	JJ:DDFGE%&#(@aJq$,?@@ ' I2I A$$g. 	I	r  c                 X    t         j                  t        j                  | d             y)z*Persist the report schedules list to disk.r   r   N)r%  r   r   r   r  s    r1   _save_scada_report_schedulesr%  # s    **4::i+JKr3   c                    | j                  d      xs dj                         }|syt        |      dkD  ry| j                  d      xs dj                         }|t        vrdd	d
j	                  t               dfS d}|dk(  r!	 t        | j                  d            }|dk  ry| j                  d      }|g }t        |t              sy|D cg c]4  }t        |t              s|j                         s%|j                         6 }}t        j                         j                         }|xs i j                  d      xs t        t        j                               ||||t!        | j                  d            t!        | j                  d            |xs i j                  d      xs ||d	}|dfS # t        t        f$ r Y yw xY wc c}w )zBuild/validate a schedule dict from a request body. Returns
    (schedule, error). ``existing`` supplies id/created_at when updating.r9   r7   r  r  r  r  r  Nr  r  r  rY  r  )Nz4custom_days must be an integer for a custom cadence.r^   )Nzcustom_days must be at least 1.r$  )Nzsensors must be a list.r  r  externalr  )	r  r9   r  r  r$  r  r%  r  r  )r   r   rC  SCADA_REPORT_CADENCESr  r   r   r   r   r  r*   r  r  r  rW  rX  r   )	r  r  r9   r  r  r$  r  r  r  s	            r1   _scada_schedule_from_bodyr%  ( s    HHV"))+D1
4y3Fxx	"1k88:G++8CX9Y8ZZ[\\\K(	Pdhh}56K ?:hhy!Ggt$.")NQZ3-?AGGIqwwyNGN




(
(
*C~2""4(=C

,="txx)**-.~2**<8?C
H T>1 :& 	PO	P Os$   F4 G	$G	5G	4GGz/scada/report-schedulesc                  *    t        t                     S )z"Return all saved report schedules.r  )r   r%  rX   r3   r1   scada_report_schedules_listr%  T s     9;<<r3   c                      t        j                  d      xs i } t        |       \  }}|rt        |      dfS t	               }|j                  |       t        |       t        |      dfS )zCreate a new report schedule.Tr  r  r`  r  r  )r   r  r%  r   r%  ri   r%  r  s       r1   scada_report_schedules_creater%  [ sh     4(.BD-d3MHc
S!3&&,.IX +H%s**r3   z"/scada/report-schedules/<sched_id>c                 &    t        j                  d      xs i }t               }t         fdt	        |      D        d      }|t        d      dfS t        |||         \  }}|rt        |      d	fS |||<   t        |       t        |
      S )z)Update an existing report schedule by id.Tr  c              3   R   K   | ]  \  }}|j                  d       k(  s|   ywr	  r  r  s      r1   r/  z0scada_report_schedules_update.<locals>.<genexpr>o r  r	  Nr  r  r  r  r`  r  )r   r  r%  r  r  r   r%  r%  r  s   `     r1   scada_report_schedules_updater%  i s     4(.BD,.I
Ni	2NPT
UC
{23S88-dYs^LMHc
S!3&&IcN +H%%r3   c                     t               }|D cg c]  }|j                  d      | k7  s| }}t        |      t        |      k(  rt        d      dfS t	        |       t        d      S c c}w )zDelete a report schedule by id.r  r  r  r  TrY  )r%  r   rC  r   r%  r  s       r1   scada_report_schedules_deleter%  z sh     -.I%Aqt)@AIA
9~Y'23S88 +d	 Br  c                    t        j                  d      }	 t        j                  | t	        j
                         j                         d|dd      }|j                  dd      rt        j                  |       |j                  dd      }|j                  d	      s|d
v rrt        5  d| j                  d      |j                  dd      d   j                         xs dt	        j
                         j                         dt        |<   ddd       yt        5  d| j                  d      |j                  dd      |j                  dd      t	        j
                         j                         dt        |<   ddd       y# 1 sw Y   yxY w# 1 sw Y   yxY w# t         $ r}|j#                  d| j                  d             t        5  d| j                  d      t%        |      xs |j&                  j(                  t	        j
                         j                         dt        |<   ddd       n# 1 sw Y   nxY wY d}~yY d}~yd}~ww xY w)zBackground body of a Test / run-now: generate + persist + record a report
    with no email, then stash the outcome for the UI to poll.r  Fr  )rE  rn   r	  
send_emailr  Tr  r7   error:)persist_failedr  r  rS  r^   r  zReport failed.)rd   r  r  rr  Nr  r  r  r   )rd   r  r  r  rr  zrun-now failed for schedule %rr9   )r  r  r  run_scheduler  r  r   r  _append_run_logr   rw   _scada_run_now_lockr  r   r  _scada_run_now_jobsry   r  r*   	__class__r  )r  r^  rn   r  rd   rl  s         r1   _scada_run_now_workerr%   s     89FA'44n((*//15(u>
 ::j$'"226:NB/X&&4G*G$ E%hll46H#\\#q1"5;;=QAQ#1#5#5#7#A#A#C/E#F+E
   	A d1C%zz-<#ZZQ7-113==?	+A'	A 	AE
 	A 	A  A98<<;OP  	A!(,,t2DS;S]]%;%;-113==?+A'	A 	A 	A 	A 	AAsu   BF &A#F 	F F AF7F  F	F 	F FF F 	I!'IAH0'	I0H9	5IIz*/scada/report-schedules/<sched_id>/run-nowc                     t               }t         fd|D        d      }|t        d      dfS t        j                  |      t        d      dfS t        j                         j                  }t        5  d d	t        |<   ddd       t        j                  t        ||fd
      j                          t        d
|      dfS # 1 sw Y   DxY w)zFTest a schedule: generate + persist + record its report now, no email.c              3   L   K   | ]  }|j                  d       k(  s|  ywr	  r  )r,  r  r  s     r1   r/  z0scada_report_schedule_run_now.<locals>.<genexpr> s      B!%%+*A!Br  Nr  r  r  z Schedule has an invalid cadence.r`  r8  )rd   r  Tr(  )rU  r^  r  )r%  r  r   r  _interval_daysrW  rX  rY  r%  r%  r  r  r%  rq   )r  r  r  r^  s   `   r1   scada_report_schedule_run_nowr%   s     -.IBYBDIE}23S88,,U3;?@#EEZZ\F	 @1:6>'@F#@ 1 ""'%'d6*C//@ @s   8CCz(/scada/report-schedules/run-now/<job_id>c                     t         5  t        j                  |       }ddd       t        d      dfS t        |      S # 1 sw Y   %xY w)z5Poll a Test / run-now job started by the route above.NzUnknown or expired job.r  r  )rb  )r%  r%  r   r   )r^  rb  s     r1   $scada_report_schedule_run_now_statusr%   sM     
 .!%%f-.
{67<<s	. .s   AA
z/scada/report-runsc                     t         j                  j                  dd      j                         } t	        j
                         }| r$|D cg c]  }|j                  d      | k(  s| }}t        |d d      }t        |      S c c}w )zReturn report run-log records, newest first. Optional ``schedule_id``
    filter powers the per-schedule History surface in the Schedules tab; the
    Auditor SCADA Reporting sub-tab reads the same log via /history.r  r7   c                 &    | j                  dd      S r  r  rx	  s    r1   r	  z#scada_report_runs.<locals>.<lambda> r  r3   Tr
  r  )r   rh  r   r   r  r  r  r   )r   rk  r  s      r1   scada_report_runsr%   sv     ,,

="
-
3
3
5C
 
-
-
/C
=Q}!5!<q==
<d
KC >s   	B#B)enforcedr%  c                    | j                         r	 t        j                  | j                               }t        |t              r|j                  dg       }t        |t              r2|D cg c]&  }t        |t              s|j                         s%|( c}S g S # t        j                  t
        f$ r d}Y w xY wc c}w )zHReturn the email list from a recipient file (flat list or {recipients}).Nr  )r   r   r   r   r   r   r   r   r   r  r*   r   )rb   r   rB  s      r1   _load_scada_recipientsr%   s    ||~	::dnn./D dD!88L"-DdD!#H!z!S'9aggiAHHI $$g. 	D	
 Is#   #B +B<B<B<B98B9c                 ,   t               g }}|D ][  }t        |t              s|j                         j	                         }|s5||vs:|j                  |       |j                  |       ] | j                  t        j                  d|id             y)z7Persist a recipient list, de-duplicated and lowercased.r  r   r   N)
r  r   r*   r   rE  r  ri   r   r   r   )rb   rA  r  rG  rB  rH  s         r1   _save_scada_recipientsr%   sz    E2'D !!S!wwy D$HHTNNN4 ! 	OODJJg6qABr3   z$/scada/report-recipients/<list_name>c                 v    t         j                  |       }|t        d      dfS t        t        |            S )z/Return the enforced or external recipient list.Unknown recipient list.r  r  )r  )SCADA_RECIPIENT_FILESr   r   r%  )	list_namerb   s     r1   scada_report_recipients_getr%   s;     !$$Y/D|67<<4T:;;r3   c                    t         j                  |       }|t        d      dfS t        j                  d      xs i }|j                  dd      }|j                  d	      xs dj                         }|d
vrt        d      dfS |j                         }|dk(  r(d|vsd|j                  d      d   vrt        d      dfS t        |      }|dk(  r2||D cg c]  }|j                          c}vr5|j                  |       n#|D cg c]  }|j                         |k7  s| }}	 t        ||       t        dt        |            S c c}w c c}w # t        $ r}t        d|       dfcY d}~S d}~ww xY w)zBAdd or remove an email in the enforced or external recipient list.Nr%  r  r  Tr  r  r7   r  r  r  r`  r  r  r  r  r  r;  r  )rU  r  )r%  r   r   r   r  r   rE  r  r%  ri   r%  r   )	r%  rb   r  r  r  rH  rA  rB  rl  s	            r1   scada_report_recipients_postr%   sp    !$$Y/D|67<<4(.BDXXh#FXXg$"++-E&&./44;;=DCtOs$**S/":M/MABCGG#D)F62a	22MM$#9qwwyD'8!99<tV, d'=d'CDD 3 :  </u56;;<s0   E
E#E*E 	E:E5/E:5E:z/scada/report-approversc                  J    t        t               j                  dg             S )z3Return the configured SCADA report approver emails.rR  r  )r   r4  r   rX   r3   r1   scada_report_approvers_getr%  6 s!     /1556H"MNNr3   c                     t        j                  d      xs i } | j                  dd      }| j                  d      xs dj                         j	                         }|dvrt        d      d	fS |d
k(  r(d|vsd|j                  d      d   vrt        d      d	fS t               }t        |j                  dg             }|d
k(  r||vr'|j                  |       n|D cg c]
  }||k7  s	| }}t        j                  |      |d<   	 t        |       t        d|d         S c c}w # t        $ r}t        d|       dfcY d}~S d}~ww xY w)z6Add or remove an approver email (SCADA/Admin-managed).Tr  r  r7   r  r  r  r  r`  r  r  r  r  r  rR  r;  r  Nr  )r   r  r   r   rE  r   r  r4  r  ri   r
  rV  rX  r   )r  r  r  rc  rm  rB  rl  s          r1   scada_report_approvers_postr%  = sW    4(.BDXXh#FXXg$"++-335E&&./44Cu,5;;s;KB;O0OABCGG!FVZZ 2B78I	!U# )81Q%ZQ8	8!7!K!KI!VF<6" df-?&@AA 9  </u56;;<s*   !
D&,D&D+ +	E4E
E
Ez/scada/report-approvalsc                  p   t         j                  j                  d      xs dj                         } | dk(  r't	        t        t        j                                     }nt        j                         }t        |t        t        d   j                  d      xs t        d   j                  d                  S )zApproval requests. ``?status=pending`` (default) returns only requests
    awaiting approval; ``?status=all`` returns the full history (newest first).rd   r  r  r'  r   r   )	approvalscan_configure)r   rh  r   rE  r  rd  r
  load_approvalsr  r   r   r   )rd   r   s     r1   scada_report_approvals_listr%  W s    
 llx(5I<<>FX4CCEFG&..0U!%gfo&9&9-&H 'L)0)<)<])K"MN Nr3   z,/scada/report-approvals/<request_id>/approvec                     t         d   j                  dd      }t        j                  | |      }|d   s!|d   dk(  rdnd}t	        |d   |d	   
      |fS t	        d|d	         S )ue   Approve a pending request → email the persisted XLSX to its external
    recipients + log the send.r'  r  r2  rU  r  Approval request not found.r  r`  r  r  approvalTrU  r%  )r   r   r
  r  r   )
request_idr		  r>  r3  s       r1   scada_report_approvals_approver&  f ss    
 FO3E#++J>F$<Wo)FFsCVG_vh7GH$NNdVH%566r3   z+/scada/report-approvals/<request_id>/rejectc                     t        j                  d      xs i }t        d   j                  dd      }t	        j
                  | ||j                  d            }|d   s!|d   d	k(  rd
nd}t        |d   |d         |fS t        d|d         S )zJReject a pending request. The report is never sent to external recipients.Tr  r'  r  r2  rm  rU  r  r%  r  r`  r  r%  r &  )r   r  r   r   r
  rz  r   )r&  r  r		  r>  r3  s        r1   scada_report_approvals_rejectr&  s s     4(.BDFO3E#**:udhhx>PQF$<Wo)FFsCVG_vh7GH$NNdVH%566r3   zscada_alert_state.jsonc                    	 t        j                  t        t        t              }|j                  dg      }|j                  d      }|sg |j                  dd      fS t        j                  dt         dt        |        d	d
| ddd      }|j                  dk7  rg d|j                   dt        |       fS |j                         j                  dg       dfS # t        $ r}g t        |      fcY d}~S d}~ww xY w)zFetch 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.
    r  r  r  r  r4  zno access_tokenr  z/messages?$top=zQ&$orderby=receivedDateTime desc&$select=subject,receivedDateTime,bodyPreview,bodyr  r  r  r  r;  rK   z
Graph API r>  r  N)r  r  r  r  r  r  r   r  r  r   rg   r  r   ry   r*   )r\  r  r  rD  r  rl  s         r1   _scada_fetch_messagesr&   s"   77%7:W
!:::; ; =  0|''(;=NOOO!!5m_ EXJ AA )0w&7%79
 s"D$4$4#5R8QRV8W7XYYYyy{w+T11 3s8|s+   AC   AC  >!C   	D )C;5D ;D c                     d| v xs d| v S )z@Mirror the UI's _scadaIsSensorLabel: Temp/RH labels are sensors.r   RHrX   )r   s    r1   r   r    s    U?+dem+r3   c                 R    | j                  d      xs i }|j                  |      duS )z<Mirror _scadaIsEnabled: enabled unless explicitly set False.r~  Fr  )rc  r   r~  s      r1   _scada_label_enabledr
&   s)    jj#)rG;;uU**r3   c                 R    | j                  d      xs i }|j                  |      du S )z;Mirror _scadaReportingDisabled: reporting[label] === false.r  Fr  )rc  r   r  s      r1   _scada_reporting_disabledr&   s)    

;'-2I==5((r3   c                 R    | j                  d      xs i }|j                  |      duS )zCPer-sensor out-of-range notify flag (DVI-483): on unless set False.r  Fr  )rc  r   r  s      r1   _scada_notify_enabledr&   s)    ZZ!'RF::eE))r3   c                     | j                  d      xs i j                  |      xs i }|j                  d      }|r|dk(  S d|v S )a+  Whether a sensor is a temperature sensor, for rate-of-change checks.

    Mirrors the UI's _scadaSensorType: an explicit meta ``type`` wins, else a
    'Temp' in the label falls back (DVI-1148). Rate rules are temperature
    change-over-time limits, so non-temp sensors are skipped by the notifier.r  rT  r  r   r  )rc  r   r  r   s       r1   _scada_label_is_temperaturer&   sM     JJv$"))%06BDAM!!U?r3   c                     | xs dj                         }|sydD ]  }	 t        j                  ||      c S  y# t        $ r Y )w xY w)zHParse a scada_store normalized ts ('YYYY-MM-DD HH:MM[:SS]') to datetime.r7   Nr  )r   r  r  r   )r   r  r;  s      r1   _scada_parse_store_tsr&   sZ    	A6 	!**1c22
   		s   8	AAc           	      *   	 t        j                  | gd      }g }|j                  |       xs g D ]K  }t        |j                  d            }| 	 |j                  |t        |j                  d            f       M |sg S |j                  d        |d   d	   t        |d
z   |z        z
  }|D cg c]  }|d	   |k\  s| c}S # t        $ r% t        j                  j                  d|        g cY S w xY w# t        t        f$ r Y w xY wc c}w )u  Trailing (datetime, value) points for a sensor from scada_store.

    Limited to the (margin+1)·window_minutes before the sensor's most recent
    stored sample, so the rate-of-change notifier judges only the 'most recent
    data received' (DVI-1148) rather than the whole history.r  )r$  limit_per_sensorz,SCADA rate check: query_series failed for %srU   r  c                     | d   S r  rX   r  s    r1   r	  z,_scada_store_recent_points.<locals>.<lambda> s
    1Q4 r3   r  r  r   r^   )minutes)r  r  ry   rm   rn   r  r   r&  ri   r  r   r   r  r  )r   window_minutesr`
  r  ptsr  r  r   s           r1   _scada_store_recent_pointsr&   s   ))5'CP CZZ$" "155=1:	JJE!%%.123 	HHH WQZ/6A:2OPPF-!adfnA--!  

KUS	 :& 		 .s/   C
 +C;5DD
+C87C8;DDc                     t        | t              sy	 t        |      }| j                  d      }| j                  d      }|dvr|t        |      k  ry|dvr|t        |      kD  ryy# t        t        f$ r Y yw xY w)zJTrue if numeric ``value`` falls outside the configured ``rng`` (low/high).FrK  r  r  T)r   r   r  r   r   r   )r  r  r  rK  r  s        r1   r  r   s    c4 El ''%.C776?D
*uSz!1:#d"3 z" s   A' 'A98A9c                     di i d} t         j                         r	 t        j                  t         j	                               }t        |t              rt        |j                  d            | d<   t        |j                  d      t              r3|d   j                         D ci c]  \  }}|t        |       c}}| d<   t        |j                  d      t              r3|d   j                         D ci c]  \  }}|t        |       c}}| d<   | S | S c c}}w c c}}w # t        j                  t        f$ r Y | S w xY w)zReturn {stale: bool, oor: {label: bool}, roc: {label:rule_id: bool}}
    persisted alert state (roc = rate-of-change transition guard, DVI-1148).F)r  r  rocr  r  r&  )SCADA_ALERT_STATE_FILEr   r   r   r   r   r   r   r   r   r   r   )ro  r  r  r  s       r1   _load_scada_alert_stater&   s    Br2E%%'		JJ5??ABE%&!%eii&8!9geii.5;@<;M;M;O#P41aAtAwJ#PE%Leii.5;@<;M;M;O#P41aAtAwJ#PE%L L5L $Q#P$$g. 	L	s0   B	D$ %D;<D$ 7DD$ D$ $E Ec                     	 t         j                  t        j                  | d             y # t        $ r" t
        j                  j                  d       Y y w xY w)Nr   r   z(Failed to persist scada_alert_state.json)r&  r   r   r   r   rm   rn   r  rB  s    r1   _save_scada_alert_stater &   sE    I))$**U1*EF I

GHIr  c                    |D ]  }| d|d    }t        |t        |d               }t        ||      \  }	}
|d   j                  |d      }|	rk|si|
gt	        j
                  t        j                  |j                  |      xs ||d|
d   |d   |d	   |
d
   |
d   |
d   d	       |d   j                  |       |	|d   |<    y)an  Fire the rate-of-change notification component for one sensor (DVI-1148).

    For each notify-enabled rate rule, compute the worst absolute change within
    the sensor's most-recent stored readings; fire the out-of-range event once
    on transition into a violation (guarded per ``{label}:{rule_id}`` in the
    ``roc`` state map) and reset the guard on recovery.rS  r  r&  r&  FNrate_of_changer  	max_delta	start_valend_valspan_minutes)	r  r   rm  
rate_deltarate_windowrate_max_delta	rate_fromrate_to	rate_span)r&  r   r+  r   r=  notify_scadaEVENT_SCADA_OUT_OF_RANGEri   )rc  r   rN  
rate_rulesro  firedr	  r)  r$  violatedrl   r
  s               r1   _scada_check_rate_rulesr2&  ! s      %qd%+E3t<L7M3NO.vt<$U|U+DT%5&&66";;u-6+#G} $%5 6#'#4";/ O">24
5 %L$$eS%%r3   c                  2	   t               } | j                  d      xs t        t              }t	        |j                  dt        d               }t        d      \  }}|r<t        j                  j                  d|       	 t        j                  d|       d|iS |s	 t        j                  d	d
d
       ddiS d
}	 t        |      }t        j                  d	t        |      |       |d
   }|j                  dd      }d}|r&	 t        j                   |j#                  dd            }t'               }	dg g |d}
t)        | j                  d            j                  dg       D cg c]  }|j                  d      r| }}d}d}|Gt        j*                  t,        j.                        }t	        ||z
  j1                         dz        }||k\  }|rG|	d   sB|r|j3                  d      nd}t5        j6                  t4        j8                  |||d       d	|
d<   ||	d<   t;        d |D        d      }|t=        |	       |
S t?        tA        |            \  }}| j                  d      xs i }| j                  d      xs i }tC               }|D ]S  }|j                  d      }|rtE        |      s#tG        | |      rtI        | |      r<tK        | |      sI|rtM        | |      rtO        | ||||	|
       |j                  |      }tQ        |t              s|jS                  |       tU        ||j                  d             }|	d!   j                  |d      }|r|s~t5        j6                  t4        jV                  |j                  |      xs |||j                  d       |j                  d"      |j                  d#      d$       |
d!   jY                  |       ||	d!   |<   V t=        |	       |
S # t        $ r% t        j                  j                  d       Y d|iS w xY w# t        $ r% t        j                  j                  d       Y ddiS w xY w# t        $ r# t        j                  j                  d       Y w xY w# t$        $ r d}Y fw xY wc c}w )%a/  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.
    r  rM  rM
  z SCADA collector fetch failed: %sFr  z(SCADA collector: failed to record healthr  Tr   )rU  r$  r   r  zno messagesz+SCADA collector: failed to persist readingsrm  r7   Nr  r  )r  r  r&  r   rQ  r/&  r  r   r  r  )rM  age_minuteslast_reading_timec              3   \   K   | ]$  }t        |j                  d             dk(  s!| & ywr  r  r  s     r1   r/  z'_run_scada_collector.<locals>.<genexpr> r  r  rP  rN  r   r  r  rK  r  )r  r   r  rK  r  )-r4  r   r   rT  r   r&  rm   rn   ro   r  record_collector_runry   r  r  rC  r  r  r  r   r&  r,  r  r  r  r  r  r=  r-&  EVENT_SCADA_STALEr  r &  r  r  r  r   r
&  r&  r&  r&  r2&  r   r  r  r.&  ri   )rc  r  r  rf  r  r   r  received_rawreceived_dtro  r0&  r  r/&  r4&  is_staler  
last_labeltrigger_msgrv
  r  rP  rN  seen_labelsr   r  r  r
  s                              r1   _run_scada_collectorr?&  = s     !F

;'I40H+IIimm$9$<=R$SU VK ,B/OHe

=uE	M,,UC 	M,,a!L =)) HL*84((S]X	?
 1+C77-r2LK	(66|7K7KCQY7Z[K $%EBrxHE 36::>O3PQ3|R(&UU8_  &J &
 KH  !1!123,;;=CD+-gCN[))*>?TX
""++$/'",.	/
 gE'N RHRK &+,?,LMIxZZ!'RFjj#)rG%K "g259#FE26OPVX]6^$VU3 5feD#FE7JuUjj#t$'QUU7^<U|u-t&&66";;u-6%%.	*+ %L&!eU9"< E"LE  	MJJ  !KL	M  	MJJ  !KL=))	M  L

JKL  	K	&sN    O1 P" =,Q %R 'R1(PP"(QQ(Q?>Q?RRc                  r   t         rt        sy	 t        j                  t         t        t              } | j                  dg      }|j                  d      }|s0t        j                  j                  d|j                  d|             |S # t        $ r" t        j                  j                  d       Y yw xY w)	zBAcquire an app-only Graph token scoped to the home (iCast) tenant.Nr  r  r  r  z"OneDrive: token request failed: %sr4  z!OneDrive: token acquisition error)r  r  r  r  r  r  r   rm   rn   r  ry   r  )rX  r>  rD  s      r1   _onedrive_graph_tokenrA&   s    j
//%7:W,,5[4\,]

>*JJA6::NaciCjk 

@As   A;B (B65B6user_upnr  c           	         |rt          d| d| d}nt          d| d}	 t        j                  |dd|  id      }|j                  d	k7  r;t        j
                  j                  d
|j                  |||j                  dd        y|j                         j                  dg       S # t        $ r# t        j
                  j                  d|       Y yw xY w)uF   List OneDrive items — root if item_id is None, otherwise by item ID.r]  /drive/items/z	/children/drive/root/childrenr  r  r  r;  rK   z,OneDrive list failed (%s) for %s item=%s: %sNr  r  zOneDrive: list error for %s)r^  r  r   rg   rm   rn   r  r&  r   ry   r  )rD  rB&  r  rJ  r  s        r1   _onedrive_list_itemsrF&   s    WXJmG9INWXJ.BC	!!#75'AR/S]_`s"JJK!--x$))DS/Syy{w++ 

:HEs   A&B' B' ')CCz/onedrive/lsc            
         t        j                  d      xs i } | j                  dd      }|st        d      dfS t        j                  j                  d      xs d}t               }|st        d	      d
fS t        |||      }|t        d      dfS g }|D ]_  }d|v }|j                  |j                  dd      |j                  dd      |rdnd|j                  d      |j                  d      d       a t        |      S )zList 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: ...}.
    r'  r  r7   No user sessionr  r  r  Nu;   OneDrive not available — Graph credentials not configuredrZ  zZCould not list OneDrive folder. Check that Files.ReadWrite.All is granted for this tenant.rU  r  r  r9   r  rd  r  )r  r9   rT  rd  r"  r  )r   r   r   r   rh  rA&  rF&  ri   )r'  rB&  r  rD  r   r   r|	  r  s           r1   onedrive_lsrI&   s    ;;v$"Dxx$H./44lly)1TG!#EZ[]```
uh
8C
{yz|E N	tR(vr*$-6v56
 	 r3   z/onedrive/downloadc                     t        j                  d      xs i } | j                  dd      }|st        d      dfS t        j                  j                  dd      j                         }|st        d      d	fS t               }|st        d
      dfS 	 t        j                  t         d| d| dd| id      }|j                  dk7  rt        d      dfS |j                         }|j                  d      }|st        d      d	fS t        j                  j                  d      xs |j                  dd      }|j                  d      xs i j                  dd      }	 t        j                  |dd !      j                  dk7  rt        d"      dfS fd#}	d$d%lm}
m} d&| d'|d(}d)j$                  v rj$                  d)   |d)<    |
 | |	             |*      S # t        $ r0 t        j                  j                  d       t        d      dfcY S w xY w# t        $ r0 t        j                  j                  d+       t        d,      dfcY S w xY w)-zProxy-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).
    r'  r  r7   rH&  r  r  r  zitem_id requiredr`  zOneDrive not availablerZ  r]  rD&  r  r  r  r;  rK   zItem not foundr  z(OneDrive download: metadata fetch failedzOneDrive errorrU  r  u(   No download URL — item may be a folderr9   rd  r  mimeTyper  r  Tr  r  c               3   D   K   j                  d      D ]  } |   y w)Nr  rc  )r  )r  	file_resps    r1   generatez#onedrive_download.<locals>.generate4 s)     "//5/A ru  r   )r  stream_with_contextrn  r  )r  r  zContent-Length)rx   z OneDrive download: stream failedzDownload error)r   r   r   r   rh  r   rA&  r  r^  rg   r   ry   rm   rn   r  r%  r  rO&  rx   )r'  rB&  r  rD  r  r  r  r  r5  rN&  r  rO&  rx   rM&  s                @r1   onedrive_downloadrP&   s\    ;;v$"Dxx$H./44lly"-335G/0#55!#E56;;4"&&m78*M'C$w&78
	
   C'!12C77~~
 88:;LGH#MM ||'G488FJ+GHHHV$*//
<VWL4"&&|SN	  C'!23S88	 	8%;H:Q#G(
 y000(1(9(9:J(KG$%+HJ7II7  4

GH-.3348  4

?@-.334s2   AG& G& &4H" A
H" &6HH"6II_sp_drive_cache	site_namec                    ddl }t        j                  |      }|r|\  }}|j                         |k  r|S t        }	 t        j                  t         d| d| ddd|  id	      }|j                  d
k7  r:t        j                  j                  d|j                  ||j                  dd        y|j                         j                  dd      }|s!t        j                  j                  d|       y	 	 t        j                  t         d| ddd|  id	      }	|	j                  d
k7  r:t        j                  j                  d|	j                  ||	j                  dd        y|	j                         j                  dg       }
h d}d}|
D ]3  }|j                  d      dk(  s|j                  d      |v r|} n|2|}5 |s+t        j                  j                  d|t        |
             y|j                  dd      }|r ||j                         t         z   ft        |<   |xs dS # t        $ r# t        j                  j                  d|       Y yw xY w# t        $ r# t        j                  j                  d|       Y yw xY w)a  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.
    r   Nz/sites/z:/sites/rS  r  r  r  r;  rK   z4_sp_resolve_drive: site fetch failed (%s) for %s: %sr  r  r7   z0_sp_resolve_drive: no site id in response for %sz*_sp_resolve_drive: site fetch error for %sz/drivesz;_sp_resolve_drive: drives fetch failed (%s) for site %s: %sr  z1_sp_resolve_drive: drives fetch error for site %s>   Shared DocumentsGeneral	Documents	driveTypedocumentLibraryr9   zJ_sp_resolve_drive: no documentLibrary drive found for site %s (drives: %d))rU   rQ&  r   	monotonicr  r  r^  rg   rm   rn   r  r&  r   ry   r  rC  _SP_DRIVE_CACHE_TTL)rD  rR&  re  r  re  	expire_tsr!  	site_respsite_iddrives_respdrivespreferred_namesdefault_drivern  s                 r1   _sp_resolve_driverb&  Q s      +F$)??y(OH"&&m78*HYKqA$w&78
	
   C'JJF%%y)..#2F .."&&tR0JJOQZ[ $((m77)73$w&78

 ""c)JJM''+2B2B4C2H !!#''4 COM "55!22uuV}/ !$ !" 

XS[	
   r*H&.0ADW0W%X	"tW  

I9U$  

PRYZs2   A2H* 0AH* 5A/I % I *)II)JJr   c                 v   	 t        j                  |dd|  idddd      }|j                  dk7  r:t        j                  j                  d	|j                  ||j                  d
d        y
|j                         j                  dg       S # t        $ r# t        j                  j                  d|       Y y
w xY w)zFetch 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.
    r  r  z(id,name,size,folder,lastModifiedDateTimer  )r  rf  r  r  rK   z,onedrive-browse: list failed (%s) for %s: %sNr  r  z"onedrive-browse: list error for %s)
r  r   rg   rm   rn   r  r&  r   ry   r  )rD  rJ  r   r  s       r1   _onedrive_browse_listrd&   s    
!!$w&78ISVW	
 s"JJ>  %4C yy{w++ 

A5Is   A)B ,B )B87B8z/documents/onedrive-browsec            
         t        j                  d      xs i } t        j                  j                  dd      j	                         }|dvrt        d      dfS t        j                  j                  dd	      j	                         j	                  d
      }t               }|st        d      dfS dd	dg}|rM|j                  d
      }t        |      D ].  \  }}|j                  |d
j                  |d|dz          d       0 |dk(  rg| j                  dd	      }|st        d      dfS |rt         d| d| d}	nt         d| d}	t        ||	d| d
|xs d       }
|
qt        d      dfS d}t        ||      }|t        d      dfS |rt         d| d| d}	nt         d| d }	t        ||	d!| d
|xs d       }
|
t        d"      dfS g }g }|
D ]Z  }|j                  d#d	      }||r| d
| n||j                  d$d	      d%}d&|v r|j                  |       J|j                  |       \ |j                  d' (       |j                  d) (       t        |||||*      S )+u  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.
    r'  r  rn!  )rn!  re  z$scope must be 'personal' or 'shared'r  r`  rb   r7   r6   uM   Graph credentials not configured — OneDrive/SharePoint browsing unavailablerZ  HomerF  Nr^   r  z$No authenticated user UPN in sessionr  r]  z/drive/root:/z
:/childrenrE&  z	personal:r=  zmCould not list personal OneDrive folder. Ensure Files.ReadWrite.All (application) is granted for this tenant.rU  InfrastructurePrecastInczqCould not resolve SharePoint site or drive. Ensure Sites.Read.All (or Sites.Selected) is granted for this tenant.z/drives/z/root:/z/root/childrenzshared:zgCould not list SharePoint folder. Ensure Sites.Read.All (or Sites.Selected) is granted for this tenant.r9   r  )r9   rb   r  r  c                 (    | d   j                         S r  rs  r  s    r1   r	  z+documents_onedrive_browse.<locals>.<lambda> s    qy0 r3   r  c                 (    | d   j                         S r  rs  r  s    r1   r	  z+documents_onedrive_browse.<locals>.<lambda> s    QvY__. r3   )r  rb   
breadcrumbr  r  )r   r   r   rh  r   r   rA&  r  r  ri   r  r^  rd&  rb&  r  )r'  r  raw_pathrD  rj&  r0
  r  r  rB&  rJ  r   sp_site_namere  r  r  r|	  r9   r   s                     r1   documents_onedrive_browserm&   s   * ;;v$"DLLWj1779E**CDcII||+11399#>H!#Ea
 	
 "2./Js# ' 	PGAttSXXeGa!en5MNO	P 
88GR(!GH#MM M
-zTC M
2FGC%eSIhZqI[U[H\2]^=[
   2$UL9\
    M(78*JOC M(>BC%eSGL>8K]W]J^2_`=\
   GE 
 vvfb!,4xj$($FF4$

 r>NN5!LL
  LL0L1	JJ.J/X*g]bccr3   )r!  r!  r  r  r  rd   capture_enabledlast_capture_tsforeground_appidle_secondscpu_pctscreen_countagent_uptime_sr_   r>	  zvision_notification_log.jsonc                      t        j                  t        t              d      } t         j                  | _        | j                  d       | j                  d       t        |        | S )zKOpen the Vision SQLite DB, creating tables on first use. Caller must close.FrD  rF  a  
        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,
            local_ip        TEXT,
            public_ip       TEXT,
            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 TABLE IF NOT EXISTS vision_agent_events (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            machine_id  TEXT NOT NULL,
            signature   TEXT NOT NULL,
            detail      TEXT,
            trace       TEXT,
            ts          REAL,
            received_at REAL NOT NULL,
            FOREIGN KEY (machine_id) REFERENCES vision_agents (machine_id)
        );
        CREATE INDEX IF NOT EXISTS idx_vision_checkins_machine_time
            ON vision_checkins (machine_id, received_at);
        CREATE INDEX IF NOT EXISTS idx_vision_agent_events_machine_time
            ON vision_agent_events (machine_id, received_at);
        CREATE TABLE IF NOT EXISTS vision_recovery_audit (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            ts          REAL NOT NULL,
            machine_id  TEXT NOT NULL,
            signature   TEXT NOT NULL,
            event_id    INTEGER NOT NULL,
            action      TEXT NOT NULL,
            cmd_id      TEXT,
            actor       TEXT NOT NULL DEFAULT 'auto-recovery',
            outcome     TEXT NOT NULL,
            detail      TEXT
        );
        CREATE INDEX IF NOT EXISTS idx_vision_recovery_audit_machine_time
            ON vision_recovery_audit (machine_id, ts);
    )	rG  rH  r*   VISION_DB_PATHrJ  rK  rL  rM  _vision_migraterN  s    r1   r'  r'  ? sU    ??3~.%HD{{DLL*+ Z Z	v DKr3   c                    | j                  d      D ch c]  }|d   	 }}d|vr2| j                  d       | j                  d       | j                          t        | j                  d            }t        d |D        d      }|)|d	   d
k(  r!| j	                  d       | j                          d|vr!| j                  d       | j                          d|vr!| j                  d       | j                          | j                  d      j                         D ch c]  }|d   	 }}d|vr!| j	                  d       | j                          d|vr!| j	                  d       | j                          | j                  d      D ch c]  }|d   	 }}d|vr!| j                  d       | j                          d|vr"| j                  d       | j                          yyc c}w c c}w c c}w )zCIdempotent schema upgrades for older Vision DBs (DVI-436, DVI-726).z PRAGMA table_info(vision_agents)r9   rd   zJALTER TABLE vision_agents ADD COLUMN status TEXT NOT NULL DEFAULT 'active'z=UPDATE vision_agents SET status = 'revoked' WHERE revoked = 1z+PRAGMA table_info(vision_enrollment_tokens)c              3   2   K   | ]  }|d    dk(  s|  yw)r9   r!  NrX   r%  s     r1   r/  z"_vision_migrate.<locals>.<genexpr> s     AaqyL'@A   Nnotnullr^   a  
            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;
        ssh_public_keyz8ALTER TABLE vision_agents ADD COLUMN ssh_public_key TEXTssh_key_writtenzOALTER TABLE vision_agents ADD COLUMN ssh_key_written INTEGER NOT NULL DEFAULT 0z1SELECT name FROM sqlite_master WHERE type='table'r   vision_agent_eventsaX  
            CREATE TABLE IF NOT EXISTS vision_agent_events (
                id          INTEGER PRIMARY KEY AUTOINCREMENT,
                machine_id  TEXT NOT NULL,
                signature   TEXT NOT NULL,
                detail      TEXT,
                trace       TEXT,
                ts          REAL,
                received_at REAL NOT NULL,
                FOREIGN KEY (machine_id) REFERENCES vision_agents (machine_id)
            );
            CREATE INDEX IF NOT EXISTS idx_vision_agent_events_machine_time
                ON vision_agent_events (machine_id, received_at);
        vision_recovery_audita  
            CREATE TABLE IF NOT EXISTS vision_recovery_audit (
                id          INTEGER PRIMARY KEY AUTOINCREMENT,
                ts          REAL NOT NULL,
                machine_id  TEXT NOT NULL,
                signature   TEXT NOT NULL,
                event_id    INTEGER NOT NULL,
                action      TEXT NOT NULL,
                cmd_id      TEXT,
                actor       TEXT NOT NULL DEFAULT 'auto-recovery',
                outcome     TEXT NOT NULL,
                detail      TEXT
            );
            CREATE INDEX IF NOT EXISTS idx_vision_recovery_audit_machine_time
                ON vision_recovery_audit (machine_id, ts);
        z"PRAGMA table_info(vision_checkins)r3  z4ALTER TABLE vision_checkins ADD COLUMN local_ip TEXTr4  z5ALTER TABLE vision_checkins ADD COLUMN public_ip TEXT)rL  rZ  r  r  rM  r  )rO  r  
agent_colstok_infor2  existing_tablescheckin_colss          r1   rw&  rw&   s    &*\\2T%UV!F)VJVz!X	
 	TU DLL!NOPH
A8A4
HC
3y>Q.  	  	z)OP
*]	
 	 &*\\;&hjqt O  O3  	 	o5  	  	'+||4X'YZ!AfIZLZ%KL,&LM 'g WPN [s   G G%G*c                    t         sy| j                         j                  dd      }d| d| d}ddd	dd
dddt        t              dt         t
         dt         |g}	 t        j                  |ddd      }|j                  dk(  ry|j                  xs |j                  xs dj                         }|xs d|j                   S # t        j                  $ r Y yt        $ r Y yt        $ r}t        |      cY d}~S d}~ww xY w)a  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.
    uG   VISION_RSYNC_MGMT_KEY not configured — cannot push key automatically.rd
  z'\''z2mkdir -p ~/.ssh && chmod 700 ~/.ssh && grep -qxF 'z.' ~/.ssh/authorized_keys 2>/dev/null || echo 'z?' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keyssshz-ozStrictHostKeyChecking=nozBatchMode=yeszConnectTimeout=10z-pz-ir  Trx  r5  r   r7   zssh exited zSSH connection timed out.zssh not found on server PATH.N)_VISION_RSYNC_MGMT_KEYr   r  r*   _VISION_RSYNC_PORT_VISION_RSYNC_USER_VISION_RSYNC_HOSTr9  r:  r<  rc  rb  TimeoutExpiredr  ry   )
public_keykey_stripped
remote_cmdri  r>  r  rl  s          r1   _vision_write_ssh_key_to_rsyncr&  
 s!    "X##%--c7;L"^ $]	_  	(o!c$%$
a 234	CDtRP!}}33::<7F$5$5#677$$ +* /. 3xs0   (C >C DD&D.
C>8D>Dc                 V    | j                  d|f      j                         }|r|d   S d S )Nz)SELECT value FROM vision_kv WHERE key = ?r  r  r  s      r1   _vision_kv_getr&  5 s/    
,,BSF
K
T
T
VC3w<(D(r3   c                 R    | j                  d||t        j                         f       y )NzINSERT INTO vision_kv (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_atr  r  s      r1   _vision_kv_setr&  : r  r3   c                     t        | t              }|sA|r?t        j                         j                  }t        | t        |       | j                          |S r  )r&  _VISION_OPEN_ENROLL_KEYrW  rX  rY  r&  rZ  r  s      r1   _vision_get_open_enroll_secretr&  C s@    D"9:Ff!!t4f=Mr3   c                 f    t        j                          t        dz  z
  }| j                  d|f       y)zHDrop check-in rows older than the retention window. Cheap, index-backed.r  z1DELETE FROM vision_checkins WHERE received_at < ?N)rU   _VISION_CHECKIN_RETENTION_DAYSrL  )rO  r   s     r1   _vision_prune_checkinsr&  M s)    YY[9EAAFLLDviPr3   z192.168.250.4r	  iG  z192.168.250.0/24)rsync_lan_iprsync_lan_portrsync_wan_iprsync_wan_port
rsync_user
lan_subnetc                  x   ddi} | j                  t               t        j                         r	 t	        j
                  t        j                               }t        |t              rt        |j                  d      t              r|d   | d<   dD ]H  }|j                  |      }t        |t              s%|j                         s6|j                         | |<   J dD ]%  }	 |j                  |      t        ||         | |<   ' | S # t        j                  t        f$ r d}Y w xY w# t         t"        f$ r Y \w xY w)zReturn Vision settings: {mock_data} + rsync server config (DVI-977).

    Mock defaults on until the first real check-in. rsync config defaults match
    the agent's built-in LAN/WAN endpoints so behaviour is unchanged until an
    admin edits them.
    	mock_dataTNr&  r&  r&  r&  r&  r&  )r  _VISION_RSYNC_DEFAULTSVISION_SETTINGS_FILEr   r   r   r   r   r   r   r   r   r   r*   r   r   r   r   )r  r  r  r  s       r1   _load_vision_settingsr&  a s%    T"HOO*+##%	JJ3==?@E eT"%))K0$7(-k(:%Q ,IIaLa%!'')"#'')HQK, : yy|/&)%(m O $$g. 	E	 ":. s#   'D  "D'D$#D$'D98D9c                 X    t         j                  t        j                  | d             y r   )r&  r   r   r   rf  s    r1   _save_vision_settingsr&   s    ##DJJx$BCr3   c                 T    | xs
 t               }|d   |d   |d   |d   |d   |d   dS )zrsync endpoint config delivered to the agent in the check-in response
    (DVI-977). The agent uses it to pick its send endpoint in place of the
    hardcoded constants; env vars still take precedence agent-side.r&  r&  r&  r&  r&  r&  )lan_iplan_portwan_ipwan_portr'  r&  )r&  )r  r  s     r1   _vision_rsync_configr&   sL     	+)+A'()'()oo r3   c                    | sy|xs
 t               }|j                  d      xs	 t        d   }	 t        j                  |d      }t        j
                  t        |       j                               |v rdS dS # t        $ r Y yw xY w)zReturn 'local' when local_ip falls inside the configured LAN subnet, else
    'remote'. Returns None when local_ip is missing or unparseable (DVI-977).Nr&  Fstrictr?  r  )	r&  r   r&  	ipaddress
ip_network
ip_addressr*   r   r   )r3  r  r  subnetnets        r1   _vision_localityr&   s     +)+AUU< H$:<$HF""6%8#..s8}/B/B/DELwZRZZ s   AA8 6A8 8	BBc                  \   t         j                         si S 	 t        j                  t         j	                               } t        | t              r6| j                         D ci c]  \  }}t        |      t        |       c}}S i S c c}}w # t        j                  t        f$ r i cY S w xY w)zDReturn {machine_id: friendly_name} display-name overrides (DVI-682).)VISION_AGENT_NAMES_FILEr   r   r   r   r   r   r   r*   r   r   )r   r  r  s      r1   r&  r&   s    "**,	zz1;;=><FtT<R4::<841aAA8ZXZZ8  '* 	s*   A
B "BB B B B+*B+c                 X    t         j                  t        j                  | d             y r   )r&  r   r   r   )r.  s    r1   _save_vision_agent_namesr&   s    &&tzz%'BCr3   
screenshotr  )r^   r   rP   r   r   r$  z17:00)r  rq   r  )rn&  capture_modecapture_fpscapture_rate_sidle_threshold_s
exclusionsr  rr  )r&  videoc                  <   t        t              i d} t        j                         r	 t	        j
                  t        j                               }t        |t               rt        |j                  d      t               r | d   j                  t        |d                t        |j                  d      t               r=|d   j                         D ]'  \  }}t        |t               st        |      | d   |<   ) | S | S # t        j                  t        f$ r Y | S w xY w)z@Return {default: {...}, machines: {machine_id: {...overrides}}}.)r.  machinesr.  r&  )r   r  VISION_POLICY_FILEr   r   r   r   r   r   r  _vision_clean_policyr   r   r   )r  r  r2  r  s       r1   r  r   s    34"ED!!#
	JJ1;;=>E%&eii	2D9O**+?i@P+QReii
3T:#(#4#:#:#< MR%b$/4H4LD,S1M
 K4K $$g. 	K	s   B>C> 'C> >DDc           
         i }d| v rt        | d         |d<   d| v r7t        | d         j                         j                         }|t        v r||d<   d| v r7	 t        | d         }|dkD  r#t        t        dt        d|            d      |d<   dD ]#  }|| v s	 t        dt        | |               ||<   % t        | j                  d	      t              rG| d	   D cg c]5  }t        |      j                         st        |      j                         7 c}|d	<   | j                  d
      }t        |t              ri }t        |j                  d      t              rUt!        |d   D ch c]:  }t        |t        t
        f      r"dt        |      cxk  rdk  rn nt        |      < c}      |d<   dD ]\  }|j                  |      }	t        |	t              s%t#        j$                  d|	j                               sJ|	j                         ||<   ^ ||d
<   |S # t        t        f$ r Y w xY w# t        t        f$ r Y w xY wc c}w c c}w )zGCoerce a policy dict to known fields/types; drop anything unrecognized.rn&  r&  r&  r   r  g?rP   )r&  r&  rr  r&  r  r  r   )rq   r  z^\d{1,2}:\d{2}$)r   r*   r   rE  _VISION_CAPTURE_MODESr  rj   rL  rC  r   r   r   r   r   r  r   r  rF  rG  )
r   r   rO  fpsr  r  r  rF  rn  r  s
             r1   r&  r&   s/   
CC!%c*;&<!=3~&'--/557(("&C	M*+CQw%*3tSc]+CQ%GM" F 8QCF,A #'','.585FY#a&,,.SV\\^YLGGJE%eii'."E&M $Yq'1!c5\'BqCPQFGWVWGW %(F $Y ZE&M! 	%A		!A!S!bhh/A1779&M779a	%  JJ+ :& 		 z*  Z
$Ys6   6H H1I;I*?IH.-H.1IIc                 X    t         j                  t        j                  | d             y r   )r&  r   r   r   r6  s    r1   _save_vision_policyr&   s    !!$**VA">?r3   c                    |xs
 t               }t        t              }|j                  |j	                  d      xs i        |j	                  d      xs i j	                  |       }t        |t              r|j                  |       |S )zHMerge default + per-machine override into the policy the agent receives.r.  r&  )r  r   r  r  r   r   )r!  r7  rH  r  s       r1   _vision_effective_policyr&    sn    ,*,F
&
'CJJvzz)$*+

:&,"11*=H(D!

8Jr3   c                     | xs t        j                          } | t        z
  t        k  ry| at        j	                         syt               }t        j                         D ]  }|j	                         st        |j                  |      j                  d      xs d}|dk  rC| |dz  z
  }|j                         D ]1  }	 |j                         rt        |      |k  r|j                          3  y# t        $ r Y Bw xY w)uw  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.Nrr  r   r  )rU   _vision_footage_purge_at _VISION_FOOTAGE_PURGE_INTERVAL_Sr<  r  r  rY  r&  r9   r   r   _vision_frame_tsr/   r   )r  r7  mdirr  r   r  s         r1   _vision_purge_footager&   s     
C
%%(HH"$$& "F"**, {{}'		6:>>?OPUTU19te|# 	A99;#3A#6#?HHJ	  s   >.C11	C=<C=z^[A-Za-z0-9._-]+$)r   r   r   r  )r  c                     t         | z  S rT   )r<  )r!  s    r1   _vision_footage_machine_dirr&  8 s    
**r3   c                     t               }	 |j                  d| f      j                         }|j                          t	        |      xr |d   dk(  S # |j                          w xY w)zTrue only for an enrolled, approved (status='active') machine. Pending and
    revoked machines are quarantined from footage/recordings (DVI-436).5SELECT status FROM vision_agents WHERE machine_id = ?rd   rv  )r'  rL  ru  r[  r   r  s      r1   _vision_machine_activer&  < s_     <DllCj]

(* 	 	

92X(22 	

s   !A A$c                     	 t        | j                        }|dkD  r|S 	 	 | j                         j                  S # t        $ r Y &w xY w# t
        $ r Y yw xY w)zCFrame timestamp: epoch-second filename stem if present, else mtime. ʚ;r  )r  r  r   r  r  r   )rb   stem_tss     r1   r&  r&  I se    		"]"N #yy{###    s    : A	 	AA		AAc                   ,    e Zd ZdZddZd ZddZd Zy)	FootageStoreu  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.
    Nc                     t         )zBFootage entries for a machine, newest first (frame + video dicts).NotImplementedError)r  r!  frmrX  r   s        r1   list_frameszFootageStore.list_framesb s    !!r3   c                     t         )zeFlask response serving the frame image (size 'thumb'|'full'), or
        None if the frame is absent.r&  )r  r!  frame_idrd  s       r1   frame_byteszFootageStore.frame_bytesf s
     "!r3   c                     t         )a$  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.r&  )r  r!  r&  r  s       r1   segment_streamzFootageStore.segment_streamk s
    
 "!r3   c                     t         )a  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.r&  )r  r!  r&  r!	  s       r1   r.   zFootageStore.read_bytesr s
     "!r3   NNr  rT   )r  
__module____qualname____doc__r&  r&  r&  r.   rX   r3   r1   r&  r&  W s    ""
"	"r3   r&  c                   2    e Zd ZdZd ZddZd Zd	dZd Zy)
LocalFootageStorezFootage 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.c                     t        |      S rT   )r&  r  r!  s     r1   _machine_dirzLocalFootageStore._machine_dir s    *:66r3   Nc           
      ,   | j                  |      }|j                         sg S t               }g }t        |j	                               D ]  }|j                         rd|j                  v r"|j                  j                         }	|	t        v r/|j                  |j                         |j                  |df       s|	t        v s||j                  |df        g }
|D ]  \  }}|dk(  r|j                  |v rt        |      }|||k  r-|||kD  r5|j                  t        |      |d| d|j                   dd| d|j                   dd	}|dk(  rd
| d|j                   |d<   |
j                  |        |
j!                  d d       |
d| S )u  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..thumb.r&  r~
  N/vision/frames/r6   ?size=thumb
?size=fullr&  r_   r!	  	thumb_urlfull_url/vision/segments/	video_urlc                     | d   S rA  rX   rZ  s    r1   r	  z/LocalFootageStore.list_frames.<locals>.<lambda> rB  r3   Tr
  )r&  r  r  r  rY  r   r9   r!  rE  _VISION_VIDEO_EXTSr  r  ri   _VISION_FRAME_EXTSr&  r   r  )r  r!  r&  rX  r   rn  video_stemsr  r  r  r   r!	  r_   r   s                 r1   r&  zLocalFootageStore.list_frames s    j)xxzIe		$ 	+A99;)qvv"5((.."C(('a\***a\*	+  	GAtw166[#8!!$B28~"r'FF"g.zl!AFF8;O-j\166(*ME w'8AaffX%Nk"JJu%	& 	&56E{r3   c                    | j                  |      }g }|dk(  r |t        |j                  |dz               z  }|t        |j                  |dz               D cg c]  }d|j                  vs| c}z  }|D ]I  }|j	                         s|j
                  j                         t        v s5t        t        |            c S  y c c}w )Nr4  .thumb.*r1  r&  )
r&  r  r  r9   r   r!  rE  r&  r   r*   )r  r!  r&  rd  rn  rB  r  s          r1   r&  zLocalFootageStore.frame_bytes s    j)
7?&:(=!>??J&4)@"A]QYVWV\V\E\q]]
 	)Ayy{qxx~~/3EE Q((	) 	 ^s   C-Cc                     | j                  |      }t        |j                  |dz               D ]K  }|j                         s|j                  j                         t        v s5t        t        |      d      c S  y )Nr1  T)conditional)	r&  r  r  r   r!  rE  r&  r   r*   )r  r!  r&  r  rn  r  s         r1   r&  z LocalFootageStore.segment_stream se    j)x$/0 	;Ayy{qxx~~/3EE QT::	; r3   c                    | j                  |      }|dk(  rt        |j                  |dz               D ]s  }|j                         s|j                  j                         t        v s5|j                         t        j                  |j                  j                         d      fc S  y g }|dk(  r |t        |j                  |dz               z  }|t        |j                  |dz               D cg c]  }d|j                  vs| c}z  }|D ]s  }|j                         s|j                  j                         t        v s5|j                         t        j                  |j                  j                         d      fc S  y c c}w )NrH  r1  	video/mp4r4  r '  r&  r  )r&  r  r  r   r!  rE  r&  r.   _VISION_CONTENT_TYPESr   r9   r&  )r  r!  r&  r!	  rn  r  rB  s          r1   r.   zLocalFootageStore.read_bytes sX   j)9AFF8d?34 799;188>>#37I#I<<>+@+D+D(+,7 7 77 
7?&:(=!>??J&4)@"A]QYVWV\V\E\q]]
 	BAyy{qxx~~/3EE||~'<'@'@HHNN$&@(B B B	B  ^s   2FFr&  rT   )	r  r&  r&  r&  r&  r&  r&  r&  r.   rX   r3   r1   r&  r&  ~ s"    E7*X	r3   r&  r'  )r   r   r   r  r  c                   4    e Zd ZdZd Zd Zd Zd Zd Zd Z	y)	_FootageLRUCacheu  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).
    c                 D   t        |      | _        t        t        |      d      | _        t        t        |      d      | _        t        j                         | _        t               | _
        i | _        	 | j                  j                  dd       y # t        $ r Y y w xY w)Nr   TrM   )r   _rootrC  r   
_max_bytes_ttlr  Lock_lock_OrderedDict_type_entries_mtimesr  r   )r  r=  r  ttl_ss       r1   __init__z_FootageLRUCache.__init__ s~    $Z
c)na0E
A&	^^%
)+	JJTD9 		s   5B 	BBc                 &    |j                  dd      S )Nr6   r   r  )r  r)  s     r1   
_safe_namez_FootageLRUCache._safe_name s     {{3%%r3   c                    t        j                          }| j                  5  | j                  j                  |      }|
	 d d d        y |\  }}| j                  rG|| j
                  j                  |d      z
  | j                  kD  r| j                  |       	 d d d        y |j                         s| j                  |       	 d d d        y | j                  j                  |       |cd d d        S # 1 sw Y   y xY wr  )	rU   r'  r'  r   r'  r'  _dropr  move_to_end)r  r)  r  r   rb   _sizes         r1   r   z_FootageLRUCache.get s    iikZZ 	MM%%c*E}	 	  KD%yyS4<<#3#3C#;;diiG

3	 	 ;;=

3	 	 MM%%c*	 	 	s   C2	AC2!"C2C22C;c                 J   | j                   | j                  |      z  }	 |j                  |j                  dz         }|j	                  |       |j                  |       | j                  5  || j                  v r| j                  |       |t        |      f| j                  |<   t        j                         | j                  |<   | j!                          d d d        |S # t        $ r+}t        j                  j                  d||       Y d }~y d }~ww xY w# 1 sw Y   |S xY w)Nz.partz%Footage cache write failed for %s: %s)r	'  r'  r  r!  r-   r  r   rm   rn   ro   r'  r'  r'  rC  rU   r'  _evict_locked)r  r)  r   rb   rS  rl  s         r1   putz_FootageLRUCache.put	 s    zzDOOC00	""4;;#89COOD!KK ZZ 	!dmm#

3"&D	!2DMM# $		DLL 	!   	JJFSQ		! s%   A C! ,A+D!	D*!DDD"c                     | j                   j                  |d       }| j                  j                  |d        |r	 |d   j                          y y # t        $ r Y y w xY wr  )r'  r  r'  r/   r   )r  r)  r   s      r1   r'  z_FootageLRUCache._drop s[    !!#t,d#a!   s   A 	AAc                    t        d | j                  j                         D              }|| j                  kD  rt	        | j                        dkD  rt        t        | j                  j                                     \  }\  }}| j                  j                  |d        | j                  j                  |d        	 |j                          ||z  }|| j                  kD  rt	        | j                        dkD  ry y y y # t        $ r Y <w xY w)Nc              3   &   K   | ]	  \  }}|  y wrT   rX   )r,  r  r6  s      r1   r/  z1_FootageLRUCache._evict_locked.<locals>.<genexpr>& s     <62rB<rl"  r^   )r  r'  r  r
'  rC  r  iterr   r  r'  r/   r   )r  r@  r7  old_pathold_sizes        r1   r'  z_FootageLRUCache._evict_locked# s     <T]]%9%9%;<<doo%#dmm*<q*@,0dmm6I6I6K1L,M)G)hMMgt,LLWd+! XE doo%#dmm*<q*@%*@%  s   =C> >	D
	D
N)
r  r&  r&  r&  r'  r'  r   r'  r'  r'  rX   r3   r1   r'  r'   s%    &
 "r3   r'  c                   ,    e Zd ZdZd Zd Zed        Zy)	_SFTPPoola  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.
    c                     |||c| _         | _        | _        || _        t	        t        |      d      | _        g | _        t        j                         | _
        y )Nr^   )_host_port_user	_key_pathrC  r   	_max_size_idler  r'  r'  )r  r$  r  r'  key_pathmax_sizes         r1   r'  z_SFTPPool.__init__: sF    -14*
DJ
!S]A.
^^%
r3   c                 R   dd l }|j                         }|j                  |j                                |j	                  | j
                  | j                  | j                  | j                  ddddd	       |j                         }|j                         j                  d       ||fS )Nr   r  F)	r!  r  rF  key_filenamer7  banner_timeoutauth_timeoutlook_for_keysallow_agentr  )paramiko	SSHClientset_missing_host_key_policyAutoAddPolicyrH  r%'  r&'  r''  r('  	open_sftpget_channel
settimeout)r  r3'  clientsftps       r1   r1  z_SFTPPool._connectA s    ##%**8+A+A+CDZZdjj4::B5e 	 	

 !%%b)t|r3   c              #     K   d x}}| j                   5  | j                  r| j                  j                         \  }}d d d        |	 |j                  d       || j                         \  }}	 | | j                   5  t        | j                        | j                  k  r'| j                  j                  ||f       	 d d d        y 	 d d d        	 |j                          y # 1 sw Y   xY w# t        $ r( 	 |j                          n# t        $ r Y nw xY wd x}}Y w xY w# 1 sw Y   [xY w# t        $ r Y y w xY w# t        $ r" 	 |j                           # t        $ r Y  w xY ww xY ww)Nr  )
r'  r*'  r  r  ry   r[  r1  rC  r)'  ri   )r  r:'  r;'  s      r1   
connectionz_SFTPPool.connectionN sk    ZZ 	0zz#zz~~/	0 %		# <==?LFD	J  tzz?T^^3JJ%%vtn5 39	0 	0  %LLN   $$%"     	   	s   E-*C$
E-C0 E-/D? 3E-?AD$ 	E-
E-D0 #E-$C-)E-0	D!:D
D!	DD!DD!E- D!!E-$D-)E-0	D<9E-;D<<E-?	E*	EE*	E&#E*%E&&E**E-N)r  r&  r&  r&  r'  r1  _contextmanager_typer='  rX   r3   r1   r#'  r#'  2 s%    &    r3   r#'  c                   \    e Zd ZdZd Zd Zd Zed        ZddZ	ddZ
d	 Zd
 ZddZd Zy)RemoteSSHFootageStoreu   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).c                 .   t         j                  d      | _        t        t        t
        t        t        t              | _	        t        t        t        t              | _        t        t         d      | _        i | _        t'        j(                         | _        y )Nr6   r   )rB  r8   _baser#'  r&  r&  r&  r@  rI  _poolr'  rD  rF  rG  _cacherC  rK  _listing_ttl	_listingsr  r'  _listing_lock)r  s    r1   r'  zRemoteSSHFootageStore.__init__x sn    /66s;
 24F"$@

 '$&D$
   :A>&^^-r3   c                 $    | j                    d| S )Nr6   )rB'  r&  s     r1   _remote_dirz!RemoteSSHFootageStore._remote_dir s    **Qzl++r3   c                    t        j                          }| j                  5  | j                  j                  |      }|r|d   |kD  r|d   cddd       S ddd       	 | j                  j                         5 }|j                  | j                  |            }ddd       D cg c](  }|j                  t        t        |dd      xs d      f* }}| j                   r5| j                  5  || j                   z   |f| j                  |<   ddd       |S |S # 1 sw Y   xY w# 1 sw Y   xY wc c}w # t        $ r g }Y mt        $ r-}t        j                  j                  d||       g cY d}~S d}~ww xY w# 1 sw Y   |S xY w)z}[(filename, mtime)] for a machine dir, briefly cached. [] on outage
        or a missing/empty dir (clean UI, never a crash).r   r^   Nr  z(Remote footage listing failed for %s: %s)rU   rG'  rF'  r   rC'  r='  listdir_attrrI'  r  r  re   r  ry   rm   rn   ro   rE'  )	r  r!  r  r  r;'  attrsr  r  rl  s	            r1   _list_remotez"RemoteSSHFootageStore._list_remote sh    iik 	!^^''
3F&)c/ay	! 	! 	!	&&( HD))$*:*::*FGHRWXQajj%:q(A(FQ"GHXEX ## N.1D4E4E.Eu-Mz*Nu!	! 	!
H HX  	E 	JJI:WZ[I	Ns_   *DD4 8!D#D4 %-D/D4 ,E9D #D,(D4 4E6E6	"E1+E61E69Fc                     | j                  dd      d   }|j                  d      r|d t        d        }	 t        |      }|dkD  r|S 	 |S # t        $ r Y |S w xY w)Nr  r^   r   z.thumbr&  )rj  r|  rC  r  r   )r9   r	  r  r_   s       r1   _name_tszRemoteSSHFootageStore._name_ts sv    {{3"1%=="(3x=.)D	tBM!	 "   		s   A 	AANc                    | j                  |      }t               }g }|D ]  \  }}	d|v r|j                  d      }
|
dk  r"||
d  j                         }|d |
 }|t        v r8|j                  |       |j                  |||d| j                  ||	      f       z|t        v s|j                  |||d| j                  ||	      f        g }|D ]b  \  }}}}}|dk(  r||v r|||k  r|||kD  r#|t        |      |d| d| dd| d| d	d
}|dk(  rd| d| |d<   |j                  |       d |j                  d d       |d | S )Nr&  r  r   r&  r~
  r&  r6   r&  r&  r&  r&  r&  c                     | d   S rA  rX   rZ  s    r1   r	  z3RemoteSSHFootageStore.list_frames.<locals>.<lambda> rB  r3   Tr
  )rM'  r  rfindrE  r&  r  ri   rO'  r&  r   r  )r  r!  r&  rX  r   r  r&  r   r9   r	  dotr  r  r   r!	  r_   r   s                    r1   r&  z!RemoteSSHFootageStore.list_frames s   !!*-e  	UKD%D **S/CQwst*""$C:D((%dCwdE8RST**dCwdE8RST	U ). 	%D#tT2w4;#628~"r' "g.zl!D6M-j\4&
KE w'8AdV%Lk"JJu!	" 	&56E{r3   c                     | j                  |      D cg c]  \  }}|	 }}}g }|r|t        fd|D              z  }|t        fd|D              z  }|r|d   S dS c c}}w )z<Pick the best matching remote filename for a frame, or None.c              3      K   | ]>  }|j                  d z         r(||j                  d      d j                         v r| @ ywr&  r  Nrw   rR'  rE  r,  r  r  r&  s     r1   r/  z1RemoteSSHFootageStore._resolve.<locals>.<genexpr> sJ      !<<9 45!AGGCLM:J:P:P:RVZ:Z !s   AAc              3      K   | ]B  }d |vr<|j                  dz         r(||j                  d      d j                         v r| D ywrV'  rW'  rX'  s     r1   r/  z1RemoteSSHFootageStore._resolve.<locals>.<genexpr> sP      
!all8c>&B!''#,- &&(D0 
s   AAr   N)rM'  r  )	r  r!  r&  r  prefer_thumbr  _mr  rB  s	     ``     r1   _resolvezRemoteSSHFootageStore._resolve s     $ 1 1* =>uq">>
& ! !  J 	f 

 
 	


 !+z!}44 ?s   A#c                 "   | d| }| j                   j                  |      }||S | j                  |       d| }	 | j                  j	                         5 }|j                  |d      5 }|j                          |j                         }ddd       ddd       | j                   j                  |      S # 1 sw Y   -xY w# 1 sw Y   1xY w# t        $ r Y yt        $ r+}	t        j                  j                  d||	       Y d}	~	yd}	~	ww xY w)znReturn a local cache path for a remote file, fetching via SFTP on a
        miss. None on outage/missing file.r6   Nr  z&Remote footage fetch failed for %s: %s)rD'  r   rI'  rC'  r='  r?  prefetchr  r  ry   rm   rn   ro   r'  )
r  r!  r9   	cache_keyr!  remote_pathr;'  r  r   rl  s
             r1   _fetchzRemoteSSHFootageStore._fetch s     "l!D6*	kkooi(?J))*56av>		&&( %DYY{D1 %RKKM779D%% {{y$//% %% % ! 	 	JJGVYZ	sM   C C+!B8CC 8C	=CC	C 	DD#!D		Dc                     | j                  ||t        |dk(        }|sy | j                  ||      }|y t        j	                  |j
                  j                               }t        t        |      |      S )Nr4  rZ'  r   )	r\'  r&  ra'  r'  r   r!  rE  r   r*   )r  r!  r&  rd  r9   rb   r  s          r1   r&  z!RemoteSSHFootageStore.frame_bytes so    }}Z3E,0GO  >{{:t,<%))$++*;*;*=>TU33r3   c                     | j                  ||t              }|sy | j                  ||      }|y t        t	        |      dd      S )Nr'  T)r!  r'  )r\'  r&  ra'  r   r*   )r  r!  r&  r  r9   rb   s         r1   r&  z$RemoteSSHFootageStore.segment_stream sH    }}Z3EF{{:t,< T[dKKr3   c                 6   |dk(  r| j                  ||t              }n| j                  ||t        |dk(        }|sy | j                  ||      }|y |dk(  rdnd}t        j                  |j                  j                         |      }|j                         |fS )NrH  r4  rc'  r'  r  )	r\'  r&  r&  ra'  r'  r   r!  rE  r.   )r  r!  r&  r!	  r9   rb   
default_ctr  s           r1   r.   z RemoteSSHFootageStore.read_bytes s    9==X7IJD==X7I04 ! BD{{:t,<$(I$5[;U
%))$++*;*;*=zJ %''r3   r&  FrT   )r  r&  r&  r&  r'  rI'  rM'  staticmethodrO'  r&  r\'  ra'  r&  r&  r.   rX   r3   r1   r@'  r@'  r sK    E
.,,  $L5 0(	4
L(r3   r@'  c                     t         dk(  r@	 t               } t        j                  j	                  dt
        t        t        t               | S t         dvr$t        j                  j                  dt                t               S # t        $ r4}t        j                  j                  d|       t               cY d}~S d}~ww xY w)a_  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.r  z2Vision footage backend = remote SFTP (%s@%s:%s/%s)z?Remote footage backend unavailable (%s); falling back to local.N)r7   r?  zAVISION_FOOTAGE_BACKEND=%r is not recognized; using local backend.)r>  r@'  rm   rn   rl   r&  r&  r&  rB  ry   r  r&  ro   )r	  rl  s     r1   _make_footage_storerj'   s     )	')+EJJOOD"$68J*
 L ]2

O"	
   	'JJQSV %&&		's   >B   	B=	)B82B=8B=c                 0    t         j                  | ||      S )ut  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.)_footage_storer.   )r!  r&  r!	  s      r1   footage_bytesrm'  = s    2 $$Z4@@r3   c                     t        t        j                               }g }t        |      D ]D  }||dz  z
  }|||k  r|||kD  rd| }|j                  ||dd|  d| dd|  d| dd       F |S )	zKSynthetic frame list so Recordings/thumbnails are demonstrable pre-capture.r  mock-r~
  r&  r6   r&  r&  r&  )r   rU   r  ri   )	r!  r&  rX  r  r  r   r  r_   r   s	            r1   _vision_mock_framesrp'  Y s    
diik
C
C5\ 1s7]?rCx>b2gbTl

*:,auKH)*Qse:F
 	 Jr3   c                 `    |rt        | d      nt        j                  | d      }|r|d   S d S )Nr^   )r  r@  r   )rp'  rl'  r&  )r!  mockframess      r1   _vision_latest_framert'  n s8    :>!*A6!--j-B 6!9(D(r3   VISION_THUMBS_CACHE_TTL)r_   r   c            	         t               } 	 | j                  d      j                         }| j                          |D cg c]  }|d   	 }}|si S d }ddlm} t        dt        t        t        |                  }i } ||      5 }|j                  ||      D ]
  \  }	}
|
||	<    	 ddd       |S # | j                          w xY wc c}w # 1 sw Y   |S xY w)	zAssemble {machine_id: {thumb_url, full_url, latest_kind}} for active agents,
    fetching each machine's latest frame in parallel (bounded by the SSH pool size
    so we never open more concurrent SFTP connections than the pool holds).z<SELECT machine_id FROM vision_agents WHERE status = 'active'r!  c                 t    t        | d      }| |r|d   nd |r|d   nd |r|j                  d      dfS d dfS )NFrr'  r&  r&  r!	  r&  r&  latest_kind)rt'  r   )r2  r  s     r1   _onez$_vision_compute_thumbs.<locals>._one s[    %c606,D.4z*$176::f-
 
 	
 >B
 
 	
r3   r   )ThreadPoolExecutorr^   )max_workersN)r'  rL  r  r[  concurrent.futuresr|'  rC  rL  rI  rC  r  )rO  rn  r  midsr{'  r|'  r%  r   exr2  rQ  s              r1   _vision_compute_thumbsr'   s     <D||J

(* 	 	

%)*AlO*D*	
 6!S5s4yABG
C		0 BtT* 	HCCH	 J' 	

* Js   B3 C C3CCc                  T    d } t        j                  | d      j                          y)zRecompute the thumbs payload on a daemon thread (single-flight; the caller
    sets _vision_thumbs_refreshing before invoking).c                     	 t               } t        5  | t        d<   t        j                         t        d<   d d d        t        5  dad d d        y # 1 sw Y   xY w# t        $ r*}t
        j                  j                  d|       Y d }~Ld }~ww xY w# 1 sw Y   y xY w# t        5  dad d d        w # 1 sw Y   w xY wxY w)Nr   r_   z+Vision thumbs background refresh failed: %sF)	r'  _vision_thumbs_lock_vision_thumbs_cacherU   ry   rm   rn   ro   _vision_thumbs_refreshing)r   rl  s     r1   _runz*_vision_thumbs_refresh_async.<locals>._run s    		2)+D$ 9/3$V,-1YY[$T*9 % 2,1)2 29 9  	SJJLcRR	S2 2$ 2,1)2 2 2sc   A %AA BAA 	B& BB BB BB>&B2)	B>2B;7B>Tr  Nr  )r'  s    r1   _vision_thumbs_refresh_asyncr'   s"    2 D.446r3   c                     t        j                          } d}t        5  t        d   }| t        d   z
  }||t        k  r|dfcddd       S |
t        sdad}ddd       |r
t                |dfS t               }t        5  |t        d<   t        j                          t        d<   ddd       |dfS # 1 sw Y   \xY w# 1 sw Y   |dfS xY w)	zStale-while-revalidate: return fresh cache within TTL; return stale data
    immediately and kick a background refresh when expired; compute synchronously
    only on a cold cache. Returns (data, source) where source is fresh/stale/cold.Fr   r_   NfreshTr  cold)rU   r'  r'  _VISION_THUMBS_CACHE_TTLr'  r'  r'  )r  start_bgr   ages       r1   _vision_get_thumbs_cachedr'   s    
 ))+CH	 #F+(..&> >=	 
 $=(,%H (*W}!#D	 1'+V$%)YY[T"1 <! 1 <s   $B2B2%B>2B;>C
c                 j   |dk(  rdnd\  }}	 t        |j                  dd      d         }t        j                  |      j	                  d      }|dk(  rdnd}|dk(  rd	nd
}d| d| d| d| d| d| d| d| d| dt        |        d| dt        |       d| dS # t
        t        f$ r |}Y aw xY w)zFA self-describing placeholder image for mock footage (no binary deps).r4  )r	  r]!  )i  rz
  rc
  r^   rr  r$
  "   r  rx  z/<svg xmlns="http://www.w3.org/2000/svg" width="z
" height="z" viewBox="0 0 r\   z"><rect width="z+" fill="#1f2937"/><rect x="0" y="0" width="zy" fill="none" stroke="#0d9488" stroke-width="2"/><text x="50%" y="42%" fill="#5eead4" font-family="monospace" font-size="z" text-anchor="middle">zO</text><text x="50%" y="58%" fill="#9ca3af" font-family="monospace" font-size="zO</text><text x="50%" y="74%" fill="#6b7280" font-family="monospace" font-size="z." text-anchor="middle">mock frame</text></svg>)r   r  r  r  r  r   r=  _h)	r!  r&  rd  r  r}
  r_   r  fs1fs2s	            r1   _vision_mock_frame_svgr'   s
   :jDAqQ'*+++B/889LM "RC"RC
9!Jqc Js!A3 s*QC ($$%3j 4 U1"Z.1A BU1"T( <U 		 
# s   AB B21B2c                     t        |       j                  dd      j                  dd      j                  dd      j                  dd      S )	z,Minimal XML/HTML text escape for SVG bodies.r  r  r  r  r  r  r  z&quot;)r*   r  r  s    r1   r'  r'   s=    FNN3(00f=WS&!''#x"8:r3   c                      t        t        j                               } dgt        z  }dgt        z  }dD ]  }d||<   	 dgt        dz
  z  dgdz  z   }i ddddd	d
ddd| dz
  ddddddddddddddddd|ddd d!d"d#d$|  d%d$|  d&d'i dd(dd)d	d*ddd| d+z
  dddd,dddd-dd.dd/dd0dd1d|dd2d d3d"d4d5|  d%d5|  d&d'i dd6dd7d	d8ddd| d9z
  dd:dd;dd:dd<dd<dd<dd<ddd|d=d<d>d<gS )?zSample machine tiles so the Collector UI is demonstrable pre-agent.
    Includes Phase-3 fields (sparkline/uptime/restarts/thumbnail).r^   )rP   r   r  r  r   r   r!  zmock-1r!  zICAST-CNC-01r  jdoer  z0.1.0r  rx  r  Trd   rv  rn&  rp&  	Mastercamrq&  r   rr&  %   rt&  i0 restarts	sparkliner3  z192.168.250.21r4  rQ  rsync_localityr?  z/vision/frames/mock-1/mock-r&  r&  )r&  r&  zmock-2zICAST-PRESS-02msmithr"  rs  OutlookiH  r  rT  r   z
10.14.2.60z203.0.113.9r  z/vision/frames/mock-2/mock-zmock-3zICAST-WELD-03r	  ih  Fr  Nr&  r&  )r   rU   _VISION_SPARK_BUCKETS)r  r@  gappyr  offline_tails        r1   _vision_mock_collectorsr'   s    diik
C3&&DC''E a3/!34sQw>L	Dx 	D^ 	D=Mv 	D	'	D#.b	D:BD	D	8	D.	D6F	D 
	D &r	D ,<=M	D 
Q		D $T		D
 
%	D
 (34D	D 
7	D 4C5D23%zB	D	Dx 	D-= 	D?OQY 	D	'	D#.b	D:BD	D	6	D,d	D4Di	D 
	D (	D -=d	D 
Q		D $U		D
 
\	D
 $/	D 
8	D 4C5D23%zB	D	.x 	._ 	.>NPU 	.	'	.#.d
	.<De	.	7	.-u	.6F	. 
	.  )$	. 1A$	. 
Q		. $\		.
 
d	.
 '	.' r3   z/vision/agents/enrollc                  P   t        j                  d      xs i } | j                  d      xs dj                         xs d}t	        t        j                               }t        j                         }t               }	 |j                  d||||t        z   f       |j                          |j                          t        ddt        j                  d	      xs i j                  d
      d|xs d |xs d       t        |||rd      S d      S # |j                          w xY w)uC  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).
    Tr  r!  r7   NzcINSERT INTO vision_enrollment_tokens (token, machine_id, issued_at, expires_at) VALUES (?, ?, ?, ?)vision-agentsr  r'  r9   zEnrollment token issued for zself-enrollrh  r  r  r  )r   r  r   r   r*   rW  rX  rU   r'  rL  _VISION_ENROLL_TTL_SrZ  r[  r	  r   r   r  s        r1   vision_agents_enrollr'   s    4(.BD((<(.B557?4J

E
))+C<D#JS+?%?@	

 	

.{{6"(b--f5.z/J].KL"	 E(#-; ;39; ; 	

s   >-D D%z/vision/agents/registerc            
      B   t        j                  d      xs i } t         j                  j                  dd      }| j                  d      xs& |j	                  d      r|dd j                         nd}| j                  d	      xs dj                         }| j                  d
      xs dj                         }| j                  d      xs dj                         xs d}| j                  d      xs dj                         xs d}|s|st        d      dfS t        j                         }t               }	 t        |d      }	t        |	      xr ||	k(  xs ||	k(  }
|
r%|st        d      df|j                          S |d}}n|j                  d|f      j                         }|st        d      df|j                          S |d   t        d      df|j                          S ||d   kD  rt        d      df|j                          S |d
   r0|r&||d
   k7  rt        d      df|j                          S |d
   d}}n$|st        d      df|j                          S |d}}|j                  d||f       |j                  d|f      j                         }|r&|d    s!|d!   }|d"   }|r|j                  d#||f       n}|r>t        t        j                                }|j                  d$|||||d k(  rd%nd&||f       n=t        t        j                                }|j                  d'||||||d k(  rd%nd&|f       |j#                          |j                          t%        d(d)|xs |d*| d+| d,|-       t        |||.      S # |j                          w xY w)/u  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`.
    Tr  r  r7   r  r  r   Nr  r!  r!  r|&  r  r  r`  Fr  r  r  z6SELECT * FROM vision_enrollment_tokens WHERE token = ?r  r  r  r  r.  r  r  r  rv  zCUPDATE vision_enrollment_tokens SET consumed_at = ? WHERE token = ?zKSELECT agent_token, status, revoked FROM vision_agents WHERE machine_id = ?r  r  rd   @UPDATE vision_agents SET ssh_public_key = ? WHERE machine_id = ?zUPDATE vision_agents SET agent_token = ?, hostname = COALESCE(?, hostname), enrolled_at = ?, status = ?, revoked = ?, ssh_public_key = COALESCE(?, ssh_public_key) WHERE machine_id = ?r^   r   zINSERT INTO vision_agents (machine_id, hostname, agent_token, enrolled_at, status, revoked, ssh_public_key) VALUES (?, ?, ?, ?, ?, ?, ?)r'  r  Agent z registered (status: r7  rh  r  )r   r  rx   r   rw   r   r   rU   r'  r&  r   r[  rL  ru  r*   rW  rX  rZ  r	  )r  r  r  r  r  r!  r|&  r  rO  r  r  r  rd   r  r  r  s                   r1   vision_agent_registerr'  3 s    4(.BD??3D((-. K'+y'A48>>#r !56<"CCExx-3::<O$*113;tHhh/06B==?G4N.OPRUUU
))+C<DG4T%H$ 
+Hz[/H 	 "%KLcQ| 	

{ ,;I& ,,H:-hj  %@A3Fn 	

m =!-%EFKj 	

i S&&%@A3Ff 	

e < "#l:K'K")PQSVV^ 	

] 03</@(f$ '")OPRUUT 	

S 0?	f$LLUj!
 <<Y!#
 (* 	 HY/"=1Kh'FV#%9:  djjl+KLLU hV	)q.BVX	 djjl+KLL0 &xc6	)q.B	 	

..-..CF81M 	 {7KTZ[[ 	

s1   97N 5N N *N N =N %C.N Nz/vision/agents/ssh-keyc                     t         j                  j                  dd      } | j                  d      r| dd j	                         nd}|st        d      dfS t        j                  d	
      xs i }|j                  d      xs dj	                         }|st        d      dfS t               }	 |j                  d|f      j                         }|st        d      df|j                          S |j                  d||d   f       |j                          |j                          d}d}|d   dk(  rUt        |      }|d	u rDd	}t               }		 |	j                  d|d   f       |	j                          |	j                          n|}t        d	||      S # |j                          w xY w# |	j                          w xY w)a  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.).
    r  r7   r  r   Nr  r  r  Tr  r|&  zssh_public_key required.r`  zRSELECT machine_id, status FROM vision_agents WHERE agent_token = ? AND revoked = 0r  r'  r!  Frd   rv  AUPDATE vision_agents SET ssh_key_written = 1 WHERE machine_id = ?)rU  
key_pushedkey_push_error)r   rx   r   rw   r   r   r  r'  rL  ru  r[  rZ  r&  )
r  rD  r  r&  rO  r  r'  r'  r>  conn2s
             r1   vision_agent_ssh_keyr'   s    ??3D $	 :DHNNE:;S@@4(.BD((+,299;J78#==<Dll`H
 (* 	 !BCSH 	

 	N\*+	
 	

 JN
8} /
;T>JLEW&( #Ndz.QQ- 	

$ s   /0F 0'F &F+ F(+F=z/vision/checkinc                     t         j                  j                  dd      } | j                  d      r| dd j	                         nd}|st        d      dfS t               }	 |j                  d	|f      j                         }|st        d
      df|j                          S |d   }|d   }|d   r|j                  d|f      j                         }|rf|j                  dt        j                         |d   f       |j                          d}|d   r	 t        j                  |d         }|d   |d   |d}nd}t        dd|      |j                          S t        j                   d      xs i }	t        j                         }
t         j                  j                  d      xs dj#                  d      d   j	                         xs t         j$                  }|	j                  d      }|	j                  d      xs |}|j                  d||
|	j                  d      |	j                  d      |	j                  d      |	j                  d       |	j                  d      |	j                  d!      |	j                  d"      |	j                  d#      |	j                  d$      |	j                  d%      |	j                  d&      |	j                  d'      |	j                  d(      ||f       |j                  d)|
|	j                  d      |f       |j                  d|f      j                         }|r|j                  d|
|d   f       t'        |       |j                          |j                          t)               }|j                  d*      rd+|d*<   t+        |       t-        |
       d}|r,d}|d   r	 t        j                  |d         }|d   |d   |d}t/        |      }t        dd,|||t1        |      -      S # t        j                  t        f$ r d}Y w xY w# |j                          w xY w# t        j                  t        f$ r d}Y w xY w).zMAgent -> server check-in. Validates bearer agent_token, stores all 14 fields.r  r7   r  r   Nr  r  r  zKSELECT machine_id, status, revoked FROM vision_agents WHERE agent_token = ?Invalid agent_token.r!  rd   r  z}SELECT id, action, params_json FROM vision_commands WHERE machine_id = ? AND acked_at IS NULL ORDER BY created_at ASC LIMIT 1zPUPDATE vision_commands SET delivered_at = COALESCE(delivered_at, ?) WHERE id = ?r  r  r  r  T)r  r  r  r  zX-Forwarded-Forr  r   r3  r4  ak  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, local_ip, public_ip)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)r!  r  r  r  rn&  ro&  rp&  rq&  rr&  rs&  rt&  r_   z`UPDATE vision_agents SET last_seen_at = ?, hostname = COALESCE(?, hostname) WHERE machine_id = ?r&  Fr   )r  r  r  config_deltar  rsync_config)r   rx   r   rw   r   r   r'  rL  ru  r[  rU   rZ  r   r   r   r   r  r  remote_addrr&  r&  r&  r&  r&  r&  )r  rD  rO  r  r!  r  r  r  r  r  r  observed_public_ipr3  r4  r  r'  s                   r1   vision_checkinr'   s?    ??3D $	 :DHNNE:;S@@<D`YH
 (* 	 !78#=t 	

q <(
X ll3 	
 hj  $YY['$-0
 =)&!%GM,B!C ")'(:KW]^t)WMx 	

w t,2iik __  !239r@@EaHNNP #"" 	 88J'HH[)?-?	M C$)*&)"*+*+)*(#()*!	
2 	$$((:&
3	
 ,,/ M	

 (* 	 LL gdm$
 	t$

 %&H||K  %h' #G=!GM$:; !'(2CvV ,J7LtR| ,;OPX;Y[ [w !00)< &!%&B 	

" (()4 sP   "0P  #A2P  O> .P  HP  5P5 >PP  PP   P25QQ>
   	uninstall
upload_logset_intervalupdate_agentpause_capturerestart_agentcleanup_legacyresume_capturecollect_screenshotstart_capture_taskz3.2.15z)^_AGENT_VERSION\s*=\s*["\']([^"\']+)["\'])r  r	  c                     t         t        z  } 	 | j                         j                  }t        }|d   |d   |k(  r|d   S t
        }	 t        j                  t        |             5 }	 |j                  d      j                  dd      j                         }ddd       ||d<   ||d<   |S # t        $ r	 t
        cY S w xY w# t        $ rL |j                  d      j                  dd      }t        j                  |      }|r|j!                  d      }Y }w xY w# 1 sw Y   xY w# t        j"                  t        t        f$ r Y w xY w)	a  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.
    r  Nr	  zversion.txtr  r  zvision_agent.pyr^   )rM  rO  r  r  r   %_LATEST_VISION_AGENT_VERSION_FALLBACK"_latest_vision_agent_version_cacher  r  r*   r  r  r   r  _AGENT_VERSION_REr  rb  r]  )rb   r	  r"  r  r  r	  rG  s          r1   _latest_vision_agent_versionr'  i sE    !#88D5		$$ /EY#g%(?Y3G__SY' 		-2-''-077KQQS			- E)E'NN+  5445  -!23::7IN)008#kk!nG	-		- 		- '2 sZ   B+ D$ &D(/C D$ +B=<B= ADDDDD!D$ $EEz/vision/commandsc                  ^   t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         }| j                  d      }|st	        d      d	fS |t
        vr.t	        d
dj                  t        t
                     d      d	fS dt        j                         j                  dd z   }t        j                         }|t        j                  |      nd}t               }	 |j                  d|f      j!                         }|st	        d      df|j#                          S |j                  d|||||f       |j%                          |j#                          t	        |      dfS # |j#                          w xY w)z8Admin enqueues a remote command for a collector machine.Tr  r!  r7   r  r  machine_id required.r  r`  zaction must be one of: r  r  cmd-Nr  ISELECT machine_id FROM vision_agents WHERE machine_id = ? AND revoked = 0z machine_id not found or revoked.r  zdINSERT INTO vision_commands (id, machine_id, action, params_json, created_at) VALUES (?, ?, ?, ?, ?))r  r  )r   r  r   r   r   _VISION_COMMAND_ACTIONSr  r  rW  rX  rY  rU   r   r   r'  rL  ru  r[  rZ  )	r  r!  r  r  r  r  r  rO  r  s	            r1   vision_commands_creater'   s    4(.BD((<(.B557Jhhx &B--/FXXhF34c99,,6tyyH_A`7a6bbcdegjjjdjjl&&r**F
))+C(.(:$**V$K<DWM
 (* 	 !CDcI 	

 	&Zc:	

 	

&!3&& 	

s   0F 'F F,r=  z/vision/agent-logc                  R   t         j                  j                  dd      } | j                  d      r| dd j	                         nd}|st        d      dfS t        j                  d	
      xs i }|j                  d      xs dj	                         }|j                  d      }|st        d      dfS t        |t              st        d      dfS t        |      t        kD  r
|t         d }t               }	 |j                  d|f      j                         }|st        d      df|j                          S |j                  d|d   ||t        j                         f       |j!                          |j                          t        d	      dfS # |j                          w xY w)zAgent 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).r  r7   r  r   Nr  r  r  Tr  r  r  zfile is required.r`  zcontent must be a string.JSELECT machine_id FROM vision_agents WHERE agent_token = ? AND revoked = 0r  zINSERT 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_atr!  rY  r  )r   rx   r   rw   r   r   r  r   r*   rC  _VISION_LOG_MAX_CHARSr'  rL  ru  r[  rU   rZ  )r  rD  r  r  r  rO  r  s          r1   vision_agent_log_uploadr'   s   
 ??3D $	 :DHNNE:;S@@4(.BDHHV"))+Dhhy!G01366gs#893>>
7|++0012<DXH
 (* 	 !BCSH 	

 	2 < $=	
 	

dS   	

s   :0F ;;F F&>   disk_low	push_fail	sftp_failexception_tracessh_key_errno13update_proxy_failz/vision/agent-eventsc            	         t         j                  j                  dd      } | j                  d      r| dd j	                         nd}|st        d      dfS t        j                  d	
      xs i }|j                  d      xs dj	                         }|st        d      dfS |t        vr!t        d| dt        t                     dfS |j                  d      }|j                  d      }|j                  d      }|	 t        |      }t               }	 |j                  d|f      j                         }|st        d      df|j                          S |d   }	t!        j                          }
|j                  d|	|||||
f       |j                  d|	|	t"        f       |j%                          |j                          t        d	      dfS # t        t        f$ r d}Y w xY w# |j                          w xY w)u   Agent → server failure-event ingest (DVI-914).

    Authenticated by bearer agent_token (same path as /vision/checkin).
    Body: {signature, detail?, trace?, ts?}.
    Inserts into vision_agent_events and prunes old rows beyond the per-machine cap.
    r  r7   r  r   Nr  r  r  Tr  rF  zsignature is required.r`  zUnknown signature 'z
'. Valid: r	  tracer_   r'  r  r!  zqINSERT INTO vision_agent_events (machine_id, signature, detail, trace, ts, received_at) VALUES (?, ?, ?, ?, ?, ?)zDELETE FROM vision_agent_events WHERE machine_id = ? AND id NOT IN (  SELECT id FROM vision_agent_events WHERE machine_id = ?  ORDER BY id DESC LIMIT ?)rY  r  )r   rx   r   rw   r   r   r  _VISION_EVENT_SIGNATURESr  r  r   r   r'  rL  ru  r[  rU   _VISION_EVENTS_PER_MACHINErZ  )r  rD  r  rF  r	  r'  r_   rO  r  r!  r  s              r1   vision_agent_events_ingestr'   s    ??3D $	 :DHNNE:;S@@4(.BD+&,"335I56;;00'	{*VD\=]<^_
 	 XXhFHHWE	$B	~	rB <DXH
 (* 	 !BCSH$ 	

# <(
iik)FE2s;	
 	 %?@	
 	

dS  ; :& 	B	8 	

s%   G 0G) AG) G&%G&)G;c            	         t         j                  j                  d      xs dj                         } t         j                  j                  d      xs dj                         }| st	        d      dfS t               }	 |rh|j                  d| |f      j                         }|st	        | |dd	      |j                          S t	        | ||d
   |d   	      |j                          S |j                  d| f      j                         }|j                          t	        | |D cg c]  }|d   |d   d c}      S # |j                          w xY wc c}w )zAdmin 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.r!  r7   r  r'  r  r`  zTSELECT content, received_at FROM vision_agent_logs WHERE machine_id = ? AND file = ?N)r!  r  r  rE  r  rE  zVSELECT file, received_at FROM vision_agent_logs WHERE machine_id = ? ORDER BY file ASC)r  rE  )r!  r  )
r   rh  r   r   r   r'  rL  ru  r[  r  )r!  r  rO  r  rn  r  s         r1   vision_agent_log_getr'  " sG   
 ,,""<06B==?JLLV$*113D34c99<D,,5T" hj	 
 *4[_` 	

 jt#&y>s=?QS 	

 ||!M
 (*	 	 	

jX\]ST1V9Q}=MN]_ _ 	

]s   >4D= D= (!D= #E
=Ez/vision/agent-logsc            	      V   t               } t               }	 |j                  d      j                         }|j	                          g }|D ]>  }|d   }|j                  ||d   xs d| j                  |      xs d|d   |d   d       @ t        |      S # |j	                          w xY w)	zDVI-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.zSELECT 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 DESCr!  r!  Nr  rE  )r!  r!  r  r  rE  r  )r&  r'  rL  r  r[  ri   r   r   )r.  rO  rn  rJ   r  r2  s         r1   vision_agent_logs_listr'  B s     %&E<D||+

 (* 	 	

D o*-!IIcN2dfI]+
 	  	

s   B B(z/vision/assistant-configc                     ddl m} m}m}m}m} t        d   d   }t        |      } |        } ||      }t        |j                  d      xs i       }	|	j                  d      s|j                  d      r|d   |	d<   g }
|j                         D ]T  \  }}|	j                  |      s|j                  dg       D ](  }|
j                  d	|d
   z   |d   dz   |d   z   d       * V t        |j                  d      xs i       }|j                  d      s|j                  d      r|d   |d<   g }|j                  d      xs |j                  dd      }|r ||      }|j                  |      rm|j                  |i       }|}|j                  dg       D ]  }|d
   |k(  s|d   } n |j                  dd      }|j                  d|z   |r|dz   |z   n|d       |j                  d      xs i j                  dd      }|
D ch c]  }|d   	 c}|D ch c]  }|d   	 c}z  }||vrd}t        |
||      S c c}w c c}w )a,  Return assistant models available to the Vision user, split into System
    Providers (org-configured) and User Models (user's own connection), plus the
    user's saved Vision selection. Option values are namespaced "source:modelId".
    Includes google models when the matching provider key exists.r   )r~  r  r  _provider_for_model_effective_key_system_onlyr'  r  r  r  r  r  system:r  r   r
  r  r,  r  r  r7   user:r  assistant_modelr  )system_providersuser_modelsselected)r  r~  r  r  r'  r'  r   r  r   r   r   ri   r   )r~  r  r  r'  r'  r  r  
global_cfgr  r  r'  r  r  r  r  r'  ua_modelr   provider_labelr'  r"	  r$  s                         r1   vision_assistant_config_getr'  ` sl     FOG$E"5)H(*J	H	%B JNN:.4"5H<<$	)B *9 5,224 << XXh+  ''&40!']W4qzA)  RVVF^)r*I==%"&&*;!#I	+Kvvo&="&&"*=H%h/==!&**7B7DEXXh+ T7h&gJE "XXgr2N 8+?M.72U:SX  
 X&,"112CRHH!12AQwZ2+5VQaj5VVEu)  35Vs   H=Ic                     ddl m} m}m}m} t
        d   d   }t        j                  d      xs i }t        |j                  dd            j                         }t        |      } |        } ||      }	t        |j                  d	      xs i       }
|
j                  d
      s|j                  d      r|d   |
d
<   t        |	j                  d      xs i       }|j                  d
      s|	j                  d      r|	d   |d
<   t               }|j                         D ]E  \  }}|
j                  |      s|j                  dg       D ]  }|j                  d|d   z           G |	j                  d      xs |	j                  dd      }|r+|j                   ||            r|j                  d|z          |r||vrt!        d      dfS t        |j                  d      xs i       }||d<   ||d<   t#        ||       t!        d      S )zPersist the Vision user's assistant model selection. Validates the value
    against the models currently available to the user (including google models).r   )r~  r  r  r'  r'  r  Tr  r  r7   r  r  r  r,  r  r'  r  r  r'  z Selected model is not available.r  r`  r  r'  rY  )r  r~  r  r  r'  r   r   r  r*   r   r   r  r   r  r   r  r   r  )r~  r  r  r'  r  r  r  r  r'  r  r  r  r$  r  r  r  r'  
vision_cfgs                     r1   vision_assistant_config_postr'   s   
  FOG$E4(.BD"%&,,.E"5)H(*J	H	%BJNN:.4"5H<<$	)B *9 5RVVF^)r*I==%"&&*;!#I	+EE,224 /<< XXh+ /		)ag-.// vvo&="&&"*=HIMM"5h"?@		'H$%e#?@#EEhll8,23J$)J !#HXx(dr3   c                     g i d} t         j                         r	 t        j                  t         j	                               }t        |t              rNt        |j                  d      t              r|d   | d<   t        |j                  d      t              r|d   | d<   | S | S # t        j                  t        f$ r Y | S w xY w)z.Return {shared: [...], users: {email: [...]}}.)re  r  re  r  )VISION_WATCH_CRITERIA_FILEr   r   r   r   r   r   r   r  r   r   )r  r  s     r1   _load_watch_criteriar'   s    2&D!))+	JJ9CCEFE%&eii148%*8_DNeii0$7$)'NDM K4K $$g. 	K	s   BB$ $C Cc                     t         5  t        j                  t        j                  | d             d d d        y # 1 sw Y   y xY wr   )_WATCH_CRITERIA_LOCKr'  r   r   r   r  s    r1   _save_watch_criteriar'   s7    	 J"--djja.HIJ J Js	   +;Ac                     t        | t              syt        | j                  d      xs d      j	                         }t        | j                  d      xs d      j	                         }|r|sy||dS )z7Coerce a criterion dict; require name + prompt strings.Nr9   r7   r	  )r9   r	  )r   r   r*   r   r   )r   r9   r	  s      r1   _clean_criterionr'   sc    c4 swwv$"%++-D"(b)//1FvF++r3   zclaude-haiku-4-5-20251001c                  &   dddt         i ddd} t        j                         rN	 t        j                  t        j                               }t        |t              rdD ]  }||v s||   | |<    | S | S # t        j                  t        f$ r Y | S w xY w)zHReturn the monitor config dict, filling missing keys with safe defaults.Fr  r  r   r7   )r~  poll_interval_mindaily_call_capr  r&  calls_todaywindow_date)
_VISION_MONITOR_DEFAULT_MODELVISION_MONITOR_CONFIG_FILEr   r   r   r   r   r   r   r   )r  r  r  s      r1   _load_vision_monitor_configr (   s     .D "))+	JJ9CCEFE%&M +AEz"'(Q+ K4K $$g. 	K	s   A A3 %
A3 3BBc                 X    t         j                  t        j                  | d             y)zMPersist monitor config. Caller must hold _VISION_MONITOR_LOCK for atomic ops.r   r   N)r'  r   r   r   rN  s    r1   _save_vision_monitor_configr(   s    ))$**S*CDr3   z/vision/monitor-configc            	         t         j                  dk(  rt        j                  d      xs i } t               }d| v rt	        | d         |d<   d| v r	 t        dt        | d               |d<   d
| v r	 t        dt        | d
               |d
<   d| v rwddl
m} |j                         D ch c]!  }|j                  dg       D ]
  }d|d   z    # }}}t        | d         j                         }|r||vrt        d      d	fS ||d<   d| v rt!        | d   t"              r| d   j%                         D ]  \  }}t!        |t"              st#        |d   j                  |      xs i       }	d|v rt	        |d         |	d<   d|v r2t!        |d   t&              r|d   D 
cg c]  }
t        |
       c}
|	d<   |	|d   |<    t)        |       t        d      S t               }t+               }	 |j-                  d      j/                         }|j1                          |D cg c]  }|d   |d   t	        |d         d }}t3               }|j                  dg       D 
cg c]  }
|
d   	 }}
t4        d   d   }|j                  di       j                  |g       D ]  }
|
d   |vs|j7                  |
d            t        ||||d    |d
   !      S # t        t        f$ r t        d      d	fcY S w xY w# t        t        f$ r t        d      d	fcY S w xY wc c}}w c c}
w # |j1                          w xY wc c}w c c}
w )"u9  Admin-only monitor config read/write.

    GET  — returns current config + enrolled machine list + available criteria
           + live calls_today/daily_call_cap.
    POST — persists a partial or full config update (validates types; clamps
           poll_interval_min >= 5; clamps daily_call_cap >= 0).
    r  Tr  r~  r'  r   z%poll_interval_min must be an integer.r  r`  r'  r   z"daily_call_cap must be an integer.r  )r  r  r'  r  zUnknown model.r&  r1  rY  z7SELECT machine_id, hostname, revoked FROM vision_agentsr!  r!  r  )r!  r!  r  re  r9   r'  r  r  r'  )rc  r&  available_criteriar'  r'  )r   ra   r  r (  r   rC  r   r   r   r   r  r  r  r   r*   r   r   r   r   r  r(  r'  rL  r  r[  r'  r   ri   )r  rJ  r  r  r  valid_models	model_valr2  mcfgr   r  rO  r  r  machines_listcriteria_datar(  caller_emails                     r1   vision_monitor_configr(   s    ~~t,2)+!$y/2C	N$&S+.q#d;N6O2P+Q'( t#P(+As48H3I/J(K$% d?5 0668(B/  AdG##L 
 DM*002IYl:%56;;$CL*T*-=t"D!*-335 -	T!$-S_005;<$'+DO'<E)$%*T*5Et*L9=j9I(JAQ(JE*%',J$- 	$C($ &
'C<DE

(* 	 	

   AjM9&	(M  )*M-:->->x-LM!F)MM6?7+Lw+//bA 1V9..%%ai01 -&+, m z* S%LMsRRS
 z* P%IJCOOP" )K 	

 NsH   K 3K4 +&LL,L" L7L<K10K14LL"L4i  	mp4_bytesc                    ddl m} 	 ddl}| st        d      	 |j                   ||             }	 t        d |j                  D        d      }|t        d      g }|j                  |      D ]  }|j                  |        |st        d	      t        |      }	||	k\  r|}
n,|	|z  }t        |      D cg c]  }|t        ||z            }
}g }|
D ]L  }|j                         } |       }|j!                  |d
d       |j                  |j#                                N ||j%                          S # t        $ r t	        d      w xY w# t        $ r}t        d|       |d}~ww xY wc c}w # |j%                          w xY w)u   Decode an H.264 .mp4 and return N evenly-spaced JPEG keyframes.

    Uses PyAV (av) for in-process decoding — no ffmpeg subprocess needed.
    Returns a list of JPEG bytes (length <= n; may be shorter if the clip is
    very short). Raises ValueError on corrupt/unreadable streams.
    r   )rJ  NzFPyAV (av) is required for segment interpretation but is not installed.zEmpty mp4 bytes.zCould not open video stream: c              3   @   K   | ]  }|j                   d k(  s|  yw)r&  Nr  r&  s     r1   r/  z$_sample_keyframes.<locals>.<genexpr> s     ?1QVVw->Q?s   zNo video stream found in mp4.zNo frames decoded from video.r:  r"  )r	  r  )r>  rJ  avr#  r  r   r?  ry   r  streamsr  ri   rC  r  r   to_imager  rp  r[  )r(  r  rJ  r(  r  rl  video_stream
all_framesframer@  r_  stepr  jpegsrR  r2  s                   r1   _sample_keyframesr(  w s    e +,,IGGGI./	 ?	))?
 <== 
%%l3 	%Ee$	% <== J:F19D9>qBAjQX/BFB 	)E.."C)CHHSH4LL(		) W  ecdde  I8>?SHI0 C 	sA   D6 E BE3 7E.AE3 6E	E+E&&E+.E3 3F_interpret_raterp  rH  c                j   |rt         nt        }t        j                         }t        5  t        j                  |       xs g }|D cg c]  }||z
  t        k  s| }}t        |      |k\  r|t        | <   	 ddd       y|j                  |       |t        | <   	 ddd       yc c}w # 1 sw Y   yxY w)zReturn True if the user is within the rate limit, False if exceeded.

    segment=True applies the stricter per-user segment sub-limit
    (_INTERPRET_SEGMENT_RATE_LIMIT) in addition to the global window.
    NFT)	_INTERPRET_SEGMENT_RATE_LIMIT_INTERPRET_RATE_LIMITrU   _interpret_rate_lockr(  r   _INTERPRET_RATE_WINDOW_SrC  ri   )r  rH  r   r  rP#  r   s         r1   _check_interpret_rater(   s     .5):OE
))+C	 ""5)/RFa37-E#EFFt9%)OE"  	C!% F s)   B)B$B$B) B)$B))B2z/vision/interpretc                   	   ddl m} m}m}m}m} t        d   }|d   }t        |j                  d            }t        |      } | |||      st        d      dfS t        j                  d	
      xs i }	t        |	j                  d      xs d      j                         }
t        |	j                  d      xs d      j                         }t        |	j                  d      xs d      j                         }t        |	j                  d      xs d      j                         }|	j                  d      }t        |	j                  d      xs d      j                         xs d}|
st        d      dfS |st        d      dfS |st        d      dfS |dvrd}|t        |t               st        d      dfS |dk(  }t"        }|rA	 t%        |	j                  d      xs t"              }t+        t,        t/        t0        |            }t3        ||      st        d      dfS t5        |
||      }|t        d      d fS |\  }}|rPt7        |      t8        kD  rt        d!      d"fS 	 t;        ||      }|st        d%      d$fS 	  ||||xs d||&      }n.t7        |      d)kD  rt        d*      d"fS 	  ||||xs d||&      }|xs% |j                  d+      xs i j                  d,d      }	 t?        j@                  tC        jB                         ||
|||r|nd|t7        |      t7        |      d-	      }tE        tF        d.d/0      5 }|jI                  |d1z          ddd       t        |3      S # t&        t(        f$ r
 t"        }Y w xY w# t(        t<        f$ r}t        d#|       d$fcY d}~S d}~ww xY w# |$ r!}t        t        |            d'fcY d}~S d}~w|$ r!}t        t        |            d(fcY d}~S d}~ww xY w# |$ r!}t        t        |            d'fcY d}~S d}~w|$ r!}t        t        |            d(fcY d}~S d}~ww xY w# 1 sw Y   xY w# tJ        $ r# tL        jN                  jQ                  d2       Y &w xY w)4a1  Interpret a captured frame or video segment (DVI-867, DVI-873).

    Body: {machine_id, frame_id, kind?, frames?, prompt, history?}
    kind: "full" (default) | "thumb" | "segment"
    frames: number of keyframes to sample for segment kind (2-6, default 4).
    Audited to vision_interpret_log.jsonl.
    r   )rW  interpret_imageinterpret_imagesr  r  r'  r  r   =Assistant is not connected. Configure an API key in Settings.r  r  Tr  r!  r7   r&  r!	  r@  r	  r  r  Nmachine_id is required.r`  zframe_id is required.zprompt is required.)r@  r4  rH  zhistory must be a list.rH  rs'  rp  z@Rate limit exceeded. Please wait before sending another request.r:$  Frame not found.r  z"Segment is too large to interpret.i  zCould not decode segment:   z(No frames could be decoded from segment.r  rZ  rU  i  @z Frame is too large to interpret.r  r'  )	r_   r'  r!  r&  r!	  rs'  r  
prompt_len	reply_lenr  r  r  r0  .Failed to append to vision_interpret_log.jsonlr  ))r  rW  r (  r!(  r  r  r   r   r   r  r   r   r  r*   r   r   r  !_INTERPRET_SEGMENT_DEFAULT_FRAMESr   r   r   rC  _INTERPRET_SEGMENT_MIN_FRAMESrL  _INTERPRET_SEGMENT_MAX_FRAMESr(  rm'  rC  _INTERPRET_SEGMENT_MAX_BYTESr(  r  r   r   rU   r?  VISION_INTERPRET_LOGr  r   rm   rn   r  )rW  r (  r!(  r  r  r'  r  r  r  r  r!  r&  r!	  r	  r  r  rz  rs'  r>  	raw_bytesr  	keyframesrl  r  	model_selr   r  s                              r1   vision_interpretr2(   so    
 6?DMEDHH]+,H"5)H!%8<\]_bbb4(.BDTXXl+1r288:J488J'-2.446Htxx)6*002D(#)r*002Fhhy!G '*0b1779ATN67<<45s::23S88//:gt#<67<<"J /F	7(+P/PQF 2C8UW]4^_ 
;_`beee:x6F~/0#55NIsy>88!EFKK	J))V<I !KLcQQ	0$Y4W_guvE y>,,!CDcII	0#Ivw$V^ftuE [8<<#9#?R"D"DEVXZ"[IO

))+$  *ff+U

 
 &g> 	#"HHUT\"	#
 q :& 	76F	7" L) 	J#=cU!CDcII	J $ 	0S*C// 	0S*C//	0 $ 	0S*C// 	0S*C//	0"	# 	# O

MNOs   	"M2 )N N; 5P 0AQ! 	QQ! 2N
NN8N3-N83N8;P OPP$P :P PQP)#Q)Q1QQQQQ! !(RRz/vision/watch-criteriac                      t         d   d   } t               }|j                  d      xs i j                  |       xs g }t        |j                  d      xs g |      S )z>Return shared defaults + the current user's personal criteria.r'  r  r  re  )re  rn!  )r   r'  r   r   )r  r   user_criterias      r1   vision_watch_criteria_getr5(  8 sX     FOG$E!DXXg&,"11%8>BM$((8,2]KKr3   c                  V   t         d   } | d   }t        | j                  d            }t        j                  d      xs i }t               }d|v rQ|st        d      d	fS |d   }t        |t              st        d
      dfS d |D        D cg c]  }||	 }}||d<   d|v rd|d   }t        |t              st        d      dfS d |D        D cg c]  }||	 }}t        |j                  d      xs i       }|||<   ||d<   t        |       t        d      S c c}w c c}w )zSave watch criteria. Admins may update shared defaults; all users update personal.

    Body: {shared?: [...], personal?: [...]}
    Each criterion: {name, prompt}
    r'  r  r   Tr  re  z%Only admins may edit shared criteria.r  r  zshared must be a list.r`  c              3   2   K   | ]  }t        |        y wrT   r'  r%  s     r1   r/  z-vision_watch_criteria_post.<locals>.<genexpr>W      @q/2@r  rn!  zpersonal must be a list.c              3   2   K   | ]  }t        |        y wrT   r8(  r%  s     r1   r/  z-vision_watch_criteria_post.<locals>.<genexpr>^ r9(  r  r  rY  )r   r   r   r   r  r'  r   r   r  r   r'  )	r'  r  r  r  r   r   r  r  r  s	            r1   vision_watch_criteria_postr;(  B s=    6?DMEDHH]+,H4(.BD!D4!HI3NN8n#t$!9:C??@C@RAM1RR XT:#t$!;<cAA@C@RAM1RRTXXg&,"-eWd S Ss   D!D!D&D&z/vision/commands/<cmd_id>/ackc                 n   t         j                  j                  dd      }|j                  d      r|dd j	                         nd}|st        d      dfS t        j                  d	
      xs i }|j                  d      xs dj	                         }|dvrt        d      dfS |j                  d      xs d}t        j                         }t               }	 |j                  d|f      j                         }|st        d      df|j                          S |j                  d| |d   f      j                         }	|	st        d      df|j                          S |j                  d|||| f       |j                          |j                          t        d	      S # |j                          w xY w)r  r  r7   r  r   Nr  r  r  Tr  rd   r  r  r`  r  z:SELECT machine_id FROM vision_agents WHERE agent_token = ?r'  z>SELECT id FROM vision_commands WHERE id = ? AND machine_id = ?r!  r  r  zYUPDATE vision_commands SET acked_at = ?, acked_status = ?, error_message = ? WHERE id = ?r  )r   rx   r   rw   r   r   r  rU   r'  rL  ru  r[  rZ  r  s
             r1   vision_command_ackr=(  g s    ??3D $	 :DHNNE:;S@@4(.BDhhx &B--/F_$>?DDHH_-5M
))+C<D HH
 (* 	 !78#=  	

 llLU<()
 (* 	 !56; 	

 	&-0	

 	

t 	

r   c                     t         }t        }||z
  }||z  }dg|z  }| D ]-  }||k  s||kD  rt        ||z
  |z        }||k\  r|dz
  }d||<   / |S )zBucket 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.r   r^   )r'  _VISION_SPARK_WINDOW_Sr   )	checkin_timesr  r  r  rq   bucket_ssparkr   r  s	            r1   _vision_compute_sparklinerC(   s}     	A#F&LEzHC!GE u9C1u9()!8a%Cc
 Lr3   z/vision/collectorsc                     t               } t        j                  j                  dd      dk7  }| j                  d      rt	        t               dt                     S t        j                         }|t        z
  }t               }	 |j                  d      j                         }|j                  d|f      j                         }|j                          i }i }|D ]P  }	|j                  |	d	   g       j                  |	d
          |j                  |	d	   g       j                  |	d          R t               }
t!               }g }|D ]  }|d	   }|d   }|d   }|j                  |g       D cg c]  }||	 }}t#        d t%        ||dd       D              }|rt'        |d      nd}t)        ||      }|j                  i d	|d|d   d|
j                  |      xs dd|d   d|d   d|rt+        |      nddt-        |      xr ||z
  t.        k  d|d   d|dn
t-        |      d|d   d|d   d|d   d|d   d|dt1        |j                  |g       |      d|r|d   ndd |r|d    nd|j                  d!      |j                  d"      |r|j                  d#      nd|d$   |d%   t3        |d$   |       d&        t	        |d| t               '      S # |j                          w xY wc c}w )(zReturn 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.thumbsrE   rF   r&  T)
collectorsr&  latest_agent_versional  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, vc.local_ip, vc.public_ip
               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, local_ip, public_ip
                   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 DESCzSELECT machine_id, received_at, agent_uptime_s FROM vision_checkins WHERE received_at >= ? ORDER BY machine_id, received_at ASCr!  rE  rt&  r"  rn&  Nc              3   2   K   | ]  \  }}||k  sd   ywr  rX   )r,  r  r  s      r1   r/  z$vision_collectors.<locals>.<genexpr> s     @TQ!a%q@rz&  r^   Frx'  r!  r  r  r  r  r  rd   rp&  rq&  rr&  r'  r'  r&  r&  r&  r&  r!	  r3  r4  )r&  r&  rz'  r3  r4  r'  )rF(  r&  thumbs_deferredrG(  )r&  r   rh  r   r   r'  r'  rU   r?(  r'  rL  r  r[  r}  ri   r&  r  r  r  rt'  r&  r   r   VISION_STALE_SECONDSrC(  r&  )r  want_thumbsr  spark_startrO  rn  histtimes_by_machineuptimes_by_machiner}
  r.  r7  r   r  r2  r  rx  r  upsr'  r  eff_pols                         r1   vision_collectorsrR(   s    %&H
 ,,""8S1S8K||K "9";t,H,JL 	L
))+C..K<D||0
" (*# 	& ||KN
 (*	 	 	

 W##AlOR8??-@PQ%%aor:AA!DTBUVW %&E "F
C #on%	!",00b9KQQ]qKK@Sc!"g%6@@:E%c64 +37

 
#
*
 EIIcN2d
 a 01	

 Q/
 9Y$
 d9oS3??S*S
 ak
 s{tS	
 a 01
 An-
 q|
 a 01
 
 23C3G3GR3PRUV
  ,D!
" fz*$#
$ $KK7";;}5176::f-T*;.q}hG/
 	#H cU(3O(D(FH H_ 	

  Ls   A K' -K<5K<'K9z/vision/collector-thumbsc                     t               } | j                  d      rWi }t               D ];  }|j                  d      |j                  d      |j                  d      d||d   <   = t        |d      S t	               \  }}t        |d	|
      S )a  DVI-860 Phase 1 (Option B): return just the latest-frame thumbnail URLs,
    keyed by machine_id, for the active agents. The frontend renders the grid
    from the fast /vision/collectors?thumbs=0 metadata first, then calls this to
    stream thumbnails in. This still does one remote-frame lookup per machine
    (parallelizing that is Phase 2 / Option C), but it is off the critical path
    so the grid is interactive immediately.r&  r&  r&  rz'  ry'  r!  T)rE(  r&  F)rE(  r&  r"  )r&  r   r'  r   r'  )r  r   r  r   r	  s        r1   vision_collector_thumbsrT(   s     %&H||K (* 	AUU;/EE*- uu]3$C, 	 cT22 -.LD&$%v>>r3   z/vision/agent-namec                     t        j                  d      xs i } | j                  d      xs dj                         }| j                  d      xs dj                         }|st	        d      dfS t        |      d	kD  rt	        d
      dfS t        5  t               }|r|||<   n|j                  |d       t        |       ddd       t	        d||xs d      S # 1 sw Y   xY w)zSet 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).Tr  r!  r7   r9   r#(  r  r`  r   z$Name must be 80 characters or fewer.N)rU  r!  r9   )
r   r  r   r   r   rC  _vision_names_lockr&  r  r&  )r  r2  r9   r.  s       r1   vision_agent_name_postrW(   s    
 4(.BD88L!'R
.
.
0CHHV"))+D67<<
4y2~CDcII	 ((*E#JIIc4  '( ds>>( (s   0CC&z/togen-admin/vision-settingsc                  *    t        t                     S r  )r   r&  rX   r3   r1   vision_settings_getrY(  4 s     1344r3   c                  d   t        j                  d      xs i } t               }d| v rt        | d         |d<   dD ]T  }|| v s| j	                  |      }t        |t              r |j                         r|j                         nt        |   ||<   V dD ]5  }|| v s	 t        | j	                  |            }d|cxk  rdk  rn n|||<   7 	 t        j                  |d   d	
       t        |       t        d|      S # t        t        f$ r Y |w xY w# t        $ r t        d      dfcY S w xY w)NTr  r&  r&  r&  r^   i  r&  Fr&  z7lan_subnet must be a valid CIDR, e.g. 192.168.250.0/24.r  r`  r3  )r   r  r&  r   r   r   r*   r   r&  r   r   r   r&  r&  r   r&  )r  r  r  r  r  s        r1   vision_settings_postr[(  : s@    4(.BD$&Hd $T+%6 7I g9A'1!S'9aggi!'')McdeMfHQKg 2 9488A;'%%"&HQK]Xl3EB (#dX.. z*   ]VWY\\\]s$   -D D  DDD/.D/z/togen-admin/vision-policyc                  4    t        t               t              S )zReturn the capture policy (default + per-machine overrides) plus the
    field defaults so the UI can render an empty/reset form.)r7  r  )r   r  r  rX   r3   r1   vision_policy_getr](  X s    
 -/:QRRr3   c                  @   t        j                  d      xs i } t               }t        | j	                  d      t
              r0t        |d         }|j                  t        | d                ||d<   t        | j	                  d      t
              ru| d   j                         D ]_  \  }}t        |      j                         }|s"|di fv r|d   j                  |d       >t        |t
              sOt        |      |d   |<   a t        |       t        d|      S )zPersist the capture policy. Body: {default?: {...}, machines?: {id: {...}}}.
    A machine override of null/{} removes that override.Tr  r.  r&  Nr<  )r   r  r  r   r   r   r  r&  r   r*   r   r  r&  r   )r  r7  r  r2  r  s        r1   vision_policy_postr_(  ` s   
 4(.BD "F$((9%t,fY'(*4	?;<"y$((:&-J'--/ 	CGCc(.."CdBZz"&&sD1B%*>r*Bz"3'	C d6**r3   z&/vision/frames/<machine_id>/<frame_id>c                 N   | xs dj                         } |xs dj                         }t        j                  j                  d      xs dj                         j	                         }|dvrd}t
        j                  |       rt
        j                  |      st        d      dfS t               j                  d      s|j                  d	      rt        | ||      }|d
dddfS t        |       st        d      dfS t        j                  | ||      }|t        d      dfS |S )zServe 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.r7   rd  r@  )r4  r@  Invalid identifier.r  r`  r&  ro'  rK   rp  rn  ro  r$(  r  )r   r   rh  r   rE  _VISION_FRAME_ID_RErG  r   r&  rw   r'  r&  rl'  r&  )r!  r&  rd  rv  r  s        r1   vision_framerc(  x s    "))+JB%%'HLLV$.557==?D$$$$Z08K8Q8QRZ8[23S88"";/83F3Fw3O$Z4@C/+57 7 	7 "*-/0#55%%j(DAD|/0#55Kr3   z(/vision/segments/<machine_id>/<frame_id>c                 d   | xs dj                         } |xs dj                         }t        j                  |       rt        j                  |      st        d      dfS t	        |       st        d      dfS t
        j                  | |t        j                        }|t        d      dfS |S )zServe 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.)r7   ra(  r  r`  zSegment not found.r  )r  )	r   rb(  rG  r   r&  rl'  r&  r   r  )r!  r&  r  s      r1   vision_segmentre(   s     "))+JB%%'H$$Z08K8Q8QRZ8[23S88!*-12C77((X7==(QD|12C77Kr3   z/vision/recordings/<machine_id>c                    | xs dj                         } | rt        j                  |       st        d      dfS t	        t
        j                  j                  d            }t	        t
        j                  j                  d            }t        t               j                  d            }|rt        | ||      }n&t        |       rt        j                  | ||      }ng }t        | ||	      S )
u   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.r7   zInvalid machine_id.r  r`  r2  rX  r&  )r&  rX  )r!  rs'  r&  )r   rb(  rG  r   _vision_parse_sincer   rh  r   r   r&  rp'  r&  rl'  r&  )r!  r&  rX  rr'  rs'  s        r1   vision_recordingsrh(   s     "))+J066zB23S88
gll..v6
7C	W\\--d3	4B%'++K89D$ZSR@	
	+++JCB+G j4HHr3   c                     | xs dj                         } | sy	 t        |       S # t        $ r Y nw xY w	 | j                  dd      }t	        j
                  |      j                         S # t        $ r Y yw xY w)zTParse the ?since= filter: epoch seconds (int/float) or ISO-8601. None if absent/bad.r7   Nr  r  )r   r  r   r  r  r  rv
  )r   r  s     r1   rg(  rg(   s|    9"


CSz ++c8,++G4>>@@ s   
$ 	004A) )	A54A5z/vision/checkins/<machine_id>c                 4   | xs dj                         } | st        d      dfS t        t        j                  j                  d            }t               }	 | g}d}||dz  }|j                  |       |d	z  }|j                  ||      j                         }|j                          g }|D ]G  }|d
   }|j                  |d   |d   |dn
t        |      |d   |d   |d   |d   |d   |d   d	       I t        | |      S # |j                          w xY w)zPer-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.
    r7   r'  r  r`  r  zSELECT 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 = ?N AND received_at >= ?z ORDER BY received_at ASCrn&  rE  rd   ro&  rp&  rq&  rr&  r  r  )	rE  rd   rn&  ro&  rp&  rq&  rr&  r  r  )r!  checkins)r   r   rg(  r   rh  r   r'  ri   rL  r  r[  r   )	r!  r  rO  r  r  rn  r   r  rx  s	            r1   vision_checkinsrm(   sD    "))+J34c99 0 0 9:E<D< **CMM% **||C(113


C !"

]+k'*{tS	 !23 01n-|/ 01

 
	 j377 	

s   AD Dz(/vision/checkins/<machine_id>/export.csvc                 f   | xs dj                         } | st        d      dfS t        t        j                  j                  d            }t        j                  j                  d      xs dj                         j                         }t        j                  j                  d      xs dj                         j                         }t               }	 | g}d}||d	z  }|j                  |       |d
z  }|j                  ||      j                         }|j                          g }|D ]  }	t        |	d   xs d      }
|r|
j                         |k7  r,|rXdj                  t        |	d   xs d      t        |	d   xs d      |
t        |	d   xs d      g      j                         }||vrd}	 t        j                  t!        |	d               j#                  d      }|	d   }|j                  ||
|	d   |	d   |	d   |	d   |	d   |dn|rdndg       t)        |      t*        k\  s n g d}t        j,                         j#                  d      }dt/        |        d| d}t1        |||      S # |j                          w xY w# t$        t&        f$ r t        |	d   xs d      }Y w xY w)a  Export a machine's raw check-in telemetry as CSV (DVI-1087 Phase 3).

    Mirrors the Activity tab's filter bar (Auditor pattern): ``since`` window,
    ``status`` exact match, ``q`` substring across foreground app / logged-in
    user / status / agent version. Rows are the raw point samples the timeline
    spans are derived from, newest first, capped at _HISTORY_EXPORT_CAP.
    r7   r'  r  r`  r  rd   r  zSELECT received_at, status, capture_enabled, foreground_app, idle_seconds, cpu_pct, agent_version, logged_in_user FROM vision_checkins WHERE machine_id = ?rk(  z ORDER BY received_at DESCr\   rp&  r  r  rE  z%Y-%m-%d %H:%M:%S UTCrn&  rq&  rr&  rf  r  )rK
  r  zForeground AppzLogged-in UserzIdle SecondszCPU %zAgent VersionzCapture EnabledrO  vision_activity_r  r  )r   r   rg(  r   rh  r   rE  r'  ri   rL  r  r[  r*   r  r  r  r  r  r   r   rC  r|  r  rt  rq  )r!  r  f_statusr  rO  r  r  rn  r}  r  rd   r  r_   rx  ry  r~  r9   s                    r1   vision_checkins_export_csvrq(   s    "))+J34c99 0 0 9:E  *0b779??AH<<C &B
-
-
/
5
5
7C<D< **CMM% ++||C(113

I Qx[&B'(2((A&'-2.A6F4G4M20NAo.4"5  uw  #~	-00a&'))12I)J  !"*+Q/?-@nq|Q-?+BSEd
 	
 y>0014KF!!#,,X6Ej45QugTBD433C 	

$ :& 	-Q}%+,B	-s   AI7 50J7J	!J0/J0z/vision/agentsc                  \   t        j                          } t               }	 |j                  d      j                         }|j	                          t               }g }|D ]  }|d   }|j                  |d   |d   xs |d   |j                  |d         xs d|d   |rt        |      ndt        |      xr | |z
  t        k  |d   t        |d	         t        |d
         t        |d         |d   xs |d   |d   |d   |d   dd        t        |      S # |j	                          w xY w)zList 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).a  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 DESCr"  r!  r!  checkin_hostnameNr  rd   r  r|&  r}&  r  r  r  r  )r!  r!  r  r  r  r  rd   r  has_ssh_keyr}&  r  r  )rU   r'  rL  r  r[  r&  ri   r   r   r   rJ(  r   )r  rO  rn  r.  r  r  r  s          r1   vision_agents_listru(  9 sK    ))+C<D||
/
 (* 	 	

$&EF n%	L/*>+=)>!IIao6>$]++4Y$9oS3??S*SkAiL)"2 34#A&7$8901BQz]o"#$4"5!"?!3	
 	( &!!/ 	

s   D D+z"/vision/agents/<machine_id>/revokec                 r   | xs dj                         } t               }	 |j                  d| f      }|j                  dk(  r.|j	                          t        d      df|j                          S dt        j                         j                  dd	 z   }|j                  d
|| t        j                         f       |j	                          |j                          t        ddt        j                  d      xs i j                  d      d|  d|        t        d| dd      S # |j                          w xY w)zRevoke a machine's agent_token (status=revoked); also enqueues an uninstall
    command so the agent self-uninstalls on its next check-in.r7   MUPDATE vision_agents SET status = 'revoked', revoked = 1 WHERE machine_id = ?r   r  r  r  r'  Nr  zqINSERT INTO vision_commands (id, machine_id, action, params_json, created_at) VALUES (?, ?, 'uninstall', NULL, ?)r'  r  r'  r9   r'  z revoked (uninstall enqueued)rh  T)rU  r!  rd   r  )r   r'  rL  rowcountrZ  r   r[  rW  rX  rY  rU   r	  r   r   )r!  rO  r  r  s       r1   vision_agent_revokery(  j s   
 "))+J<Dll[M
 <<1KKM!893> 	

 $**,**2A..3Z-	

 	

{{6"(b--f5$AB	 dz)TRR 	

s   ?D$ 0AD$ $D6z#/vision/agents/<machine_id>/approvec                 *   | xs dj                         } t               }	 |j                  d| f      j                         }|st	        d      df|j                          S |d   dk7  r%t	        d|d    d	      d
f|j                          S |d   }|j                  d| f       |j                          |j                          d}d}|rSt        |      }|du rAd}t               }	 |j                  d| f       |j                          |j                          n|}nd}t        ddt        j                  d      xs i j                  d      d|  d|        t	        d| d||      S # |j                          w xY w# |j                          w xY w)uR   Approve a self-enrolled agent (pending → active) and push its SSH key (DVI-726).r7   zESELECT status, ssh_public_key FROM vision_agents WHERE machine_id = ?r  r  r  rd   r  r  r  r9  r|&  zLUPDATE vision_agents SET status = 'active', revoked = 0 WHERE machine_id = ?FNTr'  uj   No SSH public key on file — run upgrade.ps1 on the machine and then re-approve, or add the key manually.r'  r  r'  r9   r'  u    approved (pending → active)rh  rv  )rU  r!  rd   r}&  ssh_key_error)r   r'  rL  ru  r   r[  rZ  r&  r	  r   r   )r!  rO  r  r|&  r}&  r{(  r>  r'  s           r1   vision_agent_approver|(   s    "))+J<DllSM
 (* 	 !893> 	

 x=I%<Ho!NOQTT 	

 -.ZM	
 	

 OM/?T>"OLEWM "M' 	
 {{6"(b--f5$BC	 '# A 	

" s#   0E+ !E+ (E+ (#F  +E= Fz /vision/agents/<machine_id>/denyc                 <   | xs dj                         } t               }	 |j                  d| f      j                         }|st	        d      df|j                          S |d   dk7  r%t	        d|d    d	      d
f|j                          S |j                  d| f       |j                          |j                          t        ddt        j                  d      xs i j                  d      d|  d|        t	        d| d      S # |j                          w xY w)uH   Deny a self-enrolled agent (pending → revoked); check-ins are refused.r7   r&  r  r  r  rd   r  r  r  r9  rw(  r'  r?  r'  r9   r'  u    denied (pending → revoked)rh  Tr  r  )
r   r'  rL  ru  r   r[  rZ  r	  r   r   r  s      r1   vision_agent_denyr~(   s    "))+J<DllCj]

(* 	 !893> 	

 x=I%<Ho!NOQTT 	

 	[M	
 	

{{6"(b--f5$AB	 dz)DD 	

s   0D	 !D	 #D	 	Dz/vision/agents/<machine_id>c                     | xs dj                         } t               }	 |j                  d| f      j                         }|st	        d      df|j                          S |d   st	        d      df|j                          S |j                  d	| f       |j                  d
| f       |j                  d| f       |j                          |j                          t	        d| d      S # |j                          w xY w)zDelete 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.r7   z6SELECT revoked FROM vision_agents WHERE machine_id = ?r  r  r  r  r  r9  z0DELETE FROM vision_checkins WHERE machine_id = ?z0DELETE FROM vision_commands WHERE machine_id = ?z.DELETE FROM vision_agents WHERE machine_id = ?Tr  )r   r'  rL  ru  r   r[  rZ  r  s      r1   vision_agent_deleter(   s     "))+J<DllDzm

(* 	 !893> 	

 9~!STVYY 	

 	G*WG*WE
}U

dz4@@ 	

s   0C+ !C+ A	C+ +C=z/vision/enroll-secretc                      t               } 	 t        | d      }| j                          t        |      S # | j                          w xY wr'  )r'  r&  r[  r   r)  s     r1   vision_enroll_secret_getr(   s=     <D/TB

f-- 	

r+  z/vision/enroll-secret/rotatec                      t        j                         j                  } t               }	 t	        |t
        |        |j                          |j                          t        |       S # |j                          w xY wr-  )	rW  rX  rY  r'  r&  r&  rZ  r[  r   r.  s     r1   vision_enroll_secret_rotater(   sX    
 !!J<Dt4jA

j11 	

r1  c                      t         j                  j                  d      xs t         j                  } t         j                  j                  d      xs t         j                  }|  d| S )z>Public base URL the agent should call, honoring proxy headers.r  zX-Forwarded-Hostr>  )r   rx   r   rH  r$  )protor$  s     r1   ru  ru   sO    OO 34FE??12BgllDWCvr3   z/enroll/bootstrap.ps1c                     t               } t        j                  j                  d      xs dj	                         }|r|d}}n_t        j                  d      rt
        d   j                  d      sdddd	d
fS t               }	 t        |d      }|j                          d}|  d}|j                  dd      }| j                  dd      }d| dt        j                  d       d| d| d| d}|dddd	dfS # |j                          w xY w)u  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.
    rD  r7   r  r'  r   zWrite-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
exit 1
r  rm  rn  ro  Tr  r  /enroll/agent.ziprd
  rp  z # Togen Vision agent bootstrap (rq  rr  z
# Run as Administrator.  Downloads the agent bundle and delegates to install.ps1.
$ErrorActionPreference = 'Stop'
$Server     = 'rs  a  '
$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) -------------------
# DVI-850: retry up to 3 times; validate size > 10 MB (real bundle ~47 MB).
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { }
$ZipSize = [long]0; $ZipOk = $false
for ($dlTry = 1; $dlTry -le 3; $dlTry++) {
    if ($dlTry -gt 1) { Write-Host "Retrying download (attempt $dlTry/3, last size: $ZipSize B)..."; Start-Sleep -Seconds 15 }
    Remove-Item $Zip -Force -ErrorAction SilentlyContinue
    Write-Host "Downloading Togen Vision agent (attempt $dlTry)..."
    try {
        Invoke-WebRequest -Uri 'aU  ' -OutFile $Zip -UseBasicParsing -TimeoutSec 600
        $ZipSize = [long](Get-Item $Zip -ErrorAction SilentlyContinue).Length
        if ($ZipSize -ge 10485760) { $ZipOk = $true; break }
        Write-Host "ERROR: Download too small ($ZipSize B) on attempt $dlTry." -ForegroundColor Yellow
    } catch {
        Write-Host "ERROR: Download attempt $dlTry failed: $_" -ForegroundColor Yellow
        $ZipSize = [long](Get-Item $Zip -ErrorAction SilentlyContinue).Length
    }
}
if (-not $ZipOk) {
    Write-Host "ERROR: Agent bundle download failed after 3 attempts (last size: $ZipSize B)." -ForegroundColor Red
    Write-Host 'Check network connectivity and retry. Contact your IT admin if the problem persists.'
    Remove-Item $Zip -Force -ErrorAction SilentlyContinue; exit 1
}

# -- Extract ----------------------------------------------------------------
if (Test-Path $ExtractDir) { Remove-Item -Recurse -Force $ExtractDir }
try {
    Expand-Archive -Path $Zip -DestinationPath $ExtractDir -Force
} catch {
    Write-Host "ERROR: Extraction failed (ZIP may be corrupt): $_" -ForegroundColor Red
    Write-Host "Downloaded size was $ZipSize B. Try running the script again." ; exit 1
}
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.'
rK   z"attachment; filename=bootstrap.ps1rt  )ru  r   rh  r   r   r   r'  r&  r[  r  rU   r  rv  s	            r1   vision_enroll_bootstrapr(  $ s@   & #$D\\g&,"335E 'D
F#(;(;M(J1 !<zZ  |	7TJJJJL'(G  d+Gll3%G1$7Nt}}]pOqNr s y y ! ")	 %*#6Fn 33C#  { JJLr|  z/enroll/upload-ssh-key.ps1c                      t               } | j                  dd      }dt        j                  d       d| d| d}|dd	d
ddfS )u$  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
    rd
  rp  u=   # Togen Vision - Upload machine SSH public key — generated rr  zS
# Run as Administrator on an already-enrolled Vision agent machine.
# Usage: iwr 'z^/enroll/upload-ssh-key.ps1' -UseBasicParsing | iex
$ErrorActionPreference = 'Stop'
$Server = 'a:  '

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
rK   rm  z'attachment; filename=upload-ssh-key.ps1rn  rt  )ru  r  rU   r  )r  ry  rz  s      r1   vision_enroll_upload_ssh_keyr(   sq     #$Dll3%GNt}}]pOqNr si 9 N	RFf 33H#  r3   z/enroll/upgrade.ps1c            	         t         j                  j                  d      xs dj                         } t	        t        j                  d      xr t
        d   j                  d            }|s<| r:t               }	 t        |d      }|j                          t	        |xr | |k(        }|st         j                  j                  dd      }|j                  d      r|d	d
 j                         nd}|rGt               }	 |j                  d|f      j                         }t	        |      }|j                          |sdddddfS d}t         j                  j                  dd      }	|	j                  d      r|	d	d
 j                         }t               }
|
j                  dd      }	 t         j                  j                  dd      }|j                  d      r|d	d
 j                         nd}t         j                  j                  d      xr dxs	 |r|d
d nd}t        dd      5 }|j!                  t#        j$                  d      d|dt         j&                  d       d
d
d
       dt#        j$                  d       d| d| d}|ddd dd!fS # |j                          w xY w# |j                          w xY w# 1 sw Y   [xY w# t(        $ r Y iw xY w)"u`  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.
    rD  r7   r'  r   Fr  r  r  r   NzASELECT 1 FROM vision_agents WHERE agent_token = ? AND revoked = 0a&  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
exit 1
rK   rm  rn  ro  rd
  rp  zopen-enroll-tokenr  zadmin-sessionz%/var/www/html/togen/upgrade_fetch.logr  rr  z fetch who=z ip=r0  z0# Togen Vision agent upgrade script - generated u  
# Upgrades an already-enrolled Vision agent to the latest version in-place.
# Run as Administrator.  No enrollment token is required.
# DVI-850: #Requires -RunAsAdministrator removed — evaluated at parse time, silently
# aborts entire script (before any diagnostics) when launched from SYSTEM agent subprocess.
# DVI-850: accepts optional -PreDownloadedZip path — when provided by the Python agent
# (which handles all network I/O), the ZIP download step is skipped entirely so this
# script works without any network access from PowerShell (needed on machines where
# SYSTEM+PowerShell cannot reach external URLs through the corporate proxy).
param([string]$PreDownloadedZip = "")

# 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       = 'z'
$MachineToken = 'u,  '
$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 }

# DVI-850: Runtime elevation check (warn only — does NOT abort).
try {
    $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
    if (-not $isAdmin) { Write-Warn "Not running as Administrator — some steps may fail" }
    else { Write-OK "Running as Administrator" }
} catch { Write-Warn "Could not check elevation: $_" }

# 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 {
    # DVI-850: SSH connectivity test removed — ssh.exe subprocess can hang indefinitely
    # when corporate firewalls silently drop port 4423, blocking the POST below.
    # Upgrade diagnostics focus on server reachability and machine state only.
    $Diag = New-Object System.Collections.ArrayList
    [void]$Diag.Add('ts=' + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))
    [void]$Diag.Add('host=' + $env:COMPUTERNAME + ' user=' + (try { (Get-CimInstance Win32_ComputerSystem).UserName } catch { '?' }))
    [void]$Diag.Add('whoami=' + (whoami 2>&1))
    [void]$Diag.Add('ps_version=' + $PSVersionTable.PSVersion)
    # 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) {
        $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 }
    [void]$Diag.Add('token_src=' + $(if ($dToken) { 'present' } else { '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 -ErrorAction SilentlyContinue
        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): $_" }

# ── STAGED-SWAP UPGRADE (DVI-921) ────────────────────────────────────────────
# Stage new exe as vision_agent.exe.new (no AV lock conflict — new file name).
# Register TogenVisionSwap (separate SYSTEM task, own Job Object) to fire 30 s
# after upgrade.ps1 exits.  TogenVisionSwap stops the old agent, waits 5 s for
# AV/Defender to release the file lock, renames .new → .exe, starts agent.
# upgrade.ps1 never touches TogenVisionAgent directly — no job-kill risk.
# 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. Agent bundle ZIP — use pre-downloaded copy supplied by the Python agent when
#    available (DVI-850: Python handles all networking; SYSTEM+PowerShell may be
#    unable to reach external URLs through the corporate proxy). Fall back to a
#    direct download when the script is run manually without -PreDownloadedZip.
Write-Step 'Locating agent bundle'
$Zip = Join-Path $env:TEMP 'togen-vision-agent-upgrade.zip'
$ZipSize = [long]0
$ZipOk   = $false

if ($PreDownloadedZip -and (Test-Path $PreDownloadedZip)) {
    $Zip     = $PreDownloadedZip
    $ZipSize = [long](Get-Item $Zip).Length
    if ($ZipSize -ge 10485760) { $ZipOk = $true; Write-OK "Using pre-downloaded ZIP ($([int]($ZipSize/1048576)) MB): $Zip" }
    else { Write-Warn "Pre-downloaded ZIP too small ($ZipSize B); will attempt direct download" }
}

if (-not $ZipOk) {
    Write-Step 'Downloading latest agent bundle (direct)'
    $AgentZipUrl = "$Server/enroll/agent.zip"
    Remove-Item $Zip -Force -ErrorAction SilentlyContinue
    # Try BITS first — designed for corporate networks, handles proxy auth better.
    try {
        Import-Module BitsTransfer -ErrorAction Stop
        Start-BitsTransfer -Source $AgentZipUrl -Destination $Zip -Priority Normal -ErrorAction Stop
        $ZipSize = [long](Get-Item $Zip -ErrorAction SilentlyContinue).Length
        if ($ZipSize -ge 10485760) { $ZipOk = $true; Write-OK "Downloaded via BITS: $([int]($ZipSize/1048576)) MB" }
        else { Write-Warn "BITS download too small ($ZipSize B); trying Invoke-WebRequest" }
    } catch { Write-Warn "BITS transfer failed: $_" }
    # Fall back to Invoke-WebRequest with TLS 1.2.
    if (-not $ZipOk) {
        try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { }
        for ($dlTry = 1; $dlTry -le 3; $dlTry++) {
            if ($dlTry -gt 1) { Write-Warn "Download retry $dlTry/3 (last size: $ZipSize B)..."; Start-Sleep -Seconds 15 }
            Remove-Item $Zip -Force -ErrorAction SilentlyContinue
            try {
                Invoke-WebRequest -Uri $AgentZipUrl -OutFile $Zip -UseBasicParsing -TimeoutSec 600
                $ZipSize = [long](Get-Item $Zip -ErrorAction SilentlyContinue).Length
                if ($ZipSize -ge 10485760) { $ZipOk = $true; break }
                Write-Warn "Downloaded file too small ($ZipSize B) on attempt $dlTry"
            } catch {
                Write-Warn "Download attempt $dlTry failed: $_"
                $ZipSize = [long](Get-Item $Zip -ErrorAction SilentlyContinue).Length
            }
        }
    }
}
if (-not $ZipOk) {
    Write-Host "ERROR: Agent bundle not available (pre-download missing and direct download failed, last size: $ZipSize B)." -ForegroundColor Red
    exit 1
}
Write-OK "Bundle ready: $([int]($ZipSize/1048576)) MB"

# 2b. Extract ZIP
#     Add Defender exclusions BEFORE Expand-Archive so AV cannot quarantine
#     vision_agent.exe as it is written to disk (confirmed root cause: AV cloud
#     lookup quarantines the exe in <5 s on WDAC-enforced machines).
Write-Step 'Extracting agent bundle'
$ExtractDir = Join-Path $env:TEMP ('togen-va-' + (Get-Random))
# Exclusions must be added before extraction — if Tamper Protection blocks them
# (SilentlyContinue) the exe will still be quarantined and we abort cleanly below.
Add-MpPreference -ExclusionPath $ExtractDir -ErrorAction SilentlyContinue
Add-MpPreference -ExclusionPath $InstallDir -ErrorAction SilentlyContinue
try {
    Expand-Archive -Path $Zip -DestinationPath $ExtractDir -Force
    Remove-Item $Zip -Force -ErrorAction SilentlyContinue
    Write-OK "Extracted to $ExtractDir"
} catch {
    Remove-MpPreference -ExclusionPath $ExtractDir -ErrorAction SilentlyContinue
    Remove-MpPreference -ExclusionPath $InstallDir -ErrorAction SilentlyContinue
    Write-Host "ERROR: Extraction failed: $_" -ForegroundColor Red; exit 1
}
# 2 s grace period (if exclusion was blocked by Tamper Protection, AV may still
# quarantine immediately; 2 s confirms whether the exclusion took effect).
Start-Sleep -Seconds 2

# 2g. Stage new exe as vision_agent.exe.new; register TogenVisionSwap — a
#     SEPARATE SYSTEM task with its own Job Object — to do the actual stop/
#     rename/restart.  upgrade.ps1 NEVER calls Stop-ScheduledTask $TaskName
#     directly: Windows Task Scheduler SYSTEM tasks do not set
#     JOB_OBJECT_LIMIT_BREAKAWAY_OK, so CREATE_BREAKAWAY_FROM_JOB silently
#     fails and upgrade.ps1 runs in the SAME Job Object as the agent.
#     Stop-ScheduledTask terminates the entire Job Object — killing upgrade.ps1
#     before the swap finishes.  TogenVisionSwap runs in its own Job Object
#     and can safely stop TogenVisionAgent without killing itself.
Write-Step 'Staging exe + registering TogenVisionSwap'

# Write diagnostics to Logs/agent.log (same path agent uploads via upload_logs).
$UpLog = Join-Path $LogDir 'agent.log'
function Write-UpLog { param([string]$Msg) try { Add-Content $UpLog -Value "$(Get-Date -F 'yyyy-MM-dd HH:mm:ss,fff') INFO [upgrade] $Msg" -EA SilentlyContinue } catch {} }

$SrcExe = Join-Path $ExtractDir 'vision_agent.exe'
if (-not (Test-Path $SrcExe)) {
    Write-UpLog "abort_no_src ExtractDir=$ExtractDir (AV quarantined or Tamper Protection blocked exclusion)"
    Remove-MpPreference -ExclusionPath $ExtractDir -ErrorAction SilentlyContinue
    Remove-MpPreference -ExclusionPath $InstallDir -ErrorAction SilentlyContinue
    Remove-Item -Recurse -Force $ExtractDir -ErrorAction SilentlyContinue
    exit 0
}
Write-UpLog "src_ok size=$((Get-Item $SrcExe).Length)"

# Stage new exe as vision_agent.exe.new — different filename avoids AV/OS lock
# on the active exe.  upgrade.ps1 exits normally; TogenVisionSwap does the rename.
$StagedExe = "$DestExe.new"
try {
    Copy-Item $SrcExe $StagedExe -Force -ErrorAction Stop
    Unblock-File $StagedExe -ErrorAction SilentlyContinue
    Write-UpLog "staged_ok size=$((Get-Item $StagedExe).Length)"
} catch {
    Write-UpLog "stage_failed: $_"
    Remove-MpPreference -ExclusionPath $ExtractDir -ErrorAction SilentlyContinue
    Remove-MpPreference -ExclusionPath $InstallDir -ErrorAction SilentlyContinue
    Remove-Item -Recurse -Force $ExtractDir -ErrorAction SilentlyContinue
    exit 1
}
Remove-MpPreference -ExclusionPath $ExtractDir -ErrorAction SilentlyContinue
Remove-MpPreference -ExclusionPath $InstallDir -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $ExtractDir -ErrorAction SilentlyContinue

# 2c. Stop any lingering capture-only vision_agent processes (belt-and-suspenders).
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. Ensure install and log directories exist (ExtractDir already removed above).
Write-Step 'Preparing install directory'
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
New-Item -ItemType Directory -Path $LogDir     -Force | Out-Null
Write-OK 'Install directory ready'

# 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 — done BEFORE TogenVisionSwap is started to
#     eliminate the race condition where TogenVisionSwap kills upgrade.ps1 between
#     Unregister-ScheduledTask and Register-ScheduledTask, leaving the task torn
#     (gone) so Start-ScheduledTask in TogenVisionSwap silently fails and the agent
#     never restarts.  ALL New-Scheduled* cmdlets inside the try block to guard
#     against ScheduledTasks PS module being unavailable in SYSTEM context.
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
try {
    $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
    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): $_" }

# Write TogenVisionSwap.ps1 via single-quoted here-string so $ signs are stored
# literally — they expand only when TogenVisionSwap.ps1 itself executes.
$SwapScript = Join-Path $env:TEMP 'togen-vision-swap.ps1'
@'
$TaskName   = 'TogenVisionAgent'
$CapTask    = 'TogenVisionAgentCapture'
$InstallDir = Join-Path $env:ProgramFiles 'Togen\Vision'
$DestExe    = Join-Path $InstallDir 'vision_agent.exe'
$StagedExe  = "$DestExe.new"
$LogDir     = Join-Path $env:ProgramData 'Togen\Vision\Logs'
$UpLog      = Join-Path $LogDir 'agent.log'
function Write-SwapLog {
    param([string]$Msg)
    try { Add-Content $UpLog -Value "$(Get-Date -F 'yyyy-MM-dd HH:mm:ss,fff') INFO [TogenVisionSwap] $Msg" -EA SilentlyContinue } catch {}
}
Write-SwapLog "start ok"
if (-not (Test-Path $StagedExe)) { Write-SwapLog "abort_no_staged (AV quarantined?)"; exit 0 }
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Stop-ScheduledTask -TaskName $CapTask  -ErrorAction SilentlyContinue
Get-Process -Name vision_agent -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
$dl = (Get-Date).AddSeconds(15)
while ((Get-Process -Name vision_agent -ErrorAction SilentlyContinue) -and (Get-Date) -lt $dl) { Start-Sleep -Milliseconds 300 }
Start-Sleep -Seconds 2
Write-SwapLog "agent_stopped ok"
if (-not (Test-Path $StagedExe)) { Write-SwapLog "abort_no_staged_after_stop (AV quarantined?)"; Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue; exit 0 }
Remove-Item "$DestExe.bak" -Force -ErrorAction SilentlyContinue
try {
    Move-Item $DestExe "$DestExe.bak" -Force -ErrorAction Stop
    Write-SwapLog "old_renamed ok"
} catch {
    Write-SwapLog "rename_old_failed: $_"
    Remove-Item $StagedExe -Force -ErrorAction SilentlyContinue
    Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
    exit 1
}
try {
    Move-Item $StagedExe $DestExe -Force -ErrorAction Stop
    Unblock-File $DestExe -ErrorAction SilentlyContinue
    Write-SwapLog "swap_complete size=$((Get-Item $DestExe).Length)"
} catch {
    Write-SwapLog "swap_failed: $_"
    Move-Item "$DestExe.bak" $DestExe -Force -ErrorAction SilentlyContinue
    Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
    exit 1
}
Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
Write-SwapLog "agent_restarted ok"
Unregister-ScheduledTask -TaskName 'TogenVisionSwap' -Confirm:$false -ErrorAction SilentlyContinue
'@ | Set-Content $SwapScript -Encoding UTF8

# Register TogenVisionSwap as a one-time SYSTEM task and start it immediately.
$PsSwap = 'C:\Windows\System32\WindowsPowerShell1.0\powershell.exe'
try {
    $SwapAction    = New-ScheduledTaskAction -Execute $PsSwap `
                       -Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$SwapScript`""
    $SwapTrigger   = New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(10)
    $SwapSettings  = New-ScheduledTaskSettingsSet `
                       -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
    $SwapPrincipal = New-ScheduledTaskPrincipal `
                       -UserID 'NT AUTHORITY\SYSTEM' -LogonType ServiceAccount -RunLevel Highest
    Unregister-ScheduledTask -TaskName 'TogenVisionSwap' -Confirm:$false -ErrorAction SilentlyContinue
    Register-ScheduledTask -TaskName 'TogenVisionSwap' `
        -Action $SwapAction -Trigger $SwapTrigger `
        -Settings $SwapSettings -Principal $SwapPrincipal -Force | Out-Null
    Start-ScheduledTask -TaskName 'TogenVisionSwap' -ErrorAction Stop
    Write-UpLog "swap_task_started ok"
    Write-OK 'Staged exe + TogenVisionSwap started; agent will swap in ~15 s.'
} catch {
    Write-UpLog "swap_task_failed: $_ — trying direct fallback"
    # Last-resort: direct swap in upgrade.ps1 — works only when CREATE_BREAKAWAY_FROM_JOB
    # succeeds (JOB_OBJECT_LIMIT_BREAKAWAY_OK set), which is rare for SYSTEM tasks.
    Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
    Stop-ScheduledTask -TaskName $CapTask  -ErrorAction SilentlyContinue
    Get-Process -Name vision_agent -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
    $dlt = (Get-Date).AddSeconds(10)
    while ((Get-Process -Name vision_agent -ErrorAction SilentlyContinue) -and (Get-Date) -lt $dlt) { Start-Sleep -Milliseconds 300 }
    Start-Sleep -Seconds 2
    if (Test-Path $StagedExe) {
        Remove-Item "$DestExe.bak" -Force -ErrorAction SilentlyContinue
        try {
            Move-Item $DestExe "$DestExe.bak" -Force -ErrorAction Stop
            Move-Item $StagedExe $DestExe -Force -ErrorAction Stop
            Unblock-File $DestExe -ErrorAction SilentlyContinue
            Write-UpLog "fallback_swap_complete size=$((Get-Item $DestExe).Length)"
        } catch {
            Write-UpLog "fallback_swap_failed: $_"
            Move-Item "$DestExe.bak" $DestExe -Force -ErrorAction SilentlyContinue
        }
    }
    Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
}

# 2h. Done.
Write-OK 'Upgrade complete.'
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: $([int]($ZipSize/1048576)) 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"
z attachment; filename=upgrade.ps1rt  )r   rh  r   r   r   r   r'  r&  r[  rx   rw   rL  ru  ru  r  r?  r  rU   r  r'  ry   )rD  r   rO  r  r   r   conn_agr  _embed_token
_auth_hdr2r  ry  	_auth_hdr_bearerr^	  _fhrz  s                    r1   vision_enroll_upgrader(   s   $ \\g&,"335E'++f%L'&/*=*=m*LMFe|	3DGFJJLf0&1??&&;)1)<)<Y)G!"##%R lG ooWI (*  c-
 8:V
 	
 L$$_b9JY'!!"~++-"$Dll3%GOO''<	+4+?+?	+J)AB-%%'PR  )A.AsV]wrPQ{cr93? 	P3II12D':M:MO P	P
 B$--PcBdAe f*   x	 -N
F^ 33A#  } JJL 8	P 	P  sC   9J =,J' &B K &<J<"K J$'J9<KK 	KKr(  c                      t         t        z  } | j                         st        ddt         dt          d      dfS t	        t        |       dt              S )	a  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.
    zAgent bundle not available yet.rh  z in VISION_AGENT_DIST_DIR (zZ); run build_agent.ps1 on a Windows host to produce the bundle, then deploy to the server.ri  r  Trn	  )rM  rO  r   r   r   r*   rj  s    r1   vision_enroll_agent_zipr(   sl     !#88D<<>345 6,- .GH

  	 SYd#8: :r3   c                  X   t         j                  j                  d      xs dj                         } | st	        d      dfS t               }	 |j                  d| f      j                         }|j                          g }|D ]d  }|d   rd}n
|d	   rd
}nd}d}|d   r	 t        j                  |d         }|j                  |d   |d   ||d   |d	   |d   |d   |d   |d	       f t	        | |      S # |j                          w xY w# t        j                  t        f$ r d}Y qw xY w)zNRecent commands for a machine with per-command state (queued/delivered/acked).r!  r7   r'  r  r`  zSELECT 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 20rQ  ackedr  r  rS  Nr  r  r  r  rR  r  )	r  r  r  r  r  rQ  rR  r  ro  )r!  commands)r   rh  r   r   r   r'  rL  r  r[  r   r   r   r   ri   )r!  rO  rn  cmdsr  ro  r  s          r1   vision_commands_listr(   sS    ,,""<06B==?J34c99<D||F M	

 (* 	 	

D Z=E~EE]Am$45 	D'Qx[FL/1^;L*q7H/%	
 	& j488+ 	

 (()4 s   !C6 D6DD)(D)c                      t         j                         sg S 	 t        j                  t         j	                               } t        | t              r| S g S # t        j                  t        f$ r g cY S w xY wrT   )	VISION_NOTIFICATION_LOG_FILEr   r   r   r   r   r  r   r   r  s    r1   _load_vision_notificationsr(   sc    '//1	zz6@@BC!$-t525  '* 	r@  c                     t        j                          } t               }	 |j                  d      j                         }|j                  d      j                         }|j	                          |D ci c]  }|d   |
 }}g }|D ]  }|d   }|d   xs |}	|d   }
|j                  d| dt        |d          d	|d
|	 d       |
rF| |
z
  t        kD  r:|j                  d| dt        |
       d||	 dt        | |
z
  dz         dd       |j                  |      }|s|d   dk(  s|j                  d| dt        |d          d||	 dd        t        5  t               }|D ch c]  }|j                  d       }}d}|D ]e  }|d   |v rt        j                  t        j                        j                         |d<   |j                  |       |j!                  |d          d}g |r0|dd }	 t"        j%                  t'        j(                  |d             ddd       |S # |j	                          w xY wc c}w c c}w # t*        $ r" t,        j.                  j1                  d       Y Sw xY w# 1 sw Y   S xY w)aC  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).
    z[SELECT machine_id, hostname, enrolled_at, last_seen_at FROM vision_agents WHERE revoked = 0zSELECT machine_id, status, received_at FROM vision_checkins WHERE id IN (SELECT MAX(id) FROM vision_checkins GROUP BY machine_id)r!  r!  r"  zenroll:rS  r  
enrollmentzNew Vision agent enrolled: r)  rT  r!  r  zoffline:r.  z$ is unexpectedly offline (last seen r   z
 min ago).rd   r  r%  rE  z reported status=error.r)  Fr  TNr   r   .Failed to persist vision_notification_log.json)rU   r'  rL  r  r[  ri   r   _VISION_OFFLINE_ALERT_SECONDSr   _vision_notif_lockr(  r  r  r  r  r  r  r(  r   r   r   r   rm   rn   r  )r  rO  r  r  r  latest_by_machinerB  r  r2  r$  r  lcrk  rB  r  r  r  s                    r1   _vision_evaluate_alertsr(   s    ))+C<D
!
 (* 	 U
 (* 	
 	

5;<<!+<<J o}#n%	SE3q'7#8"9: 4TF;
 	 #	/-JJ!#aI'78!"V $)),cIo-C)D(EZQ  ""3'"X,')uAc"]*;&<%=>s"V#:; +6 
 W(*&)*e** 	Ax4,001A1ABLLNAlOJJqMHHQuXH	 de*CW,77

3q8QRW" Ja 	

<@ +  W

$$%UVWW" JsN   >H/ 2II<-I	A5I<;*I/I	I<(I96I<8I99I<<Jz/vision/alertsc                      t               } t        | dd       D cg c]  }|j                  d      r| }}t        |      S c c}w )zTEvaluate alert rules and return the most recent non-dismissed alerts (newest first).r  N	dismissed)alerts)r(  rd  r   r   )rk  rB  recents      r1   vision_alertsr(  b sF     "
#C!#cd),GAAEE+4FaGFG&!! Hs
   AAz/vision/alerts/dismissc                  ^   t        j                  d      xs i } | j                  d      xs dj                         }t	        | j                  d            }|s|st        d      dfS t        5  t               }d	}|D ]2  }|s|j                  d      |k(  s|j                  d
      r,d|d
<   d}4 |r+	 t        j                  t        j                  |d             ddd       t        d      S # t        $ r" t        j                  j                  d       Y >w xY w# 1 sw Y   CxY w)zDismiss 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.Tr  r)  r7   r  zProvide a key or all=true.r  r`  Fr(  r   r   r(  NrY  )r   r  r   r   r   r   r(  r(  r(  r   r   r   r   rm   rn   r  )r  r)  	clear_allrk  r  rB  s         r1   vision_alerts_dismissr(  k s   
 4(.BD88E? b
'
'
)CTXXe_%Iy9:C??	 W(* 	#AAEE%LC/uu[)%)AkN"G		#
 W,77

3q8QRW d  W

$$%UVWW Ws6   0(D#D#+D#7*C55(D D#D  D##D,z/vision/diagnostics/fleetr  c                     t        j                          } t               }	 |j                  d      j                         }|j                  d      j                         D ch c]  }|d   	 }}i }|j                  d      j                         D ]  }|d   ||d   <    i }|j                  d      j                         D ]  }|d   ||d   <    |j                  d      j                         D ch c]  }|d   	 }}|j	                          t               }t               }	t               }
|
D ch c]  }|j                  d	      d
k(  s|d    }}t        ||z  |z        }ddddd}i }g }g }|D ]  }|d   }|d   }|d   }|d   }|dk(  s|d   rd}n|dk(  rd}n|r| |z
  t        kD  rd}nd}|j                  |d      dz   ||<   |d   }|r|dvr|j                  |d      dz   ||<   |d   }|rK|dk(  rFt        ||	      }t        d|j                  dd      z  d      }|r| |z
  |kD  r|j                  |       |j                  ||j                  |      xs d|d   ||||dn
t        |      ||j                  |d      d	        t        t!        |      ||||t#               ||      S c c}w c c}w # |j	                          w xY wc c}w ) zOFleet rollup: counts by status, error machines, stale captures, version spread.a  SELECT va.machine_id, va.hostname, va.last_seen_at, va.status, va.revoked,
                      vc.agent_version, vc.capture_enabled, vc.last_capture_ts
               FROM vision_agents va
               LEFT JOIN (
                   SELECT machine_id, agent_version, capture_enabled, last_capture_ts
                   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 DESCzLSELECT DISTINCT machine_id FROM vision_commands WHERE acked_status = 'error'r!  zOSELECT machine_id, COUNT(*) as cnt FROM vision_agent_events GROUP BY machine_idcntz_SELECT signature, COUNT(*) as cnt FROM vision_agent_events GROUP BY signature ORDER BY cnt DESCrF  z3SELECT DISTINCT machine_id FROM vision_agent_eventsrT  r  r   )rv  r  r.  r  rd   r"  rn&  r  r  r.  rv  r^   r  )r  ro&  r   r&  r   i  Nr!  )	r!  friendly_namer!  rd   r"  r  rn&  ro&  failure_event_count)r@  r$  agents_with_errorscapturing_but_staleagent_version_spreadrG(  r  fleet_failure_signature_counts)rU   r'  rL  r  r[  r&  r  r(  r   r  r(  r&  rC  ri   r   r   rC  r'  )r  rO  r  r  cmd_error_machinesevent_machine_countsfleet_sig_countsmachines_with_eventsr.  r7  rk  rB  notif_error_machinesr(  r$  version_spreadr(  
agent_rowsr2  	db_statusr  rx  disp_statusr  lctrQ(  stale_thresholds                              r1   vision_diagnostics_fleetr(   sW    ))+C<D/

 (* 	 8<||Z8

(*!ao    "]

(*	=A 56eH <1	= !
 (*	8A 01xQ{^,		8
 :>A:

(* A,     	

$&E "F
$
&C58UAEE&MW<TAlOUU 47I IL` `aaA!DFNJ $ohK	n%	!"	!Qy\#K)##KsY2OO#K"K$jja81<{ 9L0"0"4"4S!"<q"@N3!";(*.sF;G!!gkk2BB&G"GNO397#**3/"YYs^3t*!% '*{tS	"#7#;#;C#C

 
	5$L &k-/+9;'7	 	K  	


 Vs7   A K   J6,BK  1J;=K  1KK6
K   Kc                 L   | yt        | t        t        f      rt        |       S 	 t        |       S # t        t        f$ r Y nw xY w	 t        j                  t        |       j                         j                  dd            j                         S # t        t        f$ r Y yw xY w)a  Normalize a check-in `ts` to epoch seconds (float) or None.

    Agents historically sent `ts` as an ISO-8601 string (e.g. "2026-07-02T12:00:00Z")
    into the REAL `ts` column, so stored rows may be either a number or an ISO string.
    Every other timestamp in the system is epoch seconds; normalize here so the UI can
    uniformly do `new Date(ts * 1000)` instead of rendering "Invalid Date" on strings.
    Nr  r  )r   r   r  r   r   r  r  r*   r   r  rv
  )rQ  s    r1   _vision_checkin_ts_epochr(   s     {#U|$SzSzz" ++CHNN,<,D,DS(,ST^^``z" s"   
1 AAA	B B#"B#c                    t        j                          }t               }	 |j                  d| f      j                         }|s"|j	                          	 |j	                          y|j                  d| f      j                         }|j                  d| f      j                         }|j                  d| f      j                         }|j                  d| f      j                         }|j                  d| f      j                         }|j	                          t               }	t               }
|d   }|d	   }|d
k(  s|d
   rd
}n|dk(  rd}n|r||z
  t        kD  rd}nd}|r|d   nd}|xr |d   xs |d   |	j                  |       xs d|||r|d   nd|
|dn
t        |      |r|d   nd|r|d   nd|r|d   nd|r|d   nd|r|d   ndd}g }|D ]L  }|d   }|j                  t        |d         |d   |d   |d   |d   |d   |d   |dn
t        |      d       N g }|D ]?  }|d   rd}n
|d   rd }nd!}|j                  |d"   |d#   |d   |d   |d$   |d%   |d&       A t               }t        |      D cg c]  }|j                  d'      | k(  s| c}dd( }|D cg c]  }|d)   |d*   |d+   |d   |d   d, }}|D ci c]  }|d)   |d-    }}| ||||||d.S # |j	                          w xY wc c}w c c}w c c}w )/zBuild the per-agent diagnostic bundle (health, telemetry, commands, alerts).

    Returns a dict ready for jsonify, or None if the agent does not exist.
    Shared by the read-only GET endpoint and the Assistant-diagnosis endpoint.
    zbSELECT machine_id, hostname, last_seen_at, status, revoked FROM vision_agents WHERE machine_id = ?NzSELECT hostname, os_version, logged_in_user, agent_version, capture_enabled, last_capture_ts, agent_uptime_s, screen_count FROM vision_checkins WHERE machine_id = ? ORDER BY id DESC LIMIT 1zSELECT ts, received_at, cpu_pct, idle_seconds, last_capture_ts, foreground_app, agent_uptime_s, capture_enabled FROM vision_checkins WHERE machine_id = ? ORDER BY id DESC LIMIT 200zSELECT action, created_at, delivered_at, acked_at, acked_status, error_message FROM vision_commands WHERE machine_id = ? ORDER BY created_at DESC LIMIT 30zxSELECT signature, detail, trace, ts, received_at FROM vision_agent_events WHERE machine_id = ? ORDER BY id DESC LIMIT 50ztSELECT signature, COUNT(*) as cnt FROM vision_agent_events WHERE machine_id = ? GROUP BY signature ORDER BY cnt DESCrd   r"  r  r  r.  rv  rn&  r!  r  ro&  r  r  rt&  rs&  )r!  r(  rd   r"  r  rG(  rn&  ro&  r  r  rt&  rs&  r_   rE  rr&  rq&  rp&  )r_   rE  rr&  rq&  ro&  rp&  rt&  rn&  rQ  r(  r  r  rS  r  r  rR  r  )r  r  r  rQ  rR  r  ro  r!  r	  rF  r	  r'  )rF  r	  r'  r_   rE  r(  )r!  r-  r  r(  r(  failure_eventsfailure_signature_counts)rU   r'  rL  ru  r[  r  r&  r'  r(  r   r   ri   r(  r(  rd  )r!  r  rO  r  	latest_citelemetry_rowscmd_rows
event_rowssig_counts_rowsr.  
latest_verr(  r  r(  rx  r-  r  r  t_capr(  ro  rk  rB  r(  r(  signature_countss                             r1   _vision_agent_diag_bundler(   s     ))+C<D*7M
 (*	 	
 JJLD 	

C LL( M
 (* 	 * M
 (* 	 <<1 M	

 (* 	 \\) M	

 (* 	 ,,4 M	

 (* 	 	

$&E-/JhIn%IIy!1	i	3?.KK*3)%
&C89Z#8NU:=N:.6$!7@?3d *#&;4DI;D9%67$1:i-9B)$459B)$455>	.1DF I #$*1T73]+|n- !23 01 01',}t$u+	
 		 H Z=E~EEkL/n-*n-/
 	" %
&C!#LA!%%*=*KaLSbQF 	  ;kwZD']+	
	N 	 ;JJQ+%0JJ !($4 ] 	

@ M	 Ks*   3K( %B%K(  K=K=(LL(K:z&/vision/diagnostics/agent/<machine_id>c                     | xs dj                         } | st        d      dfS t        |       }|t        d      dfS t        di |S )zKPer-agent bundle: health fields, last 200 telemetry rows, commands, alerts.r7   r'  r  r`  Agent not found.r  rX   )r   r   r(  )r!  bundles     r1   vision_diagnostics_agentr(   sY     "))+J34c99&z2F~/0#55Vr3   c                    t               }	 |j                  d| f      j                         }|j                          |r|d   syt	        |d         j                         }dj                  || d       S # |j                          w xY w)zIReturn the tail of the newest uploaded log for this agent, or '' if none.z\SELECT content FROM vision_agent_logs WHERE machine_id = ? ORDER BY received_at DESC LIMIT 1r  r7   r0  N)r'  rL  ru  r[  r*   r  r  )r!  	max_linesrO  r  rP  s        r1   _vision_agent_log_tailr(   s    <Dll1M
 (*	 	 	

c)nI**,E99UI:;'((	 	

s   !A6 6Bc                    | j                  d      xs i }| j                  d      xs g | j                  d      xs g }| j                  d      xs g }d }dd| j                  d       d	|j                  d
      xs d d|j                  d      xs d d ||j                  d             d|j                  d      xs d d|j                  d      xs d dd|j                  d       d ||j                  d             d|j                  d      xs d d|j                  d      xs d d|j                  d       d|j                  d        g}|j                  d!       s|j                  d"       nfd#}d$   }|j                  d%t                      |j                  d& |d'              |j                  d( |d)              |j                  d*|j                  d+      xs d        |j                  d,|j                  d              |D 	cg c]*  }	|	j                  d-      d.k(  s|	j                  d/      s)|	, }
}	|j                  d0       |
s|j                  d1       n|
d2d3 D ]  }	|j                  d4|	j                  d5      xs d6 d7 ||	j                  d8             d9|	j                  d-      xs |	j                  d:      xs d6 d;|	j                  d/      xs d< j                                 |j                  d=       |s|j                  d>       nG|d2d3 D ]?  }|j                  d?|j                  d@      xs d6 dA|j                  dB      xs d<        A |j                  dC       |j                  |j                         r|j                         ndD       dEj	                  |      S c c}	w )FzRender a compact text prompt from a diagnostic bundle for the Assistant.

    Summarizes telemetry (latest + min/avg/max) rather than dumping every row, and
    surfaces only command errors and recent alerts, to keep the token cost modest.
    r-  r  r(  r(  c           	         | sy	 t        dt        t        j                         t        |       z
              }|dk  r| dS |dk  r|dz   dS |dk  r|d	z   d
S |dz   dS # t        t
        f$ r t        |       cY S w xY w)Nr/  r   r  zs agoi  r   zm agoi  r  zh agor  zd ago)rC  r   rU   r  r   r   r*   )r_   secss     r1   _agoz'_build_vision_diag_prompt.<locals>._ago s    	q#diikE"I567D "9V5>!$;bj\''&=dl^5))%-&& :& 	r7N	s   3A$ $B Bz## Agent healthz- machine_id: r!  z- hostname: r!  r	  z
- status: rd   z- last check-in: r"  z- agent_version: r  z	 (latest rG(  r7  z- capture_enabled: rn&  z- last_capture: ro&  z- os_version: r  z- logged_in_user: r  z- agent_uptime_s: rt&  z- screen_count: rs&  z
## Telemetry summaryz!- no telemetry check-ins recordedc                    D cg c]9  }t        |j                  |       t        t        f      s)|j                  |       ; }}|sydt	        |      ddt        |      t        |      z  ddt        |      dS c c}w )Nzn/azmin r[   z / avg z / max )r   r   r   r  rL  r  rC  rC  )r)  r  r  tels      r1   _statsz)_build_vision_diag_prompt.<locals>._stats sy    (+T1z!%%*sEl/SAEE#JTDT#d)CD	CI0Ec/J'RUVZR[\_Q`aa Us
   *A>A>r   z- check-ins in window: z- cpu_pct: rr&  z- idle_seconds: rq&  z- latest foreground_app: rp&  z- latest capture_enabled: rR  r  r  z
## Command errorsz - none in recent command historyNr   z- r  r]   r:  r  r  ro  r
  r7   z
## Recent alertsz- nonez- [rT  ] r  z
## Agent log tailz- no agent log uploadedr0  )r   ri   rC  r   r  )r(  log_tailr}
  r(  r(  r(  rP  r(  r  r  err_cmdsr  r(  s               @r1   _build_vision_diag_promptr(   s    	

8"A
**[
!
'RC::j!'RDZZ!'RF' fjj678AEE*-678!%%/2U34 aeeN&;!< => !7!@5 A155QgKhKqlqJrrst"155):#;"<=QUU+<%= >?@aeeL1:U;<!!%%(8"9"BU!CD!!%%(8"9!:;n 5679E 
LL)*89	b
 Q.s3xj9:{6)#4"567'~(>'?@A0<L1M1VQV0WXY1&**=N2O1PQR^a155#8G#Cquu_G]^H^	LL&'78#2 	yALL2aeeHo45RQUU<=P8Q7RRUEE.1JQUU7^JsK5QRQVQVWfQgQmkmPnppupupwy	y 
LL%&X 	QALL3quuV}34BquuY7G7M26NOP	Q 
LL&'	LLX^^%5!;TU99U' _s   2*OOz0/vision/diagnostics/agent/<machine_id>/assistantc                    ddl m}m}m}m} t
        d   }|d   }t        |j                  d            }t        |      } ||||      st        d      dfS | xs d	j                         } | st        d
      dfS t        j                  d      xs i }	t        |	j                  d      xs d	      j                         xs d}
t        |       }|t        d      dfS t        |      st        d      dfS t!        |       }t#        ||      }d}	  |d|dg|||
      }|
xs% |j                  d      xs i j                  dd	      }	 t%        j&                  t)        j(                         || d|t+        |      t+        |      d      }t-        t.        dd      5 }|j1                  |d z          ddd       t        |"      S # |$ r!}t        t        |            dfcY d}~S d}~w|$ r!}t        t        |            dfcY d}~S d}~ww xY w# 1 sw Y   bxY w# t2        $ r" t4        j6                  j9                  d!       Y w xY w)#zDVI-904: bundle an agent's diagnostic snapshot into a prompt and ask the
    configured Assistant for a root-cause + remediation summary. Text-only,
    user-initiated. Reuses the per-provider Assistant plumbing (assistant.chat).r   )r  rW  r  r  r'  r  r   r"(  r  r  r7   r'  r`  Tr  r  Nr(  r  zERate limit exceeded. Please wait before requesting another diagnosis.r:$  a  You are a diagnostic assistant for a fleet of Windows screen-capture 'Vision' agents. You are given one agent's health snapshot, a telemetry summary, its command errors, recent alerts, and a tail of its log. Identify the single most likely root cause of any problem and give concrete, ordered remediation steps. If the agent looks healthy, say so in one line. Be concise; respond in short sections titled 'Root cause' and 'Suggested remediation'.)roler  r  rZ  rU  r  r'  diagnose)r_   r'  r!  r!	  r  r&(  r'(  r  r  r  r0  r((  r)(  )r  r  rW  r  r  r   r   r   r  r   r   r   r  r*   r(  r(  r(  r(  r   r   rU   rC  r?  r.(  r  r   rm   rn   r  )r!  r  rW  r  r  r'  r  r  r  r  r  r(  r(  r	  r|  r  rl  r1(  r   r  s                       r1   "vision_diagnostics_agent_assistantr(   sG     6?DMEDHH]+,H"5)H!%8<\]_bbb"))+J34c99 4(.BD'*0b1779ATN&z2F~/0#55 'degjjj%j1H&vx8F	D ,v&9:FRZbpq [8<<#9#?R"D"DEVXZ"[IO

))+$f+U
  &g> 	#"HHUT\"	#
 +   ,SX&++ ,SX&++,	# 	# O

MNOs[   G
 AH# !H6H# 
HG+%H+H3H	HHH H# #(IIc                     ddl m}  t        j                         j	                         }| | d      z   j                  d      }|j                  d      }g }g }dD ]>  }t        |      }dD ]*  }||   }	|	j                         s|	j                  d      D ]  }
	 t        j                  |
j                               }|
j                  d	      j                  }|d
v r'|j!                  dd      }d}|j!                  dd      }n&|j!                  dd      }d}|j!                  dd      }dddd
j!                  |d      }|s||k(  r$|j#                  d| d| d| d| d| d|        ||k  s|j#                  d| d| d| d| d| d|         - A t%               }|j'                         D ]  \  }}|j!                  dd      s|j!                  d d      }||k(  r.|j!                  d!d"      }|j#                  d#| d$| d%| d&       ^|sa||k  sg|j!                  d!d"      }|j#                  d#| d$| d'| d&        |r2d(d)j)                  |      z   d*z   }t+        d+t-        |       d,| |       |r0d-d)j)                  |      z   d*z   }t+        d.t-        |       d/|       t-        |      t-        |      fS # t        j                  t        f$ r Y Nw xY w)0zUCheck all non-completed work orders for upcoming/overdue dates and send admin emails.r   r  r^   r  r  r#  r  r  r   )r   r   r   r  r7   zDesired Completion Dater  r  r@
  r?
  r(  r  r  r  r  z  - [r(  z
    r>  z | z | Status: z (OVERDUE) | r~  Tr_  r  r]   z  - [Recurring] z
    Next Run: z	 | Every z daysz (OVERDUE) | Every z:The following work orders have dates coming up tomorrow:

r0  r
  u   Work Order Reminder — z upcoming date(s) for zSThe following work orders have passed their target dates without being completed:

u   Work Order OVERDUE — z past-due work order(s))r   r   r  r  r   r  r  r  r  r   r   r   r   r   r  r9   r   ri   r  r   r  r  rC  )r   r	  tomorrowr  reminder_linesoverdue_linesr  r  rv  r  	json_filer   r  
date_fieldr   r9   wo_type_labelr  r  r   r_  r  r  s                          r1   _process_wor_date_remindersr(  C s   " %%'E	q))33J?Hz*INML dh'0 	dJZ(I##%&^^H5 d	::i&9&9&;<D %008==FF!%*CR!HJ5E88$4i@D!%/2!>J+E88Iy9D0=a{ |  !A  !A  BJ  L\  !]!X-&--#M?"XJ ?##('J<s4&J<YZ $i/%,,#M?"XJ ?##('J<}TF+V`Uacd/d		ddD ()H"*.."2 KyyD)99Z,x99-s3D!!"?"3 4!!)
)D6@A (Y.99-s3D  "?"3 4!!)
*=dV5JKK Jii'(PQ 	
 	&s>':&;;QRZQ[\	

 cii&'PQ 	
 	%c-&8%99PQ	

 ~M 222 ,,g6 s   #K		K&	%K&	c                     ddl } 	  | j                  d       	 t        j                         5  t	               }|r*t        j
                  j                  dt        |      |       t        j                         j                  d      }|t        k7  r4|at               \  }}|s|r!t        j
                  j                  d||       ddd       # 1 sw Y   
xY w# t        $ r" t        j
                  j                  d       Y 7w xY w)zJBackground thread: hourly recurring WO check + daily date-based reminders.r   Nr  z0Recurring scheduler created %d work order(s): %sr  z+WOR reminders sent: %d upcoming, %d overduezRecurring scheduler error)rU   r  rm   r0"  r  rn   rl   rC  r  r  r  _last_reminder_dater(  ry   r  )rU   r  r  	remindersr'  s        r1   _recurring_scheduler_loopr(   s     


4	>" 
k8:JJOO$VX[\cXdfmn*..099*E	 33*3')D)F&Iw G

(UW`bij
k 
k 
k  	>JJ  !<=	>s)   C BCC CC (DDTOGEN_RUN_SCHEDULERSuX   TOGEN_RUN_SCHEDULERS=1 — starting background scheduler/watcher threads in this processuV   TOGEN_RUN_SCHEDULERS=0 — background schedulers disabled in this process (web worker)r  c                      ddl } 	  | j                  d       	 t        j                         5  t	                ddd       ;# 1 sw Y   
xY w# t
        $ r" t        j                  j                  d       Y 7w xY w)a  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).
    r   Nr   zNews scheduler error)rU   r  rm   r0"  ry  ry   rn   r  rB"  s    r1   _news_scheduler_loopr(   sg     


2	9" +(*+ + + 	9JJ  !78	9r$  r?  z6Startup: swept %d orphaned winston run(s) to cancelledz)Startup winston orphaned-run sweep failed)r  r  )r  r  r  c                      ddl } 	  | j                  d       	 t        j                         5  t	                ddd       ;# 1 sw Y   
xY w# t
        $ r" t        j                  j                  d       Y 7w xY w)zEBackground thread: apply a scheduled app update at 2:00 AM (DVI-768).r   Nr   z Scheduled update scheduler error)rU   r  rm   r0"  r  ry   rn   r  rB"  s    r1   _scheduled_update_loopr(   sh    


2	E" .+-. . . 	EJJ  !CD	Er$  c                      ddl } 	  | j                  d       	 t        j                         5  t	                ddd       ;# 1 sw Y   
xY w# t
        $ r" t        j                  j                  d       Y 7w xY w)zNBackground thread: launch the recurring backup when its slot is due (DVI-966).r   Nr   zBackup scheduler error)rU   r  rm   r0"  r  ry   rn   r  rB"  s    r1   _backup_scheduler_loopr(   se    


2	;" .+-. . . 	;JJ  !9:	;r$  c                      ddl } 	 	 t               }t        |j                  d      xs i j                  dt        d               } | j                  t        d|      dz         	 t        j                         5  t               }t        j                  j                  d|       ddd       # t
        $ r t        d   }Y |w xY w# 1 sw Y   "xY w# t
        $ r" t        j                  j                  d       Y Ow xY w)	zEBackground thread: poll the SCADA mailbox on the configured interval.r   Nr  rL  r^   r   zSCADA collector cycle: %szSCADA collector error)rU   r4  r   r   rT  ry   r  rC  rm   r0"  r?&  rn   rl   r  )rU   rc  rj  r>  s       r1   _scada_collector_loopr(  4 s    
	J')FK(.B33,,-EFHIH 	

3q(#b()	:" E-/

 ;VDE   	J/0HIH	JE E  	:JJ  !89	:s;   ?B. &C :+C%C .CCCC (C=<C=c                 D   t         5  t               }|D ch c]  }|j                  d       }}d}| D ]q  }|j                  d      |v rt        j                  t
        j                        j                         |d<   |j                  |       |j                  |d          d}s |r0|dd }	 t        j                  t        j                  |d             ddd       yc c}w # t        $ r" t        j                   j#                  d	       Y 8w xY w# 1 sw Y   yxY w)
z?Append deduped alert candidates to the Vision notification log.r)  Fr  Tr(  Nr   r   z8Failed to persist vision_notification_log.json (monitor))r(  r(  r   r  r  r  r  r  ri   r  r(  r   r   r   r   rm   rn   r  )rB  rk  rB  r  r  r  s         r1   _vision_monitor_append_alertsr(  N s	   	 a(*&)*e** 	AuuU|t#,001A1ABLLNAlOJJqMHHQuXH	 de*Ca,77

3q8QRa a*  a

$$%_`aa as:   DC#BD0*C(#D((DDDDDc                  
   ddl } ddlm}m}m} ddlm} t               }|j                         j                         }|j                  d      |k7  rBt        5  t               }|j                  d      |k7  rd|d<   ||d<   t        |       ddd       t        |j                  d      xs t              }|j                  d      r|t!        d      d n|}d	d
d| ii}	t#               }
|
j                  dg       D ci c]  }|d   |
 }}|
j                  di       j%                         D ]  }|D ]  }|j'                  |d   |          |j                  d      xs i }t)               }	 |j+                  d      j-                         }|j/                           | j                          }d}|D ]  }|d   }|d   xs |}|j                  |      xs i }|j                  d      s9|d   }|r||z
  t0        dz  kD  rP|j                  d      xs g }|D cg c]  }||v s||    }}|st               }t3        |j                  d      xs d      }t3        |j                  d      xs d      }||k\  r!|st5        d| d|d| d| ddg       d} yt7        |d      }||d    }t9        ||d!      } | | \  }!}"d"j;                  d# |D              }#d$|#z   }$	  ||!|$d|	%      }%t        5  t               }t3        |j                  d      xs d      d'z   |d<   t        |       ddd       g }'	 tC        jD                  |%      }(tG        |(tH              r$tG        |(j                  d(      tJ              r|(d(   }'g })d}*|'D ]  }+tG        |+tH              st        |+j                  d      xs d+      },tS        |+j                  d,            sM|,sP|*d'z  }*t        |+j                  d-      xs d+      }-|)jU                  d.| d/|, d/| d|| d0|, d1|- d        |)rt5        |)       	 tC        jV                   | j                          d2||d| |*d3      }.tY        tZ        d4d56      5 }/|/j]                  |.d"z          ddd        y# 1 sw Y   xY wc c}w # |j/                          w xY wc c}w # ||f$ r,}&t<        j>                  jA                  d&||&       Y d}&~&?d}&~&ww xY w# 1 sw Y   xY w# tB        jL                  tN        f$ r) t<        j>                  jQ                  d)|||%dd*        Y w xY w# 1 sw Y   xY w# t^        $ r# t<        j>                  ja                  d7       Y w xY w)8zOne evaluation pass: check all opted-in machines against their Watch Criteria.

    Budget cap is fail-closed: once calls_today >= daily_call_cap, the remaining
    machines in the cycle are skipped and a single deduped budget alert is filed.
    r   N)r (  r  r  r  r'  r'  r  r'  r  r'  re  r9   r  r&  zNSELECT machine_id, hostname, last_seen_at FROM vision_agents WHERE revoked = 0Fr!  r!  r~  r"  r   r1  r'  zbudget:monitor_budgetzVision monitor daily call cap (z) reached (z); remaining machines skipped.r(  Trx'  r&  r@  r0  c              3   8   K   | ]  }d |d    d|d      yw)z	- name: "r9   z"
  prompt: r	  NrX   r  s     r1   r/  z(_vision_monitor_cycle.<locals>.<genexpr> s-      #
 &	{-(}=#
s   u   Evaluate the following watch criteria for this image. Respond ONLY with valid JSON — no markdown, no extra text.
Format: {"results":[{"name":"...","match":true|false,"reason":"..."}]}

Criteria:
)r9  z)Vision monitor interpret error for %s: %sr^   r  z7Vision monitor: unparseable JSON from %s (frame %s): %rrK   r7   rG  rm  z	criteria:rS  r>  r
  r  )r_   r	  r!  r&  r  matched_countr  r  r  z8Failed to append to vision_interpret_log.jsonl (monitor))1rU   r  r (  r  r  r   r   r (  r	  r  r   _VISION_MONITOR_LOCKr(  r*   r'  rw   rC  r'  r  r}  r'  rL  r  r[  rJ(  r   r(  rt'  rm'  r  rm   rn   r  r   r   r   r   r  r   r   ro   r   ri   r   r?  r.(  r  r   r  )0rU   r (  r  r  _daterJ  r	  	model_rawrh  r9  r	(  r  all_criteria	user_listmachines_cfgrO  r  r  budget_alertedr  r2  r!  r(  r  criteria_namesr  r'  r'  rx  callsr  r&  r>  r/(  r  criteria_linesr	  r  rl  r  rL  alert_candidatesr)  r  r9   rm  r   r  s0                                                   r1   _vision_monitor_cycler)  c s    OO&
%
'CKKM##%E ww}&! 	1-/Cww}%.%&M"%*M"+C0	1 CGGG$E(EFI-6-A-A)-LyY)R[H 1WXJ3GHIM(*M*7*;*;Hb*IJQAfIqLJLJ"&&w3::< 2	 	2A##AfIq1	22 77:&,"L<D\

(* 	 	


$))+CN s]L!$+$*xx	" .)	S9_0Dr0II*-3-;QqL?PLOQQ ,-%)),-23EIIm,12C<!-$UG,,"%9# ?!7"@B0 /  "& &c6>*%sHf5>	3  #
#
 

 ++ 		#Ivt=YE " 	//1E#&uyy'?'D1#E#IE- '.	/ 	ZZ&F&$'Jvzz)7Ld,S +  	Aa&quuV}*+DAEE'N#"QUU8_23 ''&se1TF!H:>&"%"*2dV5A	) 	 )*:;	]JJdiik#!$"8*-!.  E *C'B 'b&'as]=	1 	1 K 	

& R^ %n5 	JJH#sS	
	/ 	/ $$j1 	JJIXuTc{	F' ' 	]JJ  ![\	]s   4R=R#-R( 	R=$R=%S7:S8<A	T5A U5U

UR (R:S5	!S00S58T	>UU
U	U(VVc                     ddl } 	 t               }t        dt        |j	                  d      xs d            dz  } | j
                  |       t               }|j	                  d      sd	 t        j                         5  t                ddd       # 1 sw Y   
xY w# t        $ r" t        j                  j                  d       Y 7w xY w)	a  Background thread: periodically evaluate Watch Criteria for opted-in machines (DVI-871).

    Ships globally DISABLED. Nothing runs until an admin enables the feature in
    vision_monitor.json (via /vision/monitor-config) and opts individual machines in.
    r   Nr   r'  r  r   r~  zVision monitor cycle error)rU   r (  rC  r   r   r  rm   r0"  r)  ry   rn   r  rU   rJ  r;"  s      r1   _vision_monitor_loopr)   s     
)+C(; < BCDrI


:)+wwy!	?" (%'( ( ( 	?JJ  !=>	?s*   *B >B	B BB (C	C	r'  )r  r~  )r'  r'  r'  c                      dddt         j                         D  ci c]  \  } }| t        |       c}} dd}t        j	                         rL	 t        j                  t        j                               }t        |t              rd|v rt        |d         |d<   d|v r	 t        dt        |d               |d<   d|v r	 t        d	t        |d               |d<   d
|v r	 t        dt        |d
               |d
<   t        |j                  d      t              r}|d   j                         D ]g  \  }}|t         vrt        |t              s |d   j                  |t        t         j                  |i                   }d|v sWt        |d         |d<   i |S |S c c}} w # t        t        f$ r Y w xY w# t        t        f$ r Y w xY w# t        t        f$ r Y w xY w# t
        j                   t"        f$ r Y |S w xY w)zOReturn auto-recovery config with safe defaults (global off switch + allowlist).Fr   r   )r~  r  poll_interval_srG$  last_processed_idr~  r  r^   r)  r   r)  rG$  )#_VISION_RECOVERY_ALLOWLIST_DEFAULTSr   r   VISION_RECOVERY_CONFIG_FILEr   r   r   r   r   r   rC  r   r   r   r   r}  r   r   )r  r  r  r  sigr   r  s          r1   _load_vision_recovery_configr)  9 s     -P-V-V-XYTQaajYD #**,	JJ:DDFGE%&%&*5+;&<DO!U*/21c%:O6P/Q^, %-25b#eDU>V:W2X./ '%/473uEX?Y;Z4[01 eii4d;&+K&8&>&>&@ E
U&II$)%6$#K0;;CFiFmFmnqsuFvAwx$-.253C.DDOE K4KI Z &z2 
 &z2 
 &z2  $$g. 	K	s   FAG   F! ;G   F6 G   G ;BG  G  !F30G  2F33G  6GG  GG  GG  GG   G=<G=c                 X    t         j                  t        j                  | d             y)zVPersist auto-recovery config. Caller should hold _VISION_RECOVERY_LOCK for atomic ops.r   r   N)r)  r   r   r   rN  s    r1   _save_vision_recovery_configr)  f s    **4::c!+DEr3   z/vision/recovery-configc                  F   t         j                  dk(  rt        j                  d      xs i } t        5  t	               }d| v rt        | d         |d<   d| v r	 t        dt        | d               |d<   d| v r	 t        dt        | d               |d<   t        | j                  d      t              rQ| d   j                         D ];  \  }}|t        vrt        |t              s d|v s%t        |d         |d   |   d<   = t!        |       d
d
d
       t        d      S t	               }t        |t              S # t        t        f$ r t        d      d	fcY cd
d
d
       S w xY w# t        t        f$ r t        d      d	fcY cd
d
d
       S w xY w# 1 sw Y   xY w)u   Admin-only auto-recovery config read/write (DVI-916).

    GET  — returns current config + allowlist defaults.
    POST — accepts {enabled, cooldown_min, poll_interval_s, allowlist:{sig:{enabled}}} partial update.
    r  Tr  r~  r  r^   z(cooldown_min must be a positive integer.r  r`  Nr)  r   z+poll_interval_s must be a positive integer.rG$  rY  )rc  allowlist_defaults)r   ra   r  _VISION_RECOVERY_LOCKr)  r   rC  r   r   r   r   r   r   r   r   r)  r)  )r  rJ  r)  r   s       r1   vision_recovery_configr)  k s    ~~t,2" 	..0CD !%d9o!6I%Z*-aT.5I1J*KC' !D(]-0S>O9P5Q-RC)* $((;/6"&{"3"9"9"; RJC"EE %eT2  E);?i@P;QK(-i8R )--	.. $ '
(C#2UVV) ":. Z")STVYYY	. 	.Z
 ":. ]")VWY\\\	. 	.]	. 	.sZ   $FD?5F:E+AF,$F?E(F'E((F+FFFFF z/vision/recovery-auditc                      t               } 	 | j                  d      j                         }| j                          t	        |D cg c]  }t        |       c}      S # | j                          w xY wc c}w )zVAdmin view of auto-recovery audit log (DVI-916). Returns newest-first, up to 200 rows.zSELECT id, ts, machine_id, signature, event_id, action, cmd_id, actor, outcome, detail FROM vision_recovery_audit ORDER BY id DESC LIMIT 200)audit)r'  rL  r  r[  r   r   )rO  rn  r  s      r1   vision_recovery_audit_listr!)   sd     <D||E
 (* 	
 	

40a$q'011 	

0s   A A3A0c                  b   ddl } t        5  t               }ddd       j                  d      sy|j                  d      xs i }t	        dt        |j                  d      xs d            dz  }t        |j                  d      xs d      }t               }	 |j                  d|f      j                         }|j                          |sy | j                          }|}|D ]'  }	|	d	   }
|	d
   }|	d   }t	        ||
      }|j                  |      }|r|j                  d      sD|j                  d      xs d}|t        vr"t        j                  j                  d||       t               }	 |j                  d|
f      j                         }|r	 |j                          |j                  d|f      j                         }|s;|j                  d||||
|df       |j                          	 |j                          !|j                  d||||z
  f      j                         }|rB|j                  d||||
|d|d    df       |j                          	 |j                          dt!        j"                         j$                  dd z   }|j                  d||||f       |j                  d||||
||d| d| f       |j                          t        j                  j'                  d|||
||       |j                          * ||kD  r*t        5  t               }||d<   t-        |       ddd       yy# 1 sw Y   )xY w# |j                          w xY w# t(        $ r% t        j                  j+                  d|
||       Y w xY w# |j                          w xY w# 1 sw Y   yxY w)a  One evaluation pass of the auto-recovery evaluator (DVI-916).

    Reads vision_agent_events with id > last_processed_id, matches enabled
    allowlisted signatures, applies cooldown, issues vision_commands, and
    writes a durable audit row for every decision.  last_processed_id is
    advanced at the end of each pass so events are processed exactly once
    (barring a crash mid-cycle, which is safe: reprocessing is idempotent
    because each event_id can appear only once in vision_recovery_audit).
    r   Nr~  rG$  r   r  r)  z_SELECT id, machine_id, signature, received_at FROM vision_agent_events WHERE id > ? ORDER BY idr  r!  rF  r  r7   zMVision recovery: allowlist action %r for %r is not a valid command; skipping.z7SELECT id FROM vision_recovery_audit WHERE event_id = ?r'  zINSERT INTO vision_recovery_audit (ts, machine_id, signature, event_id, action, cmd_id, actor, outcome, detail) VALUES (?, ?, ?, ?, ?, NULL, 'auto-recovery', 'error', ?)zmachine not found or revokedzoSELECT id FROM vision_recovery_audit WHERE machine_id = ? AND signature = ? AND outcome = 'issued'   AND ts > ?zINSERT INTO vision_recovery_audit (ts, machine_id, signature, event_id, action, cmd_id, actor, outcome, detail) VALUES (?, ?, ?, ?, ?, NULL, 'auto-recovery', 'cooldown', ?)z$cooldown active (last issued within zm)zrcv-r  zgINSERT INTO vision_commands (id, machine_id, action, params_json, created_at) VALUES (?, ?, ?, NULL, ?)zINSERT INTO vision_recovery_audit (ts, machine_id, signature, event_id, action, cmd_id, actor, outcome, detail) VALUES (?, ?, ?, ?, ?, ?, 'auto-recovery', 'issued', ?)zauto-issued r!  zCVision auto-recovery: issued %s to %s for event %s (sig=%s, cmd=%s)zDVision auto-recovery: error processing event %s (machine=%s, sig=%s))rU   r)  r)  r   rC  r   r'  rL  r  r[  r'  rm   rn   r  ru  rZ  rW  rX  rY  rl   ry   r  r)  )rU   rJ  rG$  
cooldown_slast_idrO  r:  r  new_last_idevtevt_idr!  rF  sig_cfgr  alreadyr  r(  r  r'  s                       r1   _vision_recovery_cycler*)   s    	 -*,- 779$*IRSWW^4:;<rAJ#''-.3!4G<D(J
 (*	 	 	


$))+CK WT&
$	+v.--	*gkk)4 X&,"00JJ_	 |B	llIF9hj  x JJLs LL[ hj  Q *iA_`	 Z JJLU \\  Yj(89	
 hj  T *i;C<O;PPRSU	 6 JJL1 djjl..r22FLL-VS1
 LLK j)VVVxuYK8:	 KKMJJOOU
FIv JJLoWt W" 	002E)4E%&(/	0 	0 i- -$ 	

r  	JJ  V
I	 JJL	0 	0s\   L=!M
 0#M%AMAM-BMN%=M
M+N
NNNN"%N.c                  ~   ddl } 	 t               }t        dt        |j	                  d      xs d            } | j
                  |       |j	                  d      sW	 t        j                         5  t                ddd       # 1 sw Y   
xY w# t        $ r" t        j                  j                  d       Y 7w xY w)a3  Background thread: poll vision_agent_events and apply auto-recovery rules (DVI-916).

    Ships globally DISABLED (enabled=false in vision_recovery.json).  Nothing
    is issued until an admin enables the global switch AND enables at least one
    per-signature toggle via POST /vision/recovery-config.
    r   Nr   r)  r   r~  z Vision auto-recovery cycle error)rU   r)  rC  r   r   r  rm   r0"  r*)  ry   rn   r  r)  s      r1   _vision_recovery_loopr,)  ) s     
*,S):!;!ArBC


:wwy!	E" )&() ) ) 	EJJ  !CD	Es*   B 1B<B B
B (B<;B<r  r$  c                       xs i  xs i t         fdD              }t        fd D              }t         fdD              }|||fS )zHReturn (added, removed, modified) relative-path lists between snapshots.c              3   ,   K   | ]  }|vs|  y wrT   rX   )r,  r  r  s     r1   r/  z%_fb_diff_snapshots.<locals>.<genexpr>W s     2Qc\12r  c              3   ,   K   | ]  }|vs|  y wrT   rX   )r,  r  r$  s     r1   r/  z%_fb_diff_snapshots.<locals>.<genexpr>X s     41q|Q4r  c              3   D   K   | ]  }|v s|   |   k7  s|  y wrT   rX   )r,  r  r$  r  s     r1   r/  z%_fb_diff_snapshots.<locals>.<genexpr>Y s&     FASSVs1v5EaFs   	   )r  )r  r$  r
  r  r"  s   ``   r1   _fb_diff_snapshotsr1)  S sR    
)C
)C2c22E444GFFFH'8##r3   c                 6    dj                  t        |             S )zDRender the shared 1-click 'open Togen & remove this watcher' button.a  <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>)r	  r5  r4  s    r1   _fb_watcher_action_htmlr3)  ] s    	J f$S)*+r3   c                 l   t        |       j                  dd      j                  dd      j                  dd      }t        |      j                  dd      j                  dd      j                  dd      }d|  d}d	j                  ||t        d
z         }d}t	        j
                  ||d|      }||fS )zBRender (subject, html) for a watcher that expired and was removed.r  r  r  r  r  r  zFile Browser: watcher for z has expireda  <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>z/?open_fb_notifications=1a  <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>File Browserre  r  r  r  )r*   r  r	  r3  r=  r  )r   rel_path
safe_label	safe_pathr  r  r  r  s           r1   _fb_watcher_expired_emailr:)  j s    e*$$S'273'V(< X&&sG4'#v&wwsF'; *5'>G	G HNv	;1L#LHN 	P 
 ++[:D D=r3   c           
         d }t        |       j                  dd      j                  dd      j                  dd      }t        |      j                  dd      j                  dd      j                  dd      }d|  }	d	j                  || |d
|       |d|       |d|            }
|r|
t        |      z  }
d}t	        j
                  |	|
d|      }|	|fS )zFRender (subject, html) summarizing folder changes for a watcher email.c                     |sydj                  d |d d D              }t        |      dkD  rdj                  t        |      dz
        nd}dj                  | ||      S )Nr7   c              3      K   | ]E  }d j                  |j                  dd      j                  dd      j                  dd             G yw)z><li style="margin:2px 0;color:#111827;font-size:13px;">{}</li>r  r  r  r  r  r  N)r	  r  rp  s     r1   r/  z=_fb_watcher_change_email.<locals>._section.<locals>.<genexpr> sO      !  MSS		#w'//V<DDS&QS!s   AAr	  uJ   <li style="margin:2px 0;color:#6b7280;font-size:13px;">…and {} more</li>z<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>)r  rC  r	  )r  r   rn  rn"  s       r1   _sectionz*_fb_watcher_change_email.<locals>._section so    ww ! 3BZ! !
 <?u:?%vc%j2o6PR 	#VGT48	9r3   r  r  r  r  r  r  zFile Browser: changes in z<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>{}{}{}AddedModifiedRemovedz<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>r5)  r6)  )r*   r  r	  r3)  r=  r  )r   r7)  r
  r  r"  r1  r>)  r8)  r9)  r  r  r  r  s                r1   _fb_watcher_change_emailrB)   s    9 e*$$S'273'V(< X&&sG4'#v&wwsF'; )%1G	 	We$Z*Y(	*	  ,S11		= 
 ++[:D D=r3   c            
         t               } t               }d}	 t        t        j	                  d            }|D ]  }	 t        j                  |j                               }t        |t              s9|j                  d      xs |j                  }g }t        |      D ]  }|j                  d      }	|j                  d      }
|	r|
s+t        |      rx|rRt!        |j                  d      xs t#        |
      j$                  |
      \  }}	 t'        j(                  |||       |dz  }|j3                  |	       | j5                  |	d	       |j7                  |	       |j                  d
d      st9        |
      }||j;                         st=        |      }| j                  |	      xs i }|j                  d      }t?        j@                         jC                         }|||
||d| |	<   QtE        ||      \  }}}|s|s|rY|rWtG        |j                  d      xs t#        |
      j$                  |
||||	      \  }}	 t'        j(                  |||       |dz  }||
||d| |	<    |s6t        |      D cg c]  }|j                  d      |vr| c}|d<   	 |jI                  t        jJ                  |d              | D cg c]	  }||vs| c}D ]  }| j5                  |d	        tM        |        |S # t
        $ r g }Y w xY w# t        j                  t
        f$ r Y w xY w# t*        $ r$ t,        j.                  j1                  d|       Y 7w xY w# t*        $ r$ t,        j.                  j1                  d|       Y 1w xY wc c}w # t
        $ r$ t,        j.                  j1                  d|       Y w xY wc c}w )zScan every user's enabled watchers; email on folder changes; update state.

    Returns the number of change notifications sent this cycle.
    r   r  r  r  rb   r   r^   z)Folder watcher expiry email failed for %sNr~  TrM  rK  r4  z"Folder watcher email failed for %sr'  r   r   z0Failed to persist expired watcher removal for %s)'r?  r  r  r  r  r   r   r   r   r   r   r   r   r  r)  r0  r:)  r   r9   r=  r  ry   rm   rn   r  ri   r  r  r6  r  r<  r  r  r  r1)  rB)  r   r   rC  )ro  live_idsr  r  r  r  rL  expired_idsr  r1  r  r  r  r  new_snappriorold_snapnow_isor
  r  r"  r  r  s                          r1   _fb_run_watcher_cyclerJ)   s   
 #$EuHD(--h78  BO	zz",,.1H (D)W%0+H5 .	IA%%+C%%-Cc"1%$=g8$s)..#%?MGTP%55eWdK	 ""3'		#t$LL55D)"3'F~V]]_*62HIIcN(bEyy,H$((*446G (-c*2GMc
'9(H'M$E7HH% 8EE'N4d3innc5'#!'V!11%$GAID $)#&.IE#J[.	Ib 5h?'355;k1 '3H]#Odjj!<=BOJ #8ax&7!8 		%5!KW   $$g. 		& % P

,,GPP< ! VJJ(()MuUV'3
  O

$$FOO
 9sj   L  #LL2M";N&N	OO LLL/.L/2)MM")NN)OOc                  H   ddl } 	  | j                  t        dz         	 t        j	                         5  t               }|r t        j                  j                  d|       ddd       d# 1 sw Y   
xY w# t        $ r" t        j                  j                  d       Y 7w xY w)zABackground thread: check folder watchers on a 15-minute rotation.r   Nr   z,Folder watcher cycle sent %d notification(s)zFolder watcher loop error)
rU   r  FB_WATCHER_INTERVAL_MINUTESrm   r0"  rJ)  rn   rl   ry   r  )rU   r  s     r1   _fb_watcher_looprM)  
 s    


.34	>" W)+JJOO$RTUVW W W  	>JJ  !<=	>s(   A6 -A*!A6 *A3/A6 6(B! B!rc   	json_bodyc                 n   t         j                         5 }|j                         5 }||d<   ddd       |j                  | |      }|j	                         xs i }|j
                  dk\  r*d|j                  dd|j
                         icddd       S |cddd       S # 1 sw Y   txY w# 1 sw Y   yxY w)z:Make an authenticated internal POST via Flask test client.r'  N)r   r`  r  zRoute returned HTTP )rm   test_clientsession_transactionr  r  rg   r   )rc   rN)  r'  rg  r  rvr   s          r1   _t1_call_routerS)  ( s    		 b##% 	AfI	WWXIW.{{}">>S TXXg1EbnnEU/VWX   	 	 s(   B+BAB+B+B(	$B++B4z/diagnostics/<bot>/runsr  rP  c                 :   t        t        t        j                  j	                  dd            d      }t        j                  j	                  d      xs d }t        j                  j	                  d      xs d }t        t        j                  | |||            S )Nr   r	  r  rd   r  )r   rd   r  r%  )rL  r   r   rh  r   r   rA  rB  )rP  r   rd   r  s       r1   diag_list_runsrU)  : st     GLL$$Wb12C8E\\h'/4F%A	++CuVqQRRr3   z /diagnostics/<bot>/runs/<run_id>rf  c                     t        j                  |      }|r|j                  d      | k7  rt        d      dfS t        |      S )NrP  rK  r  r  )r:  )rA  rR  r   r   )rP  rf  r:  s      r1   diag_get_runrW)  C sA     

F
#C#''%.C'[)3..sr3   z%/diagnostics/<bot>/runs/<run_id>/logsc                 l   t        j                         }	 |j                  d|f      j                         }|j	                          |r|d   | k7  rt        d      dfS t        j                  j                  d      xs d }t        j                  j                  d      xs d }t        j                  j                  d      xs d }t        t        t        j                  j                  d	d
            d      }t        j                  |||||      }t        |      S # |j	                          w xY w)NSELECT bot FROM runs WHERE id=?rP  rK  r  r  r
  r(  r  r   r  r  )r
  step_idr  r   r  )rA  r%  rL  ru  r[  r   r   rh  r   rL  r   get_run_logs)	rP  rf  rO  r  r
  rZ)  r  r   rJ   s	            r1   diag_get_run_logsr\)  L s     Dll-y

(* 	 	

#e*#[)3..LLW%-Ellv&.$G%AGLL$$Wd34d;E!!&w!SXYD 	

s   !D! !D3z8/diagnostics/<bot>/runs/<run_id>/artifacts/<artifact_id>artifact_idc                    t        j                  |      }|r|j                  d      |k7  rt        d      dfS t        j                         }	 |j                  d|f      j                         }|j                          |r|d   | k7  rt        d      dfS t        |d         }|j                         st        d      dfS t        t        |      |j                  d	d
      d|d         S # |j                          w xY w)Nrf  rK  r  r  rY)  rP  rb   zArtifact file not found on diskr5  r  Tr9   r  )rA  get_artifactr   r   r%  rL  ru  r[  r   r   r   r*   )rP  rf  r])  artrO  r  r;  s          r1   diag_download_artifactra)  a s     
 
 
-C#''(#v-[)3..Dll<viHQQS

#e*#[)3..VE==?>?DDE
)CD&k	  	

s   !C/ /Dz/diagnostics/<bot>/settingsc                    t         j                  dk(  rt        j                  dd      xs i }i }d|v r-|d   }t        |t              r|dk  rt        d      dfS ||d<   d	|v r-|d	   }t        |t              r|dk  rt        d
      dfS ||d	<   d|v r/|d   }|#t        |t              r|dk  rt        d      dfS ||d<   t        j                  | fi |}t        d|      S t        t        j                  |             S )Nr  T)r  r  max_runsr^   z#max_runs must be a positive integerr  r`  max_log_linesz(max_log_lines must be a positive integerrr  z1retention_days must be a positive integer or nullr3  rf  )	r   ra   r  r   r   r   rA  set_settingsget_settings)rP  r  r  r  r  s        r1   diag_settingsrg)  z s#    ~~d48>BZ Aa%Q%JKSPP!"F:d"_%Aa%Q%OPRUUU&'F?#t#%&A}jC&8AE%XY[^^^'(F#$((77$11I223788r3   ztogen.icastinc.comz172.174.242.77scriptszadd_fqdn_cert.shzU^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)+$c                  8   g } t         j                         r:	 t        j                  t         j	                               }t        |t              r|} t        d | D              s | j                  dt        ddddddddd	       | S # t        t        f$ r g } Y Gw xY w)zGLoad togen_fqdns.json; always ensures the primary FQDN seed is present.c              3   L   K   | ]  }|j                  d       t        k(    ywr  N)r   _FQDN_PRIMARYr!  s     r1   r/  z_load_fqdns.<locals>.<genexpr> s     ?!quuV}-?s   "$r   rv  Nr|  z2024-01-01T00:00:00Z	r  rd   record_typerecord_targetr	  r  validated_atcert_expiry
last_error)TOGEN_FQDNS_FILEr   r   r   r   r   r  r   r   r2  r  rl)  )r  r   s     r1   r"  r"   s    G!	**-779:C#t$ ?w??q!!"02

 
	 N $ 	G	s   9B BBr  c                 X    t         j                  t        j                  | d             y r   )rs)  r   r   r   r  s    r1   _save_fqdnsru)   s    

71 =>r3   r  c                    d|  d}	 t        j                  ddddd|gddd	
      }|j                  dk(  rC|j                  j	                         }d|v r#|j                  dd      d   j	                         S dS 	 y# t        $ r Y yw xY w)zEReturn cert expiry string from /etc/letsencrypt/live/<fqdn>/cert.pem.z/etc/letsencrypt/live/z	/cert.pemopensslx509z-nooutz-enddatez-inTr   r5  r   r  r^   N)r9  r:  r<  rb  r   r  ry   )r  	cert_pathr>  rs   s       r1   _fqdn_read_cert_expiryrz)   s    (i8I	*eYGdA
 !==&&(D474K4::c1%a(..0ITI "
   s   A.A; 7A; ;	BBc                 >    t        |       }t        |d         |d<   |S )zKReturn a copy of entry with cert_expiry populated from the live filesystem.r  rq)  )r   rz)  )r   rB  s     r1   _fqdn_enrichr|)   s#    UA-ai8AmHr3   c                    | j                  d      }t        dt        t        |      dz   d            D cg c]  }dj	                  || d        }}g }|D ]  }	 t        j                  ddd|gd	d	d
      }|j                  j                         j                         D cg c]B  }|j                         r0|j                  d      s|j                         j                  d      D }}|r n |sy|dd D ]  }	 t        j                  dd| dd| gd	d	d
      }	|	j                  j                         j                         D ]?  }
|
j                         }|j                  d      r%	 t        j                  |       |c c S   yc c}w c c}w # t        $ r Y Jw xY w# t        $ r Y jw xY w# t        $ r Y w xY w)a*  Return the A record for fqdn queried against its authoritative nameservers.

    Bypasses the local resolver so stale negative-cache hits don't produce false
    'DNS not ready' failures right after the user creates the record.
    Returns the first valid IPv4 address, or None on any failure.
    r  r   r^   r   Ndigz+shortNSTr   r5  r7  rP   r  rD  )r  r  rL  rC  r  r9  r:  rb  r   r  rw   r8   ry   r&  r&  r   )r  r0
  r  apex_candidatesnameserversapexns_outr	  nsa_outrs   ips               r1   _fqdn_authoritative_lookupr)   s    JJsOE5:1c#e*q.RS>T5UVsxxqbc
+VOVK 	^^$-#$F !----/::<88:bmmC&8 
!!#&K 
   "1o 	NN!B4(C6#$E **,779 ZZ\==%((,I" I W  		" "  		sh   FAF#AF*F>A.F5-F&F5F5F	F#"F#&	F2/F51F22F55	G Gc                    	 ddl 	ddt        dt        dz  ddf	 fd} |d       	 t        j                  dt        t               gd	d	d
      }|j
                  j                         D cg c]  }|j                         s| }}|r|d   nd}	 t        j                  |      }|j                  d      dk(  r |d       t        ddd          y|j                  d      xs d|j                   } |d|       t        ddd   d|i       yc c}w # t        j                  t        f$ r dd|dd  d}Y w xY w# t        j                  $ r"  |dd       t        ddd   ddi       Y yt         $ r;} |dt        |             t        ddd   dt        |      i       Y d}~yd}~ww xY w) z@Background: invoke add_fqdn_cert.sh and update togen_fqdns.json.r   Nrd   rr)  r$   c                    	 t               }t        fd|D        d       }|y | |d<   | dk(  r;|j                  d      xs  j                  dj	                               |d<   d |d<   |||d<   t        |       y # t        $ r Y y w xY w)Nc              3   4   K   | ]  }|d    k(  s|  ywrk)  rX   )r,  r  r  s     r1   r/  z8_fqdn_issuance_thread.<locals>._patch.<locals>.<genexpr> s     >AAfI,=a>r  rd   rv  rp)  r  rr)  )r"  r  r   r  gmtimeru)  ry   )rd   rr)  rC  rB  re  r  s       r1   _patchz%_fqdn_issuance_thread.<locals>._patch	 s    	!mG>>EAy AhK!$%EE.$9 %U^^(%,,.>.! #',%",,  		s   !A= AA= =	B	B	issuingr  Tr  r5  r  r7   r  zUnparseable script output: rK   )rd   rr)  rv  r  zfqdn-issuedzFQDN cert issued: )r
	  r  zScript exited )rr)  zfqdn-issue-failedzFQDN cert issuance failed: rn   zIssuance timed out after 300szFQDN cert issuance timed out: zFQDN cert issuance error: rT   )rU   r*   r9  r:  _FQDN_CERT_SCRIPTrb  r  r   r   r   r   r   r   r	  r<  r&  ry   )
r  r)  r>  r	  rP  r  r   r  rl  re  s
   `        @r1   _fqdn_issuance_threadr)   s   s d
 d $ 9-
S*+T2dC
 %mm668GBHHJGG!&E"IB		::i(D 88H)8},TF3 ((<(PnV=N=N<O,PC7s+25dV<~	' H $$j1 	! ;IdsO;LMD	* $$ 
w#BC.4TF;<=		
  
w3s8,.07SX&		
 	

sa   AE	 .DDE	 D ).E	 AE	 E	 $EE	 EE	 	2G =G 1F;;G z/togen-admin/fqdnsc                  b    t        t               D  cg c]  } t        |        c}       S c c} w )z;Return all registered FQDNs enriched with live cert expiry.)fqdns)r   r"  r|)  rT  s    r1   togen_admin_fqdns_listr)  L s#     ;=Aa,q/ABBAs   ,c            
         ddl } t        j                  d      xs i }t        |j	                  dd            j                         j                         st        d      d	fS t        j                        st        d
      d	fS t        k(  rt        t         d      d	fS t               }t        fd|D              rt         d      dfS j                  d      }t        |      dk  r	dt        }}ndt        }}d||t!        j                  di       j	                  dd      | j#                  d| j%                               dddd	}|j'                  |       	 t)        |       t-        ddt!        j                  d      xs i j	                  d      d ||d        t        t/        |      !      d"fS # t*        $ r}t        d|       dfcY d}~S d}~ww xY w)#zCRegister a new FQDN. Body: {fqdn}. Returns entry + dns_record hint.r   NTr  r  r7   zfqdn is required.r  r`  zInvalid FQDN format.z. is the primary domain and cannot be re-added.c              3   .   K   | ]  }|d    k(    ywrk)  rX   r,  rB  r  s     r1   r/  z(togen_admin_fqdns_add.<locals>.<genexpr>a s     
.1V9
.r  z is already registered.r9  r  r   rD  CNAMEpending_dnsr'  r  r2  r  rm)  r;  r  r  z
fqdn-addedr9   zFQDN registered: )rn)  ro)  r	  )r  r  )rU   r   r  r*   r   r   rE  r   _FQDN_VALID_RErG  rl)  r"  r2  r  rC  _FQDN_SERVER_IPr   r  r)  ri   ru)  r   r	  r|)  )	re  r  r  r  rn)  ro)  r   rl  r  s	           @r1   togen_admin_fqdns_addr)  S s    4(.BDtxx#$**,224D01366%34c99}.\]^`cccmG

.g
..v%<=>CCZZ_F
6{a%(/]%,m]"&kk&"-11'9Enn%95<<>J
E NN5<G |{{6"(b--f5#D6**]K U+,c11  </u56;;<s   'G 	G*G%G*%G*z'/togen-admin/fqdns/<path:fqdn>/validatec           	      4    ddl }t               }t         fd|D        d      }|t        d      dfS |d   t        k(  rt        d      d	fS |j                  d
      dk(  rt        d      dfS t               }|*d  d}d|d
<   ||d<   t        |       t        |d      dfS |t        k7  r4d  d| dt         d}d|d
<   ||d<   t        |       t        |d      dfS d|d
<   |j                  d|j                               |d<   d|d<   t        |       t        ddt        j
                  d      xs i j                  d      d   d |i!       t        j                  t         fd"#      j!                          t        t#        |      d$%      S )&zIAuthoritative-NS DNS check; on success set dns_validated + kick issuance.r   Nc              3   4   K   | ]  }|d    k(  s|  ywrk)  rX   r)  s     r1   r/  z-togen_admin_fqdns_validate.<locals>.<genexpr> s     :&	T(9!:r  zFQDN not registered.r  r  r  z$Primary FQDN cannot be re-validated.r`  rd   r)  z"Cert issuance already in progress.r9  zDNS lookup failed: z& did not resolve via authoritative NS.r)  rr)  rQ  r%(  zDNS mismatch: z resolves to z, expected r  dns_validatedr  rp)  r  zfqdn-validatedr'  r9   z+FQDN DNS validated; cert issuance started: resolved_ipr	  Tr(  z%DNS validated; cert issuance started.)r  r  )rU   r"  r  r   rl)  r   r)  ru)  r)  r  r)  r	  r   r  r  r)  rq   r|)  )r  re  r  r   r)  r  s   `     r1   togen_admin_fqdns_validater)   s    mG:W:DAE}34c99V}%CDcIIyyi'ABCGG,T2K#D6)OP'h!lGS7<<o%TF-} ='(+ 	 (h!lGS7<<%E(O!NN+?PE.E,'{{6"(b--f5=dVD{+ 1MSSUU+5\]]r3   c                  F   ddl m}  dt        dt        dt        fd}dt        dt        dt        fd}dt        dt        dt        fd}dt        dt        dt        fd	}dt        dt        dt        fd
} | d|        | d|        | d|        | d|        | d|       y)zERegister Tier-1 tool executors into assistant.py's callback registry.r   )register_tier1_callbackrh  r'  r$   c                 @    t        dd| j                  dd      i|      S )Nr  r  r7   rS)  r   rh  r'  s     r1   _cb_qr_submitz0_register_tier1_callbacks.<locals>._cb_qr_submit s!    i(DHHXr4J)KTRRr3   c                 b    t        d| j                  dd      | j                  dd      d|      S )Nr<  r  r7   r>  )r  r>  r)  r)  s     r1   _cb_duplicate_folderz7_register_tier1_callbacks.<locals>._cb_duplicate_folder s8    1((8R0((<44
  	r3   c                 @    t        dd| j                  dd      i|      S )NrX	  r  r7   r)  r)  s     r1   _cb_prodrun_submitz5_register_tier1_callbacks.<locals>._cb_prodrun_submit s#    /(DHHXr<R1SUYZZr3   c                     t        d| j                  dd      | j                  dd      | j                  dd      t        | j                  dd            d|      S )	Nr  r^  r7   r  r  r  F)r^  r  r  r  )rS)  r   r   r)  s     r1   _cb_scs_save_to_projectz:_register_tier1_callbacks.<locals>._cb_scs_save_to_project sY    4 HHXr2 HH]B7 HH^R8 ,!>?	7

  	r3   c                 F   t        | j                  dd            j                         }|sddiS d|v rddiS t        |      }|j	                         sdd| diS |j                  d	      D cg c]  }d
|j                  vs| }}|sddiS |d   }t        D ci c]  }|d }}t        D ]3  }| j                  |d      }	|	st        |	      j                         ||<   5 t        j                  t        ||d|fd      }
|
j                          |dddS c c}w c c}w )u  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.
        r^  r7   r  zjob_id is required.r  zInvalid job_id.zJob session 'zg' not found. Upload files via the Create Carrier UI first, then use this tool with the returned job_id.r+  	CarrierQRzCNo shop drawing PDF found in this session. Upload via the UI first.r   NTr(  rg  z!Carrier sheet generation started.)r^  rd   r  )r*   r   r   rc  r  r  r9   r  r  r  r  rq   )rh  r'  r^  rd  r  	shop_pdfsr!   r  r  r  r   s              r1   _cb_create_carrierz5_register_tier1_callbacks.<locals>._cb_create_carrier sV    TXXh+,2242336>.//%f-
  "#F8 ,q q  "w/
;aff3LA
	 
 bccaL	!01A!R%11  	+ABAFLLNq		+ &iv6

 	
	 IBeff%
 2s   .DD
D	qr_submitrA  rc	  r  r  N)r  r)  r   )r)  r)  r)  r)  r)  r)  s         r1   _register_tier1_callbacksr)   s    1SD S S S4 t  [ [T [d [d $ 4 &g &gT &gd &gP K-@.2FG,3EF13JK,3EFr3   __main__PORTi  z0.0.0.0)r$  r  debugrT   )r$   N)r7   r7   r7   rg'  r  )r`  )rS  )rX   r  )r7   )T)r  )export)Fr7   r7   r7   r7   r  )r   )rd  )r;  )Nr  )r  )r@"  )r   )r^   )r@  )NNr   )r   (	  r&  r  r  r  r  r>  r&  r   r  rF  r  r9  sysr   r  r  r  rU   rW  rG  r  r   r   
_date_typer  r   r  r   r  pathlibr   r   r   r	   r
   r   r%  r   r   r   r   r   r   r   r   fpdfr   openpyxlr   r3  r   r  qrcoderD  PILr   rI  r  r  	functoolsr   collectionsr   r'  
contextlibr   r>'  r   r   r   rb   r  r*   __file__r,   r  r  r   r)   r2   r#  r4   r:   r=  bot_diagnosticsrA  r  rm   rc  loggingr  logging.handlersrB   _RotatingFileHandlerrC   _deque_typer  r   r  r  rf   r  rh   rk   r  setLevelINFO	propagater  _srhsetFormatter	Formatter
addHandlerr   _excrn   ro   before_requestrY   after_requestrt   rz   r   _session_cookie_domainr  r  r  r@  r*  r  r,  r-  rb  r
  r   r{  r|  r}  r~  rk  rr  rs  rt  rl  ru  rv  rw  rm  rx  ry  rz  r  rq  r  r  r  r  r  r  r2  rB  r  WOR_INPROGRESS_DIRWOR_COMPLETED_DIRr   r   r   r   r  r   r   r  BASE_URLr   r1  r  r   r   r   r   r   r  r   r   r   r   r   rx  r3  _TOOL_ITEM_TYPESr  rZ  rD  r[  _WEB_ITEM_GLYPH_SVG_IDS_WEB_ITEM_GLYPH_EMOJI_IDS	frozensetr]  r  ri  rh  r  rq  ro  rp  rM  r4  r7  r:  r<  rM  r_  rj  rs  rv  ry  r{  r  r  r  _ICON_ASSETS_ICON_MANUALS	_ICON_RPM_ICON_OVERSIGHT_ICON_WOR_MAINTENANCE_ICON_WOR_MOBILE_ICON_WOR_SAFETY_ICON_WOR_PREVENTIVE_ICON_WOR_ITHELPDESK_ICON_QC_BATCHES_ICON_QC_DAILY_ICON_QC_IMPORT_ICON_QC_REPORTS_ICON_KM_STATUS_ICON_KM_REPORTINGr  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r	  r  r  r  r  r  r  r!  r*  r%  r&  r  r3  r$  r6  r8  r  r   r#  r/  r  r=  r  r  r  r  r>  rU  r%  SCADA_ENFORCED_RECIPIENTS_FILESCADA_EXTERNAL_RECIPIENTS_FILEr:  VISION_COLLECTORS_FILEr&  VISION_AGENTS_FILEr&  rv&  rJ(  r&  r.(  r'  r'  r)  r<  rE  r>  homer@  rB  rD  r   rF  rG  rI  rK  rM  rO  r&  r&  r&  r&  r`  r\  r_  r]  r^  ra  r  rI  r  r'  r  ro  r  r~  r6  r	  r_  r`  r  rb  r_  ro  r  r   rI  r  r  r  r  r  r^  r`  ra  r  r!  r  r  r  r  r  r  r}  r~  r  r  r  r  r  r  r  r  r  r  r  r  r  _TOOLBAR_LABELSr  r  r  r@  rN  rU  r  r4  rs)  r|  r  r  r}  r  r  r  r  r  r/  r  r  r  r  r  r   _BOT_LABELSr  r  r  r  r  RPM_TRACKING_FILErp  rv  r  r|  r   r   r  r  r  r5  rj  r<  r  r  r  r  r  r1  r  r  r  r   r  r  r  r  r  r   r  r
  r  r  r  r  r"  r0  r1  r
  EVENT_WO_STATUS_IN_PROGRESSEVENT_WO_STATUS_CANCELLEDEVENT_WO_STATUS_COMPLETEDEVENT_WO_RECURRING_DUE_SOONEVENT_WO_RECURRING_OVERDUErY  r3  r7  r;  r>  rC  rI  rL  rQ  rS  rW  r[  r^  r`  rj  ro  rv  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r#  r  r	  r  r  r  r  rO  r  r  r  r  r  r  r  r!  r$  r'  r)  r-  r.  r0  r2  r;  r?  rB  rt  rP  rm  ry  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r	  r  r  r  r&  r)  r/  rM  r3  rT  rW  rZ  rd  rg  r+   compilera  r  rn  r  rZ  r  r  r  r  r  r  r  r  r  r  r  ALLOWED_PHOTO_EXTSr
  r  r   r  r  r  r  r  r  r#  r  r]  rc  rf  ri  rk  rm  r  r  r  r  r  r  dirnameabspathr  _TOGEN_JS_URLr  r  r  r  r  r  r  r  r  r  r  r  r  router  r  r  r  r  r  r  r  r  r  r  r'  r  rO  rT  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r"  r(  r-  r3  r;  rA  rB  r  r  r  r  rI  rG  rK  rO  rT  r^  rn  r_  ra  re  ro  ru  r}  rw  ry  r  r  r  r  r  r  r-	  r/	  r1	  r	  r5	  r	  rB	  rL	  rT	  rW	  rc	  rg	  rl	  ro	  r	  r	  r	  r	  r	  r	  r	  r	  r	  r	  r  r"
  r8
  rX
  r]
  r
  r
  r
  r
  r
  r
  r
  r
  r
  r
  r
  r
  r
  r
  r   r  r  r  r  r  r  r  r  r'  r*  r,  r.  r2  r8  r>  rA  rF  rH  rJ  r]  rz  r  r  r  r  r  r  r  r  r  r  _RPM_APPROVAL_DEFAULTSr  r  r  r  r  r  r  rq  r
  r  r  r
  r  r  r  r  r  r  r  r	  r  r  r!  r-  r/  r1  r4  r8  r=  rA  rB  rD  rH  rM  rr  rx  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r   r'  r,  r/  r:  r<  rB  rF  rH  rd   rL  rN  rP  rV  rd  r^  rl  r\  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r	  r  r  r  r  r  r  r  r  r  r  r  r  r'  r)  r+  r8  r   rR  r@  r4  r;  rE  rH  rN  rT  rV  rX  rZ  r\  r^  rb  rf  rh  rj  rl  rn  rp  rw  r  r  r  r  r  r  r  r  r  r  r  r  r
  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r   r"  r%  r:  r>  rD  ro  rV  rX  rZ  r_  re  rw  r  rq  rp  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  rt  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r8  r6  r%  r)  r+  r0  r3  r5  r8  r<  r?  rC  rE  rQ  rT  rW  rY  ra  re  ri  ro  rq  ry  r{  r  r  r  r  r  r  r  rv  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r   r  r  r  r  r  r  r  r  r  r!  r#  r  rH  rJ  rL  rU  rZ  r\  ra  rd  rh  rl  ro  rr  ru  rz  r|  r  r  r  r  r  r  r  rZ  r  _winston_sched_modrun_records_lockr  r  __annotations__r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r%  r)  r,  r0  rF  rN  rU  rX  rh  rm  rv  rw  r  ry  r  r  r  r  r  r	  ru  r  r	  r  r  r  r  r  r  r  r  r  r  r  r4  rT  rP  rU  r7  r@  rC  rL  rN  rR  rV  r^  rh  r  r  ri  rl  rq  rt  r|  r  r  r  r  r  r  r  r  r  r  r  _PRINT_AGENT_STATUSESrS  r  r  r  r  r  r  r  r  r
  r  r  r  r  r  r#  r%  r*  r0  
gettempdirr2  rG  rA  r[  r;  r>  rB  rI  rK  rO  rT  rW  r^  rc  re  rk  r{  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r$  r8  rO  rU  rr  r^  rx  rY  r[  ra  rs  r}  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r%  r8  r&  r'  r  r  r  r(  r)  r*  r+  r,  r  r  init_scada_dbr
  r  r.  init_thingpark_dbr  init_assets_dbseed_parts_catalogry   r7  r>  r@  rI  rW  r[  r_  re  rm  ru  ry  r~  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r   r  r  r  r  r!  r$  r'  r+  r,  r/  r5  r8  r;  r?  r  
init_qc_dbrU  init_logistics_dbrA  rF  _QC_ANY_LEAFrI  r_  re  rt  rx  r|  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r%  r*  r,  r3  r8  r?  rC  rX  ri  rn  rr  rv  r|  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r]  r  r  r  r  r  r  r  r  r   r  r  r	  r  r  r  r  r  r  r  r&  r(  r+  r>  rC  rG  rK  rT  r4  rX  rq  rg  ri  rt  rx  rz  r}  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  _VISITOR_STATUSESr  r  r  r  r  r  r  r  r  r&  r-  r0  r3  r
  r7  r  r=  rD  r  r  r  rK  rQ  rW  rY  r[  r]  r_  rd  rk  ru  r{  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r   r  r
  r  r  r  r>  r*  r(  r+  r.  r0  r  rf  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  rg  r  rB  r  r  r  r  r  rK  rN  r#  rS  rU  rZ  rb  r~  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r$  r   r&  r%  r(  r)  r*  r,  rr  rP  r"  r#  r!  r  r  r  r  r  r  r  r  r  r  r  r3  r5  r7  r  rB  rH  rK  rN  rS  rU  r^  r`  rl  ry  rJ  r  rs  r  r  r  r  r  r  r  rJ  r  r  r  r  r  r  r  r  r   r  r  r  r  r  r  r  r  r#  r)  r+  r.  r0  r5  r9  r=  r@  rB  rL  rO  rQ  rX  r\  rc  rg  rl  rp  rt  rz  r}  r  r  r  r  r  r  r  r  r  r@  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r	  r  r  r  r  r'  r  r  r0  r1   r,  r  rI  rL  rC  rO  rM  rP  rf  rg  r^  r_  rU  rV  rc  rZ  r[  rX  r\  r-  rK  rk  rm  r  rV  rW  rt  rw  r  ra  r  r  r  rk  r  r`  rf  r  rb  r  rE  r  r  r  r  rF  rD  r  rT   r  rB  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r    r   r
   r   r   r   r   r   r    r$   r'   r6   r:   r=   r?   rA   rG   rL   rW   rY   r\   ra   rc   ri   rs   r&   rx   r   r   r   r   r~   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r!  r   r   r   r0  r   r   r   r!  r!  r!  r"!  r&!  r+!  r.!  r5!  rk!  rD!  rA!  rB!  rO!  r|!  rC!  r]"  IDENCIA_SYNC_TIMEOUT_Sr\"  r!  r"  r!  rF!  rH!  rP!  rS!  rc!  rf!  ri!  ro!  r!  r!  rq!  r}!  r!  r!  r!  r!  r!  r!  Sr!  r!  r!  r!  r!  r!  r!  r!  r!  r!  r!  r!  r!  r!  r!  r!  r!  r "  r!  r"  r"  r"  r"  r"  r!"  r-"  r1"  r2"  r4"  r<"  rC"  rK"  r	  rO"  rP"  rU"  rX"  r^"  r`"  rb"  rd"  rf"  rp"  r  r"  r"  r"  r"  r"  r"  r"  r"  r"  r"  r"  r"  r"  r"  r"  r"  r"  r"  r  rZ  r"  r"  r"  r"  r#  r#  r	#  r#  r#  r#  r#  r#  r#  r#  r+#  r0#  r:#  r#  rE#  rL#  rQ#  r#  r[#  rV#  r\#  rg#  rh#  rn#  r  r2  r#  r#  r#  r#  r[   r#  r#  r#  r#  r#  r#  r#  r#  r#  r#  r#  r#  r#  r#  r#  r#  r#  r#  r#  r#  ro   r#  r#  r@  rq   r#  r#  r#  r#  r#  r $  r$  r$  r
$  r$  r$  r$  r$  r#$  r$  r;$  r-$  r,$  r$$  r$  POWERBI_LIVE_TIMEOUT_SrT$  rB$  r#  r$  r#  r!$  r&$  rd  r1$  r2$  r.$  r4$  r<$  r>$  r?$  r@$  rC$  rE$  rH$  rV$  rX$  rY$  rZ$  r\$  r^$  r`$  rc$  re$  ri$  rk$  rm$  rp$  rr$  r~$  r$  r$  r|$  r}$  rt$  r$  r$  r$  r  r$  r$  r$  r$  r$  r  r$  r$  r$  r#  r#  r$  r$  r$  r$  r$  r1%  r%  r%  r$  r%  r$  r$  r$  r$  r$  r$  r$  r$  r$  r$  r$  r$  r$  r$  r$  r$  r
%  r%  r%  r%  r%  r%  r%  r%  r%  r %  r"%  r$%  r&%  r(%  r+%  r3%  r5%  r7%  r:%  r<%  rI%  rB%  rJ%  r[%  rT%  r%  r%  r%  rA%  r%  r%  r%  r`%  r%  rC%  rE%  rG%  rK%  rM%  rQ%  rU%  rW%  rX%  rY%  r\%  r^%  rc%  rl%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r%  r&  r&  r&  r&  r   r
&  r&  r&  r&  r&  r&  r  r&  r &  r2&  r?&  rA&  rF&  rI&  rP&  rQ&  rZ&  rb&  rd&  rm&  _VISION_CHECKIN_FIELDSr'  r&  r?(  r'  r(  r(  r(  r'  _VISION_AGENT_STATUSESrw&  r&  r&  r&  r&  r&  r&  r&  r&  r&  r&  r&  rV(  r&  r&  r  r&  r  r&  r&  r&  r&  r&  r&  rb(  r&  r&  r&  r&  r&  r&  r&  r'  r'  r#'  r@'  rj'  rl'  rm'  rp'  rt'  r'  r'  r'  r'  r'  r'  r'  r'  r'  r'  r'  r'  r'  r'  r'  r'  	MULTILINEr'  r'  r'  r'  r'  r'  r'  r'  r'  r'  r'  r'  r'  r'  r'  r'  r'  r)  r'  r (  r(  r(  r(  r(  r(  r-(  r+(  r,(  r*(  r(  r(  r(  r(  r2(  r5(  r;(  r=(  rC(  rR(  rT(  rW(  rY(  r[(  r](  r_(  rc(  re(  rh(  rg(  rm(  rq(  ru(  ry(  r|(  r~(  r(  r(  r(  ru  r(  r(  r(  r(  r(  r(  r(  r(  r(  r(  r(  r(  r(  r(  r(  r(  r(  r(  r(  _RUN_SCHEDULERSrl   r  _recurring_threadrq   r(  _news_scheduler_threadsweep_running_sweptr  
start_loop_winston_scheduler_threadstart_periodic_pruner(  _scheduled_update_threadr(  _backup_scheduler_threadr(  _scada_collector_threadr(  r)  r)  _vision_monitor_threadr)  r)  r)  r)  r)  r!)  r*)  r,)  _vision_recovery_thread_manuals_backfill_thread_manuals_thumb_threadrL)  r1)  r3)  r:)  rB)  rJ)  rM)  _fb_watcher_threadrS)  rU)  rW)  r\)  ra)  rg)  rl)  r)  r)  r)  r"  ru)  rz)  r|)  r)  r)  r)  r)  r)  r)  r  r:  )r  r  rn  r  r  r  s   000000r1   <module>r*     s    
   	   	 	   
         { {  : : e e e  " *   # !  8 = , , 3tH~--/66==> ?5 37$ 4  $(4K;?>*
  #Ho#4

    H ,ZZ^^,EsKsR (?HI JJNNX ''&03FFG 
 $3/  -8--.BC!!(--0%*"
X&&,,TD,I#Q
 	,(,,-FGH''- 	+ + 	 * 
 
 ::&89

<  !?%'  *@CJJ&'-.12
-.
 !G!G!G!G!G!G!G
 +#<AA
- 0 C9*F 56
;"**..4FGH	"**..3IJK
"**..4GHI"]2 .> .< .< $6 2]B 2[@ 2[@ $x/+m; +k9 +k9 &5  2] B  2[ @  2[ @ $'77  %w.*-DD  %z1 $'??  %y0!(^335<<?VV  #8n446==@\\  "(^335<<?RR   O - , 2::>>-1QRS2::>>-1QRS"**..)=?]^_ "**..)=?\]^ '*4 bjjnn%57PQR rzz~~i)<=
> ZZ^^M+MN**..!2B7H~--/669OO x.((*114EE H~--/669OO 0  8 
 7   S S4n>C x.((*114EE h'')00<?.     &     7:S ST 
 !#  3     4 1  ;t ;
=T =
?4 ?
, ,3 >$@*$N69>/8 (^++-447KK  (^++-447SS ^ P U 
) V $ 8 C R 3 Q > 6 Z H  &$!:;&8X!/Fx$dDR yT"0HQZ/W[] #9E,@+UT`b H$2LU`14Y]_
T #%%( =G*8VtUY[ #-=D%3StUY[ h>%3LtUY[  *D8)7TtUY[  .E)7ItUY[#
l $[%dL^(6NW` %(* %7M#DJ\&4JSZ %(* hI$TK]'5LU]'PT(* yM%dL^(6NW`(%QU(* !=K$\Sa,:T]e %
 *$
& $.>A'Vd*8PY_ %$& $.>K$\Sa/=Zcn %$&U0
d &0B?A!NIK
U '9V)/ePVX&4)/ePVX	
{GgTA*1	,9&	!H@$Af2
$)2D2$A0P6)0 6?BT x.00299<UU 8n,,.558LL (^++-447II x.00299<UU  8n,,.558MM  x.00299<UU N""$++.??	>))+225LL h//188;SS N**,33oE  X..077:QQ N**,336II "8n446==@]] !%h!7!7!9!@!@Cc!c !%h!7!7!9!@!@Cc!c  JJNN&DN,B,B,D,K,Ki,W(XY[  h//188;SS H~--/669OO (^++-447KK  x.00299<UU h'')00;>  (^++-447KK H~--/669UU !(^335<<?[[ !(^335<<?TT "8n446==@VV 
 JJNN'( <X ''*::  **..)ABMgTTV\\^  JJNN+, 9
9499;"778  !jjnn-IJWi^^` JJNN-. BX ''*@@  "%JJNN34N<M8N"  rzz~~.HISVT   #2::>>2P#Q#XUXY  0L!M!UQUV  JJNN*+ 8X '',6  

'>?[C[ 
 ZZ^^$79IJ (;VDE ZZ^^$7? **..)@"EKRRT  ZZ^^$79IJ (;VDE ZZ^^$7A ZZ^^$69XY ZZ^^$69IJ ZZ^^$7I 
 X&&(//*<h'')00;>H~--/669OO &	( x.((*11L@X..077:QQ  8n,,.558II 
 X..077:LL  JJNN)* 8X '',6  zz~~&<=XAX  X..077:MM (@A^E^  h//188;SS  X..077:QQ  h//188;SS  h//188;SS !(^335<<?VV !(^335<<?WW !(^335<<?YY 8n,,.558LL x.00299<TT h//188;RR *1ocf{~  QR  ij  k , :  X&&(//2FF  & !K/ 8n,,.558JJ   , ! 
   "
       *4VW.9`a-8^_ 
 8 @% 
 3%KZX W>113::=WW 8n,,.558MM >))+225GG (^++-447KK 
 >))+225GG x.((*114EE (^++-447KK  >))+225GG  8n,,.558MM %';<& JM **; jM' P +A*G*G*IZSYZa!Z!Z  >))+225GG 42  
(?9 B-;J |n-  x.00299<UU 
 =I&8n,,.558MM h'')003CC
 H~--/669OO N**,336II  N**,336II 
 N**,336II 4  X..077:QQ 8n,,.558II  h//188;SS * |\  &  . X..077:QQ  h'')003CC
 00 ,CDqadD  x.((*114EE h//188;SS  8n,,.558MM  N**,33oE    ,
0$*:**	Bd 	Bt 	B;C ;t ; ;.s .t .s t *s *d *+3 +S +T +3 * N b #446#??#==?#==?#??A#>>@#99; 
t 
 
  &C D "4 "=D =T = LS LT L$T "?d ?t ?&
4 
d t 8 8 8
8t 
8 
8.dTk .d .? ?$ ?C C Cd 8@t @ @4;s ;s ;>t > >4 8&=D =T =6;C ;C ; 8n,,.558MM d .D D $ :D4 DD D8t  &8D 8S 8  ; T e <*t * *ZC$ C4 C T d F @

2T 
23$ 34 3 #*  (&<4C 4D 4F
Jt 
JC C C
8 	"(  ( 0  '4Y  (/d/D !! +
@	(1@
6	<	%P:s :s :
5 5 5	T 	Ct C C#  C W[ `d "4 .S TD[ "!:c !:H# T H	$ 	HD HT H   	$S 	$ 	$C 	$TX 	$ S      F S T > H ? ? ?   3  t  t  F3# 3$+ 3&$@F rzzAB // 	 	 	*S * *c4i *^ !<< :: ::   4 D 2 c $$ Bt B$s $C $S4Z $[^ae[e $"# "s "(+"DH"J 1#63 #63 #6Lc c  @D.33 #* '+8<t, A D 2::>>*<dCD
9d 9t 9
6$+ 6$t* 61t 1$+ 1# 1h
1$ 
1$ 
14 
1d C $+ . 4$; 3 )c )# )s )     4$;  3  .X X$ XvS T   ! ! !; ; ;
@ @T @d @;;9; Cy; c	;
 
$Z;D *$  *4  *Ncn	Z]# 9 4 ggll277??277??8+DExP?<D *%0 }Z
("q~(>6(  !  "    *&.#S4Z #d #tTXz #L =A+0 sTz $(59D[$.C . #?S #?S #?L 8	 	 J JZ 9  3A  AH =6(+&M  ,&MR =6(+<g  ,<g~ >F8,!  -!: >F8,!  -!F t t t  * 9vh'T)  (T)n 9  9" ?	8  	8 !")  #) "VH5T  6Tn P <?RR 
 #fX677  777t ?A  A" #$4  %46 "#C  $C&    , :x(!  )!, 15  254 2::>>-1MNO( H+  $&== 
4 
 "@ @ @
s  $ 4 ');;  '?1$ 	 	4 	DD DT D
3# 3$ 3 < $&;;  '0 	T 	Mt M M
d 0  "0 0 0>/c /T /# /RVY]R] /d+*cDj +*\v# v vc vdTk vrp=# p=$ p=f	s c dTk (* *$ *; ;$ ;
I Id It IG4 G G$ G $t* 04 DJ 6 vh/"7  0"7J !F84(  5(0 vh/''  0''T N  N 	A  	A 	Q   	Q #$m4E# 5 E#P &8-  9-
 &9
0  :
0 !E73Q  4Q $vh7A  8A %x843  943n $ug6S  7S #fX6/  7/4 -x@3  A3& -zB!  C!rD rtd{ re rj@t @$+ @QV @F]t ] ]@f4 f f fRQ Q4 QE Qh =6(+h7  ,h7V &2Y7  3Y7x fX.}7  /}7@ #fX6{R  7{R| >    6 >	A  	A ?Q  Q 1SG  2SGl =5  5( <$  $2 >!Q  !QH !  !    Qd Qt Q
: : 
&  
& 
)  
) )  ).    !* :  :   * $%Y/5 0 &5
7# 7cDj 7D 7t &'Y/; 0 (; 67   8   78  9$ &9Y/ 0 :D &9Y/%R 0 :%RZ  !&  "&  6(3+  4+4 +gY?5+ 5+  @5+p 67$C $  8$ &2/  3/< <&*V  +Vz w   w t  !H  "HV %x8  9D	t 	C4 CD C	'3 	'c 	'd 	' #( "' D A A$ A ; 9 "-;*R8  &D &R<D <T <' ' ' '&1 1 1 1(" .37;"J*'  LN"$(OV	3 	#* 		$ 	3
	 	 	 	 <;"  ;"| ?  *  O  !O2 18+  28+v x0)  1)* x0  1" fX.F)  /F)R A  A ?2  2
 ?VH-/  ./ !"0  #0
 !F84!9  5!9J O T d   ]  !] &20  30, -a  -a` 1v/  2v/z 8  8< vh/o%  0o%d 11%  21%h I  I, ;)B+  *B+J x0=+  1=+@ >F8,V+  -V+r !F84UF  5UFx  6(3=K  4=K@ "VH5  66 &2-  3-]S	 ]H)D )T )= ) $))-)> vh/:"  0:"zHt Hd3i HD H  $vh7"  8"$ &9M  :MBs    +fX>M  ?M     ! , >   >F #&;%<<  $vh76"  86"z  Gt GT Gd G%d %t %
 $@$ @ @ @S @37@< =6(+,>  ,,>^ !"2  #2 #$F  %F >E  E >F8,-  --, #'$!@$ !@ !@ !@S !@;?!@H !F840>  50>f  G  !G !" F  # FF 1(>  2(>^ 9vh'!"  (!"H "#%  $% #$<  %< $%R  &R '(O  )O( #eW5)  6)" #fX6`  7`F? ?t ?T ?cDj ?	4j  c	
  	4Z*CC4jC C c(	C
 
$ZCV$ 3  s Wd " !F84S  5St 127 C   *,  *,Z &2,  3,* %x8,  9,, !"5  #5, $h'')00 "- !9>># +;\ !"  #8  6(3,  4,6 &(>> !6 &	( T@$ *+5  ,5
 *VH=!,  >!,H!FH.Ql )*2  +2
 )F8<.  =. ,-8  .8
 ,vh?1  @1& $%-  &-
 $vh7<M  8<M~ ()5  *5
 (6(;1  <1L +l_  *//1QQ%ZAqD  " c  + +\S .  
<  !
< 1E  2E&	S 	 !"<  #< -.6  /6
 -x@.  A. ()1  *1
 (6(;)  <)8 ,-8  .8
 ,vh?1  @1. ()0  *0
 (6(;-  <- #$,  %,
 #fX6)  7)6 &  &( fX.I+  /I+\ %&(  '( %x8&+  9&+R %z:+  ;+, $vh7!V  8!VH >E7+0  ,0
 >F8,%9  -%9P eW-4  .4
 fX.%=  /%=P >E7+0  ,0
 >F8,"-  -"-X @ %d %uS#t^4 %, 4$; 49 "'C 'tCy '# 'RU 'T  8d d uT#Y_7M D1D 1s 1d 1 OD OS OS Oc3h ,D T .4: (!G$ !GHe# e 11O  21Oh 1#L  2#LL =6(+IF  ,IFX 0   1 $ >F8,:L  -:Lz 0G  1G %w71  81
 =5'*3  +3
 =6(+<  ,<0 $vh7j  8jZ $vh7d  8dN !F84l  5lf (6 )E7;:  <: #fX6'  7'M
  $vh7]A  8]A@-,`KA\NAb +eW=	)  >	) 2. Q 9F1( . ,vh?@D  @@DF ,vh?*!  @*!` 1
 

>?: : :	*t 	*s 	*t 	**d *t *Z
d 
 	 	
$ 
't ' '* )F8<6  =6. )F8<6  =6" (5':-  ;-
 +fX>:  ?: -x@:  A:$   $ 3 Bs t >  6 ,vh?U  @U *VH=@  >@P 9ug&7  '7, 56 +,  -2 s td{  <>  >J: :$ :A A$ A +A A A
83 8  . H$ H4 H >T  T >F8,,/  -,/^ &9/  :/. %1/  2/2 (4/  5/" <&*21  +21j >):  ):X <&*%  +%@ <&*"/  +"/J =
=  
=$4 =     =6(+.'  ,.'b "UG41'  51'h "XJ7  8'b?J%!R ;B8;@ 0B  1B vh/-  0-( 1	.  2	." ()3  *3
 (6(;
-  <
-$  2  !2
 &2
-  3
- #$&  %&
 #fX6:  7:0 -.9  /9 +fX>
-  ?
- ,vh?
-  @
-$ '()  )) $vh7-  8-$ ()@  *@. -.,  /, )*O  +O )F8<AF  =AFR +,  - +fX>+  ?+  "#  $  "VH5(  6(@ +,D -D '( ) /0H 1H ./G 0G %&%  '%.:
# *? %'7  !xhdKX+ 8 ET E!L # * "#  $ $%  &$ $vh7 B  8 BF "VH59  69& )*  + )F8<  =. %&  ' %x8  9@ !"  #2 \  e  eP #$#>  %#>Z = 2   : 9&77  d  Gt G4 Gt G+t +;d ;dTk ;| #  #L vh/L  0Ldd 	 $%-  &-` $vh7&  8&R ,vh?F  @F,  8  !8 &2+  3+ *UG<&  =& *XJ?  @  !  "  6(3+  4+ .	B	&  C	& .
C  D !"*#  #*#Z x0Y  1YD 1D  2D0V V )5  )5X EF  G.  # #L3 .;4|
 2::78 @T @ +/4 :D)X?2l !,87:&4K$ND% ! <F $Z -`2 JM  JMZXv  %&' ( ! (n  nF %inn&   4"&R5p #$786	 *+&'(E5 ) ,E5b (
#.[J #0H%5^U #0&9>  	
   '(7Y  )7Yt ()#$m4U 5 *U0 ,-S  .S$ *+%&'C ( ,C *+%&'kS ( ,kS\ !E73-  4-
 !F84Z  5Z0 +eW=)  >)
 '&:<  ;<  -  9 Od)

8 !F84;  5;, #fX6F\ 7F\R fX.A. /A.H )F8<$ =$N ?"  "DM, /&B$  C$ ,vh?$  @$ .A$  B$ '(<A  =A0 01!>  2!>H 06(C#  D#L !".  #. (6(;
2  <
2& JJNN$%\$/Bx/B/B/D*EH[*[ (	  O  J&((V:6 
  
 =6(+HI  ,HIV !"  #< &'H (H> $%X &X ()T *Tv #$  %6 #fX6A  7AH "#  $2 4   4" "VH58  68v  ?  !? %1
  2
 vh/D4  0D4N &2/.  3/.l JK L  " ug.=  /= vh/$D  0$DN xj1  2" >E7+
P  ,
P #eW55  65 >F8,%D  -%DP >H:.  /"  5'2
<  3
<  6(3(E  4(EV  8*5  6" EG/10f %';<=J > JD ()%';<=S > *S6 -	  #$/"#BG $ %BGR ?&'(#3 ) #3T  6(3B  4B6 fX.9  /9N  0 S >
 0 >V  >VB  !7=  "7=t B   B$ #fX6!F  7!FH $vh7!  8!H "VH5#H  6#H^ @C  @CFSl  !C  "C> x0@  1@N ?&  &( ()  *$ >F8,  -$ (6(;  <  ()<  *< <&*M/  +M/` *+<  ,< <&*I  +I` ) =D  DN(&A& ?:E  :Ez b7  b7X      "      
 
  ! ! ! #     	#L##$9$;<
%GP ! " (  9,  ,6 >  " $%$2  &$2N *VH=}o&1 ' >1( "#~&'" ( $" fX.~&'#" ( /#"L >F8,}o&!1 ' -!1X1 8  8 fX.'()
  * /
  *VH='()  * >  *XJ?'() * @ )*B  +B #$O  %O. #fX6'()% * 7% 06(C'()% * D% 08*E'()
 * F
05"63l. 24 )=X ?)*+R , R $%)*+9 , &9
 ())*+B , *B (6(;)*+! , <!2 &9)*+@ , :@D ?VH-)*+ , .8 ())*+B , *B (6(;)*+ , <" (8*=)*+	 , >	 /&B)*+#4 , C#4L -x@)*+ , A  .
C)*+
 , D
&  H
G='@B %&)*+ M , ' MF  !)*+8 , "8 01)*+P , 2P  " x0  1  +,*  -* Q  !F84}o&&I ' 5&IR !F84}o&8 ' 58Z       ! ! ! #.8 !Q :l#&S $ &SR =tfA  A =6(+tf   ,   '(tf   )  '&:tf   ;  '(<}o& ' = |n%@ & @ !"|n%H & #H(2 |n% & " !"|n%  & # , ?}o&M ' M ?VH-}o&! ' .! ,xjA}o&	 ' B	   }o&M ' !M
 &2}o&$ ' 3$@ $%}o&M ' &M $vh7}o&
" ' 8
" }o&: '  : 1}o&& ' 2&  }o&	B '  	B 1}o&N ' 2N &9}o&+ ' :+ )F8<}o&N ' =N  )H:>}o&N ' ?N* >}o&" ' " >F8,}o&K ' -K !F84}o&+ ' 5+ &9}o&K ' :K& &
;}o&K ' <K  }o&- '  -( 1}o&! ' 2! %x8}o&Q ' 9Q )F8<}o&( ' =( >}o&K ' K >F8,}o&  ' -  34}o&C ' 5C $%{m$
* % &
* $vh7{m$" % 8" vh/{m$#" % 0#"L$N '&:{m$$" % ;$"b ()}o&7 ' *7 (6(;}o&	! ' <	! !"l#G $ #G  !F84l# $ 5H BJJNNN##&<<> ?  2::>>4>))+225FFH I/0: !N##&AA"C D 
2F?
7 t
+: )39=@..*
 :   *51  . 4rj&R }o&L ' L vh/}o&
J ' 0
J "#}o&# ' $# "VH5}o&) ' 6)8 %&'()! * '! !"'78"H 9 #"HJ !"'()& * #&R '&:'()BJ * ;BJJ vh/{M23%/ 4 0%/P ?{M234 4 4D x0{m$ % 1> vh/{M23$ 4 0$> &'{M23: 4 (:@ ?VH-{m$  % . *8
=B-.V  !3  "3 ?  @ #$  %6 $  $, $%  &*  -  !-B 79QST .b?, % F!
<#*L@

 
D T > > >
7dTk 
7d 
7/, "
"D6$NCDC$=@'T .E   .Eb &9'  :' $vh7'  8' &9'  :' *VH=3  >3R - 	 	A A$ A
.T .c . BF26c C$J %(4Z61C:44 4 ;(@  )@ ;) )  * )F ,vh?)  @), %x8)  9) xj1  2"	 	:$ :4 :
-t - -@4 @ %)C c t -161C6 <%)#  *# <&*(  +(8 x0%(  1%(P z2  3(:B c+K89:	9 ; 	9  c+K89:3 ; !3 &2,-.(B / 3(BV +fX>,-.
S / ?
S +hZ@,-.	 / A	 c+K89:9 ;  9 1,-.	  / 2	  (6(;,-.  / <  (8*=,-. / >  !c+K89:= ; "=  6(3,-.	$ / 4	$ *VH=,-.$ / >$ *XJ?,-. / @ c+K89:> ;  > 1+,-	  . 2	  (6(;+,-  . <  (8*=+,- . >$	9&R c+K89:N ; N '(c+K89:R ; )R $%c+K89:) ; &)DA  x0()*G + 1G" '&:()*R + ;R" .A()*R + BR '(<()* + = !"c+K89:? ; #? !F84()*	# + 5	# +fX>()*# + ?# +hZ@()* + A$-  < 4 4 7# 7
) )C  '4 Od
d 
>3 >D# D
$ 
;4 ;D ;	 	S 	T 	 3 % 4 3 3 ((b P U t 2 t td tn!T !HT  T  0G# G	d 	S 3 4 c  2E:4 (4 D  8:$ 924 >E ED ET EPJUd JU$ JU4 JUt Q 3 3 3D D D $ 2$ T d .A A A(.(:A@WD
9EF<	
=7:T  4  B   O  x( A N  E  )     
     $ " 	N##&BB *))<~.|4 |~ D T .
T 
@s @s @( (8&D &^
 0
'I*,=@A)d A)$ A)J&8% 8%d 8%v74 7t 7 $4 $t $+T + +D +T +\T & 0(z*-8!,<=X.6	 vD vr
4 
"4T "4 "4$ "4JT4 Tt T Tn% %T %d %bDS D*,# ,# , ,^9 9T 9d 9LF$ Fd Ft FR =5'*"0  +"0J4 D B  5'2F  3F	?D  6(3O  4O  -w?-  @- %x82;  92;|64 6D 6
 '%9,  :, '&:;  ;;> 2VHE N  F NF 3fXF;  G;> ,xjAD  BD4 =6(+	,  ,	, $vh7",  8",J "VH5,  6,  "XJ7  8" *VH=0  >0    ,vh?-B  @-B` 08  18 1	8  2	8 #fX6O  7O. "UG47  57 "VH5C  6C $vh76  866 (6(;,  <,8 (6(;7  <76 2VHE#7  F#7L 2XJG
,  H
, *VH=,  >,2	# 	 %x86  96* %x8  9 '(< )<0t # #T #LT  $ &	d 	$ 	4 	L4 L L$ L^ "#9   $9 x %&C  'C. %x8">  9">J 1F8D9  E9B;Z
8(
8*5	(F?>86 780 </+-/#8#8 0 #  #L(.V #$;  %;6 ()
$  *
$$P,,.  !S  "S< *+ H  , HZ
$+ 
&C &D4K &(/d /29t 9 9x5s 54  * "$  " F$ F	H4 	HC 	H# #$ #^ * -   &T ( 1 9       (   C"=&	=S 	=:T :B ")%+UFL T D T M M$T d .T .# $  57 Fc F4 F* ( 
.  !#*$8=	 S T  C T "t d t ) )Bd Bt BP3 P4 P4 
S 
$ 
& * @    )FT )FXS # 'c 'D 'f * 4  :4 :,
# 
. . . .d .b D T >3 4 2t 2" 4 D 4$ # c T  $ &   F #fX6 4  7 4J1 
 R * !## !#H  ! "4(# ( *+R ,Rj &'; (;& ()j *jZ %x86P 96Pr x0$ 1$B ?  ?, '&:@  ;@8 06(C@  D@< .A  B '(<  =, 2<UVK@  WK@\  !C "CF $ug6)?  7)?X $vh7V  8VD /&B#B  C#BL /(D  E ,vh?;  @;8 %&$8  '$8N
  x0QD  1QDh "VH5  6R
,0'T $vh7SD  8SDl"+\=>, R Rj  2I !2Il ).) fs l9 :  ;0  ;0|-2 ,-T  .T
 *+T ,T "VH5$  6$( $vh7  8, &9  : %x8$  9$( '&:  ;  !F84$  5$: #fX6  7  "VH54D  64Dn "VH5+.  6+.\ %16  26 &2-  3-< ?0  0
 ?VH-O  .Op ZZ^^$8"=   ?VH-+B .+Bv (@"E !  X..077:QQ 3 4b9 .@	T&R 1-B 2-Bp "   "  ''  !''T ()
F  *
F 3   3 1.D  2.Db &2$6  3$6@ 8n,,.558MM     (7*46   
 79$O         DK*9$8	:C6 %inn& $1Fh0 9=3*@|     $ "

<> "

12449  bjj, !rzz+-  #INN$  3
1+
'T2$ BF44Dp (>i9\ $Y^^% 7HH	-
	+	
	E! ,
 $:0<+\ $Y^^% "'tbI 3$G"<** $$@"$$G #$LM (
9> #$D 8mt   C J   #Y^^%  
' ) 	
 
 / $ ')*@ $ ' *,-L  "$%5 8:;Q ! $&'@  &6;Q &>( &D#+1T#*6
*$9+
#L-6@K` 
L  
L vh/5I  05Ip ?VH-">  .">J ?VH-!  .!  !"L  #L %&0  '0, %x8=D  9=D@ &2$4  3$4N =  =<@ F 8
FK AF-A`2An6 
R,B,R62  (99tq!9 +( LJ 
 !$ HBVzzF+ +K89:) ; ) #$+K89:N ; %N ./+K89:+ ; 0+> 
  *==A1= >A" F 8 #L,,, !E73G  4G" '%9
C  :
C !F84C  5C  '&:C  ;C8 '(<  =2	 06(C%C  D%CP .A"G  B"GbC1:62Kj"J3l4 +fX>:  ?:4 ,ug>?  ?? %w7  8& 5wGH  HH0"T*&R QF1 @H 8n,,.558MM 7 I 8   79$O     FD	72 %inn& )LX"( 16t/*@^ $Y^^% 7H>DN $Y^^% "'tbI 3$G"=** 
L  
L vh/)+  0)+X ?VH-)K  .)KX ?VH-!  .!  !"L  #L %&-  '- =  =   # $  %inn&  :	2#L0R<*0.f   	 8@B^ H~--/669OO   8<TR &      >803
/
&6$B,Lf &y~~'  "( 4G(?.@b8 %inn& 8H03l %inn& #(rJ 4$G"=** 	N  	N x0&M  1&MR fX.-"  /-"` fX.!  /!  "#N  $N &'
0  (
0 '   ', 8n,,.558MM F  ) #99  2 9 2  7:4P      :	7&6&!LP %inn& "( 593+E\ 5939I(4*A: $Y^^% 7H0;| $Y^^% "'tbI 3$G"<**+4` L  L vh/ -  0 -F ?VH-$:  .$:N ?VH-!  .!  !"L  #L "#	7  $	7 !F84:  5:6 %&
.  '
. +  +  > 	L
)X $%=  &=
 $vh7	+  8	+ /%A&  B& /(D  E$  $inn&  AF 7&J0  K0" 56  7  	  !	& /. 
C 1E7C<  D< 1F8DE  EED $ug6O  7O
 $vh7B  8B0 $%
N   &
N 9F8L7   M7 86(K7   L7, h//188;SS :,
+)*

.6"$I%8x~sTz " s S4Z DQUJY]L] $ >      F  <4  !<4N 13c5e,,- 2 LS LS LS4Z L^ 3 s tDzD?P 0 '(`d  )`dP    "$  "  
 !( #H~557>>A__ #Y^^% aT : Xv / (V)
Q &&( <D $Y^^% D  (77K	  0 $$N@  #'  B !bjj!56 7   +
3$" $"NV Vt <; R Rj= =@i(L i(X8 %&A8*) rzz~~.GHPDQ !40 $inn& ! @7$44:"J "VH5;  6;B $vh7e\ 8e\P #fe_=3R >3Rl vh/C[ 0C[L $ %   )1 %BJJKR\\Z 15%E "D x0 '  1 'H    1$! 2$!R % &  
 !  !F848! 58!v _   _<    !8 %&8  '8v %x8&  9&Z &y~~'  J
, &y~~'  ; 0E #eV_=M  >Mh    ! 0  !  ! $% !5 53 54; 5p $&c4i %%y~~'  :?  $ 4 & 1i   2i ` #$L  %L #fX6   7 F *VH=( >(V$  [H  ![H| %&?  '?. &2?  3?( )*5  +5 )F8</  =/8 '(S  )S '&:+  ;+, 34  56 56  7" ,-I  .I(  *+%8  ,%8P 56<4  7<4~ ,"  ,"^ /&BS  CS@ 06(C:  D:z -x@E  AE8 (8*=A  >A. "#.  $. )F8<
2  =
2 "#e $eP '(e )eP  !Z "Zz :  :* #9  #9LCL "  " #fX6  78 &'$%&^ ' (^B,HV 34$%& ' 5)"JZ =xPB   QB RR3j  >: **..!7=DJJOObd JJOO`b (	((0IRVW9& -Y--5IRVW  "
J(((3JJOOTV\] !> 2 = = ? I-d;AACI/=CCE"I""D9 I8$dOD::?%'	E /y//7MVZ[""$	; /y//7MVZ[""$  I.t<BBD I.t<BBD I/=CCE I.t<BBD I24@FFH:* .i..6KTXY!!#a*`]F?* -Y--5IRVW  "( #2eD"1eD"1eD' # '	( *ZF
 $ufo>#W  ?#WL #eW5
2  6
2D0NE* .i..6KTXY!!#  0y//7LUYZ""$ -I,,4KTXY! ! $D $t $
+:(VUp> )))1A$O	S 	T 	 	$ 	$ $%"#$S S % &S -."#$c 3  % / 23"#$3   % 4& EF"#$ S s  % G. (5&/B9s 9  C98 )$N**,33i?BTT BJJ5T 2? ?$ ? t    -S -S4Z -`D
 D
 D
N  C  !C
 &2+2  3+2\ 4vhG'^  H'^TDGN   zrzz~~fd+,DGGUG3 g]<  @ 37	$ 	4 	 	$(4K	;?		$  X  X#

PRVWWXT2 [p ENB  t{'d~  		JT :P >r^  J

HIJs   J{: ,J|  *A J|. YJ}c	J}%|&J}* g/J};g>J};qJ~ EJ~H2J~c7J~ {:J||J|| J|+|*J|+|.J}|3J}}J}}*
J}8}7J}8~J~~
J~~#J J