v0.12.50 Updated to Family Manager and Events modal

This commit is contained in:
2026-04-02 09:58:15 -04:00
parent 6de899112b
commit 1d4116d1a3
9 changed files with 131 additions and 62 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "rosterchirp-frontend",
"version": "0.12.49",
"version": "0.12.50",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -15,6 +15,7 @@ export default function AddChildAliasModal({ onClose }) {
// Partner state
const [partner, setPartner] = useState(null);
const [selectedPartnerId, setSelectedPartnerId] = useState('');
const [respondSeparately, setRespondSeparately] = useState(false);
const [allUsers, setAllUsers] = useState([]);
const [savingPartner, setSavingPartner] = useState(false);
@@ -25,8 +26,10 @@ export default function AddChildAliasModal({ onClose }) {
api.searchUsers(''),
]).then(([aliasRes, partnerRes, usersRes]) => {
setAliases(aliasRes.aliases || []);
setPartner(partnerRes.partner || null);
setSelectedPartnerId(partnerRes.partner?.id?.toString() || '');
const p = partnerRes.partner || null;
setPartner(p);
setSelectedPartnerId(p?.id?.toString() || '');
setRespondSeparately(p?.respond_separately || false);
setAllUsers((usersRes.users || []).filter(u => u.id !== currentUser?.id));
}).catch(() => {});
}, []);
@@ -58,13 +61,18 @@ export default function AddChildAliasModal({ onClose }) {
if (!selectedPartnerId) {
await api.removePartner();
setPartner(null);
toast('Spouse/Partner removed', 'success');
} else {
const { partner: p } = await api.setPartner(parseInt(selectedPartnerId));
setPartner(p);
setRespondSeparately(false);
const { aliases: fresh } = await api.getAliases();
setAliases(fresh || []);
toast('Spouse/Partner saved', 'success');
resetForm();
toast('Spouse/Partner/Co-Parent removed', 'success');
} else {
const { partner: p } = await api.setPartner(parseInt(selectedPartnerId), respondSeparately);
setPartner(p);
setRespondSeparately(p?.respond_separately || false);
const { aliases: fresh } = await api.getAliases();
setAliases(fresh || []);
toast('Spouse/Partner/Co-Parent saved', 'success');
}
} catch (e) {
toast(e.message, 'error');
@@ -139,9 +147,9 @@ export default function AddChildAliasModal({ onClose }) {
</button>
</div>
{/* Spouse/Partner section */}
{/* Spouse/Partner/Co-Parent section */}
<div style={{ marginBottom: 16 }}>
{lbl('Spouse/Partner')}
{lbl('Spouse/Partner/Co-Parent')}
<div style={{ display: 'flex', gap: 8 }}>
<select
className="input"
@@ -163,8 +171,17 @@ export default function AddChildAliasModal({ onClose }) {
{savingPartner ? 'Saving…' : 'Save'}
</button>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, cursor: 'pointer', fontSize: 13, color: 'var(--text-secondary)' }}>
<input
type="checkbox"
checked={respondSeparately}
onChange={e => setRespondSeparately(e.target.checked)}
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: 'var(--primary)' }}
/>
Respond separately to events
</label>
{partner && (
<div className="text-sm" style={{ color: 'var(--text-secondary)', marginTop: 4 }}>
<div className="text-sm" style={{ color: 'var(--text-secondary)', marginTop: 6 }}>
Linked with {partner.display_name || partner.name}
</div>
)}

View File

@@ -789,11 +789,12 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
const [noteSaving,setNoteSaving]=useState(false);
const [avail,setAvail]=useState(event.availability||[]);
const [expandedNotes,setExpandedNotes]=useState(new Set());
const [responsesExpanded,setResponsesExpanded]=useState(false);
// Guardian Only: responder select ('all' | 'self' | 'alias:<id>' | 'partner:<id>')
const myAliases = event.my_aliases || [];
const myPartner = event.my_partner || null;
const showResponderSelect = !!(event.has_players_group && (myAliases.length > 0 || myPartner)) || !!(myPartner && event.in_guardians_group);
const [responder, setResponder] = useState('all');
const [responder, setResponder] = useState(event.in_guardians_group ? 'self' : 'all');
// Response that should be highlighted for the currently selected responder
const activeResp = !showResponderSelect || responder === 'all'
@@ -825,7 +826,7 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
? [
...(event.in_guardians_group ? [{ type:'self' }] : []),
...myAliases.map(a => ({ type:'alias', aliasId:a.id })),
...(myPartner ? [{ type:'partner', userId:myPartner.id }] : []),
...(myPartner && !myPartner.respond_separately ? [{ type:'partner', userId:myPartner.id }] : []),
]
: responder === 'self'
? [{ type:'self' }]
@@ -1002,9 +1003,9 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
<label style={{fontSize:11,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.5px',display:'block',marginBottom:4}}>Responding for</label>
<select value={responder} onChange={e=>setResponder(e.target.value)}
style={{width:'100%',padding:'7px 10px',borderRadius:'var(--radius)',border:'1px solid var(--border)',background:'var(--surface)',color:'var(--text-primary)',fontSize:13}}>
<option value="all">Entire Family</option>
{event.in_guardians_group && <option value="self">Myself</option>}
{myPartner && <option value={`partner:${myPartner.id}`}>{myPartner.display_name || myPartner.name}</option>}
<option value="all">Entire Family</option>
{myPartner && !myPartner.respond_separately && <option value={`partner:${myPartner.id}`}>{myPartner.display_name || myPartner.name}</option>}
{myAliases.map(a=><option key={a.id} value={`alias:${a.id}`}>{a.first_name} {a.last_name}</option>)}
</select>
</div>
@@ -1029,44 +1030,60 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
)}
{(isToolManager||avail.length>0)&&(
<>
<div style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px',marginBottom:8}}>Responses</div>
<div style={{display:'flex',gap:20,marginBottom:10,fontSize:13}}>
{Object.entries(counts).map(([k,n])=><span key={k}><span style={{color:RESP_COLOR[k],fontWeight:700}}>{n}</span> {RESP_LABEL[k]}</span>)}
{isToolManager&&<span><span style={{fontWeight:700}}>{event.no_response_count||0}</span> No response</span>}
</div>
{avail.length>0&&(
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',overflow:'hidden'}}>
{avail.map(r=>{
const rowKey = r.is_alias ? `alias:${r.alias_id}` : `user:${r.user_id}`;
const displayName = r.is_alias
? `${r.first_name} ${r.last_name}`
: (r.display_name || r.name);
const hasNote=!!(r.note&&r.note.trim());
const expanded=expandedNotes.has(rowKey);
return(
<div key={rowKey} style={{borderBottom:'1px solid var(--border)'}}>
<div
style={{display:'flex',alignItems:'center',gap:10,padding:'8px 12px',fontSize:13,cursor:hasNote?'pointer':'default'}}
onClick={hasNote?()=>toggleNote(rowKey):undefined}
>
<span style={{width:9,height:9,borderRadius:'50%',background:RESP_COLOR[r.response],flexShrink:0,display:'inline-block'}}/>
<span style={{flex:1}}>{displayName}</span>
{r.is_alias&&<span style={{fontSize:11,color:'var(--text-tertiary)',fontStyle:'italic'}}>child</span>}
{hasNote&&(
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2.5" style={{flexShrink:0,transition:'transform 0.15s',transform:expanded?'rotate(180deg)':'rotate(0deg)'}}><polyline points="6 9 12 15 18 9"/></svg>
)}
<span style={{color:RESP_COLOR[r.response],fontSize:12,fontWeight:600}}>{RESP_LABEL[r.response]}</span>
</div>
{hasNote&&expanded&&(
<div style={{padding:'0 12px 10px 31px',fontSize:12,color:'var(--text-secondary)',fontStyle:'italic'}}>
{r.note}
</div>
)}
</div>
);
})}
<div
onClick={()=>setResponsesExpanded(e=>!e)}
style={{display:'flex',alignItems:'center',justifyContent:'space-between',cursor:'pointer',userSelect:'none',marginBottom:responsesExpanded?8:0}}
>
<span style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px'}}>Responses</span>
<div style={{display:'flex',alignItems:'center',gap:10}}>
<div style={{display:'flex',gap:12,fontSize:12}}>
{Object.entries(counts).map(([k,n])=><span key={k}><span style={{color:RESP_COLOR[k],fontWeight:700}}>{n}</span> {RESP_LABEL[k]}</span>)}
{isToolManager&&<span><span style={{fontWeight:700}}>{event.no_response_count||0}</span> No response</span>}
</div>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2.5" style={{flexShrink:0,transition:'transform 0.15s',transform:responsesExpanded?'rotate(180deg)':'rotate(0deg)'}}><polyline points="6 9 12 15 18 9"/></svg>
</div>
)}
</div>
{responsesExpanded&&avail.length>0&&(()=>{
const RESP_ORDER={going:0,maybe:1,not_going:2};
const sortedAvail=[...avail].sort((a,b)=>{
const od=(RESP_ORDER[a.response]??99)-(RESP_ORDER[b.response]??99);
if(od!==0)return od;
const na=a.is_alias?`${a.first_name} ${a.last_name}`:(a.display_name||a.name||'');
const nb=b.is_alias?`${b.first_name} ${b.last_name}`:(b.display_name||b.name||'');
return na.localeCompare(nb);
});
return(
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',overflow:'hidden',maxHeight:avail.length>4?'140px':undefined,overflowY:avail.length>4?'auto':undefined}}>
{sortedAvail.map(r=>{
const rowKey=r.is_alias?`alias:${r.alias_id}`:`user:${r.user_id}`;
const displayName=r.is_alias?`${r.first_name} ${r.last_name}`:(r.display_name||r.name);
const hasNote=!!(r.note&&r.note.trim());
const expanded=expandedNotes.has(rowKey);
return(
<div key={rowKey} style={{borderBottom:'1px solid var(--border)'}}>
<div
style={{display:'flex',alignItems:'center',gap:10,padding:'8px 12px',fontSize:13,cursor:hasNote?'pointer':'default'}}
onClick={hasNote?()=>toggleNote(rowKey):undefined}
>
<span style={{width:9,height:9,borderRadius:'50%',background:RESP_COLOR[r.response],flexShrink:0,display:'inline-block'}}/>
<span style={{flex:1}}>{displayName}</span>
{r.is_alias&&<span style={{fontSize:11,color:'var(--text-tertiary)',fontStyle:'italic'}}>child</span>}
{hasNote&&(
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2.5" style={{flexShrink:0,transition:'transform 0.15s',transform:expanded?'rotate(180deg)':'rotate(0deg)'}}><polyline points="6 9 12 15 18 9"/></svg>
)}
<span style={{color:RESP_COLOR[r.response],fontSize:12,fontWeight:600}}>{RESP_LABEL[r.response]}</span>
</div>
{hasNote&&expanded&&(
<div style={{padding:'0 12px 10px 31px',fontSize:12,color:'var(--text-secondary)',fontStyle:'italic'}}>
{r.note}
</div>
)}
</div>
);
})}
</div>
);
})()}
</>
)}
</div>

View File

@@ -85,7 +85,8 @@ export const api = {
},
// Spouse/Partner
getPartner: () => req('GET', '/users/me/partner'),
setPartner: (partnerId) => req('POST', '/users/me/partner', { partnerId }),
setPartner: (partnerId, respondSeparately = false) => req('POST', '/users/me/partner', { partnerId, respondSeparately }),
updatePartnerRespondSeparately: (respondSeparately) => req('PATCH', '/users/me/partner', { respondSeparately }),
removePartner: () => req('DELETE', '/users/me/partner'),
// Groups