/* tickets-ui.jsx — delade bitar för arenden.jsx (kund) och admin.jsx: ikoner, meddelandelista, förslagskort, skrivfält. Datan kommer från tickets.js (window.HemsTickets). */ const TkIco=({n,size=16})=>{ const d={chev:'M9 6l6 6-6 6',image:'M4 5h16v14H4zM4 15l5-5 4 4 3-3 4 4M15 9h.01',send:'M22 2L11 13M22 2l-7 20-4-9-9-4z',info:'M12 8h.01M11 12h1v4h1M12 3a9 9 0 110 18 9 9 0 010-18z',close:'M6 6l12 12M18 6L6 18',search:'M11 4a7 7 0 100 14 7 7 0 000-14zM20 20l-4-4',filter:'M4 6h16M7 12h10M10 18h4',users:'M9 11a3 3 0 100-6 3 3 0 000 6zM3 20c0-3 2.7-5 6-5s6 2 6 5M16 11a3 3 0 100-6M17.5 15.2c2 .5 3.5 2.2 3.5 4.8'}[n]; return ; }; function tkUseHash(){ const [h,setH]=React.useState(location.hash); React.useEffect(()=>{ const f=()=>setH(location.hash); window.addEventListener('hashchange',f); return ()=>window.removeEventListener('hashchange',f); },[]); return h; } /* v863: bilagor kommer som relativ adress (/api/…/files/…). När HEMS_BASE inte är sidans egen adress (förhandsvisning mot staging) sätts basen före. */ const tkAttSrc=(u)=>u&&u[0]==='/'&&u[1]!=='/'&&window.HEMS_BASE&&HEMS_BASE()!==location.origin?HEMS_BASE()+u:u; function TkAttach({a,onZoom}){ const src=tkAttSrc(a.url); return src?{a.name||''}onZoom&&onZoom(src)}/>:
{a.name||'bild'} · bilden finns inte i demoläget
; } /* Förslag på ändring. mine = ritas från avsändarens (admins) sida. onDecide bara i kundvyn. */ function TkProposal({m,mine,onDecide,busy}){ const p=m.proposal, st=p.status, TK=window.HemsTickets; const head=st==='pending'?(mine?'Förslag skickat · väntar på kund':'Förslag på ändring'):st==='accepted'?`Godkänd ändring · ${TK.fmtTime(p.decided_at)}`:`Avböjt förslag · ${TK.fmtTime(p.decided_at)}`; return
{head}
{m.text&&
{m.text}
} {p.changes.map((c,i)=>
{c.label}{c.from} → {c.to}
)} {st==='pending'&&!mine&&onDecide&&
} {st==='pending'&&!mine&&
Godkänner du sparas ändringen på ditt konto, som när du själv ändrar i Inställningar.
}
; } /* Liten markdown för föranalyser: rubriker, listor, tabeller, citat, kodblock, **fet**, *kursiv*, `kod`. Ingen HTML släpps igenom. */ function tkMdInline(s){ const out=[], re=/(\*\*[^*]+\*\*|`[^`]+`|\*[^*\s][^*]*\*|_[^_\s][^_]*_)/g; let last=0,m,i=0; while((m=re.exec(s))){ if(m.index>last) out.push(s.slice(last,m.index)); const t=m[0]; out.push(t.startsWith('**')?{t.slice(2,-2)}:t.startsWith('`')?{t.slice(1,-1)}:{t.slice(1,-1)}); last=m.index+t.length; } if(lastx.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>c.trim()); const block=(l)=>/^(#{1,6}\s|\s*([-*+]|\d+[.)])\s|\s*\||>|```)/.test(l)||/^\s*(-{3,}|\*{3,})\s*$/.test(l); while(i); i++; continue; } if(l.startsWith('```')){ const c=[]; i++; while(i{c.join('\n')}); continue; } if(/^\s*\|/.test(l)&&i+1{head.map((c,j)=>)}{body.map((r,ri)=>{r.map((c,j)=>)})}
{tkMdInline(c)}
{tkMdInline(c)}
); continue; } if((m=l.match(LIST))){ const ord=/\d/.test(m[1]), items=[]; while(i
  • {tkMdInline(x)}
  • ))); continue; } if(l.startsWith('>')){ const q=[]; while(i')) q.push(lines[i++].replace(/^>\s?/,'')); out.push(
    {q.map((x,j)=>
    {tkMdInline(x)}
    )}
    ); continue; } const p=[l]; i++; while(i{tkMdInline(p.join(' '))}

    ); } return
    {out}
    ; } /* Föranalys (kind:"analysis"): bara admin. Hopfälld som standard. Claude Code-texter börjar med en egen "## Föranalys · …"-rad som döljs tills man visar den. */ /* v742 (André 28/9: "Vi vill bara se senaste. Hisotriken kan visas om man klickar i något så man inte väljer fel version när man felsöker"): tråden visar bara den senaste föranalysen; äldre versioner ligger under Visa tidigare versioner och är märkta Ersatt. */ function TkAnalysis({m,onDelete,history,old}){ const TK=window.HemsTickets, [open,setOpen]=React.useState(false), [raw,setRaw]=React.useState(false), [hOpen,setHOpen]=React.useState(false); const hs=Array.isArray(history)?history:[]; const cc=m.source==='claude-code'; let body=m.text||'', head=null; if(cc){ const mm=body.match(/^\s*##\s*Föranalys[^\n]*(\n|$)/); if(mm){ head=mm[0].trim(); body=body.slice(mm[0].length); } } const who=m.author||'admin', title=cc?`Föranalys · Claude Code · körd av ${who} ${TK.fmtTime(m.at)}`:`Föranalys · inklistrad av ${who} ${TK.fmtTime(m.at)}`; const pv=body.split('\n').map(x=>x.replace(/^[#>\s|*-]+|\|/g,' ').trim()).filter(x=>x&&!/^[-:\s]+$/.test(x)).join(' · ').slice(0,220); const del=m.deleted; return
    {del&&
    Borttagen · {del.by} {TK.fmtTime(del.at)}
    } {!open&&
    setOpen(true)}>{pv}
    } {open&&
    {head&&
    {raw&&{head}}
    }
    }
    {old?'Äldre version. Felsök med den senaste.':'Bara admin ser föranalysen.'}{hs.length>0&&}{onDelete&&!del&&}
    {hOpen&&hs.length>0&&
    {[...hs].reverse().map(x=>)}
    }
    ; } /* Förslagets status (S2): Mottaget, Planerat, Byggt, Avböjt. prefix = "Förslag · " för Driftcentralen, där fel och förslag står i samma lista. */ /* v741 Larmraden i ett ärende (Utred i Varningar): larmet som det stod när utredningen började. Bara admin ser den. */ function TkAlarm({m}){ const TK=window.HemsTickets, a=m.alarm||{}, ws=Array.isArray(a.warnings)?a.warnings:[], off=!!a.status&&a.status!=='connected'; const ms=(x)=>{ const n=Number(x); return !Number.isFinite(n)||n<=0?0:n<1e12?n*1000:n; }; /* v748 (André 29/9): rött när larmraden har en kritisk varning, som Kritisk i Varningar. */ const cr=ws.some(w=>w&&w.severity==='critical'); return
    LarmFrån Varningar{[m.author?`Utrett av ${m.author}`:null,TK.fmtTime(m.at)].filter(Boolean).join(' · ')}
    {off&&
    Ingen data{[a.message||'Ingen data tas emot',a.age_minutes!=null&&Number.isFinite(Number(a.age_minutes))?`senaste data för ${TK.fmtDur(Number(a.age_minutes)*60000)} sedan`:null].filter(Boolean).join(' · ')}
    } {ws.map((w,i)=>
    {w.severity==='critical'?'Kritisk':'Varning'}{w.text}{ms(w.since)>0?` · först sedd ${TK.fmtTime(ms(w.since))}`:''}
    )} {!off&&!ws.length&&m.text&&
    {m.text}
    }
    Syns aldrig för kunden.
    ; } function TkIdeaTag({t,prefix}){ const TK=window.HemsTickets; if(!t||t.type!=='forslag') return null; const k=t.idea_status, l=TK.IDEA[k]; return {prefix&&l?'Förslag · ':''}{l||'Förslag'}; } /* view 'customer' | 'admin'. Interna rader visas bara för admin. Sidan (höger/vänster) styrs av CSS per vy. */ function TkMessages({t,view,onDecide,busy,onZoom,onDelete}){ const TK=window.HemsTickets, ref=React.useRef(), [openSys,setOpenSys]=React.useState({}); React.useEffect(()=>{ const el=ref.current; if(!el) return; if(view==='admin') el.scrollTop=el.scrollHeight; },[t.id,t.messages.length,view]); /* kind:'deletion_log' = backendens eventuella revisionsrad vid borttagning; visas aldrig (André 25/9: bara utgråning). */ /* Visningsloggar ("X öppnade dina inställningar/mätvärden", kind:'view_log') visas inte (André 25/9: spammar varje gång admin tittar). */ const items=t.messages.filter(m=>!TK.isHiddenRow(m)&&(view==='admin'||(m.from!=='internal'&&!m.deleted))); const adm=view==='admin'; /* v742: bara den senaste föranalysen i tråden (den senaste som inte är borttagen); de andra följer med den som historik. */ const ans=adm?items.filter(m=>m.kind==='analysis'):[], anCur=[...ans].reverse().find(m=>!m.deleted)||ans[ans.length-1]||null, anOld=ans.filter(m=>m!==anCur); const rg=(h,a)=>{ const n=parseInt(String(h).slice(1),16); return `rgba(${n>>16},${n>>8&255},${n&255},${a})`; }; const delBtn=(m)=>adm&&onDelete&&!m.deleted&&(m.from==='admin'||m.from==='internal')?:null; const delTag=(m)=>m.deleted?Borttaget · {m.deleted.by} {TK.fmtTime(m.deleted.at)} · kunden ser inte detta:null; const atts=(m)=>(m.attachments||[]).map((a,i)=>); /* v711 (André 27/9): namnband i skribentens färg. Internt ligger bandet på mörk botten så färgen inte blandas med den gula panelen. Gul är aldrig en adminfärg (TK.okColor). */ /* v719 (André 27/9): internt band helgult med namnet i mörk text och en prick i skribentens färg; till kund som förut i skribentens färg. */ const band=(m,intl)=>{ const c=TK.okColor(m.author_color); if(intl) return
    {c?:null}{m.author||'Admin'}{TK.fmtTime(m.at)}{delBtn(m)}Internt
    ; return
    {m.author||'Supporten'}{TK.fmtTime(m.at)}{delBtn(m)}Till kund
    ; }; const one=(m)=>{ if(m.kind==='analysis') return adm&&m===anCur?:null; if(m.kind==='alarm') return adm?:null; if(m.from==='system') return
    {m.text} · {TK.fmtTime(m.at)}
    ; if(m.from==='proposal') return ; if(adm&&m.from==='admin'){ const c=TK.okColor(m.author_color); return
    {band(m,false)}
    {m.text}{atts(m)}{delTag(m)}
    ; } const cls=m.from==='internal'?'int':m.from==='customer'?'me':'them'; const who=m.from==='customer'?(view==='customer'?'Du':'Kund'):m.from==='internal'?`Internt · ${m.author||'Admin'}`:`${m.author||'Supporten'}${adm?' → kund':' · Gravio'}`; const d=delBtn(m); return
    {who} · {TK.fmtTime(m.at)}{d}{delTag(m)}
    {m.text}{atts(m)}
    ; }; /* v711: interna lappar i följd (admin) i en gul panel över hela bredden. Nytt band när skribenten byts; nästa lapp från samma person står under bandet med sin tid. */ const akey=(x)=>x.author_id||x.author||''; const intUnit=(rows)=>
    {rows.map((m,i)=>i>0&&akey(rows[i-1])===akey(m) ?
    {m.text}{atts(m)}{delTag(m)}
    {TK.fmtTime(m.at)}{delBtn(m)}
    :
    {band(m,true)}
    {m.text}{atts(m)}{delTag(m)}
    )}
    ; const isInt=(m)=>adm&&m.from==='internal'&&m.kind!=='analysis'&&m.kind!=='alarm'; /* S10 (backend 26/9): två eller fler systemrader i följd fälls ihop till "N händelser" som går att fälla ut. En ensam systemrad visas som den är. Kund, admin och förslag visas alltid. */ const blocks=[]; items.forEach(m=>{ const b=blocks[blocks.length-1]; if(m.from==='system'&&b&&b.sys) b.rows.push(m); else if(isInt(m)&&b&&b.int) b.rows.push(m); else blocks.push({sys:m.from==='system',int:isInt(m),rows:[m]}); }); return
    {blocks.map(b=>{ const k=b.rows[0].id; if(b.int) return intUnit(b.rows); if(!b.sys||b.rows.length<2) return {b.rows.map(one)}; const o=!!openSys[k]; return {o&&b.rows.map(one)}; })}
    ; } /* Skrivfält: text + upp till 4 bilder. Cmd/Ctrl+Enter skickar. */ /* mentions: [{id,name,color}] — skriv @ så öppnas listan; ↑↓ väljer, Enter/Tab infogar "@Namn ". onSend får mentions:[id] för de namn som står i texten. refs: [{id,label,sub}] — skriv # så listas öppna ärenden (filtreras på id/ämne); infogar "#B3N-2 ". */ function TkComposer({onSend,placeholder,busy,disabled,allowFiles=true,mentions=null,refs=null,tools=null}){ const TK=window.HemsTickets; const [text,setText]=React.useState(''), [files,setFiles]=React.useState([]), [err,setErr]=React.useState(null); const [men,setMen]=React.useState(null); // {kind:'@'|'#', start, q, idx} const fileRef=React.useRef(), taRef=React.useRef(), [tall,setTall]=React.useState(false); const menList=!men?[]:men.kind==='@'?(mentions||[]).filter(a=>a.name.toLowerCase().startsWith(men.q.toLowerCase())).map(a=>({key:a.id,ins:'@'+a.name,color:a.color,label:a.name,sub:null})) :(refs||[]).filter(r=>(r.id+' '+(r.label||'')).toLowerCase().includes(men.q.toLowerCase())).slice(0,8).map(r=>({key:r.id,ins:'#'+r.id,color:null,label:r.id,sub:r.label})); const scanMen=(val,caret)=>{ const before=val.slice(0,caret); const m=before.match(/(^|\s)([@#])([^\s@#]*)$/); if(m&&((m[2]==='@'&&mentions)||(m[2]==='#'&&refs))){ const q=m[3]; setMen(x=>({kind:m[2],start:caret-q.length-1,q,idx:x&&x.q===q&&x.kind===m[2]?x.idx:0})); } else setMen(null); }; const pick=(it)=>{ if(!men) return; const ta=taRef.current; const caret=ta?ta.selectionStart:text.length; const before=text.slice(0,men.start), after=text.slice(caret); const nt=before+it.ins+' '+after; setText(nt); setMen(null); requestAnimationFrame(()=>{ if(ta){ ta.focus(); const pos=before.length+it.ins.length+1; ta.setSelectionRange(pos,pos); } }); }; const esc=(s)=>s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'); const mentionIds=()=>mentions?mentions.filter(a=>new RegExp('(^|\\s)@'+esc(a.name)+'(?=\\s|$|[.,!?:])').test(text)).map(a=>a.id):undefined; const add=async(e)=>{ const fs=[...e.target.files]; e.target.value=''; const out=[]; for(const f of fs){ try{ out.push(await TK.fileToDataUrl(f)); }catch(er){ setErr(er.message); } } setFiles(x=>[...x,...out].slice(0,4)); }; const go=async()=>{ if(disabled||busy||(!text.trim()&&!files.length)) return; setErr(null); const ok=await onSend({text:text.trim(),attachments:files,mentions:mentionIds()}); if(ok!==false){ setText(''); setFiles([]); if(taRef.current) taRef.current.style.height=''; setTall(false); } }; /* v748 (André 29/9: "Ikonerna ligger konstigt"): knapparna står mitt för fältet tills texten blir högre än fältets lägsta höjd, sedan nere vid sista raden. */ const fit=(el)=>{ el.style.height='auto'; const sh=el.scrollHeight, mh=parseFloat(getComputedStyle(el).minHeight)||0; el.style.height=Math.min(160,sh)+'px'; setTall(sh>mh+2); }; const grow=(e)=>fit(e.target); /* tools(api): extra knappar vid fältet (Driftcentralens Mall). insert lägger text där markören står, på ny rad om det redan står något före. */ const posRef=React.useRef(null); const insert=(s)=>{ const ta=taRef.current; setText(cur=>{ const a=ta?Math.min(ta.selectionStart??cur.length,cur.length):cur.length, b=ta?Math.min(ta.selectionEnd??cur.length,cur.length):cur.length; const pre=cur.slice(0,a), sep=pre&&!pre.endsWith('\n')?'\n':''; posRef.current=(pre+sep+s).length; return pre+sep+s+cur.slice(b); }); requestAnimationFrame(()=>{ if(!ta) return; ta.focus(); const p=posRef.current; if(p!=null) ta.setSelectionRange(p,p); fit(ta); }); }; const api={insert,getText:()=>taRef.current?taRef.current.value:text}; return
    {allowFiles&&<>} {tools&&tools(api)}