function Poort(){
  const [email,setEmail]=useState(""); const [ww,setWw]=useState(""); const [f,setF]=useState(null); const [bezig,setBezig]=useState(false);
  async function login(){ setBezig(true); setF(null);
    const r=await db.auth.signInWithPassword({email:email.trim(),password:ww});
    if(r.error){ setF(fout(r.error)); setBezig(false); } }
  return (<div className="poort"><div className="paneel">
    <div className="merk">UP<span>golf</span></div>
    <p className="sub">Balie: log in met je medewerkersaccount.</p>
    <label>E-mailadres</label>
    <input type="email" value={email} autoComplete="username" onChange={e=>setEmail(e.target.value)} onKeyDown={e=>e.key==="Enter"&&login()}/>
    <label>Wachtwoord</label>
    <input type="password" value={ww} autoComplete="current-password" onChange={e=>setWw(e.target.value)} onKeyDown={e=>e.key==="Enter"&&login()}/>
    <button className="primair" onClick={login} disabled={bezig||!email||!ww}>{bezig?"Bezig…":"Inloggen"}</button>
    {f && <div className="fout">{f}</div>}
  </div></div>);
}

// ===== Recras aan de balie, ronde 2 (bouwspec ronde 2, 14 september 2026, sectie 4.3 en 4.5) ==========
// Een eigen code is 9 letters en cijfers en gaat exact de oude weg (kaart_bij_code, boek_tijdslot).
// Een Recras-code is 15 tekens en gaat via de edge function recras-balie, die alleen een ingelogde
// medewerker toelaat. db.functions.invoke stuurt het token van de ingelogde medewerker vanzelf mee.
function baliecodeSoort(ruw){
  const n=String(ruw||"").replace(/[^A-Za-z0-9]/g,"").length;
  if(n===9) return "eigen";
  if(n===15) return "recras";
  if(n>=10&&n<=14) return "onvolledig";
  return "onbekend";
}
// Altijd {http, d}. Bij een niet-2xx-status zit het antwoord in r.error.context (de fetch Response).
async function recrasBalie(body){
  try{
    const r=await db.functions.invoke("recras-balie",{body});
    if(!r.error) return {http:200, d:r.data};
    const ctx=r.error.context; let d=null;
    try{ d=ctx&&typeof ctx.json==="function" ? await ctx.json() : null; }catch(e){}
    return {http:(ctx&&ctx.status)||0, d:d};
  }catch(e){ return {http:0, d:null}; }
}
// ===== Afrekenen aan de balie, R21 (19 september 2026) ====================================
// Alleen via Mollie: Roy drie keer letterlijk "geen contant, nooit contant" en "als het via ons
// systeem is, is het via Molly". Geen keuzescherm tussen twee methoden, er is er maar een.
// db.functions.invoke stuurt het token van de ingelogde medewerker vanzelf mee, net als bij
// recrasBalie hierboven; mollie-betaling controleert zelf is_medewerker() bij bron="balie".
async function molliebalie(boekingId){
  try{
    const r=await db.functions.invoke("mollie-betaling",{body:{boeking_id:boekingId, bron:"balie"}});
    if(!r.error) return {ok:true, url:(r.data&&r.data.betaalUrl)||null};
    const ctx=r.error.context; let d=null;
    try{ d=ctx&&typeof ctx.json==="function" ? await ctx.json() : null; }catch(e){}
    const reden=d&&d.error;
    const tekst = reden==="geen_medewerker" ? "Je sessie is verlopen. Log opnieuw in."
      : reden==="voor deze boeking loopt al een betaling" ? "Er loopt al een betaling voor deze boeking."
      : reden==="deze boeking is al betaald" ? "Deze boeking is al betaald."
      : reden==="deze boeking staat niet meer open" ? "Deze boeking staat niet meer open."
      : "Afrekenen via Mollie is niet gelukt. Probeer het opnieuw.";
    return {ok:false, fout:tekst};
  }catch(e){ return {ok:false, fout:"Afrekenen via Mollie is niet gelukt. Probeer het opnieuw."}; }
}
const RECRAS_BALIE_TEKST = {
  recras_onbereikbaar:"Recras reageert niet. Boek zonder Recras of probeer het zo opnieuw.",
  email_nodig:"Voor een boeking in Recras is een e-mailadres nodig.",
  dagmax:"Met dit abonnement is er op deze dag al het maximum aantal keer geboekt.",
  betaallink:"Stuur deze link naar de beller. De starttijd staat vast, de betaling loopt nog.",
  geen_medewerker:"Je sessie is verlopen. Log opnieuw in.",
  uit:"Boeken naar Recras staat uit. Boek zonder Recras.",
  sport:"Deze starttijd is geen golf. Naar Recras boeken kan nu alleen voor golf.",
  geen_startmoment:"Deze starttijd staat niet in Recras. Kies een andere tijd of boek zonder Recras.",
  op:"Deze kaart is op.",
  onbekend:"Deze code kennen we niet. Kijk of hij goed is overgetypt.",
  niet_bruikbaar:"Deze kaart kun je voor deze starttijd niet gebruiken.",
  betaling_nodig:"Kies hoe er betaald wordt: de kaart dekt niet alle spelers.",
  groep_niet_ondersteund:"Naar Recras boeken kan met maximaal 4 spelers per boeking.",
  recras_onzeker:"Recras gaf geen duidelijk antwoord. Kijk in Recras of de boeking er staat voordat je opnieuw boekt.",
  recras_geweigerd:"Recras weigerde de boeking. Er is niets vastgelegd.",
  invoer:"Controleer de naam en het e-mailadres: de naam is te lang of het e-mailadres klopt niet.",
};
// Ronde 4: een abonnement loopt vaak over de jaargrens, dus de einddatum altijd met jaartal.
const datumMetJaar=s=>new Date(s+"T12:00:00").toLocaleDateString("nl-NL",{weekday:"short",day:"numeric",month:"short",year:"numeric"});
function recrasBalieFout(u){
  const d=u.d||{};
  const reden=d.reden||d.error||(u.http===0?"recras_onbereikbaar":null);
  if(reden&&RECRAS_BALIE_TEKST[reden]) return RECRAS_BALIE_TEKST[reden];
  if(reden==="boeken") return d.melding||"De starttijd kon niet worden vastgelegd.";
  if(reden==="vastleggen") return "Recras heeft de boeking"+(d.recras_boeking_id?" "+d.recras_boeking_id:"")+", maar bij ons is hij niet helemaal vastgelegd. Kijk in Recras en niet opnieuw boeken.";
  return "Er ging iets mis"+(reden?" ("+reden+")":"")+".";
}
function recrasKaartTekst(k){
  if(!k) return null;
  const datumNl=s=>s?s.split("-").reverse().join("-"):"";
  if(k.staat==="op") return {klasse:"fout", tekst:"Deze kaart is op."};
  if(k.staat==="verlopen") return {klasse:"fout", tekst:"Deze kaart is verlopen op "+datumNl(k.geldig_tot)+"."};
  if(k.staat==="geblokkeerd") return {klasse:"fout", tekst:"Deze kaart is geblokkeerd."};
  if(k.soort==="abonnement") return {klasse:"ok", tekst:"Recras: abonnement van "+(k.houder||"onbekend")+", geldig tot "+(k.geldig_tot?datumMetJaar(k.geldig_tot):"onbekend")+"."};
  const n=k.strippen_over;
  return {klasse:"ok", tekst:"Recras: "+k.kaartnaam+" van "+(k.houder||"onbekend")+", nog "+n+" strip"+(n===1?"":"pen")+"."};
}
// ======================================================================================================

function BoekPaneel({slot,types,houders,vrij,datum,onSluit,onKlaar}){
  const gast=types.filter(t=>!t.is_lid)[0]||types[0];
  const [naam,setNaam]=useState(""); const [email,setEmail]=useState(""); const [tel,setTel]=useState("");
  const [sp,setSp]=useState([{naam:"",type:gast?gast.id:"",houder:""}]); const [f,setF]=useState(null); const [bezig,setBezig]=useState(false);
  // Ronde 2: vinkje "Naar Recras boeken" (standaard uit), betaalkeuze en de uitkomst die open blijft staan.
  const [naarRecras,setNaarRecras]=useState(false);
  const [betaling,setBetaling]=useState("");
  const [uitkomst,setUitkomst]=useState(null);
  function wz(i,veld,val){ setSp(sp.map((s,n)=>n===i?Object.assign({},s,{[veld]:val}):s)); }
  // De setState met een functie, want er zit een netwerkaanroep tussen twee updates en dan is de
  // lijst uit de closure verouderd. Zelfde reden als aan de gastkant.
  function wzFn(i,patch){ setSp(ss=>ss.map((s,n)=>n===i?Object.assign({},s,patch):s)); }
  // Een gast komt aan de balie met zijn kaart of abonnement. De code wijst de kaart aan; dan is
  // de houderkeuze niet meer nodig en loopt het saldo ook echt af. Zonder code werkt alles
  // precies zoals het was.
  async function controleerCode(i, ruw){
    const code=(ruw||"").trim();
    if(!code){ wzFn(i,{kaart:null,kaartBezig:false}); return; }
    wzFn(i,{kaartBezig:true});
    const r=await db.rpc("kaart_bij_code",{p_code:code, p_sport:slot.sport||null, p_datum:datum||null});
    if(r.error){ wzFn(i,{kaart:{gevonden:false},kaartBezig:false}); return; }
    wzFn(i,{kaart:r.data,kaartBezig:false});
  }
  // Ronde 2 (4.3 punt 1): een Recras-code van 15 tekens, via recras-balie zoek_code.
  async function controleerRecrasCode(i, ruw){
    const code=(ruw||"").trim();
    if(!code){ wzFn(i,{kaart:null,kaartBezig:false}); return; }
    wzFn(i,{kaartBezig:true});
    const u=await recrasBalie({actie:"zoek_code", code:code, datum:datum||null});
    if(u.http!==200 || !u.d){ wzFn(i,{kaart:{bron:"recras", gevonden:false, fout:recrasBalieFout(u)}, kaartBezig:false}); return; }
    if(!u.d.gevonden){ wzFn(i,{kaart:{bron:"recras", gevonden:false, fout:RECRAS_BALIE_TEKST.onbekend}, kaartBezig:false}); return; }
    wzFn(i,{kaart:Object.assign({bron:"recras", gevonden:true}, u.d.kaarten[0]), kaartBezig:false});
  }
  function controleerVolgensSoort(i, v, bijVerlaten){
    const soort=baliecodeSoort(v);
    if(soort==="eigen") controleerCode(i,v);
    else if(soort==="recras") controleerRecrasCode(i,v);
    else if(soort==="onbekend" && bijVerlaten) controleerCode(i,v);
  }
  // "Code kwijt?" op deze rij: naam plus e-mailadres in plaats van de code, voor de gast die zijn
  // code niet bij zich heeft. Aangeroepen MET p_sport en p_datum, anders dan in het losse
  // saldoscherm: hier is een starttijd met een sport en een speeldatum bekend, en dan wil de balie
  // meteen het oordeel zien (geldt niet voor deze sport, of op deze datum verlopen) in plaats van
  // een saldo dat hij zelf nog moet beoordelen. Bij een treffer komt de code mee terug; die zetten
  // we in het codeveld alsof hij was overgetypt, zodat boek_tijdslot niets van dit pad hoeft te
  // weten. Bewust GEEN zoeken tijdens het typen: deze functie heeft een rem op het aantal pogingen
  // en die telt voor de hele database, dus elke halve poging gaat van hetzelfde tegoed af.
  // Ronde 2 (4.3 punt 2): tegelijk ook recras-balie zoek_naam_email; treffers als keuzeknoppen.
  async function zoekOpNaamEmail(i, zNaam, zEmail){
    if(!(zNaam||"").trim() || !(zEmail||"").trim()){ wzFn(i,{zoekFout:null}); return; }
    wzFn(i,{zoekBezig:true, zoekFout:null, recrasKeuzes:null});
    const [r,u]=await Promise.all([
      db.rpc("kaart_bij_naam_email",{p_naam:zNaam.trim(), p_email:zEmail.trim(), p_sport:slot.sport||null, p_datum:datum||null}),
      recrasBalie({actie:"zoek_naam_email", naam:zNaam.trim(), email:zEmail.trim(), datum:datum||null})
    ]);
    const keuzes=(u.http===200 && u.d && u.d.gevonden && Array.isArray(u.d.kaarten)) ? u.d.kaarten : null;
    if(r.error && !keuzes){ wzFn(i,{zoekBezig:false, zoekFout:"Zoeken lukt nu niet. Probeer het zo nog eens."}); return; }
    const d=r.error ? {gevonden:false} : r.data;
    if(d.reden==="te_veel_pogingen" && !keuzes){
      wzFn(i,{zoekBezig:false, kaart:null, code:"", zoekFout:"Er is net te vaak gezocht. Wacht een paar minuten, of zoek de kaarthouder op bij Kaarthouders."}); return; }
    if(!d.gevonden && keuzes){ wzFn(i,{zoekBezig:false, zoekFout:null, recrasKeuzes:keuzes}); return; }
    if(!d.gevonden){
      // Zelfde melding ongeacht welk van de twee velden niet klopte. De functie maakt dat verschil
      // zelf ook niet, dus er valt hier niets te tonen wat het alsnog verraadt.
      const weg = u.http!==200 && u.d && (u.d.reden==="recras_onbereikbaar"||u.d.error==="geen_medewerker");
      wzFn(i,{zoekBezig:false, kaart:null, code:"", zoekFout: weg ? recrasBalieFout(u) : "Geen kaart bij deze naam en dit e-mailadres. Controleer beide."}); return; }
    wzFn(i,{zoekBezig:false, zoekFout:null, kaart:d, code:d.code||"", recrasKeuzes:keuzes});
  }
  function kiesRecrasKaart(i, k){
    wzFn(i,{code:k.code, kaart:null, opNaam:false});
    controleerRecrasCode(i, k.code);
  }
  function codeFout(k){
    if(!k) return null;
    if(k.bron==="recras") return k.gevonden ? null : (k.fout||RECRAS_BALIE_TEKST.onbekend);
    if(!k.gevonden) return "Deze code kennen we niet. Kijk of hij goed is overgetypt.";
    if(k.bruikbaar) return null;
    if(k.reden==="geblokkeerd") return "Deze kaart is geblokkeerd.";
    if(k.reden==="geen_ritten") return "Op deze kaart staan geen ritten meer.";
    if(k.reden==="verlopen") return "Deze kaart is op deze speeldatum niet meer geldig.";
    if(k.reden==="geen_kaart") return "Bij deze gegevens staat geen actieve kaart of abonnement.";
    if(k.reden==="andere_sport") return "Deze kaart geldt voor " + (k.sport||"een andere sport") + ".";
    return "Deze kaart kunnen we nu niet gebruiken.";
  }
  const isLid=t=>{ const x=types.filter(y=>y.id===t)[0]; return x&&x.is_lid; };
  const heeftCode=s=>!!(s.code||"").trim();
  const isRecrasRij=s=>heeftCode(s)&&s.kaart&&s.kaart.bron==="recras";
  const recrasBruikbaar=k=>!!k&&k.bron==="recras"&&k.gevonden&&k.staat==="bruikbaar";
  const mist=sp.some(s=>isLid(s.type)&&!s.houder&&!heeftCode(s));
  const codeNietKlaar=sp.some(s=>heeftCode(s)&&(s.kaartBezig||!s.kaart||(s.kaart.bron==="recras"?!recrasBruikbaar(s.kaart):!s.kaart.bruikbaar)));
  // Ronde 2 (4.3 punt 3): een Recras-kaart per boeking, niet samen met een eigen code of ledenkoppeling.
  const recrasRijen=sp.map((s,i)=>i).filter(i=>isRecrasRij(sp[i])&&recrasBruikbaar(sp[i].kaart));
  const recrasIndex=recrasRijen.length?recrasRijen[0]:-1;
  const recrasKaart=recrasIndex>=0?sp[recrasIndex].kaart:null;
  const recrasDubbel=recrasRijen.length>1;
  const recrasGemengd=recrasIndex>=0&&sp.some((s,i)=>i!==recrasIndex&&((heeftCode(s)&&!isRecrasRij(s))||(isLid(s.type)&&s.houder)));
  const viaRecras=naarRecras||recrasIndex>=0;
  // Dekt de kaart alle spelers? Alleen te zeggen bij een strippenkaart of abonnement met een maximum per boeking.
  const dektAlles=!!recrasKaart && recrasKaart.max_per_boeking!==null && recrasKaart.max_per_boeking!==undefined
    && (recrasKaart.soort==="strippenkaart" ? Math.min(recrasKaart.max_per_boeking, recrasKaart.strippen_over) : recrasKaart.max_per_boeking) >= sp.length;
  const betaalNodig=viaRecras && !dektAlles;
  const blokRecras=viaRecras && (recrasDubbel||recrasGemengd||sp.length>4||!email.trim()||(betaalNodig&&!betaling));
  async function opslaan(){ setBezig(true); setF(null);
    const lijst=sp.map(s=>{ const o={spelerstype_id:s.type,naam:s.naam.trim()||naam.trim()};
      if(heeftCode(s)){ o.kaartcode=(s.code||"").trim(); return o; }
      if(isLid(s.type)&&s.houder) o.lid_id=s.houder; return o; });
    const r=await db.rpc("boek_tijdslot",{p_tijdslot_id:slot.id,p_naam:naam.trim(),p_email:email.trim()||"balie@upgolf.nl",p_deelnemers:lijst,p_telefoon:tel.trim()||null,p_kanaal:"balie"});
    if(r.error){ setF(fout(r.error)); setBezig(false); return; } onKlaar(); }
  // Ronde 2 (4.3 punt 5): met een Recras-code of het vinkje aan gaat de boeking via recras-balie.
  // De knop blijft uit tot het antwoord binnen is: dubbel drukken is de enige manier om dubbel te boeken.
  // Gemeten op de meetopstelling (14 september 2026): twee klikken direct na elkaar gaven twee
  // boek-aanroepen, omdat disabled pas na de volgende render geldt. Deze grendel werkt meteen.
  const recrasBezigRef=React.useRef(false);
  async function opslaanRecras(){
    if(recrasBezigRef.current) return;
    if(!email.trim()){ setF(RECRAS_BALIE_TEKST.email_nodig); return; }
    recrasBezigRef.current=true;
    setBezig(true); setF(null); setUitkomst(null);
    const body={actie:"boek", tijdslot_id:slot.id, naam:naam.trim(), email:email.trim(), telefoon:tel.trim()||null,
      spelers:sp.length, deelnemers:sp.map(s=>({spelerstype_id:s.type, naam:s.naam.trim()||naam.trim()})),
      code:recrasKaart?sp[recrasIndex].code.trim():null, betaling:dektAlles?"geen":betaling,
      terugkeer:window.location.origin};
    const u=await recrasBalie(body);
    const d=u.d||{};
    if(u.http===200 && d.status==="bevestigd"){
      const methode=d.betaalmethode==="recras_kaart"?"kaart":d.betaalmethode;
      onKlaar("Geboekt en in Recras gezet. Bedrag "+euro(Math.round((d.bedrag_euro||0)*100))+", betaald met "+methode+"."+(d.recras_referentie?" Recras-referentie "+d.recras_referentie+".":""));
      return;
    }
    if(u.http===200 && (d.status==="betaallink"||d.status==="deels"||d.status==="controleer_recras")){ setUitkomst(d); setBezig(false); return; }
    // Alleen bij een fout mag er opnieuw geboekt worden; na betaallink, deels of controleer_recras niet.
    recrasBezigRef.current=false;
    setF(recrasBalieFout(u)); setBezig(false);
  }
  async function bekijkOpnieuw(){
    if(!uitkomst||!uitkomst.referentie) return;
    setBezig(true);
    const u=await recrasBalie({actie:"status", referentie:uitkomst.referentie});
    setBezig(false);
    if(u.http===200 && u.d){ setUitkomst(Object.assign({}, uitkomst, {laatsteStatus:u.d})); return; }
    setF(recrasBalieFout(u));
  }
  const vergrendeld=!!uitkomst;
  return (<div className="scrim" onClick={e=>e.target===e.currentTarget&&!vergrendeld&&onSluit()}>
    <div className="paneel"><div className="greep"/>
      <h2>Boeken {slot.starttijd.slice(0,5)}</h2><p className="sub">{vrij} plek{vrij===1?"":"ken"} vrij.</p>
      <label style={{display:"flex",gap:8,alignItems:"center"}}>
        <input type="checkbox" checked={naarRecras} disabled={vergrendeld} style={{width:"auto"}} onChange={e=>setNaarRecras(e.target.checked)}/> Naar Recras boeken
      </label>
      <label>Op naam van</label><input value={naam} onChange={e=>setNaam(e.target.value)} placeholder="Achternaam"/>
      <label>{viaRecras?"E-mail (verplicht voor Recras)":"E-mail (optioneel)"}</label><input type="email" value={email} onChange={e=>setEmail(e.target.value)}/>
      <label>Telefoon (optioneel)</label><input value={tel} onChange={e=>setTel(e.target.value)}/>
      <label>Spelers</label>
      {sp.map((s,i)=>(<div key={i} style={{marginBottom:9}}>
        <div className="rij" style={{marginBottom:4}}>
          <input value={s.naam} placeholder={"Speler "+(i+1)} disabled={isLid(s.type)} onChange={e=>wz(i,"naam",e.target.value)}/>
          <select value={s.type} onChange={e=>wz(i,"type",e.target.value)}>{types.map(t=><option key={t.id} value={t.id}>{t.naam}</option>)}</select>
          {sp.length>1 && <button className="mini weg" onClick={()=>setSp(sp.filter((_,n)=>n!==i))}>✕</button>}
        </div>
        {isLid(s.type) && !heeftCode(s) && <select value={s.houder} onChange={e=>wz(i,"houder",e.target.value)}>
          <option value="">Kies kaarthouder…</option>{houders.map(h=><option key={h.id} value={h.id}>{h.naam}</option>)}</select>}
        <input value={s.code||""} placeholder="Kaartcode (optioneel)" autoComplete="off" spellCheck="false"
          style={{marginTop:4, textTransform:"uppercase", letterSpacing:".06em"}}
          onChange={e=>{ const v=e.target.value; wzFn(i,{code:v,kaart:null});
            controleerVolgensSoort(i,v,false); }}
          onBlur={e=>controleerVolgensSoort(i,e.target.value,true)}/>
        <button className="mini" style={{marginTop:4}} onClick={()=>wzFn(i,{opNaam:!s.opNaam, zoekFout:null})}>
          {s.opNaam?"Toch met code":"Code kwijt?"}</button>
        {s.opNaam && <div style={{marginTop:4,paddingLeft:8,borderLeft:"2px solid var(--lijn,#dbe3e8)"}}>
          <input value={s.zNaam||""} placeholder="Naam van de kaarthouder" autoComplete="off"
            onChange={e=>wzFn(i,{zNaam:e.target.value, zoekFout:null})}/>
          <input type="email" value={s.zEmail||""} placeholder="E-mailadres van de kaarthouder" autoComplete="off"
            style={{marginTop:4}} onChange={e=>wzFn(i,{zEmail:e.target.value, zoekFout:null})}
            onKeyDown={e=>{ if(e.key==="Enter") zoekOpNaamEmail(i,s.zNaam,s.zEmail); }}/>
          <button className="mini doe" style={{marginTop:4}} disabled={s.zoekBezig||!(s.zNaam||"").trim()||!(s.zEmail||"").trim()}
            onClick={()=>zoekOpNaamEmail(i,s.zNaam,s.zEmail)}>{s.zoekBezig?"Zoeken…":"Kaart zoeken"}</button>
          <p className="bij" style={{margin:"4px 0 0"}}>Allebei de velden zijn nodig. Op alleen een naam zoeken kan niet.</p>
          {s.zoekFout && <div className="fout" style={{marginTop:4}}>{s.zoekFout}</div>}
          {s.recrasKeuzes && s.recrasKeuzes.map(k=>{ const t=recrasKaartTekst(k);
            return (<div key={"rk"+k.kaart_ref} style={{marginTop:6}}>
              <button className="mini doe" disabled={k.staat!=="bruikbaar"} onClick={()=>kiesRecrasKaart(i,k)}>
                Recras: {k.houder||"onbekend"} · {k.kaartnaam} · {k.code}</button>
              <div className={t.klasse} style={{marginTop:4}}>{t.tekst}</div>
            </div>);})}
        </div>}
        {s.kaartBezig && <div className="bij" style={{margin:"3px 0 0"}}>Even kijken…</div>}
        {!s.kaartBezig && s.kaart && s.kaart.bron!=="recras" && s.kaart.bruikbaar && s.kaart.soort==="strippenkaart" &&
          <div className="ok" style={{marginTop:4}}>Kaart van {s.kaart.houder}, nog {s.kaart.saldo} rit{s.kaart.saldo===1?"":"ten"}.</div>}
        {!s.kaartBezig && s.kaart && s.kaart.bron!=="recras" && s.kaart.bruikbaar && s.kaart.soort==="abonnement" &&
          <div className="ok" style={{marginTop:4}}>Abonnement van {s.kaart.houder}, geldig tot {datumMetJaar(s.kaart.geldig_tot)}.</div>}
        {!s.kaartBezig && s.kaart && s.kaart.bron==="recras" && s.kaart.gevonden &&
          <div className={recrasKaartTekst(s.kaart).klasse} style={{marginTop:4}}>{recrasKaartTekst(s.kaart).tekst}</div>}
        {!s.kaartBezig && s.kaart && codeFout(s.kaart) && <div className="fout" style={{marginTop:4}}>{codeFout(s.kaart)}</div>}
        {!s.kaartBezig && recrasDubbel && recrasRijen.indexOf(i)>0 &&
          <div className="fout" style={{marginTop:4}}>Een Recras-kaart geldt voor de hele reservering. Vul hem maar één keer in.</div>}
      </div>))}
      {sp.length<vrij && !vergrendeld && <button className="mini" onClick={()=>setSp(sp.concat([{naam:"",type:gast?gast.id:"",houder:"",code:""}]))}>+ speler</button>}
      {mist && <div className="fout">Kies bij elk kaarthouder-tarief de bijbehorende houder, of vul een kaartcode in.</div>}
      {recrasGemengd && <div className="fout">Combineren van deze kaart met een andere kaart kan online nog niet.</div>}
      {viaRecras && sp.length>4 && <div className="fout">{RECRAS_BALIE_TEKST.groep_niet_ondersteund}</div>}
      {viaRecras && !vergrendeld && <div style={{marginTop:10}}>
        <label>Betaling</label>
        {dektAlles
          ? <div className="ok" style={{marginTop:4}}>Geen betaling nodig</div>
          : <div className="acties" style={{display:"flex",gap:8,flexWrap:"wrap"}}>
              {/* Geen contant-knop hier: Roy drie keer letterlijk "geen contant, nooit contant",
                  bevestigd op 19 september 2026. Dit is het bestaande boekscherm met kaart (actie
                  "boek" in recras-balie, los van het nieuwe afrekenpaneel hierboven); die knop stond
                  er al voor R21 en is nu verwijderd. De instelling recras_betaalmethode_contant_id
                  blijft ongewijzigd staan, alleen de knop is weg. */}
              {[["pin","PIN"],["betaallink","Betaallink sturen"]].map(([w,t])=>
                <button key={w} className={"mini"+(betaling===w?" doe":"")} aria-pressed={betaling===w} onClick={()=>setBetaling(w)}>{t}</button>)}
            </div>}
        {!recrasKaart && <p className="bij" style={{margin:"6px 0 0"}}>Prijs volgens Recras. Het bedrag staat na het boeken in de melding.</p>}
        {!email.trim() && <div className="fout" style={{marginTop:6}}>{RECRAS_BALIE_TEKST.email_nodig}</div>}
      </div>}
      {uitkomst && uitkomst.status==="betaallink" && <div className="ok">
        <div>{RECRAS_BALIE_TEKST.betaallink}</div>
        <input readOnly value={uitkomst.url} style={{marginTop:6}} onFocus={e=>e.target.select()}/>
        <button className="mini doe" style={{marginTop:6}} onClick={()=>{ try{ navigator.clipboard.writeText(uitkomst.url); }catch(e){} }}>Kopieer link</button>
      </div>}
      {uitkomst && uitkomst.status==="deels" && <div className="fout">De boeking staat, maar Recras is niet helemaal bijgewerkt ({uitkomst.stap}). Kijk in Recras bij boeking {uitkomst.recras_boeking_id} en rond daar af. Niet opnieuw boeken.</div>}
      {uitkomst && uitkomst.status==="controleer_recras" && <div className="fout">Recras rekent {euro(Math.round(uitkomst.factuur*100))}, wij verwachtten {euro(Math.round(uitkomst.verwacht*100))}. De betaling is niet vastgelegd. Kijk in Recras bij factuur {uitkomst.recras_factuur_id}.</div>}
      {uitkomst && (uitkomst.status==="deels"||uitkomst.status==="controleer_recras") && <>
        <button className="mini" style={{marginTop:6}} disabled={bezig} onClick={bekijkOpnieuw}>Opnieuw bekijken</button>
        {uitkomst.laatsteStatus && <p className="bij" style={{margin:"6px 0 0"}}>Recras: boeking {uitkomst.laatsteStatus.recras_status||"onbekend"}, factuur {uitkomst.laatsteStatus.recras_factuur_id||"geen"} {uitkomst.laatsteStatus.factuur_status||""}. Bij ons: {uitkomst.laatsteStatus.status}.</p>}
      </>}
      {!vergrendeld && <button className="primair" onClick={viaRecras?opslaanRecras:opslaan}
        disabled={bezig||!naam.trim()||mist||codeNietKlaar||blokRecras}>{bezig?"Bezig…":"Boeking vastleggen"}</button>}
      {f && <div className="fout">{f}</div>}
      {vergrendeld
        ? <button className="sluit" onClick={()=>onKlaar(uitkomst.status==="betaallink"?"Boeking vastgelegd, betaallink gemaakt":"Boeking vastgelegd, controleer Recras")}>Sluiten</button>
        : <button className="sluit" onClick={onSluit}>Annuleren</button>}
    </div></div>);
}

function VerplaatsPaneel({boeking,sloten,onSluit,onKlaar}){
  const [doel,setDoel]=useState(""); const [f,setF]=useState(null); const [bezig,setBezig]=useState(false);
  const n=boeking.spelers.length;
  // De SPORT hoort bij de keuze, en dat ontbrak. Gemeten op 12 augustus 2026: een golfflight van drie
  // kon zonder enige waarschuwing naar 15:00 verplaatst worden, en dat is een voetgolfstarttijd van
  // veertig plekken. De databasefunctie `verplaats_boeking` controleert de sport niet, dus die stond
  // er daarna ook echt in. Dat is een balie die drie golfers naar de voetgolfbaan stuurt.
  // Hier filteren wij op dezelfde sport als de starttijd waar de boeking nu op staat. De sport staat
  // bovendien in de tekst van elke keuze, zodat de balie ziet waar hij naartoe gaat.
  // De grendel in de database blijft een punt voor de bouwer bij UP; dit is de voorkant ervan.
  const eigenSport = boeking.sport || null;
  const opties=sloten.filter(s=>s.status==="open"&&s.id!==boeking.slotId&&(s.max_spelers-s.bezet)>=n
    && (!eigenSport || s.sport===eigenSport));
  async function go(){ setBezig(true); setF(null);
    const r=await db.rpc("verplaats_boeking",{p_boeking_id:boeking.boeking_id,p_nieuw_tijdslot_id:doel});
    if(r.error){ setF(fout(r.error)); setBezig(false); return; } onKlaar("Boeking verplaatst"); }
  return (<div className="scrim" onClick={e=>e.target===e.currentTarget&&onSluit()}>
    <div className="paneel"><div className="greep"/><h2>Verplaatsen</h2>
      <p className="sub">{boeking.contact} · {n} speler{n===1?"":"s"} · {boeking.referentie}
        {eigenSport ? " · "+sportLabel(eigenSport) : ""}</p>
      <label>Nieuwe starttijd</label>
      <select value={doel} onChange={e=>setDoel(e.target.value)}><option value="">Kies…</option>
        {opties.map(s=><option key={s.id} value={s.id}>{s.starttijd.slice(0,5)}, {sportLabel(s.sport)}, {s.max_spelers-s.bezet} vrij</option>)}</select>
      {opties.length===0 && <div className="fout">Geen {eigenSport?sportLabel(eigenSport).toLowerCase()+"-":""}starttijd op deze dag met {n} vrije plekken. Kies een andere dag, of annuleer en boek opnieuw.</div>}
      <button className="primair" onClick={go} disabled={bezig||!doel}>{bezig?"Bezig…":"Verplaatsen"}</button>
      {f && <div className="fout">{f}</div>}<button className="sluit" onClick={onSluit}>Annuleren</button>
    </div></div>);
}

// ===== Afrekenpaneel, R21 (19 september 2026) ==============================================
// Voor een boeking die bij ons nog OPEN staat (b.betaalstatus==="open"), bijvoorbeeld een
// walk-in-greenfee die via "+ Boeken" is gemaakt. Alleen Mollie, geen contant (zie molliebalie
// hierboven). Geen bevestigingsmail bij deze weg (Roy: "Als mensen gewoon langskomen, hoeven we
// geen mail"), dat is een keuze in mollie-webhook en niet iets wat dit scherm bepaalt. Of dit ook
// bij Recras terechtkomt, bepaalt de instelling recras_balie_afrekenen_naar_recras; dat gebeurt
// server-side, niet hier. Geen koppeling met het kassasysteem Povis: dat is een latere vraag.
function AfrekenPaneel({boeking,onSluit,onKlaar}){
  const [f,setF]=useState(null); const [bezig,setBezig]=useState(false); const [url,setUrl]=useState(null);
  async function start(){
    setBezig(true); setF(null);
    const u=await molliebalie(boeking.boeking_id);
    setBezig(false);
    if(!u.ok){ setF(u.fout); return; }
    setUrl(u.url);
    if(u.url) window.open(u.url, "_blank", "noopener");
  }
  return (<div className="scrim" onClick={e=>e.target===e.currentTarget&&onSluit()}>
    <div className="paneel"><div className="greep"/><h2>Afrekenen</h2>
      <p className="sub">{boeking.contact} · {boeking.referentie} · {euro(boeking.bedrag_cent)}</p>
      {!url && <>
        <p className="sub">Alleen via Mollie, geen contant. Start de betaling; de gast rondt hem af op het scherm dat opent (kaart, iDEAL of Pay by Bank).</p>
        <button className="primair" onClick={start} disabled={bezig}>{bezig?"Bezig…":"Start betaling (Mollie)"}</button>
      </>}
      {url && <>
        <p className="sub">Betaalscherm geopend in een nieuw tabblad. Is de gast klaar met betalen, klik dan op Vernieuwen om de tee sheet bij te werken.</p>
        <button className="mini" onClick={()=>window.open(url,"_blank","noopener")}>Betaalscherm opnieuw openen</button>
        <button className="primair" onClick={()=>onKlaar("Vernieuwd")}>Vernieuwen</button>
      </>}
      {f && <div className="fout">{f}</div>}
      <button className="sluit" onClick={onSluit}>Sluiten</button>
    </div></div>);
}

// ===== Bevestigen zonder de pagina te bevriezen =========================================
// Het annuleren van een boeking hing aan `window.confirm`. Twee dingen mis daarmee, allebei gezien op
// 12 augustus 2026:
//   1. Het is een SYSTEEMdialoog met "127.0.0.1:8901 zegt:" erboven. Dat is het enige punt in de hele
//      balie waar de browser zichtbaar wordt, en het is precies de handeling die je aan iemand laat
//      zien. Het oogt onaf naast de rest van het scherm.
//   2. Zolang die dialoog openstaat is de hele pagina bevroren. Bij het meten liep de browser er
//      dertig seconden op vast. Aan een balie met een aanraakscherm is dat een scherm dat "hangt".
// Dit paneel gebruikt dezelfde vormgeving als de andere panelen, en de knop die iets weggooit is
// rood en staat rechts.
function Bevestig({vraag,toelichting,knop,onJa,onSluit}){
  return (<div className="scrim" onClick={e=>e.target===e.currentTarget&&onSluit()}>
    <div className="paneel"><div className="greep"/>
      <h2>{vraag}</h2>
      {toelichting && <p className="sub">{toelichting}</p>}
      <button className="primair" onClick={onJa}>{knop}</button>
      <button className="sluit" onClick={onSluit}>Nee, laat staan</button>
    </div></div>);
}
// ========================================================================================

// ===== Twee grenzen in de tee sheet, en waarom ze zo staan ==============================
//
// GRENS 1: boven acht plekken worden de bolletjes een balk.
// Bolletjes tellen is iets wat een mens tot ongeveer vier a zes stuks in een oogopslag doet; daarboven
// moet hij echt tellen en is de rij dus geen snelle weergave meer. Acht is de ruime bovengrens waar een
// rij nog als vorm te herkennen is. Praktisch scheidt die grens de twee sporten hier ondubbelzinnig:
// golf heeft vier plekken, voetgolf veertig, en er zit niets tussenin. Golf houdt dus zijn bolletjes,
// voetgolf krijgt de balk, en een toekomstig sjabloon met zes of acht plekken slaat niet meteen om.
// Er zat ook een harde kant aan: veertig bolletjes van tien pixels met vier ertussen is 556 pixels aan
// bolletjes op een rij, wat de knoppen ernaast wegdrukt.
const PEGS_MAX = 8;
//
// ===== DE ENE PLEK: legenda op spelerscategorie, 22 augustus 2026 =========================
// Daniels legenda (screenshot 02) kleurt op GREENFEE, STRIP, LID, VRIJ, niet op sport zoals de
// balie tot nu toe deed. Eigen meting op staging, 22 augustus 2026: de databasefunctie `teesheet`
// geeft per speler al een veld `categorie` mee (los, strippenkaart, abonnement, personeel; uit de
// bestaande view `deelnemers_categorie`), en dat dekt precies deze vier legendatermen. Geen
// databasewijziging nodig, dit is puur een voorkant die een al aanwezig veld nu gebruikt.
//   GREENFEE (aqua)  <- categorie "los"           (betaalwijze greenfee)
//   STRIP    (rood)  <- categorie "strippenkaart"
//   LID      (navy)  <- categorie "abonnement" EN "personeel" (allebei een seizoenskaart)
//   VRIJ     (licht) <- geen speler op die plek, de plek zelf
// PERSONEEL heeft geen eigen kleur in Daniels legenda; ik heb hem bij LID gezet omdat beide uit
// dezelfde betaalwijze (seizoenskaart) komen. Dat is de minst vastleggende keuze: personeel een
// eigen kleur geven kan later, dit is een samenvoeging en geen weggegooide informatie, want de
// echte categorie blijft in de data staan.
// JEUGD is geen spelerstype in Daniels legenda en krijgt daarom geen legendakleur; die blijft
// zichtbaar als klein tekstmerkje op de speler zelf, zie .jeugd-merk in stijl.css.
// TERUGVALMOGELIJKHEID: dit is de ENE plek voor die keuze. Terugzetten naar kleur per sport
// betekent CATEGORIE_KLEUR hier vervangen door een sport-keuze en de drie regels .cat-* in
// stijl.css aanpassen; verder hoeft niets in dit bestand te veranderen.
const CATEGORIE_KLEUR = {
  los:            {klas:"cat-los",   label:"Greenfee"},
  strippenkaart:  {klas:"cat-strip", label:"Strip"},
  abonnement:     {klas:"cat-abo",   label:"Lid"},
  personeel:      {klas:"cat-abo",   label:"Lid"},
};
function catKlas(categorie){ const c=CATEGORIE_KLEUR[categorie]; return c?c.klas:""; }
// De legenda zelf, in Daniels volgorde: Greenfee, Strip, Lid, Vrij.
function Legenda(){
  return (<div className="legenda">
    <span className="legenda-item"><span className="legenda-swatch cat-los"/>Greenfee</span>
    <span className="legenda-item"><span className="legenda-swatch cat-strip"/>Strip</span>
    <span className="legenda-item"><span className="legenda-swatch cat-abo"/>Lid</span>
    <span className="legenda-item"><span className="legenda-swatch cat-vrij"/>Vrij</span>
  </div>);
}
// ============================================================================================
//
// GRENS 2: boven zes spelers staat de deelnemerslijst standaard dicht.
// Een golfflight is er vier, en een groep over twee starttijden ook hooguit vier per tijd. Die passen op
// een regel, en vouwen zou daar alleen een klik toevoegen zonder iets te winnen. Boven de zes breekt de
// knoppenrij op een baliescherm over meerdere regels en duwt de rij eronder weg. Bij veertig spelers werd
// het een muur van veertig knoppen onder elkaar, en dat was precies de klacht.
const SPELERS_MAX_OPEN = 6;
// ========================================================================================

// De capaciteit van een starttijd. Klein aantal: bolletjes, want die leest een balie in een oogopslag.
// Groot aantal: een balk met een vulniveau EN het getal ernaast. Het getal staat er altijd bij, dus de
// weergave werkt ook voor iemand die de kleur niet ziet; de balk is de snelle indruk, het getal is het
// antwoord. De balk draagt dezelfde tekst als toegankelijke naam, zodat een schermlezer niet alleen een
// leeg blokje aantreft.
// `spelersCats` is de lijst categorieën van de spelers die er echt staan, in boekingsvolgorde
// (alleen meegegeven bij golf, waar het aantal plekken toch al onder PEGS_MAX blijft). Ontbreekt
// hij, dan valt een bezet bolletje terug op de oude aqua "vol"-kleur. Sinds `teesheet` (22 augustus
// 2026) alle boekingen inclusief no-shows in één bevraging teruggeeft, geldt die beperking niet meer:
// elke rij, ook een no-show, komt nu uit dezelfde bevraging met hetzelfde categorieveld.
function Capaciteit({bezet,max,dicht,spelersCats}){
  if(max<=PEGS_MAX) return (<div className="pegs">
    {Array.apply(null,{length:max}).map((_,i)=>{
      if(dicht) return <div key={i} className="peg dicht"/>;
      if(i>=bezet) return <div key={i} className="peg"/>;
      const cat=spelersCats&&spelersCats[i];
      const klas=cat?catKlas(cat):"";
      return <div key={i} className={"peg vol "+klas}/>;
    })}</div>);
  const tekst=bezet+" van de "+max;
  const deel=max>0?Math.max(0,Math.min(100,Math.round(bezet/max*100))):0;
  return (<div className={"cap"+(dicht?" dicht":"")} role="img" aria-label={tekst+" plekken bezet"}
      data-bezet={bezet} data-max={max}>
    <div className="cap-balk"><div className="cap-vul" style={{width:deel+"%"}}/></div>
    <div className="cap-tekst">{tekst}</div>
  </div>);
}

// ===== Waarom voetgolf GEEN namenlijst krijgt ==========================================
// Bij voetgolf kan een gast bij het boeken nergens een naam per speler opgeven. Hij vult één naam in,
// die van het gezelschap, en de databasefunctie `boek_tijdslot` zet die ene naam op elke deelnemersrij
// (`coalesce(v_d->>'naam', p_naam)`). Op de tee sheet stond daardoor veertig keer dezelfde naam met
// veertig aanvinkvakjes eronder. Dat is geen lijst, dat is ruis: er valt niets aan te herkennen en niets
// aan te kiezen.
//
// Wat de balie bij zo'n groep wél moet kunnen: bijhouden HOEVEEL er binnen zijn, want bij een groep van
// veertig komt zelden iedereen. Daarom staat er bij voetgolf een teller met min en plus in plaats van
// een lijst. De plus checkt de eerstvolgende nog niet ingecheckte deelnemer in, de min haalt de laatst
// ingecheckte er weer af. Dat gebeurt met dezelfde bestaande databasefunctie `zet_incheck` die de
// vinkjes ook gebruiken, dus de stand in de database is precies dezelfde als voorheen: het scherm telt
// niet zelf iets bij, het vinkt een echte deelnemersrij aan.
//
// Bij GOLF verandert er niets. Daar zijn het vier spelers met elk een eigen tarief, en daar is een naam
// per speler wel zinvol; die lijst met de uitklapknop blijft staan zoals hij is.
//
// De knoppen gaan tijdens het opslaan even op slot. Zonder dat slot pakken twee snelle klikken achter
// elkaar dezelfde deelnemer (het scherm weet de nieuwe stand pas na het verversen), en dan lijkt de
// teller te blijven hangen.
function Teller({spelers,onCheck}){
  const [bezig,setBezig]=useState(false);
  const binnen=spelers.filter(p=>p.ingecheckt).length;
  const totaal=spelers.length;
  const volgende=spelers.filter(p=>!p.ingecheckt)[0]||null;
  const laatste=spelers.filter(p=>p.ingecheckt).slice(-1)[0]||null;
  async function stap(p,nu){ if(!p||bezig) return; setBezig(true); try{ await onCheck(p.id,nu); } finally { setBezig(false); } }
  return (<div className="teller" data-spelers={totaal} data-ingecheckt={binnen}>
    <button className="teller-knop" disabled={bezig||binnen===0}
      aria-label="Eén speler minder ingecheckt" onClick={()=>stap(laatste,true)}>−</button>
    <div className="teller-stand">
      <span className="teller-getal">{binnen}</span>
      <span className="teller-van">van de {totaal} binnen</span>
    </div>
    <button className="teller-knop" disabled={bezig||binnen===totaal}
      aria-label="Eén speler meer ingecheckt" onClick={()=>stap(volgende,false)}>+</button>
  </div>);
}

// De spelers van een boeking. Kleine boeking: gewoon open, want daar helpt vouwen niet. Grote boeking:
// dicht, met een knop die zegt wat erin zit EN hoeveel er ingecheckt zijn, want dat laatste is wat een
// balie wil weten voordat hij openklapt. De ingecheckt-stand staat dus ook in gevouwen toestand op het
// scherm. Inchecken per speler werkt ongewijzigd zodra de lijst open staat.
// Voetgolf slaat dit hele blok over en krijgt de teller hierboven; zie de uitleg daar.
function Spelers({spelers,onCheck,sport}){
  if(sport==="footgolf") return <Teller spelers={spelers} onCheck={onCheck}/>;
  const groot=spelers.length>SPELERS_MAX_OPEN;
  return <SpelersLijst spelers={spelers} onCheck={onCheck} groot={groot}/>;
}
function SpelersLijst({spelers,onCheck,groot}){
  const [open,setOpen]=useState(!groot);
  const inTotaal=spelers.filter(p=>p.ingecheckt).length;
  const zin=spelers.length+" spelers, "+inTotaal+" ingecheckt";
  return (<div className="spelerblok" data-spelers={spelers.length} data-ingecheckt={inTotaal}>
    {groot && <button className="vouw" aria-expanded={open?"true":"false"} onClick={()=>setOpen(!open)}>
      <span className="vouw-teken" aria-hidden="true">{open?"−":"+"}</span>{(open?"Verberg ":"Toon ")+zin}</button>}
    {(!groot||open) && <div className="spelers">{spelers.map(p=>(
      <button className={"speler"+(p.ingecheckt?" in":"")+(p.categorie?" "+catKlas(p.categorie):"")} key={p.id} onClick={()=>onCheck(p.id,p.ingecheckt)}>
        <span className="tick">{p.ingecheckt?"✓":""}</span>{p.naam}<span className="type">{p.type}</span>
        {p.jeugd && <span className="jeugd-merk">jeugd</span>}</button>))}</div>}
  </div>);
}

// ===== Vaste spelersvakken per starttijd, nabouw van T-Control ===========================
// Aanleiding: Roy herkende de opdracht van 22 augustus als eigen stijl in eigen vorm, niet als
// een nabouw van Daniel. Daniels eigen screenshots tonen de tee sheet-RIJEN niet (daar stond
// "kan tee sheet niet laden"), dus is voor de rijen het echte voorbeeld opgezocht: T-Control,
// het Nederlandse teesheet-systeem uit het golfgo-lijstje van de NGF.
// Bron, zelf geopend en tot 8x ingezoomd, 22 augustus 2026 avond:
//   https://www.teecontrol.com/en
//   https://www.teecontrol.com/screens/en/teecontrol-screen-0.png (marketingscreenshot, iconen
//     zonder leesbare naam)
//   https://www.teecontrol.com/screens/en/teecontrol-screen-3.png (wel leesbare namen)
// Gevonden: T-Control toont per starttijd een RIJ MET EEN VAST AANTAL VAKKEN (evenveel als er
// plekken zijn). Een bezet vak is een gevulde kleur met de naam van de speler erin, een vrij vak
// is een lichte, kale rechthoek. DAT deel nemen we over.
// Wat we NIET overnemen: in T-Control draagt de kleur van een vak de BOEKING (elke reservering een
// eigen kleur, ook al staan er twee reserveringen in dezelfde starttijd naast elkaar), niet de
// categorie van de speler. Nagemeten op screen-3: "Bart Jansen" staat op de ene starttijd in geel
// en op een andere in groen, dus de kleur volgt niet de persoon of zijn type. Dat is dus geen
// bruikbaar precedent voor "legendakleur als vakkleur". Hier is gekozen om de VORM van T-Control
// (vaste vakken, naam erin, leeg vak licht) te combineren met ONZE EIGEN kleurkeuze (categorie,
// CATEGORIE_KLEUR hierboven), zodat de legenda boven het blad blijft kloppen met wat in de vakken
// staat. Alleen voor golf: bij voetgolf blijft de teller (zie Teller hierboven) het instrument voor
// een groep die soms uit veertig mensen bestaat, een rij van veertig vakken zou het blad onleesbaar
// maken.
// `onKlik` opent/sluit de detailkaart eronder (zie TeeSheet: `open`/`toggel`). De vakkenrij zelf
// blijft altijd zichtbaar, alleen de kaart met naam-op-naam, incheckknoppen, verplaatsen, no-show
// en annuleren klapt in en uit. Geen `title`-attribuut meer op een vak: dat gaf de kale
// browsertooltip van de naam, en die verscheen los boven het scherm zodra de muis er toevallig
// op stond. Gemeld door Roy (22 augustus 2026, ingelogd) als een losse zwevende naam-badge over de
// boekingskaart. De naam staat al leesbaar IN het vak, dus de tooltip voegde niets toe.
function SpelerVakken({max,boekingen,dicht,onKlik}){
  const spelers = boekingen.flatMap(b=>b.spelers.map(p=>p));
  const vakken = Array.apply(null,{length:max}).map((_,i)=>spelers[i]||null);
  return (<div className="vakken" onClick={onKlik} role={onKlik?"button":undefined} tabIndex={onKlik?0:undefined}
      onKeyDown={onKlik?(e)=>{ if(e.key==="Enter"||e.key===" "){ e.preventDefault(); onKlik(); } }:undefined}>
    {vakken.map((p,i)=>{
      if(dicht) return <div key={i} className="vak dicht"/>;
      if(!p) return <div key={i} className="vak vrij">Vrij</div>;
      const klas=p.categorie?catKlas(p.categorie):"";
      return (<div key={i} className={"vak "+klas}>
        <span className="vak-naam">{p.naam}</span>
        {p.ingecheckt && <span className="vak-vink" aria-hidden="true">✓</span>}
      </div>);
    })}
  </div>);
}

// ===== De stand van een starttijd, in de vormtaal van het clubhuisscherm =====================
// Roy, 26 augustus 2026: "kunnen we dat clubhuisscherm ook hebben als tee sheet, qua lay-out".
// Het clubhuisscherm leest snel doordat elke rij een kaart is met een gekleurde kop aan de linkerkant
// en een statuswoord rechts. Diezelfde twee dingen staan nu ook hier. De logica per boeking is
// letterlijk dezelfde functie als daar (`clubhuisStand` in clubhuis.jsx), zodat de bar en de balie
// nooit een ander woord bij dezelfde reservering kunnen zien.
//
// Een starttijd kan meer dan een boeking dragen. Dan wint wat aandacht vraagt: een no-show valt op
// boven een onbetaalde, en een onbetaalde boven een rij waar iedereen binnen is. Anders zou een volle
// flight met een no-show erin er groen uitzien.
const TS_STAND_VOLGORDE = ["st-noshow","st-onbetaald","st-nietgemeld","st-deels","st-verwacht","st-aangemeld"];
function teeStand(boekingen, voorbij){
  if(!boekingen || boekingen.length===0) return null;
  const standen = boekingen.map(b=>clubhuisStand(b, !!voorbij));
  for(const k of TS_STAND_VOLGORDE){
    const g = standen.find(x=>x.klas===k);
    if(g) return g;
  }
  return standen[0];
}

function TeeSheet({baan,datum,types,houders,toon,herlaadHouders}){
  const [sloten,setSloten]=useState([]); const [laden,setLaden]=useState(true); const [f,setF]=useState(null);
  const [boek,setBoek]=useState(null); const [verpl,setVerpl]=useState(null); const [annul,setAnnul]=useState(null); const [afrek,setAfrek]=useState(null);
  // ===== Detailkaart per starttijd staat standaard DICHT, nabouw van T-Control ============
  // Opdracht van Roy (22 augustus 2026, ingelogd): de vakkenrij is de rust, de kaart met naam-op-
  // naam, incheckknoppen, verplaatsen, no-show en annuleren opent pas als iemand de rij of een vak
  // aanklikt. Er gaat niets weg, alleen de standaardstand verandert: `open` onthoudt per
  // starttijd-id of de kaart openstaat, niets erin betekent dicht. Bij een verversing (ververs())
  // blijft deze state gewoon staan, want hij hangt niet af van `sloten`.
  const [open,setOpen]=useState({});
  function toggel(id){ setOpen(o=>Object.assign({},o,{[id]:!o[id]})); }
  // `vers` betekent: ververs de gegevens maar laat staan wat er staat. Dat is het verschil tussen een
  // NIEUWE dag openen en een bestaande dag opnieuw ophalen na een handeling.
  // WAAROM DIT ONDERSCHEID ER IS. Het blad werd bij elke verversing weggegooid en opnieuw opgebouwd,
  // want de laadmelding verving de hele lijst. Bij vier spelers merk je dat niet. Bij een voetgolfrij
  // van veertig spelers wel: na elke ingecheckte speler klapte de lijst weer dicht en moest de balie
  // opnieuw openklappen. Nu blijft het blad staan tijdens een verversing, en klapt er niets dicht.
  // `teesheet` geeft sinds de wijziging van 22 augustus 2026 (Enterprise, staging) de velden `status`
  // en `no_show` per boeking zelf mee, inclusief no-shows (status in ('actief','no_show')). De losse
  // nabevraging op `boekingen` die dat eerder aan de voorkant repareerde is daarmee overbodig geworden
  // en is verwijderd; zie het testverslag van die datum.
  const laad=useCallback((vers)=>{ if(!baan) return; if(!vers) setSloten([]); setLaden(true); setF(null);
    db.rpc("teesheet",{p_baan_id:baan,p_datum:datum}).then(r=>{
      if(r.error) setF(fout(r.error)); else setSloten(r.data||[]); setLaden(false); }); },[baan,datum]);
  const ververs=useCallback(()=>laad(true),[laad]);
  useEffect(()=>{ laad(false); },[laad]);
  async function incheckBk(bId,alle){ const r=await db.rpc("zet_incheck_boeking",{p_boeking_id:bId,p_ingecheckt:!alle}); if(r.error) toon(fout(r.error)); else ververs(); }
  // Let op het `await` voor de verversing: de teller bij voetgolf houdt zijn knoppen op slot zolang deze
  // functie loopt, en zonder dat await zijn ze weer vrij vóórdat de nieuwe stand binnen is. Twee snelle
  // klikken pakken dan dezelfde deelnemer en de teller lijkt te blijven staan.
  async function incheck(id,nu){ const r=await db.rpc("zet_incheck",{p_deelnemer_id:id,p_ingecheckt:!nu}); if(r.error) toon(fout(r.error)); else await ververs(); }
  // De reden die bij een dichtgezette starttijd hoort. Stond op "Balie", en op het scherm werd dat
  // "ONDERHOUD: BALIE": een woord in hoofdletters dat niets uitlegt. Nu staat er waar het vandaan komt.
  async function status(slot,st){ const r=await db.rpc("zet_slotstatus",{p_tijdslot_id:slot.id,p_status:st,p_opmerking:st==="open"?null:"Dichtgezet aan de balie"}); if(r.error) toon(fout(r.error)); else { toon(st==="open"?"Vrijgegeven":"Geblokkeerd"); ververs(); } }
  // R28 (20 september 2026): annuleren gaat voortaan via de edge function recras-balie, actie
  // "annuleer", en niet meer rechtstreeks via db.rpc("annuleer_boeking"). Reden: alleen de edge
  // function doet na de databasefunctie ook de HTTP-aanroep die een Recras-kaart die wij hadden
  // dichtgezet weer opent (vouchers/{id}/reactivate, besluit 33). Een rechtstreekse db.rpc-aanroep
  // gaf de strip in onze eigen boekhouding wel terug, maar liet de kaart bij Recras dicht staan.
  async function annuleerNu(bk){ setAnnul(null);
    const u=await recrasBalie({actie:"annuleer", boekingsreferentie:bk.referentie});
    if(u.http!==200 || !u.d || u.d.status!=="geannuleerd"){
      const reden=u.d&&u.d.reden;
      const tekst = reden==="geen_medewerker" ? "Je sessie is verlopen. Log opnieuw in."
        : reden==="niet_gevonden" ? "Deze boeking is niet meer te vinden."
        : "Annuleren is niet gelukt. Probeer het opnieuw.";
      toon(tekst); return;
    }
    const d=u.d;
    const strepen=Number(d.strepen_teruggezet)||0;
    let msg="Geannuleerd.";
    if(strepen>0) msg+=" "+strepen+" strip"+(strepen===1?"":"pen")+" teruggezet.";
    const kaarten=Array.isArray(d.recras_kaarten)?d.recras_kaarten:[];
    for(const k of kaarten){
      const n=Number(k&&k.aantal_teruggegeven)||0;
      if(n>0) msg+=" "+n+" Recras-strip"+(n===1?"":"pen")+" teruggezet.";
      if(k&&k.heropend===true) msg+=" Kaart bij Recras weer opengezet.";
      else if(k&&k.heropend===false) msg+=" Let op: kaart bij Recras kon niet automatisch heropend worden, controleer dit zelf.";
    }
    if(strepen===0 && kaarten.length===0) msg+=" Geen strip verrekend.";
    toon(msg); ververs(); }
  async function noshow(bk,aan){ const r=await db.rpc("zet_noshow",{p_boeking_id:bk.boeking_id,p_noshow:aan}); if(r.error) toon(fout(r.error)); else { toon(aan?"Gemarkeerd als no-show":"No-show ongedaan"); ververs(); } }
  const spTot=sloten.reduce((n,s)=>n+s.bezet,0);
  const plTot=sloten.reduce((n,s)=>n+(s.status==="open"?s.max_spelers:0),0);
  // ===== Bezetting PER SPORT, en waarom niet over de hele dag ============================
  // Eén percentage over alle plekken van de dag liegt. Een golfstarttijd heeft vier plekken, een
  // voetgolfstarttijd veertig, en er staan er dertien van die veertig op een dag. De voetgolftijden
  // leveren daarmee 520 van de 632 plekken, en dat trekt elk dagcijfer omlaag.
  // Gemeten op woensdag 26 augustus 2026, de dag van de demo: 114 van de 632 plekken bezet, dat is
  // 18 procent, terwijl er die dag zestien starttijden in gebruik zijn en het blad vol oogt. Precies
  // dat cijfer gelooft een baliemedewerker niet, en terecht.
  // Uitgesplitst zegt dezelfde dag: golf 37 van de 112 plekken is 33 procent, voetgolf 77 van de 520
  // is 15 procent. Twee getallen die je wél kunt navertellen aan de rij die je voor je ziet.
  // Daarom: per sport, over de plekken van die sport, en alleen voor de sporten die die dag ook echt
  // gepland staan. Dichtgezette starttijden tellen niet mee, want daar kan niemand op.
  // Het aantal plekken staat onder het percentage, zodat het cijfer altijd na te rekenen is.
  const perSport=[];
  sloten.filter(s=>s.status==="open").forEach(s=>{
    const k=s.sport||"onbekend";
    let r=perSport.filter(x=>x.sport===k)[0];
    if(!r){ r={sport:k,bezet:0,plekken:0}; perSport.push(r); }
    r.bezet+=s.bezet; r.plekken+=s.max_spelers;
  });
  const nu=new Date(); const vandaag=datum===iso(nu);
  return (<div>
    {/* Sectiekop en legenda, nabouw van Daniels screenshot 02 (zelf geopend, 22 augustus 2026).
        Alleen op de tee sheet: de andere balieschermen behouden hun bestaande titelvoering. */}
    <div className="sectiekop">
      <div className="sectiekop-titel">Tee sheet</div>
      <div className="sectiekop-sub">UP Golf Weesp</div>
    </div>
    <Legenda/>
    <div className="stats">
      <div className="stat"><div className="k">Spelers</div><div className="v">{spTot}</div></div>
      <div className="stat vrij"><div className="k">Vrij</div><div className="v">{plTot-spTot}</div></div>
      {/* De omzet stond hier. Die is verhuisd naar de dagstaat: de tee sheet is het scherm waar de
          hele dag iedereen langsloopt, en een dagomzet hoeft daar niet in beeld te staan. */}
      {perSport.map(r=>(<div className="stat" key={r.sport}>
        <div className="k">Bezet {sportLabel(r.sport).toLowerCase()}</div>
        <div className="v">{r.plekken>0?Math.round(r.bezet/r.plekken*100):0}%</div>
        <div className="bijstat">{r.bezet} van de {r.plekken} plekken</div>
      </div>))}
    </div>
    {laden && sloten.length===0 && <div className="laden">Laden…</div>}
    {f && <div className="fout">{f}</div>}
    {!laden && !f && sloten.length===0 && <div className="laden">Geen starttijden op deze dag.</div>}
    {sloten.length>0 && <div className="t-kaart"><div className="blad">
      {sloten.map(s=>{ const vrij=s.max_spelers-s.bezet; const dicht=s.status!=="open";
        const voorbij=vandaag && s.starttijd<nu.toTimeString().slice(0,8);
        // De sport staat nu ook als KLASSE op de rij, niet alleen als label erin. Reden: de
        // opdrachtgever wil het hele vlak van de rij in de kleur van de sport, en dat hoort niet
        // van een `:has()`-selector af te hangen die een oudere browser stil overslaat. Het label
        // en het streepje blijven staan, dus wie de kleur niet ziet leest nog steeds het woord.
        const sportrij = s.sport==="footgolf" ? " rij-voetgolf" : s.sport==="golf" ? " rij-golf" : "";
        // ===== Standaard dicht, nabouw van T-Control (22 augustus 2026) ======================
        // Roys oordeel op het scherm: alle boekingskaarten stonden standaard uitgeklapt onder de
        // vakkenrij, en dat maakte het blad juist voller dan voorheen, terwijl T-Control rustig is
        // door precies dit: de rij zelf is de standaardstand, de kaart erbij is een keuze. Niets
        // aan functies is weg, `open` bepaalt alleen of de kaart met naam-op-naam, incheck,
        // verplaatsen, no-show en annuleren erbij staat. Is er niets te tonen (rij helemaal vrij),
        // dan is er ook niets om open te klappen en blijft de rij niet-klikbaar.
        const heeftDetail = s.boekingen.length>0;
        const isOpen = heeftDetail && !!open[s.id];
        const klik = heeftDetail ? ()=>toggel(s.id) : undefined;
        const toetsKlik = heeftDetail ? (e)=>{ if(e.key==="Enter"||e.key===" "){ e.preventDefault(); toggel(s.id); } } : undefined;
        const stand = teeStand(s.boekingen, voorbij);
        return (<div className={"row"+sportrij+(dicht?" blocked":"")+(voorbij?" past":"")+(stand?" tsl "+stand.klas:"")} key={s.id}>
          {/* De sport per rij. Zonder dit ziet de balie alleen bolletjes en moet hij aan het AANTAL
              zien of het golf of voetgolf is, want voetgolf heeft er veertig en golf vier. De
              teesheet-functie geeft `sport` gewoon mee, het werd alleen nergens getoond. */}
          <div className="row-top">
            {/* `row-klik` is een aparte, kleinere doos dan de hele rij, met opzet NIET om `row-acties`
                heen: de knoppen +Boeken/Blok/Open staan daar als aparte buur naast, dus een klik
                daarop bubbelt niet omhoog naar deze toggle en andersom. */}
            <div className={"row-klik"+(heeftDetail?"":" zonder-klik")} onClick={klik}
                role={heeftDetail?"button":undefined} tabIndex={heeftDetail?0:undefined}
                aria-expanded={heeftDetail?(isOpen?"true":"false"):undefined} onKeyDown={toetsKlik}>
              <div className="time">{s.starttijd.slice(0,5)}</div>
              <Sportmerk sport={s.sport}/>
              {/* Bij voetgolf blijft de bezettingsbalk staan (veertig plekken, een balk leest sneller
                  dan veertig vakken). Bij golf verving vroeger dezelfde Capaciteit hier vier bolletjes;
                  die staan nu als vakken met naam onder de rij, zie SpelerVakken hieronder. */}
              {s.sport==="footgolf" && <Capaciteit bezet={s.bezet} max={s.max_spelers} dicht={dicht}/>}
              {/* De incheckstand op de rij zelf. Bij een groep van veertig is dat het enige getal dat de
                  balie tijdens het ontvangen nog wil zien: hoeveel er binnen zijn. Het staat er alleen als
                  er ook echt een grote boeking op die starttijd staat, dus een golfflight van vier houdt
                  zijn rij precies zoals hij was. Het getal is de som over alle boekingen op deze rij. */}
              {(function(){
                const groot=s.boekingen.some(b=>b.spelers.length>SPELERS_MAX_OPEN);
                if(!groot) return null;
                const binnen=s.boekingen.reduce((n,b)=>n+b.spelers.filter(x=>x.ingecheckt).length,0);
                const tot=s.boekingen.reduce((n,b)=>n+b.spelers.length,0);
                return <span className={"in-stand"+(binnen>0&&binnen>=tot?" klaar":"")}
                  data-ingecheckt={binnen} data-spelers={tot}>{binnen}/{tot} binnen</span>;
              })()}
              {stand && <span className={"ts-stand "+stand.klas}>{stand.woord}</span>}
              {heeftDetail && <span className="rij-vouw" aria-hidden="true">{isOpen?"−":"+"}</span>}
            </div>
            <div className="row-acties">
              {!dicht && vrij>0 && <button className="mini" onClick={()=>{herlaadHouders(); setBoek({slot:s,vrij:vrij});}}>+ Boeken</button>}
              {!dicht && s.bezet===0 && <button className="mini" onClick={()=>status(s,"onderhoud")}>Blok</button>}
              {dicht && <button className="mini" onClick={()=>status(s,"open")}>Open</button>}
            </div></div>
          {/* De vaste spelersvakken, nabouw van T-Control (zie SpelerVakken hierboven). Alleen
              golf: bij voetgolf zou een rij van veertig vakken het blad onleesbaar maken, daar
              blijven de bezettingsbalk hierboven en de teller in de boekingskaart het instrument.
              De vakkenrij zelf klapt de kaart ook in/uit, want dat is de tweede klikplek uit de
              opdracht ("de rij of een vak aanklikt"). */}
          {s.sport!=="footgolf" && <SpelerVakken max={s.max_spelers} boekingen={s.boekingen} dicht={dicht} onKlik={klik}/>}
          {/* Een dichtgezette starttijd toont de reden. Komt die reden uit het demo-scherm dat een
              Recras-koppeling nabootst, dan staat dat er met zoveel woorden bij, en niet alleen in
              de lopende tekst. Wie deze tee sheet ziet moet niet kunnen denken dat er een echte
              koppeling meekijkt. Blijft altijd zichtbaar, ook dicht: dit is een korte statuszin en
              geen detail om in te klappen. */}
          {/* Stond als "ONDERHOUD: BALIE" op het scherm: de ruwe kolomwaarde plus een los woord, in
              hoofdletters door het stijlblad. Nu een gewone zin. De opmerking blijft erachter staan,
              want daar zet de blokkade-nabootsing haar kenmerk in. */}
          {dicht && <div className="geblokt">
            {isNagebootst(s.opmerking) && <span className="sim-tag">nagebootst</span>}
            {slotstandWoord(s.status)}{s.opmerking?" · "+s.opmerking:""}</div>}
          {/* "Vrij" stond hier vroeger ALTIJD bij een lege rij. Bij golf (en elke sport met vakken)
              zegt de vakkenrij dat al vier keer, dus stond het er dubbel: Roy zag "vier Vrij-vakken
              én daaronder nog los het woord Vrij" (22 augustus 2026, ingelogd). Bij voetgolf is er
              geen vakkenrij, dus blijft deze regel daar het enige "leeg"-signaal. */}
          {!dicht && s.boekingen.length===0 && s.sport==="footgolf" && <div className="leeg">Vrij</div>}
          {isOpen && s.boekingen.map(b=>{ const alle=b.spelers.every(x=>x.ingecheckt); const ns=b.status==="no_show";
            return (<div className={"bk"+(b.betaalstatus==="open"?" onbetaald":"")+(ns?" noshow":"")} key={b.boeking_id}>
              <div className="bk-top"><span className="bk-naam">{b.contact}</span><span className="ref">{b.referentie}</span>
                {ns && <span className="hold-tag rood">no-show</span>}
                {!ns && b.betaalstatus==="open" && <span className="hold-tag amber">niet betaald</span>}
                <span className="bedrag">{euro(b.bedrag_cent)}</span></div>
              <Spelers spelers={b.spelers} onCheck={incheck} sport={s.sport}/>
              <div className="bk-acties">
                <button className={"mini "+(alle?"":"groen")} onClick={()=>incheckBk(b.boeking_id,alle)}>{alle?"Incheck ongedaan":"Alles inchecken"}</button>
                {b.betaalstatus==="open" && <button className="mini groen" onClick={()=>setAfrek(b)}>Afrekenen</button>}
                <button className="mini" onClick={()=>setVerpl(Object.assign({},b,{slotId:s.id,sport:s.sport}))}>Verplaats</button>
                {!ns && <button className="mini" onClick={()=>noshow(b,true)}>No-show</button>}
                {ns && <button className="mini doe" onClick={()=>noshow(b,false)}>Toch gekomen</button>}
                <button className="mini gevaar" onClick={()=>setAnnul(b)}>Annuleer</button>
              </div></div>);})}
        </div>);})}
    </div></div>}
    {boek && <BoekPaneel slot={boek.slot} vrij={boek.vrij} datum={datum} types={types} houders={houders} onSluit={()=>setBoek(null)} onKlaar={(m)=>{setBoek(null); toon(m||"Boeking vastgelegd"); ververs();}}/>}
    {verpl && <VerplaatsPaneel boeking={verpl} sloten={sloten} onSluit={()=>setVerpl(null)} onKlaar={m=>{setVerpl(null); toon(m); ververs();}}/>}
    {afrek && <AfrekenPaneel boeking={afrek} onSluit={()=>setAfrek(null)} onKlaar={m=>{setAfrek(null); toon(m); ververs();}}/>}
    {annul && <Bevestig
      vraag={"Reservering "+annul.referentie+" annuleren?"}
      toelichting={annul.contact+", "+annul.spelers.length+" speler"+(annul.spelers.length===1?"":"s")+", "+euro(annul.bedrag_cent)+". De plekken komen meteen weer vrij. Terugdraaien kan niet met een knop."}
      knop="Ja, annuleren"
      onJa={()=>annuleerNu(annul)} onSluit={()=>setAnnul(null)}/>}
  </div>);
}

function Dagstaat({baan,datum,toon}){
  const [data,setData]=useState(null); const [laden,setLaden]=useState(true); const [f,setF]=useState(null);
  const laad=useCallback(async()=>{ if(!baan) return; setLaden(true); setF(null);
    const r=await db.rpc("dagstaat",{p_baan_id:baan,p_datum:datum});
    if(r.error) setF(fout(r.error)); else setData(r.data); setLaden(false); },[baan,datum]);
  useEffect(()=>{ laad(); },[laad]);
  if(laden) return <div className="laden">Laden…</div>;
  if(f) return <div className="fout">{f}</div>;
  const b=(data&&data.boekingen)||[];
  // De omzet staat hier met opzet NIET. Hij hoort op Rapportage, achter het wachtwoord: dat is de
  // keuze van Roy. De dagstaat is een werkscherm dat iedereen achter de balie openslaat, net als de
  // tee sheet, en een omzetbedrag hoort daar niet de hele dag te staan.
  return (<div>
    <div className="stats">
      <div className="stat"><div className="k">Spelers vandaag</div><div className="v">{data?data.spelers_totaal:0}</div></div>
      <div className="stat"><div className="k">Ingecheckt</div><div className="v">{data?data.ingecheckt:0}</div></div>
      <div className="stat open"><div className="k">Nog afrekenen</div><div className="v">{data?data.nog_te_betalen:0}</div></div>
    </div>
    {b.length===0 && <div className="kaart"><p className="bij" style={{margin:0}}>Geen boekingen op deze dag.</p></div>}
    {b.length>0 && <div className="blad">
      {b.map(r=>{ const alleIn=r.spelers>0&&r.ingecheckt>=r.spelers; const ns=r.status==="no_show";
        return (<div className={"ds-row"+(alleIn?" klaar":"")+(ns?" ns":"")} key={r.boeking_id}>
          <div className="ds-tijd">{r.starttijd.slice(0,5)}</div>
          <div className="ds-mid"><div className="ds-naam">{r.contact}{ns?", no-show":""}</div>
            <div className="ds-sub">{r.spelers} speler{r.spelers===1?"":"s"} · {r.ingecheckt}/{r.spelers} ingecheckt{r.telefoon?" · "+r.telefoon:""}</div></div>
          <div className="ds-rechts"><div className="ds-euro">{euro(r.bedrag_cent)}</div>
            <span className={"ds-badge "+(r.betaalstatus==="betaald"?"betaald":"open")}>{r.betaalstatus==="betaald"?"betaald":"balie"}</span></div>
        </div>);})}
    </div>}
  </div>);
}

function Wachtlijst({baan,toon}){
  const [lijst,setLijst]=useState([]); const [laden,setLaden]=useState(true); const [f,setF]=useState(null);
  const laad=useCallback(async()=>{ if(!baan) return; setLaden(true);
    const r=await db.from("wachtlijst").select("*").eq("baan_id",baan).neq("status","vervallen").order("datum").order("aangemaakt_op");
    if(r.error) setF(fout(r.error)); setLijst(r.data||[]); setLaden(false); },[baan]);
  useEffect(()=>{ laad(); },[laad]);
  async function zet(id,st){ const r=await db.rpc("zet_wachtlijst_status",{p_id:id,p_status:st}); if(r.error) toon(fout(r.error)); else { toon("Bijgewerkt"); laad(); } }
  if(laden) return <div className="laden">Laden…</div>;
  return (<div>
    <div className="kop"><h3>Wachtlijst</h3></div>
    <p className="bij" style={{marginTop:-4}}>Mensen die zich aanmeldden voor een volle dag. Bel of mail ze als er plek komt.</p>
    {lijst.length===0 && <div className="kaart"><p className="bij" style={{margin:0}}>Niemand op de wachtlijst.</p></div>}
    {lijst.map(w=>(<div className="kaart" key={w.id}>
      <div className="kop"><h3>{w.naam}</h3><span className="pil">{kortDat(w.datum)}</span>
        <span className="pil">{w.dagdeel}</span><span className="pil">{w.aantal} pers.</span>
        {w.status!=="wacht" && <span className="pil aan">{w.status}</span>}
        <div className="acties">
          {w.status==="wacht" && <button className="mini doe" onClick={()=>zet(w.id,"benaderd")}>Benaderd</button>}
          {w.status!=="geboekt" && <button className="mini groen" onClick={()=>zet(w.id,"geboekt")}>Geboekt</button>}
          <button className="mini gevaar" onClick={()=>zet(w.id,"vervallen")}>Verwijder</button></div></div>
      <p className="bij" style={{margin:0}}>{w.email}{w.telefoon?" · "+w.telefoon:""}{w.opmerking?" · "+w.opmerking:""}</p>
    </div>))}
  </div>);
}


// Een reservering opzoeken op het nummer dat de gast aan de telefoon noemt. `zoek_boeking` bestond al
// en werd door de balie nergens gebruikt, terwijl dit aan een balie de meest gestelde vraag is.
// De functie normaliseert zelf: hoofdletters, alles weg wat geen letter of cijfer is, streepje terug op
// de vaste plek. Iemand die aan de telefoon "yums zvlr" hoort en dat zo intikt, vindt de boeking dus.
// Zoeken op naam kan zonder nieuwe databasefunctie. Gemeten op de policies: `boekingen_balie` geeft een
// ingelogde medewerker volledige toegang tot `boekingen`, en die tabel heeft een kolom `naam`. De datum
// en de starttijd komen uit `tijdsloten` via de bestaande koppeling op `tijdslot_id`.
// Twee grenzen die er met opzet in zitten:
//   1. minstens twee tekens, anders haalt één letter zowat de hele lijst op;
//   2. hooguit MAX_TREFFERS rijen, met een melding als het er meer waren, want een balie moet kiezen
//      uit een lijst die op het scherm past en niet door honderd regels scrollen.
const MAX_TREFFERS = 25;

function Zoeken(){
  const [ref,setRef]=useState("");
  const [bezig,setBezig]=useState(false);
  const [uit,setUit]=useState(null);
  const [f,setF]=useState(null);
  const [naam,setNaam]=useState("");
  const [naamBezig,setNaamBezig]=useState(false);
  const [treffers,setTreffers]=useState(null);
  const [teveel,setTeveel]=useState(false);

  async function zoek(referentie){
    const r0 = (referentie===undefined ? ref : referentie);
    if(!String(r0).trim()) return;
    setBezig(true); setF(null); setUit(null);
    const r=await db.rpc("zoek_boeking",{p_referentie:String(r0).trim()});
    setBezig(false);
    if(r.error){ setF(fout(r.error)); return; }
    setUit(r.data||{gevonden:false});
  }

  async function zoekOpNaam(){
    const term=naam.trim();
    if(term.length<2){ setF("Typ minstens twee letters."); return; }
    setNaamBezig(true); setF(null); setTreffers(null); setUit(null); setTeveel(false);
    const r=await db.from("boekingen")
      .select("boekingsreferentie,naam,status,betaalstatus,tijdsloten(datum,starttijd)")
      .ilike("naam","%"+term+"%")
      .limit(MAX_TREFFERS+1);
    setNaamBezig(false);
    if(r.error){ setF(fout(r.error)); return; }
    const rijen=(r.data||[]).slice();
    // Sorteren doen wij hier en niet in de query: de datum zit in de gekoppelde tabel en daarop
    // sorteren via de API is foutgevoelig. Eerst de eerstvolgende starttijd, dan de rest.
    rijen.sort((a,b)=>{
      const da=(a.tijdsloten&&a.tijdsloten.datum)||"", db_=(b.tijdsloten&&b.tijdsloten.datum)||"";
      if(da!==db_) return da<db_?-1:1;
      const ta=(a.tijdsloten&&a.tijdsloten.starttijd)||"", tb=(b.tijdsloten&&b.tijdsloten.starttijd)||"";
      return ta<tb?-1:ta>tb?1:0;
    });
    setTeveel(rijen.length>MAX_TREFFERS);
    setTreffers(rijen.slice(0,MAX_TREFFERS));
  }

  const woord = s => s==="betaald" ? "Betaald"
    : s==="open" ? "Nog niet betaald"
    : s==="mislukt" ? "Vervallen"
    : s==="terugbetaald" ? "Terugbetaald" : "Onbekend";

  // `paneel` is de klasse van het uitschuifblad van een dialoog: 520 pixels breed, afgeronde
  // bovenhoeken en een eigen schuifbalk. Op een gewoon scherm levert dat een smalle witte kolom links
  // op, terwijl elk ander baliescherm de volle breedte gebruikt. Gemeten op 1280 breed op
  // 12 augustus 2026. `scherm` is de gewone schermbreedte.
  return (
    <div className="scherm">
      <div className="kaart">
        <div className="kop">Reservering opzoeken</div>
        <p className="bij">Hoofdletters en het streepje maken niet uit.</p>
        <input value={ref} onChange={e=>setRef(e.target.value)} placeholder="ABCD-1234"
          onKeyDown={e=>e.key==="Enter"&&zoek()}/>
        <div className="acties">
          <button className="doe primair" onClick={()=>zoek()} disabled={bezig||!ref.trim()}>{bezig?"Zoeken…":"Zoeken"}</button>
        </div>

        <div className="zoekwissel">Of zoek op naam</div>
        <input value={naam} onChange={e=>setNaam(e.target.value)} placeholder="Achternaam of een deel ervan"
          onKeyDown={e=>e.key==="Enter"&&zoekOpNaam()}/>
        <div className="acties">
          <button className="doe" onClick={zoekOpNaam} disabled={naamBezig||naam.trim().length<2}>{naamBezig?"Zoeken…":"Zoek op naam"}</button>
        </div>

        {f && <div className="fout">{f}</div>}

        {treffers && treffers.length===0 &&
          <div className="leeg">Geen reserveringen gevonden op die naam.</div>}

        {treffers && treffers.length>0 && <div>
          {teveel && <div className="bij">Meer dan {MAX_TREFFERS} gevonden. Hieronder staan de eerste {MAX_TREFFERS}; typ een groter deel van de naam om het korter te maken.</div>}
          <div className="sub">{treffers.length} gevonden, tik er een aan</div>
          {/* Eigen klasse in plaats van `rij`. Die klasse geeft elk kind `flex:1`, waardoor het
              boekingsnummer midden in de knop over twee regels afbrak en de knoppen ongelijk breed
              werden. `status` is bovendien de ruwe databasewaarde, en `no_show` met een liggend
              streepje hoort niet op een scherm dat een gast onder ogen kan krijgen. */}
          {treffers.map(t=>(
            <button key={t.boekingsreferentie} className="treffer" onClick={()=>{ setRef(t.boekingsreferentie); setTreffers(null); zoek(t.boekingsreferentie); }}>
              <span className="treffer-naam">{t.naam}</span>
              <span className="treffer-ref">{t.boekingsreferentie}</span>
              <span className="treffer-wanneer">
                {t.tijdsloten ? kortDat(t.tijdsloten.datum)+" om "+String(t.tijdsloten.starttijd).slice(0,5) : "starttijd onbekend"}
              </span>
              <span className="treffer-stand">{statusWoord(t.status)}</span>
            </button>))}
        </div>}
        {uit && !uit.gevonden && <div className="leeg">Geen reservering gevonden met dit nummer.</div>}
        {/* `gegeven` in plaats van `rij`: de klasse `rij` is bedoeld voor invoervelden naast elkaar
            en gaf hier een label en een waarde die elk de halve breedte pakten en op verschillende
            hoogtes uitkwamen. Nu staat het label links en de waarde er netjes achter. */}
        {uit && uit.gevonden && <div className="gegevens">
          <div className="gegeven"><span className="gegeven-naam">Nummer</span><span className="ref">{uit.referentie}</span></div>
          <div className="gegeven"><span className="gegeven-naam">Datum</span><span>{langDat(uit.datum)}</span></div>
          <div className="gegeven"><span className="gegeven-naam">{uit.groep?"Eerste starttijd":"Starttijd"}</span><span>{String(uit.starttijd).slice(0,5)}</span></div>
          {uit.groep && <div className="gegeven"><span className="gegeven-naam">Starttijden</span><span>{uit.aantal_boekingen} na elkaar</span></div>}
          <div className="gegeven"><span className="gegeven-naam">Bedrag</span><span>{euro(uit.bedrag_cent)}</span></div>
          <div className="gegeven"><span className="gegeven-naam">Betaling</span><span>{woord(uit.betaalstatus)}</span></div>
          {/* zoek_boeking geeft bewust geen naam, e-mail, telefoon of interne id terug. De tweede helft
              van deze toelichting was achterhaald en is op 22 augustus 2026 rechtgezet: er stond dat
              zoek_boeking ook niet teruggeeft of de reservering nog doorgaat, en dat dat nog op de
              databasewenslijst stond. Dat klopt niet meer. zoek_boeking geeft status, gaat_door en
              actieve_boekingen wel terug, en boeking_bij_naam_email sinds migratie 20260822205249
              ook. Dit SCHERM toont die stand nog steeds niet, en dat is een keuze en geen gebrek: de
              regel hieronder verwijst de balie naar de tee sheet, waar de stand per starttijd staat.
              Wil je hem hier wel tonen, dan is het veld er nu voor. */}
          <p className="bij">Dit toont de betaling, niet of de reservering nog doorgaat. Kijk voor dat laatste in de tee sheet van die dag.</p>
        </div>}
      </div>
    </div>
  );
}
