Guiden viser trin for trin, hvordan man lægger LunchHub som app på hjemmeskærmen – på iPhone og Android, dansk og engelsk. Åbnes den fra app-ikonet, går den direkte til LunchHub.
${onServer()?"":`Her kan guiden kun vises som test. Når siden kører fra serveren (start.command), virker lunchhub.localhost:8080/getlunchhub også.`}
`;
}
function bindGuidePanel(){
if(!$("#guideSec")) return;
$("#gpCopy").onclick=()=>navigator.clipboard?.writeText(GUIDE_URL).then(()=>toast("Linket er kopieret"),()=>toast("Kunne ikke kopiere","error"));
$("#gpPreview").onclick=openGuidePreview;
$("#gpPng").onclick=async()=>saveFile("lunchhub-getlunchhub-qr.png", await qrPng(GUIDE_URL), "image/png");
$("#gpSvg").onclick=()=>saveFile("lunchhub-getlunchhub-qr.svg", '\n'+qrSvg(GUIDE_URL,{size:1024}), "image/svg+xml");
}
function openGuidePreview(){
const bg=modal(`
${I.phone}
Test af app-guiden
Sådan ser lunchhub.dk/getlunchhub ud på en mobil
${qrSvg(GUIDE_URL,{size:140})}
Skift mellem iPhone og Android og mellem dansk og engelsk øverst i guiden. Scan QR-koden med din egen telefon for at teste den rigtige side, når den er online.
`;
const f=$("#fpFrame");
const mark = h => { $("#fpTitle").textContent=TITLE[h]||""; document.querySelectorAll("[data-fptab]").forEach(b=>b.setAttribute("aria-selected", b.dataset.fptab===h)); };
f.onload=()=>{ $("#fpLoad")?.remove(); LH_FP.syncTheme();
try{ f.contentWindow.addEventListener("hashchange",()=>{ const h=(f.contentWindow.location.hash||"").slice(1)||"indsendmenu";
const k=h==="retbank"?"menubank":h; if(tabs.some(t=>t[0]===k)){ S.fpTab[kind]=k; mark(k); syncMenuUrl(); } }); }catch(e){} };
document.querySelectorAll("[data-fptab]").forEach(b=>b.onclick=()=>{ S.fpTab[kind]=b.dataset.fptab; mark(b.dataset.fptab); syncMenuUrl();
try{ f.contentWindow.location.hash=b.dataset.fptab; }catch(e){ f.src="menu.html#"+b.dataset.fptab; } });
}
/* ---------- Egne adresser: lunchhub.dk/indsendmenu, /indsamlmenu, /menubank, /co2 ----------
Åbnes en af dem (også før login), går hubben direkte til den del efter login. På serveren følger adressen med,
når man skifter fane; på claude.ai bruges #indsendmenu osv. */
const MENU_ROUTES = {indsendmenu:["menuin","indsendmenu"], indsamlmenu:["menuout","indsamlmenu"], menubank:[null,"menubank"], co2:["menuout","co2"]};
function menuRouteFromUrl(){
const seg=(location.pathname.split("/").filter(Boolean).pop()||"").toLowerCase(), h=(location.hash||"").replace(/^#\/?/,"").toLowerCase();
return MENU_ROUTES[seg] ? seg : MENU_ROUTES[h] ? h : null;
}
let MENU_DEEP = menuRouteFromUrl();
function applyMenuDeep(u){
const k=MENU_DEEP; if(!k || !u) return; MENU_DEEP=null;
let [v,tab]=MENU_ROUTES[k]; if(!v) v=canMenuCollect(u)?"menuout":"menuin";
if(v==="menuin" && !canMenuSend(u) && canMenuCollect(u)){ v="menuout"; if(tab==="indsendmenu") tab="indsamlmenu"; }
if((v==="menuin"&&!canMenuSend(u)) || (v==="menuout"&&!canMenuCollect(u))){ setTimeout(()=>toast(tl("Du har ikke adgang til den del af menuerne","You don't have access to that part of the menus"),"error"),300); return; }
S.view=v; S.fpTab={...(S.fpTab||{}), [v]:tab};
}
function syncMenuUrl(){
if(!onServer()) return;
const cur=location.pathname, inMenu=S.user && ["menuin","menuout"].includes(S.view);
const want = inMenu ? "/"+((S.fpTab&&S.fpTab[S.view])||FP_TABS[S.view][0][0]) : (MENU_ROUTES[cur.replace(/^\/+|\/+$/g,"")] && S.user ? "/" : null);
if(want && want!==cur) try{ history.replaceState(null,"",want+location.search+location.hash); }catch(e){}
}
addEventListener("hashchange",()=>{ const k=menuRouteFromUrl(); if(k && S.user){ MENU_DEEP=k; applyMenuDeep(S.user); render(); } });
/* ---------- Superadmin: Anthropic API-nøgle (til "Kom med forslag", menu fra billede og oversættelse) ----------
Nøglen sendes én gang til serveren og gemmes i data/keys.json. Den sendes aldrig tilbage til browseren –
panelet viser kun de sidste fire tegn. */
function aiKeyPanel(){
return `
Bruges i Indsend/Indsaml menu til Kom med forslag, menu fra billede og oversættelse til engelsk. Uden nøgle virker alt andet.
`;
if(!SRV.on){ box.innerHTML = intro + `
Her på claude.ai bruger menuerne Claude direkte, så der skal ikke bruges en nøgle. Nøglen skal kun sættes, når LunchHub kører fra serveren (lunchhub.dk eller start.command).
${pill}${st.savedAt?`Opdateret ${esc(fmt(st.savedAt))}${st.savedBy?" af "+esc(st.savedBy):""}`:""}
${st.source==="env" ? `
Nøglen står i serverens opsætning (api/config.php) og kan ikke ændres her. Fjern den derfra for at styre den herfra.
` : `
`}
${st.source==="env"?``:""}
`;
const msg=(t,ok)=>{ const m=$("#aiMsg"); m.textContent=t; m.className="ai-msg "+(ok?"ok":"bad"); };
const done = st2 => { delete LH_FP.cache.sample; draw(st2); };
$("#aiShow") && ($("#aiShow").onclick=()=>{ const i=$("#aiKeyIn"), on=i.type==="password"; i.type=on?"text":"password"; $("#aiShow").textContent=on?"Skjul":"Vis"; $("#aiShow").setAttribute("aria-pressed",on); });
$("#aiForm") && ($("#aiForm").onsubmit=async e=>{ e.preventDefault(); if(blocked()) return; const k=$("#aiKeyIn").value.trim();
if(!k) return msg("Indsæt nøglen først.",false);
if(!/^sk-ant-[A-Za-z0-9_\-]{20,}$/.test(k)) return msg("Det ligner ikke en Anthropic-nøgle. Den starter med sk-ant-.",false);
const r=await api("",{key:k, by:S.user.name}); if(!r.ok) return msg(r.error||"Kunne ikke gemme nøglen.",false);
done(r); toast("Nøglen er gemt på serveren"); const t=await api("/test",{}); msg(t.ok?"Forbindelsen virker. Claude svarer.":"Nøglen er gemt, men testen fejlede: "+(t.error||"ukendt fejl"), t.ok); });
$("#aiTest") && ($("#aiTest").onclick=async()=>{ msg("Tester …",true); const t=await api("/test",{}); msg(t.ok?"Forbindelsen virker. Claude svarer.":"Testen fejlede: "+(t.error||"ukendt fejl"), t.ok); });
$("#aiDel") && ($("#aiDel").onclick=async()=>{ if(blocked()) return; const b=$("#aiDel"); if(b.dataset.arm!=="1"){ b.dataset.arm="1"; b.textContent="Klik igen for at fjerne"; setTimeout(()=>{ if(b.isConnected){ b.dataset.arm=""; b.textContent="Fjern nøgle"; } },3000); return; }
const r=await api("",{key:"", by:S.user.name}); if(!r.ok) return msg(r.error||"Kunne ikke fjerne nøglen.",false); done(r); toast("Nøglen er fjernet"); });
};
try{ draw(await api("")); }catch(e){ box.innerHTML = intro + `
Kunne ikke hente status fra serveren.
`; }
}
/* ===================== SERVER (PHP + MySQL på Simply.com) ===================== */
/* Kører siden fra lunchhub.dk (eller en anden server med api/), hentes og gemmes alt i databasen:
brugere, virksomheder, tekster, opslag, chat, indbakke, "læst", timer og menuer.
- Login sker på serveren (cookie), adgangskoder og PIN-koder kommer aldrig ud i browseren.
- Ændringer gemmes automatisk: efter hver handling sammenlignes data med det, serveren har, og forskellen sendes.
- Hvert 4. sekund hentes andres ændringer.
- Personaleudlæg hentes med resten; de ændres kun via /api/expense (billederne ligger beskyttet på serveren).
Uden server (fx som forhåndsvisning på claude.ai) kører hubben som før med demo-data i browseren. */
/* Alle kald går direkte til api/index.php?r=…, så hubben virker, selv hvis .htaccess i roden mangler eller ikke læses */
const apiUrl = u => { if(typeof u!=="string" || !u.startsWith("/api/") || u.startsWith("/api/index.php")) return u;
const rest=u.slice(5), q=rest.indexOf("?"); return "/api/index.php?r="+(q<0?rest:rest.slice(0,q)+"&"+rest.slice(q+1)); };
const rawFetch = window.fetch.bind(window);
window.fetch = (u, o) => rawFetch(apiUrl(u), o);
const api = async (path, body, opt={}) => {
const r = await fetch("/api/"+path, { method: body===undefined ? "GET" : "POST", cache:"no-store", credentials:"same-origin",
headers: { "X-LH":"1", ...(body!==undefined ? {"Content-Type":"application/json"} : {}) }, body: body===undefined ? undefined : JSON.stringify(body), signal: opt.signal });
const j = await r.json().catch(()=>({}));
if(!r.ok){ const e=new Error(j.error||tl("Serveren svarede ikke","The server didn't respond")); e.status=r.status; e.code=j.code||""; throw e; }
return j;
};
const isDemoItem = x => !!x && (x.demo===true || /^demo-/.test(String(x.id??"")));
const demoUid = id => !!user(id)?.demo;
/* Demo-data: originalerne ligger i DEMO_* og lægges kun ind, når demo er slået til (eller uden server) */
const DEMO_IDS = id => SRV.on ? (id===3 ? 9001 : 9000+id) : id; // på serveren får demo-brugere egne id'er, så de aldrig blandes med rigtige
function loadDemo(){
const m = DEMO_IDS;
DEMO_USERS.filter(u=>!(SRV.on && u.username==="WT")).forEach(u=>{ if(!USERS.some(x=>x.id===m(u.id))) USERS.push({...JSON.parse(JSON.stringify(u)), id:m(u.id), demo:SRV.on, ...(SRV.on?{title:u.title+" (demo)"}:{})}); });
DEMO_BOARD.forEach(p=>BOARD.push({...JSON.parse(JSON.stringify(p)), id:"demo-b"+p.id, author:m(p.author), readBy:p.readBy.map(m), demo:true}));
Object.entries(DEMO_CHAT).forEach(([c,list])=>{ CHAT[c]=CHAT[c]||[]; list.forEach(x=>CHAT[c].push({...x, id:"demo-"+c+"-"+x.id, author:m(x.author), demo:true})); CHAT[c].sort((a,b)=>a.at.localeCompare(b.at)); });
DEMO_MESSAGES.forEach(x=>MESSAGES.push({...x, id:"demo-"+x.id, demo:true}));
}
const SAVE_COLS = {
users: { items:()=>USERS.filter(u=>!u.demo && !u.kiosk), key:u=>String(u.id),
pick:u=>({id:u.id, username:u.username, name:u.name, title:u.title||"", role:u.role, companies:u.companies||[], perms:u.perms||[], contact:normContact(u.contact)}) },
companies:{ items:()=>SRV.compTouched?CIDS.map(c=>COMPANIES[c]):[], key:c=>c.id, pick:c=>({id:c.id, name:c.name, domain:c.domain||"", afd:c.afd||"", logo:c.logo||null, wordmark:c.wordmark||null}) },
texts: { items:()=>SRV.textTouched?[{id:"main", ...TEXT}]:[], key:()=>"main", pick:t=>({id:"main", loginTitle:t.loginTitle, loginIntro:t.loginIntro, loginHelp:t.loginHelp, composerHint:t.composerHint}) },
board: { items:()=>BOARD.filter(p=>!isDemoItem(p)), key:p=>String(p.id), del:true,
pick:p=>({id:p.id, company:p.company, author:p.author, cat:p.cat, title:p.title, body:p.body||"", html:p.html||"", images:p.images||[], links:p.links||[], pinned:!!p.pinned, ack:!!p.ack, readBy:p.readBy||[]}) },
chat: { items:()=>Object.entries(CHAT).flatMap(([ch,l])=>l.filter(m=>!isDemoItem(m)).map(m=>({...m, ch}))), key:m=>String(m.id),
pick:m=>({id:m.id, ch:m.ch, author:m.author, text:m.text||"", img:m.img||null}) },
inbox: { items:()=>MESSAGES.filter(m=>!isDemoItem(m)), key:m=>String(m.id), pick:m=>({id:m.id, status:m.status, note:m.note||""}) },
reads: { items:()=>S.user&&lastRead[S.user.id]?[{id:"u"+S.user.id, c:lastRead[S.user.id]}]:[], key:r=>r.id, pick:r=>({id:r.id, c:r.c}) },
};
const normContact = c => (c && !Array.isArray(c)) ? {photo:c.photo||null, phone:c.phone||"", email:c.email||"", whatsapp:c.whatsapp||""} : {photo:null, phone:"", email:"", whatsapp:""};
const snapOf = (col,it) => JSON.stringify(SAVE_COLS[col].pick(it));
/* Læg serverens dokumenter ind i hubbens egne lister (samme objekter beholdes, så åbne visninger peger rigtigt) */
function applyItem(col, it){
if(col==="users"){ it.contact=normContact(it.contact); const u=USERS.find(x=>x.id===it.id); if(u){ Object.keys(u).forEach(k=>{ if(!(k in it) && !["emp","pw"].includes(k)) delete u[k]; }); Object.assign(u,it); } else USERS.push(it); return; }
if(col==="companies"){ if(COMPANIES[it.id]) Object.assign(COMPANIES[it.id], {name:it.name||COMPANIES[it.id].name, domain:it.domain||COMPANIES[it.id].domain, afd:it.afd||"", logo:it.logo||undefined, wordmark:it.wordmark||undefined}); return; }
if(col==="texts"){ TEXT_KEYS_LH.forEach(k=>{ if(it[k]) TEXT[k]=it[k]; }); return; }
if(col==="board"){ const p=BOARD.find(x=>String(x.id)===String(it.id)); if(p) Object.assign(p,it); else BOARD.push(it); return; }
if(col==="chat"){ const {ch,...m}=it; const l=(CHAT[ch] ||= []); const i=l.findIndex(x=>String(x.id)===String(m.id)); if(i>=0) l[i]=Object.assign(l[i],m); else { l.push(m); l.sort((a,b)=>a.at.localeCompare(b.at)); } return; }
if(col==="inbox"){ const m=MESSAGES.find(x=>String(x.id)===String(it.id)); if(m) Object.assign(m,it); else MESSAGES.push(it); return; }
if(col==="reads"){ if(S.user && it.id==="u"+S.user.id) lastRead[S.user.id]=Array.isArray(it.c)?{}:{...it.c}; }
if(col==="expenses"){ const e=EXPENSES.find(x=>x.id===it.id); if(e) Object.assign(e,it); else EXPENSES.push(it); }
}
function removeItem(col, id){
id=String(id);
if(col==="users"){ const i=USERS.findIndex(u=>String(u.id)===id && !u.demo); if(i>=0) USERS.splice(i,1); }
if(col==="board") BOARD=BOARD.filter(p=>String(p.id)!==id);
if(col==="chat") Object.keys(CHAT).forEach(c=>{ CHAT[c]=CHAT[c].filter(m=>String(m.id)!==id); });
if(col==="inbox") MESSAGES=MESSAGES.filter(m=>String(m.id)!==id);
if(col==="expenses") EXPENSES=EXPENSES.filter(e=>e.id!==id);
delete SRV.snap[col]?.[id];
}
const TEXT_KEYS_LH = ["loginTitle","loginIntro","loginHelp","composerHint"];
function snapAll(){ for(const col in SAVE_COLS){ SRV.snap[col]={}; } for(const col of ["users","board","chat","inbox","reads"]) SAVE_COLS[col].items().forEach(it=>SRV.snap[col][SAVE_COLS[col].key(it)]=snapOf(col,it)); }
/* Hent alt ved login */
async function loadState(){
const s=await api("state");
SRV.ver=s.ver; SRV.session=true;
USERS.length=0; BOARD=[]; Object.keys(CHAT).forEach(k=>delete CHAT[k]); CHAT.alle=[]; MESSAGES=[]; EXPENSES=[];
s.users.forEach(u=>applyItem("users",u)); s.companies.forEach(c=>applyItem("companies",c)); s.texts.forEach(t=>applyItem("texts",t));
s.board.forEach(p=>applyItem("board",p)); s.chat.forEach(m=>applyItem("chat",m)); s.inbox.forEach(m=>applyItem("inbox",m)); s.reads.forEach(r=>applyItem("reads",r)); (s.expenses||[]).forEach(e=>applyItem("expenses",e));
TID.server=true; TID.d={logins:{}, emp:{}, perms:{}, entries:[], absence:[], closures:[], rules:null, info:null, seeded:null}; TID.dirty=null; TID.merge(s.tid);
SRV.demo=false; // demo-data er fjernet
snapAll();
startPolling(); setTimeout(()=>{ if(S.user && !isBureau(S.user)) askNotify(); }, 2500);
return user(s.me.id);
}
/* ---------- Gem ændringer ---------- */
function syncSoon(){ if(!SRV.session || S.demoSession) return; clearTimeout(SRV.soonT); SRV.soonT=setTimeout(flush, 60); }
async function flush(){
if(!SRV.session || S.demoSession) return;
if(SRV.busy){ SRV.again=true; return; }
SRV.busy=true;
try{
for(const col in SAVE_COLS){
const C=SAVE_COLS[col], snap=SRV.snap[col]||(SRV.snap[col]={}), items=C.items(), seen=new Set(), changed=[];
for(const it of items){ const k=C.key(it); seen.add(k); const j=snapOf(col,it); if(snap[k]!==j) changed.push({it, j}); }
const deleted=C.del?Object.keys(snap).filter(k=>!seen.has(k)):[];
if(!changed.length && !deleted.length) continue;
changed.forEach(x=>snap[C.key(x.it)]=x.j); // regnes som sendt (rulles tilbage ved fejl)
let res;
try{ res=await api("save",{col, items:changed.map(x=>C.pick(x.it)), deleted}); }
catch(e){ changed.forEach(x=>delete snap[C.key(x.it)]); if(Date.now()-SRV.lastErr>8000){ SRV.lastErr=Date.now(); toast(e.status===401?tl("Du er blevet logget ud. Log ind igen.","You've been logged out. Please log in again."):tl("Kunne ikke gemme. Prøver igen om lidt.","Couldn't save. Retrying shortly."),"error"); } if(e.status===401) return sessionLost(); continue; }
res.items.forEach(it=>{ applyItem(col,it); const local=C.items().find(x=>C.key(x)===String(it.id)); snap[String(it.id)]=local?snapOf(col,local):snapOf(col,it); });
res.deleted.forEach(id=>delete snap[String(id)]);
if(res.rejected.length){
res.rejected.forEach(r=>{ if(r.current) { applyItem(col,r.current); const local=C.items().find(x=>C.key(x)===String(r.id)); if(local) snap[String(r.id)]=snapOf(col,local); } else if(r.id!=null){ removeItem(col,r.id); } });
toast(res.rejected[0].error||tl("Ændringen blev ikke gemt","The change wasn't saved"),"error");
if(S.user && !document.querySelector(".modal-bg")) render();
}
if(res.ver>SRV.ver && col!=="reads"){/* andres ændringer hentes af polling */}
}
} finally {
SRV.busy=false;
if(SRV.again){ SRV.again=false; setTimeout(flush,30); }
}
}
/* ---------- Hent andres ændringer ---------- */
/* Nye beskeder, opslag osv. hentes hvert 1,5 sekund, mens siden er åben (hvert 10. sekund i baggrunden), og med det samme, når man vender tilbage */
function startPolling(){ clearTimeout(SRV.polling); const gen=SRV.pollGen=(SRV.pollGen||0)+1;
const loop=async()=>{ if(gen!==SRV.pollGen) return; await poll(); if(gen===SRV.pollGen) SRV.polling=setTimeout(loop, document.hidden?10000:1500); };
SRV.polling=setTimeout(loop, 1500);
if(!SRV.wake){ SRV.wake=true; const now=()=>{ if(SRV.session && !document.hidden) poll(); };
document.addEventListener("visibilitychange", now); addEventListener("focus", now); addEventListener("online", now); }
}
function stopPolling(){ clearTimeout(SRV.polling); SRV.polling=null; SRV.pollGen=(SRV.pollGen||0)+1; }
async function poll(){
if(!SRV.session || SRV.busy || SRV.polling_now) return;
SRV.polling_now=true;
try{ await pollOnce(); } finally { SRV.polling_now=false; }
}
async function pollOnce(){
let c; try{ c=await api("changes?since="+SRV.ver); }catch(e){ if(e.status===401) sessionLost(); return; }
if(c.reload){ if(!typingNow()) return location.reload(); }
if(c.ver<=SRV.ver) return;
SRV.ver=c.ver;
let touched=false; const freshInbox=[], freshChat=[], freshBoard=[], expNews=[];
for(const col in c.items) for(const it of c.items[col]){
const k=String(it.id), snap=SRV.snap[col]||(SRV.snap[col]={});
if(col==="expenses"){ const old=EXPENSES.find(e=>e.id===it.id); if(S.user && it.uid===S.user.id && old && (old.status!==it.status || (it.history||[]).length>(old.history||[]).length)) expNews.push({it, was:old.status});
applyItem(col,it); touched=true; continue; }
if(col==="inbox" && !MESSAGES.some(m=>String(m.id)===k)) freshInbox.push(it);
if(col==="chat" && !Object.values(CHAT).some(l=>l.some(m=>String(m.id)===k)) && it.author!==S.user?.id) freshChat.push(it);
if(col==="board" && !BOARD.some(p=>String(p.id)===k) && it.author!==S.user?.id) freshBoard.push(it);
applyItem(col,it);
if(!SAVE_COLS[col]) { touched=true; continue; }
const local=SAVE_COLS[col].items().find(x=>SAVE_COLS[col].key(x)===k); snap[k]=local?snapOf(col,local):snapOf(col,it); touched=true;
}
for(const col in c.deleted) for(const id of c.deleted[col]){ removeItem(col,id); touched=true; }
if(c.tid) TID.pull();
if(!touched) return;
const fi=S.user?freshInbox.filter(m=>expInboxOk(S.user,m)):[];
for(const m of fi.slice(-3)) m.kind==="Udlæg"
? lhNotify(`Nyt udlæg fra ${m.from}`, m.subject.replace(/^Udlæg: /,"")+" · "+(m.body||""), ()=>{ S.view="udlaeg"; S.expTab="todo"; S.expSel=m.expense; render(); }, "ud"+m.id)
: lhNotify(`Ny henvendelse fra ${COMPANIES[m.site]?.domain||m.site}`, `${m.from}${m.company?" · "+m.company:""}: ${m.subject}`, ()=>{ S.view="inbox"; S.sel=m.id; render(); }, "in"+m.id);
for(const m of freshChat.slice(-3)){ const a=user(m.author), watching=!document.hidden && (S.view==="chat"&&S.chatC===m.ch || S.view==="intranet"&&m.ch==="alle");
if(!watching) lhNotify(`${a?a.name.split(" ")[0]:"Ny besked"} i ${chName(m.ch)}`, m.text||tl("Sendte et billede","Sent a picture"), ()=>{ S.view="chat"; S.chatC=m.ch; render(); }, "ch"+m.ch, a); }
for(const p of freshBoard.slice(-2)){ const a=user(p.author);
lhNotify(tl("Nyt opslag","New post")+(a?" · "+a.name.split(" ")[0]:""), p.title||"", ()=>{ S.view="board"; render(); }, "bo"+p.id, a); }
for(const {it,was} of expNews){ const last=(it.history||[]).slice(-1)[0];
lhNotify(it.status!==was?({godkendt:"Dit udlæg er godkendt og betalt",afvist:"Dit udlæg er afvist",afventer:"Dit udlæg er genåbnet"}[it.status]||"Dit udlæg er opdateret"):`Ny besked om dit udlæg fra ${last?.by||""}`,
(last?.note||"")||kr(it.amount)+" · "+it.purpose, ()=>{ S.view="udlaeg"; S.expTab="mine"; render(); }, "ux"+it.id); }
refreshAfterSync();
}
const typingNow = () => !!document.activeElement && document.activeElement.matches("input,textarea,select,[contenteditable=true]");
function refreshAfterSync(){
if(!S.user || CLOCK.active) return;
updateTitle();
/* Skriver man i chatten, opdateres samtalen alligevel (kladden gemmes og markøren sættes tilbage) */
const ae=document.activeElement, inChat=ae && ae.id==="ctext" && !document.querySelector(".modal-bg") && !(($("#cprev")||{}).hidden===false);
if(inChat && !["menuin","menuout"].includes(S.view)){ const pos=ae.selectionStart, y=window.scrollY; render(); window.scrollTo(0,y); const t=$("#ctext"); if(t){ t.focus({preventScroll:true}); try{ t.setSelectionRange(pos,pos); }catch(e){} } return; }
if(typingNow() || document.querySelector(".modal-bg")){ clearTimeout(SRV.typingRetry); SRV.typingRetry=setTimeout(refreshAfterSync, 1500); return; }
if(["menuin","menuout"].includes(S.view)){ refreshMenuUI(); return; } // menudelen genindlæses ikke midt i arbejdet
const y=window.scrollY; render(); window.scrollTo(0,y);
}
function sessionLost(){ SRV.session=false; stopPolling(); if(S.user && !S.demoSession){ S.user=null; S.impersonatedBy=null; renderLogin(tl("Du er blevet logget ud. Log ind igen.","You've been logged out. Please log in again.")); } }
/* ---------- Notifikationer: kort på siden + lyd, og en notifikation fra browseren, når LunchHub ligger i baggrunden ---------- */
function lhNotify(title, text, go, tag, who){
text=String(text||"").slice(0,160);
sound.play("ok");
let wrap=$(".toasts"); if(!wrap){ wrap=document.createElement("div"); wrap.className="toasts"; wrap.setAttribute("aria-live","polite"); document.body.appendChild(wrap); }
const el=document.createElement("div"); el.className="toast notif"; el.setAttribute("role","status");
el.innerHTML=`${who?avatar(who,30):I.bell}`;
const close=()=>{ el.classList.add("out"); setTimeout(()=>el.remove(),180); };
el.querySelector(".x").onclick=close; el.querySelector(".nf-go").onclick=()=>{ close(); if(go && S.user) go(); };
wrap.appendChild(el); while(wrap.children.length>4) wrap.firstChild.remove();
let tm=setTimeout(close,6500);
el.onmouseenter=()=>{ clearTimeout(tm); el.querySelector(".bar").style.animationPlayState="paused"; };
el.onmouseleave=()=>{ tm=setTimeout(close,2000); el.querySelector(".bar").style.animationPlayState="running"; };
if(document.hidden && "Notification" in window && Notification.permission==="granted"){
const opt={body:text, tag:"lh-"+(tag||Date.now()), icon:"/LL-Favicon/apple-touch-icon.png", renotify:true};
const open=()=>{ try{ window.focus(); }catch(e){} if(go && S.user) go(); };
try{ const n=new Notification(title, opt); n.onclick=()=>{ open(); n.close(); }; }
catch(e){ navigator.serviceWorker?.getRegistration().then(r=>r&&r.showNotification(title,opt)).catch(()=>{}); }
}
}
function lhUnread(){ const u=S.user; if(!u || isBureau(u)) return 0;
try{ return unreadChat(u) + MESSAGES.filter(m=>expInboxOk(u,m)&&m.status==="ny").length + BOARD.filter(p=>(p.company==="alle"||allowed(u).includes(p.company))&&p.ack&&!p.readBy.includes(u.id)).length + expPending(u); }catch(e){ return 0; } }
function updateTitle(){ const n=lhUnread(), base=document.title.replace(/^\(\d+\) /,""); document.title=n?`(${n}) ${base}`:base;
try{ if(navigator.setAppBadge) n?navigator.setAppBadge(n):navigator.clearAppBadge(); }catch(e){} }
/* Lille spørgsmål om at slå notifikationer til (vises én gang pr. enhed, indtil man svarer) */
function askNotify(){
if(!("Notification" in window) || Notification.permission!=="default" || document.querySelector(".nf-ask")) return;
try{ if(localStorage.getItem("lh.notify.ask")==="no") return; }catch(e){}
const el=document.createElement("div"); el.className="nf-ask panel"; el.setAttribute("role","dialog"); el.setAttribute("aria-label",tl("Notifikationer","Notifications"));
el.innerHTML=`${I.bell}
${tl("Få besked med det samme","Get notified right away")}${tl("Nye chatbeskeder, opslag, henvendelser og udlæg – også når LunchHub ligger i baggrunden.","New chat messages, posts, enquiries and expenses – even when LunchHub is in the background.")}
`;
document.body.appendChild(el);
el.querySelector("#nfYes").onclick=async()=>{ el.remove(); try{ const r=await Notification.requestPermission(); if(r==="granted") lhNotify(tl("Notifikationer er slået til","Notifications are on"), tl("Du får besked, når der sker noget nyt.","You'll be notified when something new happens.")); }catch(e){} };
el.querySelector("#nfNo").onclick=()=>{ el.remove(); try{ localStorage.setItem("lh.notify.ask","no"); }catch(e){} };
}
/* ---------- Login, logout og adgangskoder ---------- */
async function serverLogin(un, pw, rem){
const r=await api("login",{username:un, password:pw, remember:rem});
const u=await loadState();
return {u, mustChange:!!r.user.mustChange};
}
async function doLogout(){
if(SRV.on && SRV.session && !S.demoSession){ try{ await flush(); await api("logout",{}); }catch(e){} location.replace(location.pathname.replace(/\/(indsendmenu|indsamlmenu|menubank|co2)$/,"/")||"/"); return; }
remember.clear(); S.user=null; S.impersonatedBy=null; S.demoSession=false; renderLogin();
}
function changeOwnPassword(){
if(blocked()) return;
modal(``);
$("#cpx").onclick=closeProfile; softFocus($("#cpo"));
$("#cpw").onsubmit=async e=>{ e.preventDefault(); const er=m=>{ const x=$("#cpe"); x.textContent=m; x.hidden=false; };
const o=$("#cpo").value, a=$("#cp1").value, b=$("#cp2").value;
if(a.length<6) return er(tl("Koden skal være mindst 6 tegn.","The password must be at least 6 characters."));
if(a!==b) return er(tl("De to koder er ikke ens.","The two passwords don't match."));
try{ await api("password",{old:o, password:a}); closeProfile(); toast(tl("Din nye kode er gemt","Your new password is saved")); }catch(x){ er(x.message); } };
}
/* ---------- Superadmin: henvendelser fra hjemmesiderne ---------- */
async function renderIntegrationsServer(){
const box=$("#integ"); if(!box) return;
let st={}; try{ st=await api("settings/keys"); }catch(e){}
const formUrl=location.origin+"/api/henvendelse", apiUrl=location.origin+"/api/inbox/henvendelser";
box.innerHTML=`
${esc(COMPANIES[c].domain)} ${last?tl("Seneste henvendelse ","Last enquiry ")+esc(fmt(last)):tl("Klar til at modtage","Ready to receive")}
${tl("Formularen sender til","The form sends to")}POST ${esc(formUrl)}
${tl("Siden","The site")} ${esc(COMPANIES[c].domain)} ${tl("må sende hertil uden nøgle. Andre sider bliver afvist.","may send here without a key. Other sites are rejected.")}
${tl("API-nøgle til andre systemer","API key for other systems")}
${tl("Kopiér nøglen nu – den vises kun denne ene gang:","Copy the key now – it's only shown once:")} ${esc(r.key)}
`; }
catch(e){ toast(e.message,"error"); } });
box.querySelectorAll("[data-rk]").forEach(b=>b.onclick=async()=>{ if(blocked()) return; try{ await api("settings/keys",{site:b.dataset.rk, remove:true}); toast(tl("Nøglen er fjernet","The key has been removed")); renderIntegrationsServer(); }catch(e){ toast(e.message,"error"); } });
box.querySelectorAll("[data-test]").forEach(b=>b.onclick=async()=>{ if(blocked()) return;
try{ const r=await fetch("/api/henvendelse",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({site:b.dataset.test,type:"kontakt",side:"/test",navn:"Test fra Superadmin",virksomhed:"LunchHub",email:"test@lunchhub.dk",emne:"Andet",besked:"Dette er en testhenvendelse."})});
if(!r.ok) throw new Error((await r.json().catch(()=>({}))).error||"Fejl"); toast(tl("Testhenvendelsen ligger nu i indbakken","The test enquiry is now in the inbox")); setTimeout(poll,300); }
catch(e){ toast(e.message,"error"); } });
}
/* Serveren svarer ikke: vis hvad der er galt (aldrig et tomt login, der ikke gemmer noget) */
function serverDown(why){
$("#app").innerHTML=`
${tl("LunchHub kan ikke få fat i serveren","LunchHub can't reach the server")}
${tl("Der kan ikke logges ind eller gemmes noget, før det virker igen.","Nothing can be saved until this works again.")}
${esc(why||"?")}
${tl("Tjek","Check")} /api/index.php?r=health – ${tl("der skal stå","it should say")} {"ok":true,"db":true}.
${tl("Står der noget om databasen, så tjek oplysningerne i api/config.php.","If it mentions the database, check the details in api/config.php.")}
${tl("Får du en fejlside, så tjek at alle filerne i api/ og api/lib/ er uploadet.","If you get an error page, check that all files in api/ and api/lib/ are uploaded.")}
`;
setTimeout(()=>location.reload(), 30000);
}
/* ---------- Opstart ---------- */
async function boot(){
let b=null, why="";
if(onServer()){ try{ const r=await fetch("/api/boot",{cache:"no-store",credentials:"same-origin"}); const ct=r.headers.get("content-type")||"";
if(r.ok && ct.includes("json")) b=await r.json();
else why=ct.includes("json")?((await r.json().catch(()=>({}))).error||("HTTP "+r.status)):("HTTP "+r.status+" – api/index.php svarer ikke med data"); }
catch(e){ why=tl("Ingen forbindelse til serveren","No connection to the server"); } }
if(onServer() && !(b && b.server)) return serverDown(why);
if(b && b.server){
SRV.on=true; SRV.demo=false; TID.server=true;
(b.companies||[]).forEach(c=>applyItem("companies",c)); if(b.texts) applyItem("texts",b.texts);
applyFpMenus();
if(b.me){ try{ const u=await loadState(); if(!isBureau(u)) startMenuSync(); if(b.me.mustChange) return renderChangePw(u,false); login(u); }catch(e){ renderLogin(); } }
else renderLogin();
} else {
// Ingen server: forhåndsvisning med demo-data i browseren (som før)
SRV.demo=true; loadDemo(); await seedTid(); startMenuSync();
const remembered = remember.load();
if(remembered) login(remembered); else { remember.clear(); renderLogin(); }
}
if(location.hash==="#tid") openClock();
if(SRV.on) rawFetch("/api/health",{cache:"no-store"}).then(r=>{ SRV.rewrite=r.ok; }).catch(()=>{ SRV.rewrite=false; }); // tjekker at .htaccess i roden virker
setInterval(()=>{ if(SRV.session) flush(); }, 3000);
setInterval(()=>{ if(S.user) updateTitle(); }, 5000);
addEventListener("pagehide", ()=>{ if(SRV.session) flush(); });
}
/* ===================== PERSONALEUDLÆG ===================== */
/* Ansatte med adgang indsender udlæg: billede af kvitteringen → Claude udfylder dato, beløb, varer og butik → den ansatte
skriver hvad det er købt til. Dem der godkender (admins + adgang) svarer med en kvittering for overførslen og godkender.
Godkendte udlæg samles og kan hentes som PDF/CSV eller sendes direkte til revisor. Billeder ligger beskyttet på serveren. */
let EXPENSES = [];
const expPermsOf = u => { const p=TID.d.perms&&TID.d.perms[u.id]; return p && Array.isArray(p.expense) ? p.expense : []; };
const canExpenseSend = u => !!u && !isBureau(u) && (isAdmin(u) || expPermsOf(u).includes("send"));
const canExpenseApprove = u => !!u && !isBureau(u) && (isAdmin(u) || expPermsOf(u).includes("approve"));
function setExpensePerm(uid, key, on){
const cur=new Set(expPermsOf(user(uid))); on?cur.add(key):cur.delete(key);
const p=TID.d.perms[uid]||{}; TID.setPerm(uid, {menu:Array.isArray(p.menu)?p.menu:[], expense:["send","approve"].filter(k=>cur.has(k))});
}
const expPending = u => canExpenseApprove(u) ? EXPENSES.filter(e=>e.status==="afventer" && (isAdmin(u)||allowed(u).includes(e.company))).length : 0;
const kr = n => (n==null||n==="") ? "–" : Number(n).toLocaleString("da-DK",{minimumFractionDigits:2, maximumFractionDigits:2})+" kr.";
const expImg = name => name ? "/api/index.php?r=expense/file/"+encodeURIComponent(name) : "";
const EXP_ST = {afventer:["Afventer","--warn"], godkendt:["Godkendt","--ok"], afvist:["Afvist","--danger"]};
const expChip = e => { const [l,c]=EXP_ST[e.status]||[e.status,"--muted"]; return `${l}`; };
const expDate = d => d ? new Date(d+"T12:00").toLocaleDateString("da-DK",{day:"numeric",month:"short",year:"numeric"}) : "–";
const parseKr = v => { const t=String(v||"").trim().replace(/\s|kr\.?/gi,""); if(!t) return null; const n=Number(t.includes(",")?t.replace(/\./g,"").replace(",","."):t); return isFinite(n)?n:null; };
function viewExpenses(){
const u=S.user, send=canExpenseSend(u), appr=canExpenseApprove(u);
const tabs=[send&&["new","Nyt udlæg"], send&&["mine","Mine udlæg"], appr&&["todo",`Til godkendelse${expPending(u)?` (${expPending(u)})`:""}`], appr&&["done","Godkendte udlæg"]].filter(Boolean);
if(!tabs.some(t=>t[0]===S.expTab)) S.expTab=(appr&&expPending(u))?"todo":tabs[0][0];
$("#main").innerHTML=`
`);
let st; try{ st=await api("settings/clockips"); }catch(e){ box.innerHTML=`
${esc(e.message)}
`; return; }
let ips=st.ips.map(x=>({...x}));
const draw=()=>{ box.innerHTML=`
Når der står mindst én adresse her, kan man kun stemple ind og ud fra de netværk – fx køkkenets wifi. Står listen tom, kan man stemple overalt. Admins kan stadig rette tider hvor som helst.
${ips.length?`Kun fra ${ips.length} ${ips.length===1?"netværk":"netværk"}`:"Overalt (ingen begrænsning)"}Din adresse lige nu: ${esc(st.myIp)}
${ips.map((r,i)=>``).join("")}
Stå på køkkenets wifi og klik "Tilføj min nuværende adresse". Et helt net kan skrives som fx 85.191.12.0/24. Skifter internetudbyderen adresse, skal den opdateres her.`;
document.querySelectorAll("[data-ipi]").forEach(x=>x.oninput=()=>ips[+x.dataset.ipi].ip=x.value.trim());
document.querySelectorAll("[data-ipl]").forEach(x=>x.oninput=()=>ips[+x.dataset.ipl].label=x.value);
document.querySelectorAll("[data-ipr]").forEach(x=>x.onclick=()=>{ ips.splice(+x.dataset.ipr,1); draw(); });
$("#ipMe").onclick=()=>{ if(!ips.some(r=>r.ip===st.myIp)) ips.push({ip:st.myIp, label:"Køkkenet"}); draw(); };
$("#ipAdd").onclick=()=>{ ips.push({ip:"", label:""}); draw(); box.querySelector(`[data-ipi="${ips.length-1}"]`)?.focus(); };
$("#ipSave").onclick=async()=>{ if(blocked()) return; try{ const r=await api("settings/clockips",{ips:ips.filter(x=>x.ip)}); st=r; ips=r.ips.map(x=>({...x})); draw();
toast(ips.length?(r.ok?"Gemt. Stempeluret virker nu kun fra de netværk":"Gemt – men din nuværende adresse er ikke på listen, så her kan man ikke stemple"):"Gemt. Man kan stemple overalt"); }catch(x){ toast(x.message,"error"); } };
};
draw();
}
/* Indbakken: et udlæg vises kort med en knap, der åbner det under Udlæg */
const expInboxOk = (u,m) => m.kind==="Udlæg" ? canExpenseApprove(u) && (isAdmin(u)||allowed(u).includes(m.site)) : canInbox(u,m.site);
function expInboxDetail(sel){
const e=EXPENSES.find(x=>x.id===sel.expense), box=document.querySelector(".mdetail"); if(!box) return;
box.innerHTML=`