diff --git a/.claude/settings.json b/.claude/settings.json index 5192318..de5c798 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -78,7 +78,10 @@ "Bash(node /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_scoring_wizard_logic.mjs)", "Bash(tee /tmp/wizard_setup.json)", "Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/wizard_setup.json'\\)\\)['round_id']\\)\")", - "Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/wizard_setup.json'\\)\\)['session_cookie']\\)\")" + "Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/wizard_setup.json'\\)\\)['session_cookie']\\)\")", + "Bash(grep -n \"activePlayerIndex\\\\|isLastPlayer\\\\|nextPlayer\\\\|canAdvance\\\\|advanceToNextPlayerOrHole\\\\|isEntryComplete\\\\|activePlayerIdRef\\\\|const loadHoles\\\\|useEffect\\(\\(\\) => {$\" /opt/teecup/frontend/components/round-detail.tsx)", + "Bash(node /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_scoring_wizard_v2.mjs)", + "Bash(tee /tmp/wizard_setup2.json)" ], "additionalDirectories": [ "/opt/teecup/frontend/app/my-rounds/[id]" diff --git a/CLAUDE.md b/CLAUDE.md index da6f866..35aef7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3412,6 +3412,59 @@ Ferdig og verifisert: ingen migrasjon, `docker compose up -d --build teecup_api teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no` upåvirket. +- **Scoringsflyt v2: full skjermovertagende veiviser, BYGGET, IKKE ENNÅ + RULLET UT (2026-07-26) -- ERSTATTER forrige rundes forsøk.** Brukeren + testet forrige rundes "auto-fremdrift-knapp"-versjon live og var tydelig: + "ingen forbedring i det hele tatt", "visuelt like overveldende og + rotete" -- den inline-baserte tilnærmingen var IKKE nok, en ekte + skjermovertagende veiviser (som opprinnelig vurdert og lagt til side pga. + risiko uten visuell testing) var det som faktisk kreves. Bygget nå for + ekte, pluss et eksplisitt nytt krav: akkumulert score-så-langt for RUNDEN + synlig for HVER spiller samtidig (ikke bare aktiv), matchende + konkurrentappens vedvarende "E"/"+1"-visning ved siden av hvert navn. + **Datalasting endret** (nødvendig for punktet over): laster nå hull for + ALLE deltakere med det samme rundén lastes (ikke lenger lat lasting kun + for aktiv spiller), og sanntid-signalet (WS) henter nå alles hull på + nytt, ikke bare énes. + **Ny `ScoringWizard`-komponent** (fullskjerm, `fixed inset-0 z-50`, egen + stack utenfor `
`): tre steg maks, drevet av `stat_level` -- + "strokes_only" (kun Slag), "strokes_and_putts" (+ Putter/avstand første + putt), "full" (+ ett samlet detalj-steg: kølle/utslag/innspill/chip/ + bunker/straffeslag/anywayslag). Bevisst FÆRRE, grovere steg enn + konkurrentens egne 5-6 skjermer (risikoreduksjon uten visuell testing, + og TeeCups stat_level-modell gjør en så fin oppdeling mindre naturlig). + Alle spillerne vises som en fast, ikke-trykkbar kontekst-rad øverst i + veiviseren (aktiv fremhevet) -- speiler konkurrentappens "mist aldri + oversikten"-prinsipp. "Forrige"/"Neste" beveger seg gjennom stegene; + siste steg for siste spiller blir "Ferdig" (lukker + går til neste hull, + starter på spiller 1 igjen), ellers "Neste: {navn}" (bytter spiller i + SAMME veiviser, nullstiller til steg 1). + **Hovedsiden forenklet radikalt:** den gamle Slag/Putter/"flere + detaljer"-inline-blokken er FJERNET -- erstattet med én kompakt liste, + ett kort per spiller: navn, "HCP X · {til-par så langt} ({N} hull)", og + en stor rund knapp som viser gjeldende hulls slagtall (eller "–") og + åpner veiviseren ved trykk. `NumberPicker`s `showGolfTerms`-funksjon fra + forrige runde gjenbrukes uendret inni veiviserens Slag-steg (det arbeidet + var ikke bortkastet). + **Verifisert:** stegmaskinen (steg-antall per stat_level, fremover/ + bakover-navigasjon, disabled-gating per steg, "avbryt på steg 1 lukker", + "siste steg for siste spiller fullfører") og akkumulert-score- + beregningen UAVHENGIG simulert i Node (16/16). Et ekte API-rundtur- + script som sender NØYAKTIG samme felt-kombinasjon som veiviseren ville + sendt (fullt detalj-steg for en "full"-spiller, kun slag for en + "strokes_only"-gjest) bekreftet begge lagres korrekt. Full regresjon + (35-punkts co-player-testsuite) fortsatt grønn, `test_isolation.sql` + 12/12 (ingen migrasjon). Ekte typesjekket produksjonsbuild kompilerte + rent, samme engangs `next dev`-container-sjekk ga 200. + **Samme ærlige begrensning som alle håndkodede runder denne uken:** ingen + ekte nettleser-interaksjonstest -- gitt at FORRIGE runde ble avvist + nettopp fordi den så gal ut i praksis til tross for at logikken var + korrekt, er dette IKKE en ubetydelig forbehold denne gangen. Bruker bør + teste grundig før tillit. + **Rullet ut mot ekte systemer 2026-07-26**, bruker bekreftet eksplisitt: + ingen migrasjon, `docker compose up -d --build teecup_api + teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard` + → 200, `teeoff.no` upåvirket. Neste steg: 0a. **Spillerliste-redesign — LIVE, men ikke nettleser-testet diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index d01925a..5beaf46 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -113,16 +113,21 @@ function statKey(playerId: string, holeNumber: number) { return `${playerId}:${holeNumber}` } -// "Er dette godt nok registrert til å gå videre til neste spiller?" -// (2026-07-26, inspirert av en konkurrentapps samlebånd-flyt: én spiller -// om gangen, automatisk videre uten å måtte navigere manuelt tilbake til -// spillerlisten). Krever KUN det stat_level faktisk gjør obligatorisk -- -// "full"-nivåets ekstra detaljer (kølle/retning/chip osv.) er fortsatt -// valgfrie og blokkerer ALDRI videre fremdrift, samme som før. -function isEntryComplete(stat: HoleStat, statLevel: StatLevel): boolean { - if (stat.strokes === null) return false - if (statLevel === "strokes_only") return true - return stat.putts !== null +// Til-par-formatering, golfvis (ekte minustegn) -- samme konvensjon som +// round-scorecard.tsx/round-leaderboard.tsx (2026-07-26, brukt for +// akkumulert score-så-langt per spiller i den nye scoringsflyten). +function formatToPar(value: number): string { + if (value === 0) return "E" + return value > 0 ? `+${value}` : `−${Math.abs(value)}` +} + +function computeRunningToPar(holes: ApiHole[] | undefined): { toPar: number; holesPlayed: number } | null { + if (!holes) return null + const played = holes.filter((h) => h.played && h.score !== null) + if (played.length === 0) return null + const score = played.reduce((sum, h) => sum + (h.score as number), 0) + const par = played.reduce((sum, h) => sum + h.par, 0) + return { toPar: score - par, holesPlayed: played.length } } function apiGenderToUi(g: ApiGender): Gender { @@ -296,11 +301,10 @@ export function RoundDetail({ roundId }: { roundId: string }) { // panelet ligger nå INNI hvert spillerkort i den vertikale listen, kan // derfor gjelde en ANNEN rad enn den aktive/valgte, ulikt tidligere). const [editingPlayerId, setEditingPlayerId] = useState(null) - // "Flere detaljer" er kollapset som default (etterspurt av bruker - // 2026-07-25 -- scorekortet var "veldig dårlig designet", altfor mye - // stablet oppå hverandre for et enkelt slag-registrering) slik at en - // rask slag-/putt-registrering ikke krever noe scrolling i det hele tatt. - const [detailsOpen, setDetailsOpen] = useState(false) + // Hvilken spillers scoringsveiviser (fullskjerm) som er åpen for AKTIVT + // HULL, om noen (2026-07-26 -- erstatter den tidligere "vis alt inline for + // aktiv spiller"-flyten, inspirert av en konkurrentapps samlebåndsflyt). + const [wizardPlayerId, setWizardPlayerId] = useState(null) // Hull-panelet gjenbrukes ved bytte av hull -- "Forrige"/"Neste hull" ligger // NEDERST i panelet, så uten dette ville brukeren blitt stående scrollet // helt ned (der knappene er) mens det NYE hullets Slag-felt (øverst i @@ -357,36 +361,40 @@ export function RoundDetail({ roundId }: { roundId: string }) { [roundId], ) + // Laster hull for ALLE deltakere (2026-07-26, ikke bare aktiv spiller) -- + // den nye scoringslisten viser akkumulert score-så-langt for HVER spiller + // samtidig, ikke kun den ene som tilfeldigvis er "aktiv". useEffect(() => { - if (activePlayerId && !holesByParticipant[activePlayerId]) { - void loadHoles(activePlayerId) + if (!round) return + for (const p of round.participants) { + if (!holesByParticipant[p.id]) { + void loadHoles(p.id) + } } - }, [activePlayerId, holesByParticipant, loadHoles]) + }, [round, holesByParticipant, loadHoles]) - // Alltid gjeldende activePlayerId tilgjengelig INNI WebSocket-handleren - // under, uten å måtte koble socket-en til/fra ved hvert fanebytte (som en - // vanlig closure over `activePlayerId` i effektens dependency-array ville - // krevd -- ville ødelagt WS-tilkoblingen hver gang brukeren bytter fane). - const activePlayerIdRef = useRef(null) + // Alltid gjeldende deltaker-id-er tilgjengelig INNI WebSocket-handleren + // under, uten å måtte koble socket-en til/fra ved hver ny runde-lasting + // (en vanlig closure over `round` i effektens dependency-array ville + // krevd det). + const participantIdsRef = useRef([]) useEffect(() => { - activePlayerIdRef.current = activePlayerId - }, [activePlayerId]) + participantIdsRef.current = round?.participants.map((p) => p.id) ?? [] + }, [round]) // Sanntid (2026-07-26, oppfølging av ADR-027): et rent "noe endret seg"- // signal -- IKKE selve dataen. Klienten reagerer med de vanlige REST- - // kallene den uansett gjør ved førstegangslasting. Aktiv spillers hull - // hentes på nytt DIREKTE (overskriver når svaret kommer, ingen mellomsteg - // med tom stat som ville blinket til for spilleren som selv nettopp - // registrerte et slag) -- de ANDRE spillernes cache droppes i stedet, slik - // at et senere fanebytte henter friskt fremfor en stale verdi. + // kallene den uansett gjør ved førstegangslasting. Henter nå ALLE + // deltakeres hull på nytt (ikke bare én aktiv) siden scoringslisten + // viser alles akkumulerte score-så-langt samtidig. useEffect(() => { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`) socket.onmessage = () => { void loadRound() - const active = activePlayerIdRef.current - setHolesByParticipant((prev) => (active && prev[active] ? { [active]: prev[active] } : {})) - if (active) void loadHoles(active) + for (const id of participantIdsRef.current) { + void loadHoles(id) + } } return () => socket.close() }, [roundId, loadRound, loadHoles]) @@ -446,19 +454,17 @@ export function RoundDetail({ roundId }: { roundId: string }) { hole !== null && currentStat.strokes - currentStat.putts <= hole.par - 2 - // Samlebånd-fremdrift (2026-07-26): når aktiv spiller er ferdig registrert - // for hullet, gå til NESTE spiller i rekkefølgen -- er hen den siste, - // gå til neste hull og start på spiller 1 igjen. Fjerner behovet for å - // manuelt skrolle opp til spillerlisten og velge neste spiller for hånd. - const activePlayerIndex = activePlayer ? players.findIndex((p) => p.id === activePlayer.id) : -1 - const isLastPlayer = activePlayerIndex === -1 || activePlayerIndex === players.length - 1 - const nextPlayer = !isLastPlayer ? players[activePlayerIndex + 1] : null - const canAdvance = !readOnly && activePlayer !== null && isEntryComplete(currentStat, activePlayer.statLevel) + // Veiviserens mål (2026-07-26) -- HVEM sin score som registreres akkurat + // nå, uavhengig av `activePlayerId` (som fortsatt kun styrer hvem sin + // "Så langt i runden"-oversikt lenger ned som vises). + const wizardPlayer = players.find((p) => p.id === wizardPlayerId) ?? null + const wizardApiHole = wizardPlayerId ? (holesByParticipant[wizardPlayerId]?.find((h) => h.hole_number === activeHole) ?? null) : null + const wizardStat = wizardApiHole ? apiHoleToStat(wizardApiHole) : emptyStat() - async function updateStat(patch: Partial) { - if (readOnly || !activePlayerId || !currentApiHole) return - const merged: HoleStat = { ...currentStat, ...patch } - const res = await fetch(`/rounds/${roundId}/participants/${activePlayerId}/holes/${activeHole}`, { + async function updateWizardStat(patch: Partial) { + if (readOnly || !wizardPlayerId) return + const merged: HoleStat = { ...wizardStat, ...patch } + const res = await fetch(`/rounds/${roundId}/participants/${wizardPlayerId}/holes/${activeHole}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", @@ -468,7 +474,7 @@ export function RoundDetail({ roundId }: { roundId: string }) { const updated: ApiHole = await res.json() setHolesByParticipant((prev) => ({ ...prev, - [activePlayerId]: (prev[activePlayerId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)), + [wizardPlayerId]: (prev[wizardPlayerId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)), })) } @@ -487,11 +493,19 @@ export function RoundDetail({ roundId }: { roundId: string }) { scrollToHolePanel() } - function advanceToNextPlayerOrHole() { - if (nextPlayer) { - setActivePlayerId(nextPlayer.id) - scrollToHolePanel() + // Samlebånd-fremdrift (2026-07-26): kalles fra veiviserens siste steg for + // siste spiller på hullet -- bytter til NESTE spiller i rekkefølgen (samme + // veiviser, nullstilt til steg 1), eller -- er aktiv spiller den siste -- + // lukker veiviseren og går videre til neste hull, klar for spiller 1 igjen. + function advanceWizardPlayer() { + const idx = players.findIndex((p) => p.id === wizardPlayerId) + const isLast = idx === -1 || idx === players.length - 1 + if (!isLast) { + const next = players[idx + 1] + setActivePlayerId(next.id) + setWizardPlayerId(next.id) } else { + setWizardPlayerId(null) setActivePlayerId(players[0]?.id ?? null) goNext() } @@ -836,186 +850,77 @@ export function RoundDetail({ roundId }: { roundId: string }) { - {/* Slag/Putter og (ved statLevel="full") alle detaljene vises - ALLTID samlet under hverandre -- ikke bak en fane man må - oppdage og trykke på (funnet 2026-07-25: brukeren huket - av "All statistikk", men fikk aldri se noe utover - slag/putter siden det lå bak en fane). */} -
- updateStat({ strokes: v, played: true })} - readOnly={readOnly} - /> - - {activePlayer.statLevel !== "strokes_only" && ( - updateStat({ putts: v })} - readOnly={readOnly} - /> - )} - - {activePlayer.statLevel === "full" && ( - <> - {/* Rett under antall putter (etterspurt av bruker 2026-07-25) -- - hører naturlig sammen med Putter-feltet rett over, ikke med - kølle/retning/chip-gruppen lenger ned. */} - updateStat({ firstPuttBucket: v as PuttBucket })} - readOnly={readOnly} - /> - - {/* Alt annet lever bak én kollapsbar seksjon, lukket som - default -- panelet forblir kort for det vanlige - slag-/putt-registreringstilfellet. */} -
+ {/* Registrering (2026-07-26, inspirert av en konkurrentapps + samlebåndsflyt): én kompakt rad per spiller med + akkumulert score-så-langt for RUNDEN alltid synlig, og en + trykkflate som åpner en fokusert, skjermovertagende + veiviser for AKKURAT denne spilleren og hullet -- i + stedet for ett langt skjema man skrollet gjennom for én + spiller om gangen, med manuell fanebytting mellom + spillere (rapportert av bruker 2026-07-26 som "ingen + forbedring", "visuelt like overveldende og rotete"). */} +
    + {players.map((player) => { + const playerHoles = holesByParticipant[player.id] + const playerApiHole = playerHoles?.find((h) => h.hole_number === activeHole) ?? null + const hasScore = Boolean(playerApiHole?.played) && playerApiHole?.score !== null + const running = computeRunningToPar(playerHoles) + return ( +
  • + + + {player.name} + {player.isSelf && ( + + Deg + + )} + + + HCP {player.hcp !== null ? player.hcp.toFixed(1).replace(".", ",") : "ikke satt"} + {running && ( + <> + {" · "} + {formatToPar(running.toPar)} så langt ({running.holesPlayed} hull) + + )} + + - {detailsOpen && ( -
    -
    - - {activePlayer.isSelf && ownBagClubs.length > 0 ? ( -
    - {ownBagClubs.map((club) => { - const selected = currentStat.club === club - return ( - - ) - })} -
    - ) : ( - updateStat({ club: e.target.value })} - placeholder="F.eks. Driver, 3-jern" - disabled={readOnly} - className="h-12 rounded-2xl text-base" - /> - )} -
    +
  • + ) + })} +
- {hole.par !== 3 && ( - updateStat({ teeShot: v as TeeShot })} - readOnly={readOnly} - /> - )} - - updateStat({ approach: v as Approach })} - readOnly={readOnly} - /> - -
- updateStat({ chip: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} /> - updateStat({ bunker: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} /> - updateStat({ penalty: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} /> -
- - updateStat({ anywayStrokes: v })} - readOnly={readOnly} - /> -
- )} -
- - )} - - -
-
- - {/* Samlebånd-knapp (2026-07-26): går til NESTE SPILLER på - samme hull, eller -- er aktiv spiller den siste -- - videre til neste hull og starter på spiller 1 igjen. - Erstatter den manuelle "skroll opp, velg neste - spiller"-runden man tidligere måtte gjøre for hånd. */} - -
- {!readOnly && !canAdvance && ( -

- {activePlayer?.statLevel === "strokes_only" - ? "Registrer slag for å gå videre." - : "Registrer slag og putter for å gå videre."} -

- )} +
+ +
)} @@ -1023,6 +928,280 @@ export function RoundDetail({ roundId }: { roundId: string }) { )}
+ + {/* Skjermovertagende scoringsveiviser (2026-07-26) -- rendres HELT + UTENFOR
, øverst i DOM-treet, slik at den dekker hele + skjermen uavhengig av hvor langt man har skrollet i bakgrunnen. */} + {wizardPlayer && hole && ( + setWizardPlayerId(null)} + onAdvance={advanceWizardPlayer} + readOnly={readOnly} + ownBagClubs={ownBagClubs} + /> + )} + + ) +} + +// --- Scoringsveiviser (2026-07-26) ------------------------------------------ +// Skjermovertagende, ett-spiller-om-gangen registreringsflyt -- inspirert av +// en konkurrentapps samlebåndsflyt (video studert bilde for bilde), men +// BEVISST forenklet til TeeCups eget stat_level-drevne steg-antall i stedet +// for å kopiere konkurrentens nøyaktige skjermrekkefølge/design: +// - "strokes_only": ett steg (Slag) +// - "strokes_and_putts": to steg (Slag, Putter+avstand første putt) +// - "full": tre steg (Slag, Putter+avstand første putt, resten samlet) +// "Neste"/"Forrige" beveger seg gjennom stegene for ÉN spiller; på siste +// steg blir knappen "Neste: {navn}" (bytter til neste spiller, nullstiller +// til steg 1) eller "Ferdig" (er spilleren den siste, lukker veiviseren og +// varsler forelderen om å gå videre til neste hull). + +type WizardStep = "strokes" | "putts" | "details" + +function wizardStepsFor(statLevel: StatLevel): WizardStep[] { + if (statLevel === "strokes_only") return ["strokes"] + if (statLevel === "strokes_and_putts") return ["strokes", "putts"] + return ["strokes", "putts", "details"] +} + +function ScoringWizard({ + players, + player, + hole, + stat, + onUpdate, + onClose, + onAdvance, + readOnly, + ownBagClubs, +}: { + players: Player[] + player: Player + hole: Hole + stat: HoleStat + onUpdate: (patch: Partial) => void + onClose: () => void + onAdvance: () => void + readOnly: boolean + ownBagClubs: string[] +}) { + const playerIndex = players.findIndex((p) => p.id === player.id) + const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1 + const nextPlayer = !isLastPlayer ? players[playerIndex + 1] : null + + const steps = wizardStepsFor(player.statLevel) + const [stepIndex, setStepIndex] = useState(0) + // Nullstill til steg 1 hver gang veiviseren åpnes for en NY spiller -- + // uten dette ville "Neste: {navn}" hoppet rett til der FORRIGE spiller + // slapp, i stedet for å starte på Slag igjen for den nye spilleren. + useEffect(() => { + setStepIndex(0) + }, [player.id]) + + const step = steps[Math.min(stepIndex, steps.length - 1)] + const isFirstStep = stepIndex === 0 + const isLastStep = stepIndex === steps.length - 1 + + const canProceed = step === "strokes" ? stat.strokes !== null : step === "putts" ? stat.putts !== null : true + + function back() { + if (isFirstStep) { + onClose() + return + } + setStepIndex((i) => i - 1) + } + function forward() { + if (!isLastStep) { + setStepIndex((i) => i + 1) + return + } + onAdvance() + } + + return ( +
+
+ +
+ + Hull {hole.holeNumber} · Par {hole.par} + + {player.name} +
+
+ + {/* Kontekst-rad: alle spillerne, aktiv fremhevet -- samme prinsipp som + konkurrentappen: man mister aldri oversikten over hvem som allerede + er ferdig på hullet. Ren informasjon, ikke en snarvei -- bytte + spiller midt i et steg ville latt uferdige felt henge løst. */} +
+ {players.map((p) => ( + + {p.name} + + ))} +
+ +
+
+ {step === "strokes" && ( + onUpdate({ strokes: v, played: true })} + readOnly={readOnly} + /> + )} + + {step === "putts" && ( + <> + onUpdate({ putts: v })} + readOnly={readOnly} + /> + onUpdate({ firstPuttBucket: v as PuttBucket })} + readOnly={readOnly} + /> + + )} + + {step === "details" && ( + <> +
+ + {player.isSelf && ownBagClubs.length > 0 ? ( +
+ {ownBagClubs.map((club) => { + const selected = stat.club === club + return ( + + ) + })} +
+ ) : ( + onUpdate({ club: e.target.value })} + placeholder="F.eks. Driver, 3-jern" + disabled={readOnly} + className="h-12 rounded-2xl text-base" + /> + )} +
+ + {hole.par !== 3 && ( + onUpdate({ teeShot: v as TeeShot })} + readOnly={readOnly} + /> + )} + + onUpdate({ approach: v as Approach })} + readOnly={readOnly} + /> + +
+ onUpdate({ chip: v })} readOnly={readOnly} max={stat.strokes ?? undefined} /> + onUpdate({ bunker: v })} readOnly={readOnly} max={stat.strokes ?? undefined} /> + onUpdate({ penalty: v })} readOnly={readOnly} max={stat.strokes ?? undefined} /> +
+ + onUpdate({ anywayStrokes: v })} + readOnly={readOnly} + /> + + )} +
+
+ +
) }