v0.12.38 event form bug fixes
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-backend",
|
"name": "rosterchirp-backend",
|
||||||
"version": "0.12.37",
|
"version": "0.12.38",
|
||||||
"description": "RosterChirp backend server",
|
"description": "RosterChirp backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.12.37}"
|
VERSION="${1:-0.12.38}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="rosterchirp"
|
IMAGE_NAME="rosterchirp"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-frontend",
|
"name": "rosterchirp-frontend",
|
||||||
"version": "0.12.37",
|
"version": "0.12.38",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -68,15 +68,48 @@ function fmt12(val) {
|
|||||||
return `${h}:${String(mm).padStart(2,'0')} ${ampm}`;
|
return `${h}:${String(mm).padStart(2,'0')} ${ampm}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile TimeInput — same behaviour as desktop but styled for mobile inline use
|
// Mobile TimeInput — free-text time entry with smart-positioned scrollable dropdown
|
||||||
function TimeInputMobile({ value, onChange }) {
|
function TimeInputMobile({ value, onChange }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [inputVal, setInputVal] = useState(fmt12(value));
|
const [inputVal, setInputVal] = useState(fmt12(value));
|
||||||
|
const [keyboardOffset, setKeyboardOffset] = useState(0);
|
||||||
|
const [dropdownPos, setDropdownPos] = useState({ top: 0, bottom: 'auto', left: 0 });
|
||||||
const wrapRef = useRef(null);
|
const wrapRef = useRef(null);
|
||||||
const listRef = useRef(null);
|
const listRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => { setInputVal(fmt12(value)); }, [value]);
|
useEffect(() => { setInputVal(fmt12(value)); }, [value]);
|
||||||
|
|
||||||
|
// Detect keyboard height via Visual Viewport API
|
||||||
|
useEffect(() => {
|
||||||
|
const handleViewportChange = () => {
|
||||||
|
if (window.visualViewport) {
|
||||||
|
const offset = window.innerHeight - window.visualViewport.height;
|
||||||
|
setKeyboardOffset(offset > 0 ? offset : 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (open) {
|
||||||
|
handleViewportChange();
|
||||||
|
window.visualViewport?.addEventListener('resize', handleViewportChange);
|
||||||
|
return () => window.visualViewport?.removeEventListener('resize', handleViewportChange);
|
||||||
|
} else {
|
||||||
|
setKeyboardOffset(0);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Calculate dropdown pixel position using getBoundingClientRect (required for position:fixed)
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && wrapRef.current) {
|
||||||
|
const rect = wrapRef.current.getBoundingClientRect();
|
||||||
|
const dropdownHeight = 5 * 40;
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom - keyboardOffset;
|
||||||
|
if (spaceBelow >= dropdownHeight) {
|
||||||
|
setDropdownPos({ top: rect.bottom, bottom: 'auto', left: rect.left });
|
||||||
|
} else {
|
||||||
|
setDropdownPos({ top: 'auto', bottom: window.innerHeight - rect.top + keyboardOffset, left: rect.left });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, keyboardOffset]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !listRef.current) return;
|
if (!open || !listRef.current) return;
|
||||||
const idx = TIME_SLOTS.findIndex(s => s.value === value);
|
const idx = TIME_SLOTS.findIndex(s => s.value === value);
|
||||||
@@ -100,22 +133,30 @@ function TimeInputMobile({ value, onChange }) {
|
|||||||
return (
|
return (
|
||||||
<div ref={wrapRef} style={{ position: 'relative', display: 'inline-block' }}>
|
<div ref={wrapRef} style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
<input
|
<input
|
||||||
|
type="text"
|
||||||
value={inputVal}
|
value={inputVal}
|
||||||
onChange={e => setInputVal(e.target.value)}
|
onChange={e => setInputVal(e.target.value)}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => setOpen(true)}
|
||||||
onBlur={e => setTimeout(() => commit(e.target.value), 150)}
|
onBlur={e => setTimeout(() => commit(e.target.value), 150)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(inputVal); } if (e.key === 'Escape') { setInputVal(fmt12(value)); setOpen(false); } }}
|
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(inputVal); } if (e.key === 'Escape') { setInputVal(fmt12(value)); setOpen(false); } }}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
inputMode="text"
|
||||||
|
enterKeyHint="done"
|
||||||
style={{ fontSize: 15, color: 'var(--primary)', fontWeight: 600, background: 'transparent', border: 'none', outline: 'none', cursor: 'text', width: 90 }}
|
style={{ fontSize: 15, color: 'var(--primary)', fontWeight: 600, background: 'transparent', border: 'none', outline: 'none', cursor: 'text', width: 90 }}
|
||||||
/>
|
/>
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed', zIndex: 400,
|
position: 'fixed',
|
||||||
|
zIndex: 9999,
|
||||||
|
top: dropdownPos.top,
|
||||||
|
bottom: dropdownPos.bottom,
|
||||||
|
left: dropdownPos.left,
|
||||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
|
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
|
||||||
width: 130, maxHeight: 5 * 40, overflowY: 'auto',
|
width: 130, maxHeight: 5 * 40, overflowY: 'auto',
|
||||||
|
pointerEvents: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{TIME_SLOTS.map(s => (
|
{TIME_SLOTS.map(s => (
|
||||||
@@ -520,8 +561,8 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* End date/time */}
|
{/* End date/time */}
|
||||||
<div onClick={()=>setShowEndDate(true)} style={{ display:'flex',alignItems:'center',padding:'6px 20px 14px 56px',cursor:'pointer',borderBottom:'1px solid var(--border)' }}>
|
<div style={{ display:'flex',alignItems:'center',padding:'6px 20px 14px 56px',borderBottom:'1px solid var(--border)' }}>
|
||||||
<span style={{ flex:1,fontSize:15,color:'var(--text-secondary)' }}>{fmtDateDisplay(ed)}</span>
|
<span onClick={()=>setShowEndDate(true)} style={{ flex:1,fontSize:15,color:'var(--text-secondary)',cursor:'pointer' }}>{fmtDateDisplay(ed)}</span>
|
||||||
{!allDay && (
|
{!allDay && (
|
||||||
<TimeInputMobile value={et} onChange={newEt => {
|
<TimeInputMobile value={et} onChange={newEt => {
|
||||||
setEt(newEt);
|
setEt(newEt);
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ function TimeInput({ value, onChange, style }) {
|
|||||||
// Keep display in sync when value changes externally
|
// Keep display in sync when value changes externally
|
||||||
useEffect(() => { setInputVal(fmt12(value)); }, [value]);
|
useEffect(() => { setInputVal(fmt12(value)); }, [value]);
|
||||||
|
|
||||||
// Scroll the dropdown so the selected slot is near the top
|
// Scroll the dropdown so that selected slot is near the top
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !listRef.current) return;
|
if (!open || !listRef.current) return;
|
||||||
const idx = TIME_SLOTS.findIndex(s => s.value === value);
|
const idx = TIME_SLOTS.findIndex(s => s.value === value);
|
||||||
@@ -185,6 +185,7 @@ function TimeInput({ value, onChange, style }) {
|
|||||||
return (
|
return (
|
||||||
<div ref={wrapRef} style={{ position: 'relative', ...style }}>
|
<div ref={wrapRef} style={{ position: 'relative', ...style }}>
|
||||||
<input
|
<input
|
||||||
|
type="text"
|
||||||
className="input"
|
className="input"
|
||||||
value={inputVal}
|
value={inputVal}
|
||||||
onChange={e => setInputVal(e.target.value)}
|
onChange={e => setInputVal(e.target.value)}
|
||||||
@@ -196,17 +197,20 @@ function TimeInput({ value, onChange, style }) {
|
|||||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(inputVal); } if (e.key === 'Escape') { setInputVal(fmt12(value)); setOpen(false); } }}
|
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(inputVal); } if (e.key === 'Escape') { setInputVal(fmt12(value)); setOpen(false); } }}
|
||||||
style={{ width: '100%', cursor: 'text' }}
|
style={{ width: '100%', cursor: 'text' }}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
inputMode="text"
|
||||||
|
enterKeyHint="done"
|
||||||
placeholder="9:00 AM"
|
placeholder="9:00 AM"
|
||||||
/>
|
/>
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', top: '100%', left: 0, zIndex: 300,
|
position: 'absolute', top: '100%', left: 0, zIndex: 9999,
|
||||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
borderRadius: 'var(--radius)', boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
borderRadius: 'var(--radius)', boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
||||||
width: '100%', minWidth: 120,
|
width: '100%', minWidth: 120,
|
||||||
maxHeight: 5 * 36, overflowY: 'auto',
|
maxHeight: 5 * 36, overflowY: 'auto',
|
||||||
|
pointerEvents: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{TIME_SLOTS.map(s => (
|
{TIME_SLOTS.map(s => (
|
||||||
@@ -350,7 +354,7 @@ function MobileScheduleFilter({ selected, onMonthChange, view, eventTypes, filte
|
|||||||
<div style={{padding:'8px 12px 12px',borderTop:'1px solid var(--border)'}}>
|
<div style={{padding:'8px 12px 12px',borderTop:'1px solid var(--border)'}}>
|
||||||
<div style={{position:'relative',marginBottom:8}}>
|
<div style={{position:'relative',marginBottom:8}}>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{position:'absolute',left:9,top:'50%',transform:'translateY(-50%)',pointerEvents:'none'}}><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{position:'absolute',left:9,top:'50%',transform:'translateY(-50%)',pointerEvents:'none'}}><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
<input value={filterKeyword} onChange={e => onFilterKeyword(e.target.value)} autoComplete="off" onFocus={onInputFocus} onBlur={onInputBlur}
|
<input value={filterKeyword} onChange={e => onFilterKeyword(e.target.value)} autoComplete="new-password" onFocus={onInputFocus} onBlur={onInputBlur}
|
||||||
placeholder="Search events…" autoCorrect="off" autoCapitalize="off" spellCheck={false}
|
placeholder="Search events…" autoCorrect="off" autoCapitalize="off" spellCheck={false}
|
||||||
style={{width:'100%',padding:'7px 8px 7px 28px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:13,boxSizing:'border-box'}}/>
|
style={{width:'100%',padding:'7px 8px 7px 28px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:13,boxSizing:'border-box'}}/>
|
||||||
</div>
|
</div>
|
||||||
@@ -389,7 +393,7 @@ function EventTypePopup({ userGroups, onSave, onClose, editing=null }) {
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div style={{position:'absolute',top:'100%',left:0,zIndex:300,background:'var(--surface)',border:'1px solid var(--border)',borderRadius:'var(--radius)',padding:16,width:270,boxShadow:'0 4px 20px rgba(0,0,0,0.2)'}}>
|
<div style={{position:'absolute',top:'100%',left:0,zIndex:300,background:'var(--surface)',border:'1px solid var(--border)',borderRadius:'var(--radius)',padding:16,width:270,boxShadow:'0 4px 20px rgba(0,0,0,0.2)'}}>
|
||||||
<div style={{marginBottom:8}}><label className="settings-section-label">Name</label><input className="input" value={name} onChange={e => setName(e.target.value)} autoComplete="off" autoCorrect="off" style={{marginTop:4}} autoFocus/></div>
|
<div style={{marginBottom:8}}><label className="settings-section-label">Name</label><input className="input" value={name} onChange={e => setName(e.target.value)} autoComplete="new-password" autoCorrect="off" style={{marginTop:4}} autoFocus/></div>
|
||||||
<div style={{marginBottom:8}}><label className="settings-section-label">Colour</label><input type="color" value={colour} onChange={e => setColour(e.target.value)} style={{marginTop:4,width:'100%',height:32,padding:2,borderRadius:4,border:'1px solid var(--border)'}}/></div>
|
<div style={{marginBottom:8}}><label className="settings-section-label">Colour</label><input type="color" value={colour} onChange={e => setColour(e.target.value)} style={{marginTop:4,width:'100%',height:32,padding:2,borderRadius:4,border:'1px solid var(--border)'}}/></div>
|
||||||
<div style={{marginBottom:8}}><label className="settings-section-label">Default Group</label><select className="input" value={groupId} onChange={e=>setGroupId(e.target.value)} style={{marginTop:4}}><option value="">None</option>{userGroups.map(g=><option key={g.id} value={g.id}>{g.name}</option>)}</select></div>
|
<div style={{marginBottom:8}}><label className="settings-section-label">Default Group</label><select className="input" value={groupId} onChange={e=>setGroupId(e.target.value)} style={{marginTop:4}}><option value="">None</option>{userGroups.map(g=><option key={g.id} value={g.id}>{g.name}</option>)}</select></div>
|
||||||
<div style={{marginBottom:12}}>
|
<div style={{marginBottom:12}}>
|
||||||
@@ -453,7 +457,7 @@ function CustomRecurrenceFields({ rule, onChange }) {
|
|||||||
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',padding:12,display:'flex',flexDirection:'column',gap:10}}>
|
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',padding:12,display:'flex',flexDirection:'column',gap:10}}>
|
||||||
<div style={{display:'flex',alignItems:'center',gap:8,fontSize:13}}>
|
<div style={{display:'flex',alignItems:'center',gap:8,fontSize:13}}>
|
||||||
<span style={{color:'var(--text-tertiary)'}}>Every</span>
|
<span style={{color:'var(--text-tertiary)'}}>Every</span>
|
||||||
<input type="number" className="input" min={1} max={99} value={rule.interval||1} onChange={e => upd('interval',Math.max(1,parseInt(e.target.value)||1))} autoComplete="off" style={{width:60,textAlign:'center'}}/>
|
<input type="number" className="input" min={1} max={99} value={rule.interval||1} onChange={e => upd('interval',Math.max(1,parseInt(e.target.value)||1))} autoComplete="new-password" style={{width:60,textAlign:'center'}}/>
|
||||||
<select className="input" value={rule.unit||'week'} onChange={e=>upd('unit',e.target.value)} style={{flex:1}}>
|
<select className="input" value={rule.unit||'week'} onChange={e=>upd('unit',e.target.value)} style={{flex:1}}>
|
||||||
{['day','week','month','year'].map(u=><option key={u} value={u}>{u}{(rule.interval||1)>1?'s':''}</option>)}
|
{['day','week','month','year'].map(u=><option key={u} value={u}>{u}{(rule.interval||1)>1?'s':''}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -475,8 +479,8 @@ function CustomRecurrenceFields({ rule, onChange }) {
|
|||||||
<label key={val} style={{display:'flex',alignItems:'center',gap:10,marginBottom:6,fontSize:13,cursor:'pointer'}}>
|
<label key={val} style={{display:'flex',alignItems:'center',gap:10,marginBottom:6,fontSize:13,cursor:'pointer'}}>
|
||||||
<input type="radio" name="recur_ends" checked={(rule.ends||'never')===val} onChange={()=>upd('ends',val)}/>
|
<input type="radio" name="recur_ends" checked={(rule.ends||'never')===val} onChange={()=>upd('ends',val)}/>
|
||||||
{lbl}
|
{lbl}
|
||||||
{val==='on' && (rule.ends||'never')==='on' && <input type="date" className="input" value={rule.endDate||''} onChange={e => upd('endDate',e.target.value)} autoComplete="off" style={{marginLeft:8,flex:1}}/>}
|
{val==='on' && (rule.ends||'never')==='on' && <input type="date" className="input" value={rule.endDate||''} onChange={e => upd('endDate',e.target.value)} autoComplete="new-password" style={{marginLeft:8,flex:1}}/>}
|
||||||
{val==='after' && (rule.ends||'never')==='after' && <><input type="number" className="input" min={1} max={999} value={rule.endCount||13} onChange={e => upd('endCount',parseInt(e.target.value)||1)} autoComplete="off" style={{width:64,textAlign:'center',marginLeft:8}}/><span style={{color:'var(--text-tertiary)'}}>occurrences</span></>}
|
{val==='after' && (rule.ends||'never')==='after' && <><input type="number" className="input" min={1} max={999} value={rule.endCount||13} onChange={e => upd('endCount',parseInt(e.target.value)||1)} autoComplete="new-password" style={{width:64,textAlign:'center',marginLeft:8}}/><span style={{color:'var(--text-tertiary)'}}>occurrences</span></>}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -663,10 +667,13 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{width:'100%',maxWidth:1024,overflowX:'auto'}}>
|
<div style={{width:'100%',maxWidth:1024,overflowX:'auto'}}>
|
||||||
|
{/* form wrapper suppresses Chrome Android's autofill chip bar; autoComplete="new-password"
|
||||||
|
on individual inputs is ignored by Chrome but respected on the form element */}
|
||||||
|
<form autoComplete="off" onSubmit={e => e.preventDefault()}>
|
||||||
<div style={{minWidth:500}} onKeyDown={e=>{if(e.key==='Enter'&&e.target.tagName!=='TEXTAREA') e.preventDefault();}}>
|
<div style={{minWidth:500}} onKeyDown={e=>{if(e.key==='Enter'&&e.target.tagName!=='TEXTAREA') e.preventDefault();}}>
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div style={{marginBottom:20}}>
|
<div style={{marginBottom:20}}>
|
||||||
<input className="input" placeholder="Add title" value={title} onChange={e => setTitle(e.target.value)} autoComplete="off" autoCorrect="off" autoCapitalize="sentences" style={{fontSize:20,fontWeight:700,border:'none',borderBottom:'2px solid var(--border)',borderRadius:0,padding:'4px 0',background:'transparent',width:'100%'}}/>
|
<input className="input" placeholder="Add title" value={title} onChange={e => setTitle(e.target.value)} autoComplete="new-password" autoCorrect="off" autoCapitalize="sentences" style={{fontSize:20,fontWeight:700,border:'none',borderBottom:'2px solid var(--border)',borderRadius:0,padding:'4px 0',background:'transparent',width:'100%'}}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Event Type */}
|
{/* Event Type */}
|
||||||
@@ -685,7 +692,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|||||||
<FormRow label="Date & Time">
|
<FormRow label="Date & Time">
|
||||||
<div style={{display:'flex',flexDirection:'column',gap:8}}>
|
<div style={{display:'flex',flexDirection:'column',gap:8}}>
|
||||||
<div style={{display:'flex',alignItems:'center',gap:8,flexWrap:'nowrap'}}>
|
<div style={{display:'flex',alignItems:'center',gap:8,flexWrap:'nowrap'}}>
|
||||||
<input type="date" className="input" value={sd} onChange={e => setSd(e.target.value)} autoComplete="off" style={{width:150,flexShrink:0}}/>
|
<input type="date" className="input" value={sd} onChange={e => setSd(e.target.value)} autoComplete="new-password" style={{width:150,flexShrink:0}}/>
|
||||||
{!allDay&&(
|
{!allDay&&(
|
||||||
<>
|
<>
|
||||||
<TimeInput value={st} onChange={setSt} style={{width:120,flexShrink:0}}/>
|
<TimeInput value={st} onChange={setSt} style={{width:120,flexShrink:0}}/>
|
||||||
@@ -694,7 +701,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|||||||
setEt(newEt); userSetEndTime.current=true;
|
setEt(newEt); userSetEndTime.current=true;
|
||||||
if(sd===ed && newEt<=st){ const d=new Date(buildISO(sd,st)); d.setDate(d.getDate()+1); const p=n=>String(n).padStart(2,'0'); setEd(`${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`); }
|
if(sd===ed && newEt<=st){ const d=new Date(buildISO(sd,st)); d.setDate(d.getDate()+1); const p=n=>String(n).padStart(2,'0'); setEd(`${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`); }
|
||||||
}} style={{width:120,flexShrink:0}}/>
|
}} style={{width:120,flexShrink:0}}/>
|
||||||
<input type="date" className="input" value={ed} onChange={e => {setEd(e.target.value);userSetEndTime.current=true;}} autoComplete="off" style={{width:150,flexShrink:0}}/>
|
<input type="date" className="input" value={ed} onChange={e => {setEd(e.target.value);userSetEndTime.current=true;}} autoComplete="new-password" style={{width:150,flexShrink:0}}/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -752,12 +759,12 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|||||||
|
|
||||||
{/* Location */}
|
{/* Location */}
|
||||||
<FormRow label="Location">
|
<FormRow label="Location">
|
||||||
<input className="input" placeholder="Add location" value={loc} onChange={e => setLoc(e.target.value)} autoComplete="off" autoCorrect="off" autoCapitalize="off" />
|
<input className="input" placeholder="Add location" value={loc} onChange={e => setLoc(e.target.value)} autoComplete="new-password" autoCorrect="off" autoCapitalize="off" />
|
||||||
</FormRow>
|
</FormRow>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<FormRow label="Description">
|
<FormRow label="Description">
|
||||||
<textarea className="input" placeholder="Add description" value={desc} onChange={e=>setDesc(e.target.value)} rows={3} autoComplete="off" autoCorrect="off" style={{resize:'vertical'}}/>
|
<textarea className="input" placeholder="Add description" value={desc} onChange={e=>setDesc(e.target.value)} rows={3} autoComplete="new-password" autoCorrect="off" style={{resize:'vertical'}}/>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
|
|
||||||
<div style={{display:'flex',gap:8,marginTop:8}}>
|
<div style={{display:'flex',gap:8,marginTop:8}}>
|
||||||
@@ -766,6 +773,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|||||||
{event&&(isToolManager||(userId&&event.created_by===userId))&&<button className="btn btn-sm" style={{marginLeft:'auto',background:'var(--error)',color:'white'}} onClick={()=>onDelete(event)}>Delete</button>}
|
{event&&(isToolManager||(userId&&event.created_by===userId))&&<button className="btn btn-sm" style={{marginLeft:'auto',background:'var(--error)',color:'white'}} onClick={()=>onDelete(event)}>Delete</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{showScopeModal&&<RecurringChoiceModal title="Edit recurring event" onConfirm={doSave} onCancel={()=>setShowScopeModal(false)}/>}
|
{showScopeModal&&<RecurringChoiceModal title="Edit recurring event" onConfirm={doSave} onCancel={()=>setShowScopeModal(false)}/>}
|
||||||
</>
|
</>
|
||||||
@@ -989,7 +997,7 @@ function EventTypesPanel({ eventTypes, userGroups, onUpdated, isMobile=false })
|
|||||||
<span style={{fontWeight:700,fontSize:16}}>{sheetMode==='create'?'New Event Type':'Edit Event Type'}</span>
|
<span style={{fontWeight:700,fontSize:16}}>{sheetMode==='create'?'New Event Type':'Edit Event Type'}</span>
|
||||||
<button onClick={closeSheet} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',fontSize:20,lineHeight:1}}>✕</button>
|
<button onClick={closeSheet} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',fontSize:20,lineHeight:1}}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<input autoFocus value={sheetName} onChange={e => setSheetName(e.target.value)} autoComplete="off" autoCorrect="off" onKeyDown={e=>e.key==='Enter'&&saveSheet()} placeholder="Type name…"
|
<input autoFocus value={sheetName} onChange={e => setSheetName(e.target.value)} autoComplete="new-password" autoCorrect="off" onKeyDown={e=>e.key==='Enter'&&saveSheet()} placeholder="Type name…"
|
||||||
style={{width:'100%',padding:'12px 14px',border:'1px solid var(--border)',borderRadius:'var(--radius)',fontSize:16,marginBottom:12,boxSizing:'border-box',background:'var(--background)',color:'var(--text-primary)'}}/>
|
style={{width:'100%',padding:'12px 14px',border:'1px solid var(--border)',borderRadius:'var(--radius)',fontSize:16,marginBottom:12,boxSizing:'border-box',background:'var(--background)',color:'var(--text-primary)'}}/>
|
||||||
<div style={{display:'flex',alignItems:'center',gap:12,marginBottom:16}}>
|
<div style={{display:'flex',alignItems:'center',gap:12,marginBottom:16}}>
|
||||||
<label style={{fontSize:14,color:'var(--text-tertiary)',flexShrink:0}}>Colour</label>
|
<label style={{fontSize:14,color:'var(--text-tertiary)',flexShrink:0}}>Colour</label>
|
||||||
@@ -1711,7 +1719,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
|||||||
className="input"
|
className="input"
|
||||||
placeholder={`Keyword… (space = OR, "phrase")`}
|
placeholder={`Keyword… (space = OR, "phrase")`}
|
||||||
value={filterKeyword}
|
value={filterKeyword}
|
||||||
onChange={e => { setFilterKeyword(e.target.value); if (!e.target.value) setFilterFromDate(null); }} autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false}
|
onChange={e => { setFilterKeyword(e.target.value); if (!e.target.value) setFilterFromDate(null); }} autoComplete="new-password" autoCorrect="off" autoCapitalize="off" spellCheck={false}
|
||||||
style={{ marginBottom:8, fontSize:13 }} />
|
style={{ marginBottom:8, fontSize:13 }} />
|
||||||
<select
|
<select
|
||||||
className="input"
|
className="input"
|
||||||
|
|||||||
@@ -290,6 +290,9 @@ export default function UserManagerModal({ onClose }) {
|
|||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
<div className="modal" style={{ maxWidth: 600, width: '100%' }}>
|
<div className="modal" style={{ maxWidth: 600, width: '100%' }}>
|
||||||
|
{/* form wrapper suppresses Chrome Android's autofill chip bar; autoComplete="off"
|
||||||
|
on individual inputs is ignored by Chrome but respected on the form element */}
|
||||||
|
<form autoComplete="off" onSubmit={e => e.preventDefault()}>
|
||||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||||
<h2 className="modal-title" style={{ margin: 0 }}>User Manager</h2>
|
<h2 className="modal-title" style={{ margin: 0 }}>User Manager</h2>
|
||||||
<button className="btn-icon" onClick={onClose}>
|
<button className="btn-icon" onClick={onClose}>
|
||||||
@@ -421,6 +424,7 @@ export default function UserManagerModal({ onClose }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
|
|||||||
value={email} onChange={e => setEmail(e.target.value)}
|
value={email} onChange={e => setEmail(e.target.value)}
|
||||||
disabled={isEdit}
|
disabled={isEdit}
|
||||||
style={{ width:'100%', ...(isEdit ? { opacity:0.6, cursor:'not-allowed' } : {}) }}
|
style={{ width:'100%', ...(isEdit ? { opacity:0.6, cursor:'not-allowed' } : {}) }}
|
||||||
autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck="false" onFocus={onIF} onBlur={onIB} />
|
autoComplete="new-password" autoCorrect="off" autoCapitalize="off" spellCheck="false" onFocus={onIF} onBlur={onIB} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: First Name + Last Name */}
|
{/* Row 2: First Name + Last Name */}
|
||||||
@@ -250,13 +250,13 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
|
|||||||
{lbl('First Name', true)}
|
{lbl('First Name', true)}
|
||||||
<input className="input" placeholder="Jane"
|
<input className="input" placeholder="Jane"
|
||||||
value={firstName} onChange={e => setFirstName(e.target.value)}
|
value={firstName} onChange={e => setFirstName(e.target.value)}
|
||||||
autoComplete="off" autoCapitalize="words" onFocus={onIF} onBlur={onIB} />
|
autoComplete="new-password" autoCapitalize="words" onFocus={onIF} onBlur={onIB} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{lbl('Last Name', true)}
|
{lbl('Last Name', true)}
|
||||||
<input className="input" placeholder="Smith"
|
<input className="input" placeholder="Smith"
|
||||||
value={lastName} onChange={e => setLastName(e.target.value)}
|
value={lastName} onChange={e => setLastName(e.target.value)}
|
||||||
autoComplete="off" autoCapitalize="words" onFocus={onIF} onBlur={onIB} />
|
autoComplete="new-password" autoCapitalize="words" onFocus={onIF} onBlur={onIB} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -266,7 +266,7 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
|
|||||||
{lbl('Phone', false, '(optional)')}
|
{lbl('Phone', false, '(optional)')}
|
||||||
<input className="input" type="tel" placeholder="+1 555 000 0000"
|
<input className="input" type="tel" placeholder="+1 555 000 0000"
|
||||||
value={phone} onChange={e => setPhone(e.target.value)}
|
value={phone} onChange={e => setPhone(e.target.value)}
|
||||||
autoComplete="off" onFocus={onIF} onBlur={onIB} />
|
autoComplete="new-password" onFocus={onIF} onBlur={onIB} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{lbl('App Role', true)}
|
{lbl('App Role', true)}
|
||||||
@@ -621,6 +621,9 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
{/* form wrapper suppresses Chrome Android's autofill chip bar; autoComplete="new-password"
|
||||||
|
on individual inputs is ignored by Chrome but respected on the form element */}
|
||||||
|
<form autoComplete="new-password" onSubmit={e => e.preventDefault()}>
|
||||||
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minHeight:0, background:'var(--background)' }}>
|
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minHeight:0, background:'var(--background)' }}>
|
||||||
|
|
||||||
{/* LIST VIEW */}
|
{/* LIST VIEW */}
|
||||||
@@ -629,7 +632,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
|
|||||||
<div style={{ padding:'16px 16px 8px', flexShrink:0 }}>
|
<div style={{ padding:'16px 16px 8px', flexShrink:0 }}>
|
||||||
<input className="input" placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)}
|
<input className="input" placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)}
|
||||||
onFocus={onIF} onBlur={onIB}
|
onFocus={onIF} onBlur={onIB}
|
||||||
autoComplete="off" autoCorrect="off" spellCheck={false}
|
autoComplete="new-password" autoCorrect="off" spellCheck={false}
|
||||||
style={{ width:'100%', maxWidth: isMobile ? '100%' : 400 }} />
|
style={{ width:'100%', maxWidth: isMobile ? '100%' : 400 }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex:1, overflowY:'auto', padding:'0 16px', paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 16, overscrollBehavior:'contain' }}>
|
<div style={{ flex:1, overflowY:'auto', padding:'0 16px', paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 16, overscrollBehavior:'contain' }}>
|
||||||
@@ -677,6 +680,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
{/* Mobile footer — fixed, hidden when keyboard is up */}
|
{/* Mobile footer — fixed, hidden when keyboard is up */}
|
||||||
{isMobile && !inputFocused && (
|
{isMobile && !inputFocused && (
|
||||||
|
|||||||
Reference in New Issue
Block a user